Files
crumbs/src/lib/sync/client.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

191 lines
4.6 KiB
TypeScript

import { browser } from '$app/environment';
import type { Note } from '$lib/types/index.js';
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta, getPendingAttachments, removePendingAttachment } from './idb.js';
import { mergeNotes } from './crdt.js';
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
import { get } from 'svelte/store';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
let syncStatus: SyncStatus = 'synced';
let syncInterval: ReturnType<typeof setInterval> | null = null;
const statusListeners: Set<(status: SyncStatus) => void> = new Set();
export function onSyncStatusChange(listener: (status: SyncStatus) => void) {
statusListeners.add(listener);
return () => statusListeners.delete(listener);
}
function setStatus(status: SyncStatus) {
syncStatus = status;
statusListeners.forEach((l) => l(status));
}
export function getSyncStatus(): SyncStatus {
return syncStatus;
}
/**
* Push local changes to server.
*/
async function pushChanges(): Promise<boolean> {
let queue;
try {
queue = await getSyncQueue();
} catch {
return true; // IDB unavailable — nothing to push
}
if (queue.length === 0) return true;
try {
const res = await fetch('/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ changes: queue })
});
if (res.ok) {
await clearSyncQueue();
return true;
}
return false;
} catch {
return false;
}
}
/**
* Push pending offline attachments to server.
*/
async function pushPendingAttachments(): Promise<void> {
const pending = await getPendingAttachments();
for (const item of pending) {
try {
const formData = new FormData();
formData.append('file', new File([item.optimized], item.filename, { type: item.mimeType }));
formData.append('thumbnail', new File([item.thumbnail], `${item.filename}_thumb.webp`, { type: 'image/webp' }));
const res = await fetch(`/api/notes/${item.noteId}/attachments`, {
method: 'POST',
body: formData
});
if (res.ok) {
await removePendingAttachment(item.id);
}
} catch {
// Will retry on next sync cycle
}
}
}
/**
* Pull remote changes and merge with local state.
*/
async function pullChanges(): Promise<Note[]> {
let lastSync = '0';
try {
lastSync = (await getMeta('lastSyncTimestamp')) || '0';
} catch {
// IDB unavailable — pull everything
}
try {
const res = await fetch(`/api/sync?since=${lastSync}`);
if (!res.ok) return [];
const remoteNotes: Note[] = await res.json();
try {
const localNotes = await getAllNotes();
const localMap = new Map(localNotes.map((n) => [n.id, n]));
// Build pending-fields map from sync queue to protect unpushed local changes
let pendingFieldsMap = new Map<string, Set<string>>();
try {
const queue = await getSyncQueue();
for (const item of queue) {
if (item.data) {
const fields = pendingFieldsMap.get(item.noteId) || new Set<string>();
Object.keys(item.data).forEach((k) => fields.add(k));
pendingFieldsMap.set(item.noteId, fields);
}
}
} catch {
// Queue unavailable — merge without protection
}
for (const remote of remoteNotes) {
const local = localMap.get(remote.id);
if (local) {
const pendingFields = pendingFieldsMap.get(remote.id);
const merged = mergeNotes(local, remote, pendingFields);
await putNote(merged);
} else {
await putNote(remote);
}
}
await setMeta('lastSyncTimestamp', String(Date.now()));
} catch {
// IDB write failed — server data still returned for store use
}
return remoteNotes;
} catch {
return [];
}
}
/**
* Full sync cycle: push then pull.
*/
export async function sync(): Promise<void> {
if (!browser || !navigator.onLine) {
setStatus('offline');
return;
}
setStatus('syncing');
try {
const pushed = await pushChanges();
if (!pushed) {
setStatus('error');
return;
}
await pushPendingAttachments();
await pullChanges();
setStatus('synced');
// Reload notes from server and persist to IDB
await loadNotes(get(currentFilter));
} catch {
setStatus('error');
}
}
/**
* Start background sync (every 30 seconds).
*/
export function startSync() {
if (!browser) return;
// Initial sync
sync();
// Periodic sync
syncInterval = setInterval(sync, 30_000);
// Sync on online/offline events
window.addEventListener('online', () => sync());
window.addEventListener('offline', () => setStatus('offline'));
}
export function stopSync() {
if (syncInterval) {
clearInterval(syncInterval);
syncInterval = null;
}
}