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
191 lines
4.6 KiB
TypeScript
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;
|
|
}
|
|
}
|