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} + + +