Files
crumbs/src/lib/sync/sync.test.ts
T
Claude 57a05bbee0 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
2026-03-12 20:19:10 +01:00

185 lines
5.4 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { mergeNotes, hasChanges, diffNotes } from './crdt.js';
import type { Note } from '../types/index.js';
function makeNote(overrides: Partial<Note> = {}): Note {
return {
id: 'test-id',
title: 'Test',
content: 'Content',
color: 'default',
pinned: false,
archived: false,
trashed: false,
trashedAt: null,
checklistMode: false,
sortOrder: 0,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
version: 1,
...overrides
};
}
describe('mergeNotes (LWW)', () => {
it('should prefer remote when remote is newer', () => {
const local = makeNote({ title: 'Local', updatedAt: new Date('2024-01-01') });
const remote = makeNote({ title: 'Remote', updatedAt: new Date('2024-01-02') });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Remote');
});
it('should prefer local when local is newer', () => {
const local = makeNote({ title: 'Local', updatedAt: new Date('2024-01-02') });
const remote = makeNote({ title: 'Remote', updatedAt: new Date('2024-01-01') });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Local');
});
it('should prefer higher version when timestamps are equal', () => {
const ts = new Date('2024-01-01');
const local = makeNote({ title: 'Local', updatedAt: ts, version: 1 });
const remote = makeNote({ title: 'Remote', updatedAt: ts, version: 2 });
const result = mergeNotes(local, remote);
expect(result.title).toBe('Remote');
});
it('should prefer local when timestamps and versions are equal', () => {
const ts = new Date('2024-01-01');
const local = makeNote({ title: 'Local', updatedAt: ts, version: 1 });
const remote = makeNote({ title: 'Remote', updatedAt: ts, version: 1 });
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', () => {
it('should detect title change', () => {
const a = makeNote({ title: 'A' });
const b = makeNote({ title: 'B' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect content change', () => {
const a = makeNote({ content: 'old' });
const b = makeNote({ content: 'new' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect color change', () => {
const a = makeNote({ color: 'default' });
const b = makeNote({ color: 'coral' });
expect(hasChanges(a, b)).toBe(true);
});
it('should detect pinned change', () => {
const a = makeNote({ pinned: false });
const b = makeNote({ pinned: true });
expect(hasChanges(a, b)).toBe(true);
});
it('should return false when notes are identical', () => {
const a = makeNote();
const b = makeNote();
expect(hasChanges(a, b)).toBe(false);
});
});
describe('diffNotes', () => {
it('should return only changed fields', () => {
const old = makeNote({ title: 'Old', content: 'Same' });
const updated = makeNote({ title: 'New', content: 'Same' });
const diff = diffNotes(old, updated);
expect(diff.title).toBe('New');
expect(diff.content).toBeUndefined();
});
it('should return empty object when no changes', () => {
const a = makeNote();
const b = makeNote();
const diff = diffNotes(a, b);
expect(Object.keys(diff)).toHaveLength(0);
});
it('should track multiple changed fields', () => {
const old = makeNote({ title: 'Old', color: 'default', pinned: false });
const updated = makeNote({ title: 'New', color: 'coral', pinned: true });
const diff = diffNotes(old, updated);
expect(diff.title).toBe('New');
expect(diff.color).toBe('coral');
expect(diff.pinned).toBe(true);
});
});