feat(sync): F6 — stable client_id in Dexie, localStorage as cache

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 23:50:55 +02:00
parent d78f57c041
commit a031493fec
3 changed files with 116 additions and 4 deletions

View file

@ -1351,6 +1351,24 @@ function deriveFromFieldMeta(row: Record<string, unknown>): string | undefined {
return max || 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 Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module // toSyncName() and fromSyncName() are now derived from per-module

View file

@ -1278,13 +1278,96 @@ export function createUnifiedSync(
// ─── Client ID ──────────────────────────────────────────────── // ─── 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 { function getOrCreateClientId(): string {
const key = 'mana-sync-client-id';
if (typeof localStorage === 'undefined') return crypto.randomUUID(); if (typeof localStorage === 'undefined') return crypto.randomUUID();
let id = localStorage.getItem(key); let id = localStorage.getItem(CLIENT_ID_LOCALSTORAGE_KEY);
if (!id) { if (!id) {
id = crypto.randomUUID(); id = crypto.randomUUID();
localStorage.setItem(key, id); localStorage.setItem(CLIENT_ID_LOCALSTORAGE_KEY, id);
} }
return 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<void> {
if (typeof localStorage === 'undefined') return;
let dexieId: string | undefined;
try {
const row = (await db
.table<ClientIdentityRow>(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<ClientIdentityRow>(CLIENT_IDENTITY_TABLE).put({
id: CLIENT_IDENTITY_ROW,
clientId: adopt,
createdAt: new Date().toISOString(),
});
} catch {
// Best-effort. Next boot will retry.
}
}

View file

@ -9,6 +9,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { Component, Snippet } from 'svelte'; import type { Component, Snippet } from 'svelte';
import ToastContainer from '$lib/components/ToastContainer.svelte'; import ToastContainer from '$lib/components/ToastContainer.svelte';
import GlobalFeedbackPill from '$lib/components/feedback/GlobalFeedbackPill.svelte';
import { onDestroy, setContext, tick } from 'svelte'; import { onDestroy, setContext, tick } from 'svelte';
import { createReminderScheduler } from '@mana/shared-stores'; import { createReminderScheduler } from '@mana/shared-stores';
import { todoReminderSource } from '$lib/modules/todo/reminder-source'; import { todoReminderSource } from '$lib/modules/todo/reminder-source';
@ -68,7 +69,7 @@
startMemoroLlmWatcher, startMemoroLlmWatcher,
stopMemoroLlmWatcher, stopMemoroLlmWatcher,
} from '$lib/modules/memoro/llm-watcher.svelte'; } 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 { syncBilling } from '$lib/stores/sync-billing.svelte';
import { networkStore } from '$lib/stores/network.svelte'; import { networkStore } from '$lib/stores/network.svelte';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
@ -625,6 +626,12 @@
} }
await syncBilling.load(); 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(); const getToken = () => authStore.getValidToken();
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active); unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
// Expose on window for SYNC_DEBUG.md (Schritt C). Not a security // Expose on window for SYNC_DEBUG.md (Schritt C). Not a security
@ -1114,6 +1121,10 @@
locale={($locale || 'de') === 'de' ? 'de' : 'en'} locale={($locale || 'de') === 'de' ? 'de' : 'en'}
/> />
{/if} {/if}
<!-- Global "Idee?" feedback pill — self-hides on /onboarding,
/feedback, /community, and for unauthenticated users. -->
<GlobalFeedbackPill />
</AuthGate> </AuthGate>
<ToastContainer /> <ToastContainer />