mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:01: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;
|
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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue