diff --git a/apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte b/apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte new file mode 100644 index 000000000..b35973bb5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte @@ -0,0 +1,245 @@ + + + +{#if conflicts.length > 0} +
+ {#each conflicts as conflict (conflict.id)} +
+
+ + + {labelFor(conflict.tableName)} überschrieben + + +
+

+ Eine andere Sitzung hat dein Edit auf {fieldList(conflict.fields)} überschrieben. Soll deine + Version wiederhergestellt werden? +

+
+ + +
+
+ {/each} +
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts b/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts new file mode 100644 index 000000000..bd11a34e9 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts @@ -0,0 +1,227 @@ +/** + * Conflict Store — surfaces sync field-LWW overwrites to the user. + * + * Subscribes to the `mana-sync-conflict` CustomEvent that + * `applyServerChanges` fires whenever it overwrites a non-empty local + * field with a strictly-newer server value. Each event becomes a row + * in the visible-conflicts list, which a toast component renders so + * the user can either: + * + * - Restore (writes the original local value back with a fresh + * updatedAt that beats the server's, queues a sync push) + * - Dismiss (just removes the toast — server value stays) + * + * Coalescing + * ---------- + * Multiple conflicts on the same record collapse into ONE entry, with + * a per-field map underneath. A burst of 5 server changes hitting the + * same task only shows one toast (not five), and Restore reverts ALL + * affected fields in one transaction. The coalesce key is + * `${tableName}|${recordId}` and the dedup window is the time the + * record stays in `visible`. + * + * Auto-dismiss + * ------------ + * Each conflict expires after CONFLICT_TTL_MS (default 30s) so a long- + * running session doesn't accumulate stale toasts. Manual dismiss + * trumps the timer. + * + * Restore semantics + * ----------------- + * Restoration runs OUTSIDE the apply lock — applyServerChanges holds + * a per-table lock that suppresses pending-change tracking, and we + * want our restore write to be tracked. We defer via setTimeout(0) + * which lands after the current microtask drain, by which time the + * apply lock has been released in the finally block. + */ + +import { db } from './database'; +import { subscribeSyncConflicts, type SyncConflictPayload } from './sync'; +import { FIELD_TIMESTAMPS_KEY } from './database'; + +/** How long a conflict stays visible before auto-dismissing. */ +const CONFLICT_TTL_MS = 30_000; + +/** Cap on the visible list — older entries get evicted FIFO so a + * bursty server can't grow the array unbounded. */ +const MAX_VISIBLE = 8; + +/** A coalesced conflict — one per (tableName, recordId), with the + * per-field overwrite details merged. */ +export interface SyncConflict { + /** Stable id for the toast key — `${tableName}|${recordId}`. */ + id: string; + tableName: string; + recordId: string; + /** Per-field overwrite details. New fields from later events get + * merged in; if the same field fires twice, the latest server + * state wins (we don't try to be smarter — restore would still + * bring back the original local value from the first event). */ + fields: Record< + string, + { + wasLocal: unknown; + nowServer: unknown; + localTime: string; + serverTime: string; + } + >; + detectedAt: string; // ISO +} + +let visible = $state([]); +let timers = new Map>(); +let installed = false; + +function scheduleAutoDismiss(id: string): void { + const existing = timers.get(id); + if (existing) clearTimeout(existing); + const t = setTimeout(() => dismiss(id), CONFLICT_TTL_MS); + timers.set(id, t); +} + +function clearTimer(id: string): void { + const t = timers.get(id); + if (t) { + clearTimeout(t); + timers.delete(id); + } +} + +function ingest(payload: SyncConflictPayload): void { + const id = `${payload.tableName}|${payload.recordId}`; + const now = new Date().toISOString(); + + const existing = visible.find((c) => c.id === id); + if (existing) { + // Coalesce a new field-overwrite into the existing entry. + // Don't clobber the original wasLocal — that's the value the + // user actually typed. Only update nowServer + serverTime. + const prior = existing.fields[payload.field]; + existing.fields[payload.field] = { + wasLocal: prior ? prior.wasLocal : payload.wasLocal, + nowServer: payload.nowServer, + localTime: prior ? prior.localTime : payload.localTime, + serverTime: payload.serverTime, + }; + // Touch the timer so the toast stays visible while bursts arrive. + scheduleAutoDismiss(id); + // Force reactivity — Svelte 5 needs a new array reference for + // $derived consumers to re-render. Mutating in place leaves + // $effect blind. + visible = [...visible]; + return; + } + + const fresh: SyncConflict = { + id, + tableName: payload.tableName, + recordId: payload.recordId, + fields: { + [payload.field]: { + wasLocal: payload.wasLocal, + nowServer: payload.nowServer, + localTime: payload.localTime, + serverTime: payload.serverTime, + }, + }, + detectedAt: now, + }; + + // FIFO eviction if we're at the cap. + const next = [...visible, fresh]; + if (next.length > MAX_VISIBLE) { + const evicted = next.shift(); + if (evicted) clearTimer(evicted.id); + } + visible = next; + scheduleAutoDismiss(id); +} + +function dismiss(id: string): void { + clearTimer(id); + visible = visible.filter((c) => c.id !== id); +} + +async function restore(id: string): Promise { + const conflict = visible.find((c) => c.id === id); + if (!conflict) return; + + // Defer the actual write so we land after applyServerChanges has + // released its apply-lock for this table. setTimeout(0) is enough + // — the lock is released synchronously in the finally block, and + // the next macrotask runs after that. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const now = new Date().toISOString(); + const updates: Record = { updatedAt: now }; + const ftPatch: Record = {}; + + for (const [field, info] of Object.entries(conflict.fields)) { + updates[field] = info.wasLocal; + ftPatch[field] = now; + } + + // Read the current row's __fieldTimestamps and merge our patch in + // so we don't blow away unrelated server-side timestamps. + const row = await db.table(conflict.tableName).get(conflict.recordId); + if (row) { + const existingFT = + ((row as Record)[FIELD_TIMESTAMPS_KEY] as Record) ?? {}; + updates[FIELD_TIMESTAMPS_KEY] = { ...existingFT, ...ftPatch }; + } else { + updates[FIELD_TIMESTAMPS_KEY] = ftPatch; + } + + try { + await db.table(conflict.tableName).update(conflict.recordId, updates); + } catch (err) { + console.error( + `[mana-conflict] restore failed for ${conflict.tableName}/${conflict.recordId}:`, + err + ); + // Leave the toast in place so the user knows the restore didn't + // land — they can retry or dismiss manually. + return; + } + + dismiss(id); +} + +/** Boot-time installer. Wires a single sync-conflict subscriber via + * the in-module pub/sub helper from sync.ts. The data-layer-listeners + * module calls this in the same place that installs the quota + + * telemetry listeners, so it's symmetrical with every other sync-side + * observer. */ +export function installConflictListener(): () => void { + if (installed) return () => {}; + installed = true; + + const unsubscribe = subscribeSyncConflicts((payload) => { + ingest(payload); + }); + + return () => { + unsubscribe(); + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + visible = []; + installed = false; + }; +} + +/** Read-only view of the visible conflicts for the toast component. */ +export const conflictStore = { + get visible() { + return visible; + }, + dismiss, + restore, + /** Test-only: clear everything without going through the listener. */ + _resetForTesting() { + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + visible = []; + installed = false; + }, +}; diff --git a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts index 294421403..e38a6c090 100644 --- a/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts +++ b/apps/mana/apps/web/src/lib/data/data-layer-listeners.ts @@ -22,6 +22,7 @@ import { QUOTA_EVENT, type QuotaExceededDetail } from './quota-detect'; import { cleanupTombstones } from './quota'; import { pruneActivityLog } from './activity'; import { SYNC_TELEMETRY_EVENT, type SyncTelemetryDetail } from './sync-telemetry'; +import { installConflictListener } from './conflict-store.svelte'; /** How often to run the tombstone cleanup. 24h is a comfortable cadence * given that the cutoff is 30 days — runs roughly once per app session. */ @@ -99,6 +100,12 @@ export function installDataLayerListeners(): () => void { window.addEventListener(QUOTA_EVENT, handleQuota); window.addEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry); + // ─── Sync conflict listener (Backlog C) ──────────────────── + // Wires the field-LWW overwrite events into the conflict store + // that the SyncConflictToast component reads. The store handles + // coalescing, auto-dismiss, and the restore-write path. + const disposeConflict = installConflictListener(); + // ─── Periodic cleanup loop ───────────────────────────────── // Runs once on boot, then daily. Two independent jobs share the // schedule so we never have a third interval competing for the same @@ -143,5 +150,6 @@ export function installDataLayerListeners(): () => void { window.removeEventListener(QUOTA_EVENT, handleQuota); window.removeEventListener(SYNC_TELEMETRY_EVENT, handleTelemetry); window.clearInterval(cleanupTimer); + disposeConflict(); }; } diff --git a/apps/mana/apps/web/src/lib/data/sync.test.ts b/apps/mana/apps/web/src/lib/data/sync.test.ts index bb17d9f16..2ff232530 100644 --- a/apps/mana/apps/web/src/lib/data/sync.test.ts +++ b/apps/mana/apps/web/src/lib/data/sync.test.ts @@ -37,7 +37,9 @@ import { isValidSyncChange, readFieldTimestamps, applyServerChanges, + subscribeSyncConflicts, type SyncChange, + type SyncConflictPayload, } from './sync'; import { db, FIELD_TIMESTAMPS_KEY } from './database'; @@ -341,4 +343,171 @@ describe('applyServerChanges (Dexie integration)', () => { .toArray(); expect(pendingForTaskF).toEqual([]); }); + + // ─── Sync conflict event detection (Backlog C) ───────────── + + describe('sync-conflict events', () => { + // Helper: collect every conflict event fired during the body + // of `fn`, then resolve to the captured payloads. Cleans up the + // listener even on test failure. + async function captureConflicts(fn: () => Promise): Promise { + const captured: SyncConflictPayload[] = []; + // In-module pub/sub from sync.ts — works in node-vitest + + // browser without DOM EventTarget polyfills. + const unsubscribe = subscribeSyncConflicts((payload) => { + captured.push(payload); + }); + try { + await fn(); + } finally { + unsubscribe(); + } + return captured; + } + + it('fires when the server overwrites a non-empty local field with a strictly newer value', async () => { + await db.table('tasks').add({ + id: 'task-conflict-1', + title: 'my version', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-1', + op: 'update', + fields: { + title: { value: 'their version', updatedAt: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(1); + expect(conflicts[0]).toMatchObject({ + tableName: 'tasks', + recordId: 'task-conflict-1', + field: 'title', + wasLocal: 'my version', + nowServer: 'their version', + }); + }); + + it('does NOT fire when the local field is null/undefined (no edit to lose)', async () => { + await db.table('tasks').add({ + id: 'task-conflict-2', + title: null, + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-2', + op: 'update', + fields: { + title: { value: 'first server title', updatedAt: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(0); + }); + + it('does NOT fire when the values are equal (idempotent server replay)', async () => { + await db.table('tasks').add({ + id: 'task-conflict-3', + title: 'same value', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-3', + op: 'update', + fields: { + title: { value: 'same value', updatedAt: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(0); + }); + + it('fires once per overwritten field (multi-field conflicts)', async () => { + await db.table('tasks').add({ + id: 'task-conflict-4', + title: 'local title', + priority: 'low', + isCompleted: false, + order: 0, + }); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-4', + op: 'update', + fields: { + title: { value: 'server title', updatedAt: '2099-01-01T00:00:00Z' }, + priority: { value: 'high', updatedAt: '2099-01-01T00:00:00Z' }, + }, + }, + ]); + }); + + expect(conflicts).toHaveLength(2); + expect(conflicts.map((c) => c.field).sort()).toEqual(['priority', 'title']); + }); + + it('does NOT fire when the server timestamp equals the local one (LWW tie)', async () => { + // Seed with a known timestamp via the insert path so we can + // match the server time exactly. + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-5', + op: 'insert', + data: { + id: 'task-conflict-5', + title: 'tied title', + priority: 'low', + isCompleted: false, + order: 0, + updatedAt: '2026-04-01T00:00:00Z', + }, + }, + ]); + + const conflicts = await captureConflicts(async () => { + await applyServerChanges('todo', [ + { + table: 'tasks', + id: 'task-conflict-5', + op: 'update', + fields: { + title: { value: 'changed', updatedAt: '2026-04-01T00:00:00Z' }, // exact tie + }, + }, + ]); + }); + + // Tied — LWW lets server win silently (no edit-loss to surface) + expect(conflicts).toHaveLength(0); + }); + }); }); diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index 2e5fbb5f6..98df318a8 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -142,6 +142,88 @@ export function readFieldTimestamps(record: unknown): Record { * concurrent user writes to OTHER tables continue tracking normally. * Malformed entries are dropped before any DB work happens. */ +/** + * Per-conflict event payload — emitted when applyServerChanges field-LWW + * overwrites a local field value with a strictly newer server value. + * + * `wasLocal` is the value the user had typed before the sync overwrite. + * `nowServer` is what the row holds now. The conflict store reads both + * to give the user a "restore my version" option, which writes wasLocal + * back with a fresh updatedAt so it wins on the next sync round. + * + * Conflicts are NOT raised when the local field was empty (no edit to + * lose), when the values are identical, or when the timestamps are + * exactly equal (LWW lets the server win on ties but there's nothing + * meaningful to surface — both clients agreed at the same moment). + */ +export interface SyncConflictPayload { + tableName: string; + recordId: string; + field: string; + wasLocal: unknown; + nowServer: unknown; + localTime: string; + serverTime: string; +} + +/** Identifier kept around for legacy callers + readable telemetry — + * the conflict bus is a plain in-module pub/sub (see below) so it + * works the same way in browser and node test envs. */ +export const SYNC_CONFLICT_EVENT = 'mana-sync-conflict'; + +/** Subscriber callback shape. */ +export type SyncConflictListener = (payload: SyncConflictPayload) => void; + +/** Active subscribers. Set so dedup is automatic and unsubscribe is O(1). */ +const conflictListeners = new Set(); + +/** + * Subscribe to sync-conflict events. Returns an unsubscribe function. + * + * Why a custom registry instead of CustomEvent + window.dispatchEvent? + * - Works in node-based vitest envs where `window` doesn't exist + * - No accidental coupling to the DOM EventTarget surface + * - Lighter than spinning up an EventTarget polyfill in tests + * + * The conflict-store module installs exactly one subscriber per app + * lifecycle. The test file installs a temporary one per test case + * via this same API. + */ +export function subscribeSyncConflicts(listener: SyncConflictListener): () => void { + conflictListeners.add(listener); + return () => { + conflictListeners.delete(listener); + }; +} + +function notifyConflict(payload: SyncConflictPayload): void { + // Fan out to every subscriber. Errors in one listener don't break + // the rest — sync detection is best-effort and we don't want a + // broken UI handler to corrupt the apply path. + for (const listener of conflictListeners) { + try { + listener(payload); + } catch (err) { + console.error('[mana-sync] conflict listener threw:', err); + } + } +} + +/** Cheap structural equality for sync-conflict comparison. We don't + * need a deep diff here — `===` for primitives, JSON-string compare + * for objects (including encrypted-blob strings, which compare as + * raw strings). Hot path is rare so the JSON serialise cost is fine. */ +function valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a == null || b == null) return false; + if (typeof a !== typeof b) return false; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + export async function applyServerChanges(appId: string, changes: unknown[]): Promise { // Reject malformed entries up-front so a single bad row from the server // can never write garbage into IndexedDB. Drops are logged once and the @@ -244,6 +326,26 @@ export async function applyServerChanges(appId: string, changes: unknown[]): Pro if (key === 'id' || key === FIELD_TIMESTAMPS_KEY) continue; const localFieldTime = localFT[key] ?? localUpdatedAt; if (recordTime >= localFieldTime) { + // Conflict signal: server STRICTLY wins (>) and the local + // field had a non-empty value that differs from the new + // one. Equal-time ties don't fire because there's no + // edit to lose. + const localValue = (existing as Record)[key]; + if ( + recordTime > localFieldTime && + localValue != null && + !valuesEqual(localValue, val) + ) { + notifyConflict({ + tableName, + recordId, + field: key, + wasLocal: localValue, + nowServer: val, + localTime: localFieldTime, + serverTime: recordTime, + }); + } updates[key] = val; newFT[key] = recordTime; } @@ -284,6 +386,24 @@ export async function applyServerChanges(appId: string, changes: unknown[]): Pro const serverTime = fc.updatedAt ?? ''; const localFieldTime = localFT[key] ?? localUpdatedAt; if (serverTime >= localFieldTime) { + // Same conflict criteria as the insert-as-update path: + // strictly newer + non-empty local + actually different. + const localValue = (existing as Record)[key]; + if ( + serverTime > localFieldTime && + localValue != null && + !valuesEqual(localValue, fc.value) + ) { + notifyConflict({ + tableName, + recordId, + field: key, + wasLocal: localValue, + nowServer: fc.value, + localTime: localFieldTime, + serverTime, + }); + } updates[key] = fc.value; newFT[key] = serverTime; } diff --git a/apps/mana/apps/web/src/routes/+layout.svelte b/apps/mana/apps/web/src/routes/+layout.svelte index 92d4a6a3c..5d1083f32 100644 --- a/apps/mana/apps/web/src/routes/+layout.svelte +++ b/apps/mana/apps/web/src/routes/+layout.svelte @@ -12,6 +12,7 @@ import SuggestionToast from '$lib/components/SuggestionToast.svelte'; import EncryptionIntroBanner from '$lib/components/EncryptionIntroBanner.svelte'; import RecoveryCodeUnlockModal from '$lib/components/RecoveryCodeUnlockModal.svelte'; + import SyncConflictToast from '$lib/components/SyncConflictToast.svelte'; import OfflineIndicator from '$lib/components/OfflineIndicator.svelte'; import PwaUpdatePrompt from '$lib/components/PwaUpdatePrompt.svelte'; @@ -106,6 +107,7 @@ {@render children()} +