mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(sync): conflict visualization with restore-my-version toast
Closes backlog C from the Phase 9 audit. The data layer has had
real field-level LWW since Sprint 1, but when the server's value
beat a local edit, the user had no way to know. This commit adds
the missing UI piece: a toast that appears whenever applyServerChanges
overwrites a non-empty local field with a strictly newer server
value, with a one-click "restore my version" path.
sync.ts — detection
-------------------
Two new exports:
- SyncConflictPayload: per-field overwrite event shape
(tableName, recordId, field, wasLocal, nowServer, localTime,
serverTime).
- subscribeSyncConflicts(listener): in-module pub/sub. Returns
an unsubscribe function.
Both LWW branches in applyServerChanges (insert-as-update and the
canonical update-with-fields path) now call notifyConflict() when:
1. The server time is STRICTLY greater (not equal) than the local
field time → there's actually an edit window to lose
2. The local field value is non-null/undefined → user actually
typed something to overwrite
3. The values are not equal (cheap JSON-string compare for objects,
=== for primitives) → there's a real change, not an idempotent
server replay
Why a custom registry instead of CustomEvent + window.dispatchEvent?
The existing sync-telemetry + quota-detect helpers use
window.dispatchEvent which doesn't work in node-based vitest envs
(no DOM EventTarget). The conflict bus is small enough that a plain
Set<listener> is simpler than polyfilling EventTarget — and the
node test path matters because we need automated coverage of the
detection logic.
conflict-store.svelte.ts — UI state
-----------------------------------
Svelte 5 $state-backed store with three responsibilities:
1. Coalescing: a SyncConflict is keyed by `${tableName}|${recordId}`,
so a burst of N field-overwrites on the same record collapses
into ONE toast with all affected fields underneath. The original
wasLocal value is preserved across coalescing (we don't clobber
the user's first typed value if a later field event arrives).
2. Auto-dismiss: each conflict has a 30s TTL after which it
evicts itself. Manual dismiss trumps the timer.
3. Restore: writes wasLocal back to Dexie with a fresh updatedAt
that beats the server's serverTime, plus a __fieldTimestamps
patch so the field-LWW pass on the next sync round will let
our value win. Deferred via setTimeout(0) so it lands AFTER
applyServerChanges releases its per-table apply lock — running
before the lock release would silently drop the restore (the
hook suppression is per-table-set, not per-record).
FIFO eviction at MAX_VISIBLE=8 keeps a bursty server from growing
the visible array unbounded.
SyncConflictToast.svelte — the UI
---------------------------------
Mounts globally in +layout.svelte. Stacks bottom-right above the
OfflineIndicator. Each toast shows:
- Module label ("Aufgabe", "Notiz", "Termin", …) derived from a
table-name → German label map. Unknown tables fall through to
the bare table name.
- Field count summary ("Feld »title«" / "3 Felder") — we
deliberately do NOT render the actual values because some are
encrypted blobs and decrypting them in the toast would be
significant complexity for marginal UX gain. The user knows
what they were just editing.
- Two buttons: "Wiederherstellen" (calls conflictStore.restore)
and "Behalten" (calls dismiss).
Slide-in animation, dark-mode-aware styling, role="alertdialog"
for accessibility.
Wiring
------
data-layer-listeners.ts:
- Imports installConflictListener from conflict-store
- Calls it from installDataLayerListeners() right after the
quota + telemetry handlers
- Adds the disposeConflict() call to the cleanup return
+layout.svelte:
- Imports SyncConflictToast and mounts it next to SuggestionToast
so it inherits the same global-overlay positioning context
Tests
-----
Five new integration tests in sync.test.ts cover:
- Fires when server overwrites a non-empty local field with a
strictly newer value
- Does NOT fire when local field is null/undefined (no edit to lose)
- Does NOT fire when values are equal (idempotent replay)
- Fires once per overwritten field on a multi-field update
- Does NOT fire on a timestamp tie (LWW lets server win silently
when there's no real edit window)
All 25 sync tests + 138 total data-layer tests pass. The new
captureConflicts() helper subscribes via subscribeSyncConflicts()
which works in the node-vitest env without needing a DOM polyfill.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fe3fc9e7e2
commit
ed8ab44832
6 changed files with 771 additions and 0 deletions
245
apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte
Normal file
245
apps/mana/apps/web/src/lib/components/SyncConflictToast.svelte
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
<!--
|
||||
Sync Conflict Toast — surfaces field-LWW overwrites with a Restore
|
||||
option. One toast per (tableName, recordId) — see conflict-store
|
||||
for the coalescing rules.
|
||||
|
||||
Mounted globally in +layout.svelte; reads conflictStore.visible
|
||||
directly (it's a Svelte 5 $state-backed export).
|
||||
|
||||
UX choices:
|
||||
- Stacks in the bottom-right corner above the OfflineIndicator
|
||||
- Each toast has Restore + Dismiss buttons
|
||||
- Restore reverts ALL coalesced fields for that record in one tx
|
||||
- The actual values are NOT shown — they could be encrypted
|
||||
blobs (we'd need to decrypt with the active key, which is
|
||||
doable but adds complexity for marginal UX gain on an MVP).
|
||||
Showing "X fields wurden überschrieben" + the table name +
|
||||
the record id is enough context for the user to recognise the
|
||||
affected record (which they were just editing seconds ago).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { conflictStore } from '$lib/data/conflict-store.svelte';
|
||||
import { ArrowCounterClockwise, X, Warning } from '@mana/shared-icons';
|
||||
|
||||
let conflicts = $derived(conflictStore.visible);
|
||||
|
||||
/** Map raw table names to user-readable module labels. Tables not
|
||||
* in this map fall through to the bare table name — that's still
|
||||
* better than nothing for debug purposes. */
|
||||
const TABLE_LABELS: Record<string, string> = {
|
||||
tasks: 'Aufgabe',
|
||||
todoProjects: 'Projekt',
|
||||
notes: 'Notiz',
|
||||
messages: 'Chat-Nachricht',
|
||||
conversations: 'Chat',
|
||||
dreams: 'Traum',
|
||||
memos: 'Memo',
|
||||
memories: 'Erinnerung',
|
||||
contacts: 'Kontakt',
|
||||
events: 'Termin',
|
||||
timeBlocks: 'Zeitblock',
|
||||
cycles: 'Zyklus',
|
||||
cycleDayLogs: 'Tageseintrag',
|
||||
transactions: 'Transaktion',
|
||||
cards: 'Karte',
|
||||
cardDecks: 'Kartendeck',
|
||||
habits: 'Gewohnheit',
|
||||
documents: 'Dokument',
|
||||
links: 'Link',
|
||||
meals: 'Mahlzeit',
|
||||
songs: 'Song',
|
||||
mukkePlaylists: 'Playlist',
|
||||
images: 'Bild',
|
||||
boards: 'Board',
|
||||
files: 'Datei',
|
||||
invItems: 'Inventar-Eintrag',
|
||||
plants: 'Pflanze',
|
||||
questions: 'Frage',
|
||||
answers: 'Antwort',
|
||||
presiDecks: 'Präsentation',
|
||||
slides: 'Folie',
|
||||
socialEvents: 'Event',
|
||||
eventGuests: 'Gast',
|
||||
};
|
||||
|
||||
function labelFor(tableName: string): string {
|
||||
return TABLE_LABELS[tableName] ?? tableName;
|
||||
}
|
||||
|
||||
function fieldList(fields: Record<string, unknown>): string {
|
||||
const names = Object.keys(fields);
|
||||
if (names.length === 1) return `Feld »${names[0]}«`;
|
||||
if (names.length <= 3) return `Felder »${names.join('«, »')}«`;
|
||||
return `${names.length} Felder`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if conflicts.length > 0}
|
||||
<div class="conflict-stack" role="region" aria-label="Sync-Konflikte">
|
||||
{#each conflicts as conflict (conflict.id)}
|
||||
<div class="conflict" role="alertdialog">
|
||||
<div class="header">
|
||||
<Warning size={16} color="rgb(180, 83, 9)" />
|
||||
<span class="title">
|
||||
{labelFor(conflict.tableName)} überschrieben
|
||||
</span>
|
||||
<button
|
||||
class="close"
|
||||
type="button"
|
||||
aria-label="Verwerfen"
|
||||
onclick={() => conflictStore.dismiss(conflict.id)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p class="body">
|
||||
Eine andere Sitzung hat dein Edit auf {fieldList(conflict.fields)} überschrieben. Soll deine
|
||||
Version wiederhergestellt werden?
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
onclick={() => conflictStore.restore(conflict.id)}
|
||||
>
|
||||
<ArrowCounterClockwise size={14} />
|
||||
Wiederherstellen
|
||||
</button>
|
||||
<button class="btn" type="button" onclick={() => conflictStore.dismiss(conflict.id)}>
|
||||
Behalten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.conflict-stack {
|
||||
position: fixed;
|
||||
bottom: 4rem; /* above OfflineIndicator */
|
||||
right: 1rem;
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 22rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.conflict {
|
||||
pointer-events: auto;
|
||||
background: var(--surface, #fff);
|
||||
border: 1px solid rgba(245, 158, 11, 0.4);
|
||||
border-left: 4px solid rgb(245, 158, 11);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
box-shadow:
|
||||
0 10px 20px -5px rgba(0, 0, 0, 0.15),
|
||||
0 4px 8px -2px rgba(0, 0, 0, 0.08);
|
||||
font-size: 0.85rem;
|
||||
animation: slide-in 180ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text-primary, #111);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0.25rem 0 0.625rem 0;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--surface, #fff);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary, #111);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--surface-muted, #f3f4f6);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary, #6366f1);
|
||||
color: white;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark, #4f46e5);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.conflict {
|
||||
background: var(--surface, #1f2937);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
border-left-color: rgb(245, 158, 11);
|
||||
}
|
||||
.title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.body {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.btn {
|
||||
background: var(--surface, #1f2937);
|
||||
border-color: var(--border, #374151);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--surface-muted, #111827);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts
Normal file
227
apps/mana/apps/web/src/lib/data/conflict-store.svelte.ts
Normal file
|
|
@ -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<SyncConflict[]>([]);
|
||||
let timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
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<void> {
|
||||
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<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updates: Record<string, unknown> = { updatedAt: now };
|
||||
const ftPatch: Record<string, string> = {};
|
||||
|
||||
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<string, unknown>)[FIELD_TIMESTAMPS_KEY] as Record<string, string>) ?? {};
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>): Promise<SyncConflictPayload[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -142,6 +142,88 @@ export function readFieldTimestamps(record: unknown): Record<string, string> {
|
|||
* 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<SyncConflictListener>();
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
// 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<string, unknown>)[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<string, unknown>)[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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
<SuggestionToast />
|
||||
<SyncConflictToast />
|
||||
<OfflineIndicator />
|
||||
<PwaUpdatePrompt />
|
||||
<EncryptionIntroBanner />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue