fix: resolve lost updates in multi-user collaboration sync

Concurrent edits to shared notes (e.g., one user checks a shopping list
item while another adds an item) were lost because the sync system used
whole-document Last-Write-Wins. This replaces it with a 3-way line-level
content merge using node-diff3, so changes to different lines are
preserved from both users.

Key changes:
- Add node-diff3 for 3-way line-level content merging
- Remove the timestamp gate in processSyncPush that rejected entire
  offline sync pushes when another user had updated the note
- Add content merge to both the sync push path and the PATCH endpoint,
  using version snapshots as the merge base
- Send baseVersion from the client so the server can find the correct
  common ancestor for 3-way merge
- Protect pending local fields during client pull merge by reading
  the sync queue and preserving queued-but-unpushed changes
- Add comprehensive tests for content merge, server sync, and CRDT merge

https://claude.ai/code/session_01FNpVBBjM6vKzfR8y9NM9Ci
This commit is contained in:
Claude
2026-03-12 20:19:10 +01:00
committed by Fred Cabassut
parent 3e7f5188d1
commit 9ed5c52fb6
13 changed files with 520 additions and 72 deletions
+65
View File
@@ -55,6 +55,71 @@ describe('mergeNotes (LWW)', () => {
const result = mergeNotes(local, remote);
expect(result.title).toBe('Local');
});
it('should preserve pending local fields when remote is newer', () => {
const local = makeNote({
title: 'Local title',
content: 'Local content',
color: 'coral',
updatedAt: new Date('2024-01-01'),
version: 1
});
const remote = makeNote({
title: 'Remote title',
content: 'Remote content',
color: 'default',
updatedAt: new Date('2024-01-02'),
version: 2
});
// Content has pending local changes; title and color do not
const pendingFields = new Set(['content']);
const result = mergeNotes(local, remote, pendingFields);
// Remote wins overall, but content is preserved from local
expect(result.title).toBe('Remote title');
expect(result.content).toBe('Local content');
expect(result.color).toBe('default'); // remote wins for non-pending field
expect(result.version).toBe(2); // metadata from remote
});
it('should take remote values for non-pending fields when remote is newer', () => {
const local = makeNote({
pinned: true,
archived: true,
updatedAt: new Date('2024-01-01')
});
const remote = makeNote({
pinned: false,
archived: false,
updatedAt: new Date('2024-01-02')
});
const pendingFields = new Set(['pinned']);
const result = mergeNotes(local, remote, pendingFields);
expect(result.pinned).toBe(true); // pending — preserved
expect(result.archived).toBe(false); // not pending — remote wins
});
it('should ignore pendingFields when local is newer', () => {
const local = makeNote({
title: 'Local',
updatedAt: new Date('2024-01-02'),
version: 2
});
const remote = makeNote({
title: 'Remote',
updatedAt: new Date('2024-01-01'),
version: 1
});
const pendingFields = new Set(['title']);
const result = mergeNotes(local, remote, pendingFields);
// Local wins entirely since it's newer
expect(result.title).toBe('Local');
});
});
describe('hasChanges', () => {