mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
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
185 lines
5.4 KiB
TypeScript
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);
|
|
});
|
|
});
|