Files
crumbs-original/src/lib/sync/crdt.ts
T
Claude 9ed5c52fb6 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

75 lines
2.5 KiB
TypeScript

import type { Note } from '$lib/types/index.js';
/** Fields that can be individually merged between local and remote notes */
const MUTABLE_FIELDS = ['title', 'content', 'color', 'pinned', 'archived', 'trashed', 'checklistMode', 'sortOrder'] as const;
/**
* Last-Write-Wins (LWW) merge strategy with pending-field protection.
*
* When remote is newer, takes remote values for all fields EXCEPT those
* listed in `pendingFields` — these represent locally-queued changes that
* haven't been pushed yet and must be preserved.
*/
export function mergeNotes(local: Note, remote: Note, pendingFields?: Set<string>): Note {
const localTime = new Date(local.updatedAt).getTime();
const remoteTime = new Date(remote.updatedAt).getTime();
const remoteWins = remoteTime > localTime ||
(remoteTime === localTime && remote.version > local.version);
if (!remoteWins) {
return { ...local };
}
// Remote wins — take remote as base, but preserve pending local fields
if (!pendingFields || pendingFields.size === 0) {
return { ...remote };
}
const merged = { ...remote };
for (const field of MUTABLE_FIELDS) {
if (pendingFields.has(field)) {
(merged as unknown as Record<string, unknown>)[field] = (local as unknown as Record<string, unknown>)[field];
}
}
return merged;
}
/**
* Determine if a local note has changes compared to a remote note.
*/
export function hasChanges(local: Note, remote: Note): boolean {
return (
local.title !== remote.title ||
local.content !== remote.content ||
local.color !== remote.color ||
local.pinned !== remote.pinned ||
local.archived !== remote.archived ||
local.trashed !== remote.trashed ||
local.checklistMode !== remote.checklistMode ||
local.sortOrder !== remote.sortOrder
);
}
/**
* Generate a diff of changed fields between two notes.
*/
export function diffNotes(
oldNote: Note,
newNote: Note
): Partial<Note> {
const diff: Partial<Note> = {};
if (oldNote.title !== newNote.title) diff.title = newNote.title;
if (oldNote.content !== newNote.content) diff.content = newNote.content;
if (oldNote.color !== newNote.color) diff.color = newNote.color;
if (oldNote.pinned !== newNote.pinned) diff.pinned = newNote.pinned;
if (oldNote.archived !== newNote.archived) diff.archived = newNote.archived;
if (oldNote.trashed !== newNote.trashed) diff.trashed = newNote.trashed;
if (oldNote.checklistMode !== newNote.checklistMode) diff.checklistMode = newNote.checklistMode;
if (oldNote.sortOrder !== newNote.sortOrder) diff.sortOrder = newNote.sortOrder;
return diff;
}