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
89 lines
2.7 KiB
TypeScript
89 lines
2.7 KiB
TypeScript
import { diff3Merge } from 'node-diff3';
|
|
|
|
/**
|
|
* Resolve a conflict region by comparing each line against the base.
|
|
* If only one side changed a line, take that change.
|
|
* If both sides changed the same line, prefer remote (LWW).
|
|
* Handles different-length regions by appending extra lines.
|
|
*/
|
|
function resolveConflict(
|
|
aLines: string[],
|
|
oLines: string[],
|
|
bLines: string[]
|
|
): string[] {
|
|
const maxLen = Math.max(aLines.length, bLines.length, oLines.length);
|
|
const result: string[] = [];
|
|
|
|
for (let i = 0; i < maxLen; i++) {
|
|
const a = i < aLines.length ? aLines[i] : undefined;
|
|
const o = i < oLines.length ? oLines[i] : undefined;
|
|
const b = i < bLines.length ? bLines[i] : undefined;
|
|
|
|
const aChanged = a !== o;
|
|
const bChanged = b !== o;
|
|
|
|
if (aChanged && !bChanged && a !== undefined) {
|
|
// Only local changed this line — take local
|
|
result.push(a);
|
|
} else if (bChanged && !aChanged && b !== undefined) {
|
|
// Only remote changed this line — take remote
|
|
result.push(b);
|
|
} else if (aChanged && bChanged) {
|
|
// Both changed — LWW: prefer remote
|
|
if (b !== undefined) result.push(b);
|
|
// If remote deleted (undefined) but local has it, skip (remote wins)
|
|
} else {
|
|
// Neither changed, or both are the same as base
|
|
if (o !== undefined) result.push(o);
|
|
else if (b !== undefined) result.push(b);
|
|
else if (a !== undefined) result.push(a);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 3-way line-level merge for note content.
|
|
* Takes a base (common ancestor), local (incoming), and remote (server current).
|
|
* For conflict regions, resolves line-by-line: if only one side changed a line,
|
|
* take that change. If both changed the same line, prefer remote (LWW).
|
|
*/
|
|
export function mergeContent(base: string, local: string, remote: string): string {
|
|
// If any two are identical, short-circuit
|
|
if (local === remote) return remote;
|
|
if (local === base) return remote;
|
|
if (remote === base) return local;
|
|
|
|
const baseLines = base.split('\n');
|
|
const localLines = local.split('\n');
|
|
const remoteLines = remote.split('\n');
|
|
|
|
const regions = diff3Merge(localLines, baseLines, remoteLines);
|
|
const merged: string[] = [];
|
|
|
|
for (const region of regions) {
|
|
if (region.ok) {
|
|
merged.push(...region.ok);
|
|
} else if (region.conflict) {
|
|
merged.push(...resolveConflict(
|
|
region.conflict.a,
|
|
region.conflict.o,
|
|
region.conflict.b
|
|
));
|
|
}
|
|
}
|
|
|
|
return merged.join('\n');
|
|
}
|
|
|
|
/**
|
|
* 2-way merge fallback when no base is available.
|
|
* Uses an empty string as the base, so all lines from both sides appear
|
|
* as additions. For conflicts (same region), prefers remote.
|
|
*/
|
|
export function mergeContentTwoWay(local: string, remote: string): string {
|
|
if (local === remote) return remote;
|
|
return mergeContent('', local, remote);
|
|
}
|