diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index 61284d7..b6bc90a 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -24,6 +24,20 @@ export interface StoreRegistryDeps { getSession: () => Promise; /** Normalize an identity id for shim keying. Default: trim (identity-ish). */ normalizeId?: (id: string) => string; + /** + * ANTI-FORK budget for account resolution (polyfill-era). Before provisioning + * a "missing" account, the registry re-reads its shim record a bounded number + * of times with backoff, so a record merely lagging by broker sync (fresh + * session over a persistent wallet) is FOUND and REUSED instead of triggering + * a second set of scope docs (the account fork). See the note in + * store-registry.ts. Only if every attempt still reads 0 do we provision. + * + * Default (production): a real budget (~8 attempts / ≲8.5s). Set `attempts: 1` + * to disable the retry entirely — appropriate for a SYNCHRONOUS in-memory + * store (the unit fake `ng`) where a 0-row read is authoritative and the + * backoff would only add dead time; there is no sync lag to wait out there. + */ + provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number }; } export interface EventuallyConfig { @@ -84,6 +98,13 @@ export function configureStoreRegistry(deps: StoreRegistryDeps): void { registryDeps = { getSession: deps.getSession, normalizeId: deps.normalizeId ?? ((id: string) => id.trim()), + // Production default: a real anti-fork budget. Consumers over a synchronous + // store pass `{ attempts: 1 }` to opt out (see StoreRegistryDeps). + provisionRetry: { + attempts: deps.provisionRetry?.attempts ?? 8, + baseMs: deps.provisionRetry?.baseMs ?? 300, + maxStepMs: deps.provisionRetry?.maxStepMs ?? 1500, + }, }; } diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index b4c27ab..35cb753 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -35,6 +35,28 @@ import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; import { getStoreRegistryDeps } from "./polyfill"; import { ensureRepoOpen } from "./open-repo"; + +// --- provisioning anti-fork retry budget (polyfill-era) -------------------- +// +// The shim (account→docs trust root) lives in the shared wallet's PRIVATE store. +// On a fresh session over a persistent wallet, that store's repo may not have +// synced yet from the broker: a targeted account resolve then reads 0 rows even +// though the account WAS persisted in an earlier session. If ensureAccount took +// that 0 at face value it would RE-PROVISION a second set of scope documents — +// an account FORK: one session writes/reads one set, another (or the same after +// a cache drop) the other, EMPTY set → the user "loses" their data on reconnect. +// +// Guard: before deciding an account is genuinely new, re-read it with a BOUNDED +// backoff (open + await the anchor repo, then retry the targeted query). Only if +// EVERY attempt within the budget still reads 0 do we treat it as a first-time +// account and provision. A pre-existing account merely lagging by sync is thus +// found on a later attempt and REUSES its docs (no fork); a truly new account +// exhausts the budget cheaply and is provisioned exactly once. The budget is a +// hard ceiling — never an unbounded poll — and is INJECTED (StoreRegistryDeps. +// provisionRetry): production defaults to ~8 attempts / ≲8.5s; a synchronous +// in-memory store (unit fake `ng`) passes `attempts: 1` to skip it (a 0-row read +// is authoritative there — no lag to wait out). Disappears at the real +// multi-store migration (deterministic per-user store NURIs make this moot). import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import type { Nuri, Scope } from "./types"; @@ -257,6 +279,51 @@ export async function resolveAccount(id: string): Promise } } +/** + * Resolve ONE account, retrying a BOUNDED number of times before giving up, to + * distinguish a genuinely-absent account from one whose shim record has simply + * not synced yet on a fresh session (the account-FORK guard — see the + * PROVISION_RETRY_* budget note at the top of this module). + * + * On the FIRST miss it opens/subscribes the shim anchor repo (the shared + * wallet's private store, where the shim lives) so the broker pushes its state, + * then re-reads with capped exponential backoff. Returns the record as soon as + * any attempt finds it (populating `accountCache` via resolveAccount), or `null` + * if the whole budget is exhausted with 0 rows — the only case in which the + * caller may treat the account as new and provision it. + * + * No-op fast path: a cache hit returns immediately with no retries. With the + * unit fake `ng` (no doc_subscribe, synchronous store) a present account is + * found on attempt 0 and an absent one exhausts the (short-sleep) budget without + * changing behaviour — the fork can only arise against a real, lagging broker. + */ +async function resolveAccountReliably(id: string): Promise { + // Cache hit → session-idempotent, no query, no fork risk. + const cached = accountCache.get(accountKey(id)); + if (cached) return cached; + + const budget = getStoreRegistryDeps().provisionRetry; + const attempts = Math.max(1, budget.attempts ?? 8); + const baseMs = budget.baseMs ?? 300; + const maxStepMs = budget.maxStepMs ?? 1500; + + for (let attempt = 0; attempt < attempts; attempt++) { + const found = await resolveAccount(id); + if (found) return found; + if (attempt === attempts - 1) break; // budget exhausted → genuinely absent + // The shim anchor repo may not be synced on a fresh session; open it once so + // the broker pushes its state, then back off before the next targeted read. + try { + await ensureRepoOpen(await anchorNuri()); + } catch { + /* tolerant: a failed open just leaves the next read to behave as before */ + } + const step = Math.min(baseMs * 2 ** attempt, maxStepMs); + await new Promise((r) => setTimeout(r, step)); + } + return null; +} + /** All known accounts (from the shim). */ export async function allAccounts(): Promise { return [...(await loadShim()).values()]; @@ -278,7 +345,15 @@ export async function ensureAccount(id: string): Promise { const key = accountKey(id); // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead // of a full-shim scan (loadShim). Off the read/write hot path entirely. - const existing = await resolveAccount(id); + // + // ANTI-FORK (polyfill-era): resolve with a BOUNDED retry, NOT a single read. + // A single 0-row read on a fresh, still-syncing session would misfire as + // "new account" and RE-PROVISION a second set of scope docs (the fork — see + // the PROVISION_RETRY_* note at the top of this module). We only fall through + // to provisioning once the whole retry budget has confirmed 0 rows. A cache + // hit inside resolveAccountReliably keeps same-session resolves free and + // deterministic (the cached record wins over any re-provision). + const existing = await resolveAccountReliably(id); if (existing) return existing; const [docPublic, docProtected, docPrivate] = await Promise.all([ diff --git a/packages/client/test/discovery.test.ts b/packages/client/test/discovery.test.ts index dc2272b..a9e50dc 100644 --- a/packages/client/test/discovery.test.ts +++ b/packages/client/test/discovery.test.ts @@ -188,6 +188,8 @@ function inject() { configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(), + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + provisionRetry: { attempts: 1 }, }); resetRegistryCache(); setCurrentUser(null); diff --git a/packages/client/test/inbox.test.ts b/packages/client/test/inbox.test.ts index ce47feb..b30cb92 100644 --- a/packages/client/test/inbox.test.ts +++ b/packages/client/test/inbox.test.ts @@ -152,7 +152,8 @@ const TARGET = "did:ng:o:host-inbox"; function inject() { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); - configureStoreRegistry({ getSession: async () => SESSION }); + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); setCurrentUser(null); return ng; } diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index b72bd1e..03298bb 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -45,7 +45,8 @@ function inject() { sparql_query: mock(async () => ({ results: { bindings: [] } })), }; configure({ ng: ng as any, useShape: (() => {}) as any }); - configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim(), provisionRetry: { attempts: 1 } }); resetRegistryCache(); resetCaps(); setCurrentUser(null); diff --git a/packages/client/test/read-model.test.ts b/packages/client/test/read-model.test.ts index 45a4035..e2e8ed4 100644 --- a/packages/client/test/read-model.test.ts +++ b/packages/client/test/read-model.test.ts @@ -35,6 +35,8 @@ function inject(triplesByDoc: Record>) { configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), normalizeId: (u: string) => u, + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + provisionRetry: { attempts: 1 }, }); return ng; } @@ -97,6 +99,8 @@ test("a doc that fails to read is skipped, not aborting the batch", async () => configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), normalizeId: (u: string) => u, + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + provisionRetry: { attempts: 1 }, }); const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]); diff --git a/packages/client/test/store-registry.test.ts b/packages/client/test/store-registry.test.ts index 455d75a..d2af8f0 100644 --- a/packages/client/test/store-registry.test.ts +++ b/packages/client/test/store-registry.test.ts @@ -158,6 +158,8 @@ function inject() { configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(), + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + provisionRetry: { attempts: 1 }, }); resetRegistryCache(); return ng; @@ -213,6 +215,8 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to protectedStoreId: "PROT", publicStoreId: "PUB", }), + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + provisionRetry: { attempts: 1 }, }); resetRegistryCache(); expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV"); @@ -358,7 +362,8 @@ test("injection: a malicious id still round-trips through the shim", async () => test("normalizeId defaults to trim when not provided", async () => { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); - configureStoreRegistry({ getSession: async () => SESSION }); + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); resetRegistryCache(); const a = await ensureAccount(" Ivy "); const b = await ensureAccount("Ivy"); // trimmed key matches diff --git a/packages/client/test/subscribe.test.ts b/packages/client/test/subscribe.test.ts index ada884c..3740840 100644 --- a/packages/client/test/subscribe.test.ts +++ b/packages/client/test/subscribe.test.ts @@ -49,7 +49,8 @@ function makeFakeNg(failFor: Set = new Set()) { function inject(failFor?: Set) { const ng = makeFakeNg(failFor); configure({ ng: ng as any, useShape: (() => {}) as any }); - configureStoreRegistry({ getSession: async () => SESSION }); + // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. + configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); return ng; }