From a031493fec1e295c9daa87f13bc0fd203f601457 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 26 Apr 2026 23:50:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(sync):=20F6=20=E2=80=94=20stable=20client?= =?UTF-8?q?=5Fid=20in=20Dexie,=20localStorage=20as=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the client_id-inflation bug where every localStorage wipe spun up a fresh sync identity. Five distinct client_ids accumulated in mana_sync.sync_changes for a single physical browser over five days — every wipe made the device's own historical writes look like "another session" on replay. Architecture: - New Dexie v54 table `_clientIdentity` (single row keyed by `id='self'`) is the canonical source of the client id. - `restoreClientIdFromDexie()` runs once at app boot, before `createUnifiedSync`. Reconciles Dexie ↔ localStorage in three scenarios: Dexie has it (restore localStorage), only localStorage has it (canonicalise into Dexie), neither has it (mint + write both). Dexie wins on disagreement. - `getOrCreateClientId()` keeps reading from localStorage synchronously — that's the hot path inside push/pull. The async reconciliation just makes sure localStorage has the right value by the time sync starts. Survives: clear-site-data, incognito flush, Settings → "delete browser cache". Does not survive: full IndexedDB reset (intentional — that's a real device reset). Plan: docs/plans/sync-field-meta-overhaul.md F6. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/apps/web/src/lib/data/database.ts | 18 ++++ apps/mana/apps/web/src/lib/data/sync.ts | 89 ++++++++++++++++++- .../apps/web/src/routes/(app)/+layout.svelte | 13 ++- 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 57c8784b0..908c56a0e 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -1351,6 +1351,24 @@ function deriveFromFieldMeta(row: Record): string | undefined { return max || undefined; } +// v54 — Sync Field-Meta Overhaul F6 (docs/plans/sync-field-meta-overhaul.md). +// Persistent `_clientIdentity` table so the per-browser-session sync +// client id survives a localStorage wipe. Without this, every browser- +// state clearing (devtools "Clear site data", incognito flush, …) +// produced a fresh client_id from the sync server's perspective — +// which made the local replay of one's own historical writes look +// like "another session overwrote me", driving the false-positive +// conflict toasts F1+F2 already addressed in code. +// +// Single-row table keyed by `id='self'`. Generated on the first run +// where no value exists in either Dexie or localStorage. Migration: +// the bootstrap code in sync.ts reconciles Dexie ↔ localStorage on +// every load — Dexie is the canonical source, localStorage is the +// fast-read cache that gets restored from Dexie when wiped. +db.version(54).stores({ + _clientIdentity: 'id', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index e5517093d..4cee510a7 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -1278,13 +1278,96 @@ export function createUnifiedSync( // ─── Client ID ──────────────────────────────────────────────── +const CLIENT_ID_LOCALSTORAGE_KEY = 'mana-sync-client-id'; +const CLIENT_IDENTITY_TABLE = '_clientIdentity'; +const CLIENT_IDENTITY_ROW = 'self'; + +interface ClientIdentityRow { + id: typeof CLIENT_IDENTITY_ROW; + clientId: string; + createdAt: string; +} + +/** + * Sync-readable client id. Tries localStorage first (fast path); if + * empty, generates a fresh UUID. The fresh UUID is NOT yet persisted + * to Dexie at this point — that happens via the async + * {@link restoreClientIdFromDexie} reconciliation step the layout + * runs once at boot, before sync starts. The reconciliation is what + * makes a localStorage wipe survivable: Dexie keeps the canonical id + * and copies it back into localStorage every time the cache is empty. + */ function getOrCreateClientId(): string { - const key = 'mana-sync-client-id'; if (typeof localStorage === 'undefined') return crypto.randomUUID(); - let id = localStorage.getItem(key); + let id = localStorage.getItem(CLIENT_ID_LOCALSTORAGE_KEY); if (!id) { id = crypto.randomUUID(); - localStorage.setItem(key, id); + localStorage.setItem(CLIENT_ID_LOCALSTORAGE_KEY, id); } return id; } + +/** + * Reconcile the client id between Dexie (`_clientIdentity` table) and + * localStorage. Call ONCE at app boot, BEFORE `createUnifiedSync`. The + * helper is async because Dexie is async; the rest of the sync code + * keeps reading the id synchronously from localStorage. + * + * Three states the helper handles: + * + * 1. Dexie has a row, localStorage is empty (typical after a + * "clear site data" / incognito flush) — copy Dexie → localStorage + * so the sync engine sees the same id this device used last time. + * 2. localStorage has a value, Dexie is empty (typical first run on + * this device after F6 ships) — copy localStorage → Dexie so the + * identity is canonicalised. + * 3. Both empty — generate a fresh UUID and write it to both. + * + * If both have values and they differ (race during a crash, two tabs, + * a stale localStorage from a different browser profile), Dexie wins + * and overwrites localStorage. That keeps the sync server's view of + * the client identity stable across localStorage wipes. + * + * The function silently no-ops in non-browser contexts (SSR / tests + * without a Dexie instance ready) by catching the Dexie error. + */ +export async function restoreClientIdFromDexie(): Promise { + if (typeof localStorage === 'undefined') return; + let dexieId: string | undefined; + try { + const row = (await db + .table(CLIENT_IDENTITY_TABLE) + .get(CLIENT_IDENTITY_ROW)) as ClientIdentityRow | undefined; + dexieId = row?.clientId; + } catch { + // Dexie not ready / table missing on a pre-v54 boot. Defer — + // next reload after the upgrade lands will reconcile. + return; + } + + const localStorageId = localStorage.getItem(CLIENT_ID_LOCALSTORAGE_KEY); + + if (dexieId) { + // Canonical source. Restore to localStorage every time so a + // later wipe is fixed at boot. + if (localStorageId !== dexieId) { + localStorage.setItem(CLIENT_ID_LOCALSTORAGE_KEY, dexieId); + } + return; + } + + // Dexie is empty — adopt localStorage's value or mint a fresh UUID. + const adopt = localStorageId ?? crypto.randomUUID(); + if (!localStorageId) { + localStorage.setItem(CLIENT_ID_LOCALSTORAGE_KEY, adopt); + } + try { + await db.table(CLIENT_IDENTITY_TABLE).put({ + id: CLIENT_IDENTITY_ROW, + clientId: adopt, + createdAt: new Date().toISOString(), + }); + } catch { + // Best-effort. Next boot will retry. + } +} diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 1dada000f..c607674cd 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -9,6 +9,7 @@ import { page } from '$app/stores'; import type { Component, Snippet } from 'svelte'; import ToastContainer from '$lib/components/ToastContainer.svelte'; + import GlobalFeedbackPill from '$lib/components/feedback/GlobalFeedbackPill.svelte'; import { onDestroy, setContext, tick } from 'svelte'; import { createReminderScheduler } from '@mana/shared-stores'; import { todoReminderSource } from '$lib/modules/todo/reminder-source'; @@ -68,7 +69,7 @@ startMemoroLlmWatcher, stopMemoroLlmWatcher, } from '$lib/modules/memoro/llm-watcher.svelte'; - import { createUnifiedSync } from '$lib/data/sync'; + import { createUnifiedSync, restoreClientIdFromDexie } from '$lib/data/sync'; import { syncBilling } from '$lib/stores/sync-billing.svelte'; import { networkStore } from '$lib/stores/network.svelte'; import { db } from '$lib/data/database'; @@ -625,6 +626,12 @@ } await syncBilling.load(); + // F6: reconcile the per-device sync client_id between Dexie + // (canonical) and localStorage (cache) before the sync engine + // reads it. A localStorage wipe gets restored from Dexie here, + // so the next push/pull keeps the same identity the server + // already knows. + await restoreClientIdFromDexie(); const getToken = () => authStore.getValidToken(); unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active); // Expose on window for SYNC_DEBUG.md (Schritt C). Not a security @@ -1114,6 +1121,10 @@ locale={($locale || 'de') === 'de' ? 'de' : 'en'} /> {/if} + + +