mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
d78f57c041
commit
a031493fec
3 changed files with 116 additions and 4 deletions
|
|
@ -1351,6 +1351,24 @@ function deriveFromFieldMeta(row: Record<string, unknown>): 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
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
<!-- Global "Idee?" feedback pill — self-hides on /onboarding,
|
||||
/feedback, /community, and for unauthenticated users. -->
|
||||
<GlobalFeedbackPill />
|
||||
</AuthGate>
|
||||
|
||||
<ToastContainer />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue