From 5e91771da62c8aef89d466a761b923d1cb1a570a Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 13 Jul 2026 17:46:16 +0200 Subject: [PATCH] =?UTF-8?q?fix(client):=20r=C3=A9solution=20de=20compte=20?= =?UTF-8?q?barri=C3=A8re-autoritative=20=E2=80=94=20fin=20du=20fork=20?= =?UTF-8?q?=C3=A0=20la=20reconnexion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: à la reconnexion, resolveAccount lisait le shim depuis le store-root (did:ng:${privateStoreId}), NON abonnable → pas de barrière first-State → un "0 rows" à froid est ambigu → le retry (resolveAccountReliably/provisionRetry) échoue → nouveau compte provisionné → FORK → données du compte invisibles. Cause NextGraph (vérifiée nextgraph-rs): "trouvable-sans-lookup" (store-root) et "abonnable" (did:ng:o:) sont DISJOINTS — pas de doc à la fois devinable et attendable → une résolution shim purement barrière est impossible. Fix (indirection pointeur → doc-shim abonnable): - Les AccountRecord migrent dans un doc-shim doc_create'd (did:ng:o:..., a une barrière). - Un pointeur écrit-une-fois dans le store-root ( ) le nomme. resolveShimDoc lit le pointeur → ensureRepoOpen(docShim) [barrière] → lecture de compte AUTORITATIVE (cold 0 = absent pour de vrai). Retry de compte SUPPRIMÉ. - Micro-garde résiduel (pointerGuard, ex-provisionRetry) sur le SEUL triple pointeur écrit-une-fois; ne peut jamais forker un compte; fork de pointeur réconcilié au doc-shim canonique (lexicographiquement-min), sans perte. - Migration: migrateLegacyRecords copie (pas déplace) les comptes de l'ancien store-root vers le doc-shim avant toute conclusion "absent"; idempotent; wallet neuf → no-op. Tests: unit 128/128, e2e réel 42/42 (CONTRACT 2 = non-fork du compte à la reconnexion), red-before/green-after prouvé. Docs: nextgraph-current-state (antagonisme + indirection), simulation, migration-guide. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/migration-guide.md | 11 +- docs/nextgraph-current-state.md | 69 +++ docs/simulation.md | 22 +- packages/client/e2e/sdk-entry.ts | 13 +- packages/client/src/polyfill.ts | 33 +- packages/client/src/store-registry.ts | 546 ++++++++++++------ packages/client/test/access-log.test.ts | 1 - packages/client/test/anti-fork.test.ts | 278 +++++---- .../client/test/cold-start-anchor.test.ts | 23 +- packages/client/test/discovery.test.ts | 21 +- packages/client/test/inbox.test.ts | 2 +- packages/client/test/isolation-active.test.ts | 2 +- packages/client/test/open-repo.test.ts | 2 - packages/client/test/read-model.test.ts | 4 - packages/client/test/store-registry.test.ts | 48 +- packages/client/test/subscribe.test.ts | 2 +- packages/client/test/watch-shape.test.ts | 18 +- 17 files changed, 744 insertions(+), 351 deletions(-) diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 6be7d63..1cb2500 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -43,10 +43,13 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)). resolvers) is designed to survive that swap unchanged. ### 3. Drop the resolver / shim -The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store) -has no target equivalent — the target has no central directory. Remove it: -`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads -replace the fan-out; per-user wallets replace the shared one. +The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribable +doc-shim reached via a write-once pointer in the store-root — see +[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim +indirection*) has no target equivalent — the target has no central directory. Remove +it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim + +migration SPARQL, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out; +per-user wallets replace the shared one. ### 4. Real inbox → drop the in-lib read emulation Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet diff --git a/docs/nextgraph-current-state.md b/docs/nextgraph-current-state.md index a709f2b..5fdc81a 100644 --- a/docs/nextgraph-current-state.md +++ b/docs/nextgraph-current-state.md @@ -217,6 +217,75 @@ broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires repo's NURI + ReadCap — there is no store-level read inheritance (see § Capability / ReadCap granularity). +### Findable-without-lookup vs subscribable (first-`State` barrier) — DISJOINT + +Two properties a fresh session might want from a document, and **no single document +has both**: + +- **Findable without a lookup.** The ONLY NURI a fresh session can NAME with nothing + but the session in hand is the store-root, `did:ng:${privateStoreId}` (from + `session.private_store_id`). Any per-document repo is `did:ng:o:` with a + **random** RepoID minted by `doc_create` — **not derivable**, so it must be looked + up somewhere first. +- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then + an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync + barrier — **after it, presence is guaranteed and absence is definitive** (pinned + empirically by CONTRACT 3 in `packages/client/e2e/`). But this barrier exists only + for a repo `doc_subscribe` can open, i.e. a `did:ng:o:` repo. A **store-root + has no first-`State` barrier**: an anchored read on it can return 0 rows during + sync-lag with no signal distinguishing "still syncing" from "genuinely empty". + +These two are **mutually exclusive**: the guessable target (store-root) is not +barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not +guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable +cold (findable) AND authoritative on a cold read (barrier). A cold "0 rows" read of a +store-root graph is therefore fundamentally ambiguous — which is the trap the shim's +account map fell into (see next section). + +### The pointer → doc-shim indirection (how the polyfill shim resolves accounts) + +`store-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}` +(the "shim", the account→document trust root). It must be reachable by a fresh +reconnecting session (findable) AND authoritative on a cold read (so a fresh page +does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single +document is both (previous section), the shim uses an **indirection**: + +1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State` + barrier). **All `AccountRecord`s live inside it.** Because it is subscribable, an + anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0 + means the account is genuinely absent. +2. **pointer** — a single well-known, **write-once** triple in the store-root graph, + ` `. + The store-root is findable-without-lookup, so a fresh session can always read it; + the pointer being the OLDEST, write-once triple in that graph, it is near-always + already synced on a cold read. + +**Resolution** (`resolveShimDoc`): read the pointer from the store-root → open the +named doc-shim through its barrier (`ensureRepoOpen`) → read the account +AUTHORITATIVELY. First login (no pointer): `doc_create` the doc-shim, publish the +pointer, done. A **pointer fork** (two devices each writing a pointer before either +synced) is **benign**: reconcile to the lexicographically-smallest doc-shim NURI +(content-addressed, so every device converges), and no account data is lost — a +non-canonical doc-shim's records are migrated forward on the next resolve. + +**The account-level retry is GONE.** Before this indirection the shim lived directly +in the store-root graph, so an account read had no barrier and a cold 0 was ambiguous; +the lib compensated with a **bounded account-level retry** (`provisionRetry` / +`resolveAccountReliably`) that re-read the account several times before concluding +"new". Moving the records behind the doc-shim barrier makes the account read +authoritative on the FIRST read, so **that retry was removed** — a barrier is +deterministic where a retry only guessed. The only residual bounded guard is a small +re-read of the **pointer** itself (`pointerGuard`, one write-once triple): it can +NEVER re-provision or fork an account — at worst it takes a couple extra reads to see +a pointer that is still landing. + +**Migration (no existing account lost).** A wallet whose accounts were written under +the OLD scheme has its `AccountRecord`s in the store-root graph, not a doc-shim. On +resolution, `migrateLegacyRecords` reads that old location best-effort and RE-INSERTS +each legacy record into the doc-shim (copy, not move — the store-root copy is left +inert), BEFORE any "account absent" conclusion. So a legacy account is READ (and made +barrier-authoritative going forward), never re-provisioned into a fork. + ### The union is read-only — writes must target one document `resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` / diff --git a/docs/simulation.md b/docs/simulation.md index ef4e7cd..dc0fa2f 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -98,12 +98,22 @@ public/protected/private stores — on top of one shared wallet. `docs.docCreate` primitive. The `scope` (`public|protected|private`) is a logical attribute tracked here, not a physical store. - **The `sharedWalletShim`** is the mapping `account → its 3 scope-document - NURIs`, persisted as RDF in the shared wallet's private store (the anchor, - always known from the session: `RegistrySession.privateStoreId`). That makes - identity resolution cross-device: another device opening the same wallet reads - the same shim and finds the same accounts. It is the account→document trust root, - which is why every untrusted value that reaches its SPARQL is escaped (see - SPARQL hardening below). + NURIs`. It is persisted as RDF, but **not directly in the store-root graph** — it + lives in a subscribable **doc-shim** reached through a write-once **pointer** in the + store-root, an indirection forced by a NextGraph fact: "findable-without-lookup" + (store-root) and "subscribable / cold-read-authoritative" (`did:ng:o:` repo with a + first-`State` barrier) are DISJOINT. The pointer (findable) names the doc-shim + (authoritative); resolution reads the pointer from the store-root, opens the doc-shim + through its barrier, and reads the account authoritatively — so a fresh reconnecting + session never mistakes sync-lag for "account absent" (which would provision a FORK). + Full rationale — including why the old account-level retry (`provisionRetry`) is + removed and how legacy store-root records are migrated forward — is in + [`nextgraph-current-state.md`](./nextgraph-current-state.md) §§ *Findable vs + subscribable* / *The pointer → doc-shim indirection*. This map is the + account→document trust root, which is why every untrusted value that reaches its + SPARQL is escaped (see SPARQL hardening below). It makes identity resolution + cross-device: another device opening the same wallet reads the same pointer → the + same doc-shim → the same accounts. - **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)` makes a dedicated document for one entity (mirrors the target, where each entity is its own document/repo with a future inbox) and appends its NURI to the diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index 7e16be8..edfa07b 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -86,12 +86,13 @@ configureStoreRegistry({ }, // Identity normalization used as the shim key (lowercase, strip leading `@`). normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(), - // REAL broker: enable the anti-fork bounded retry. On a faithful reconnect the - // shim (private-store graph) may not be synced when the first read fires → 0 - // rows; without the retry the registry would provision a NEW account (a fork), - // stranding session 1's data. Bounded backoff waits the sync-lag window out - // before concluding "genuinely new" (CONTRACT 2 non-fork). - provisionRetry: { attempts: 8, baseMs: 150, maxStepMs: 2000 }, + // REAL broker: enable the POINTER micro-guard. The account records live in a + // subscribable doc-shim reached via a write-once pointer triple in the store-root; + // the account read is barrier-authoritative (no account retry). The only residual + // store-root sync-lag is the pointer read — this bounded guard re-reads JUST that + // one write-once triple on a cold reconnect (CONTRACT 2 non-fork). It can never + // provision or fork an account. + pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 }, }); // ── Bridge marshaling note ───────────────────────────────────────────────── diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index b4daaed..bfed918 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -25,17 +25,18 @@ export interface StoreRegistryDeps { /** Normalize an identity id for shim keying. Default: trim (identity-ish). */ normalizeId?: (id: string) => string; /** - * ANTI-FORK bounded retry budget. On a FRESH page over a persistent wallet the - * shim (in the private store's graph) may not be synced when the first read - * fires → 0 rows; treating that transient 0 as "account absent" makes the - * registry PROVISION a new set of scope docs — an account FORK. Since the - * deterministic Rust barrier (`user_connect`) is not reachable from JS, a - * BOUNDED retry (capped backoff) is the compensation: wait out the sync-lag - * window before concluding "genuinely new". Enable it where the REAL broker is - * used (app + e2e). Left UNSET (the default) → `attempts: 1` = NO retry (a - * single read), which keeps the synchronous unit fakes fast and unchanged. + * POINTER micro-guard budget. The account records now live in a subscribable + * doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple + * in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts + * need NO retry (this replaces the deleted account-level `provisionRetry`). The + * ONLY residual sync-lag window is the store-root pointer read itself — one + * write-once triple. This bounded guard re-reads JUST that pointer a few times if a + * fresh cold read misses it; it can never provision or fork an account (worst case: + * a couple extra reads before an existing pointer is seen). Enable it where the REAL + * broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single + * read, keeping the synchronous unit fakes fast and unchanged. */ - provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number }; + pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number }; } export interface EventuallyConfig { @@ -62,10 +63,10 @@ export interface EventuallyConfig { let cfg: EventuallyConfig | null = null; let currentUser: PrincipalId | null = null; -/** Required fields of StoreRegistryDeps after defaults are applied. `provisionRetry` - * defaults to `{ attempts: 1 }` (no retry) when the consumer leaves it unset. */ +/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard` + * defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */ type ResolvedRegistryDeps = Required< - Pick + Pick >; let registryDeps: ResolvedRegistryDeps | null = null; /** The emulated ReadCap/WriteCap registry. Empty until the app declares caps; @@ -101,9 +102,9 @@ export function configureStoreRegistry(deps: StoreRegistryDeps): void { registryDeps = { getSession: deps.getSession, normalizeId: deps.normalizeId ?? ((id: string) => id.trim()), - // Default: no retry (single read). Only the real-broker consumers (app + e2e) - // opt into the bounded anti-fork retry; unit fakes stay synchronous. - provisionRetry: deps.provisionRetry ?? { attempts: 1 }, + // Default: single read (no re-read). Only the real-broker consumers (app + e2e) + // opt into the bounded pointer micro-guard; unit fakes stay synchronous. + pointerGuard: deps.pointerGuard ?? { attempts: 1 }, }; } diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index 7d22604..d29674b 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -9,10 +9,38 @@ * physical NextGraph store. Isolation is enforced by the app layer + the * emulated cap registry, not by crypto. * - * The mapping (account → its 3 document NURIs) is the **sharedWalletShim**, - * persisted as RDF in the shared wallet's private store (the anchor, always - * known from the session). That makes login cross-device: another device - * opening the same wallet reads the same shim and finds the same accounts. + * The mapping (account → its 3 document NURIs) is the **sharedWalletShim**. It + * is persisted as RDF, but NOT in the store-root graph anymore — see the + * indirection below. That makes login cross-device: another device opening the + * same wallet reads the same shim and finds the same accounts. + * + * ── The indirection: pointer (store-root) → doc-shim (subscribable) ───────── + * On the real NextGraph platform "findable-without-lookup" and "subscribable" + * are DISJOINT (verified at the source, see docs/nextgraph-current-state.md + * § *Findable vs subscribable*): + * - the ONLY NURI a fresh session can name WITHOUT a lookup is the store-root + * `did:ng:${privateStoreId}` — but a store-root has NO first-`State` sync + * BARRIER, so a cold "0 rows" on it is AMBIGUOUS (could be sync-lag, could + * be truly empty); + * - the ONLY thing that DOES have a first-`State` barrier is a `did:ng:o:` + * doc from `doc_create` — but its RepoID is RANDOM, so a fresh session + * cannot GUESS it; it must be looked up. + * So a purely-barrier shim resolution is impossible: you cannot have a doc that + * is both guessable and authoritative on a cold read. The indirection bridges + * this: a well-known, write-ONCE **pointer** triple in the store-root names a + * **doc-shim** (`did:ng:o:...`) that holds all account records and IS + * subscribable. Resolution reads the pointer (a single oldest write-once triple, + * near-always synced), then opens the doc-shim through its `ensureRepoOpen` + * BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent). + * + * A pointer FORK (two devices writing the pointer before either synced) is + * BENIGN: the pointer is reconciled to a canonical doc-shim (lexicographically- + * smallest NURI) and destroys no account data — worst case a doc-shim not chosen + * is migrated forward on next resolve. This is why the OLD account-level retry + * (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now + * barrier-authoritative, so it never needs to be retried to distinguish sync-lag + * from absence. A micro-guard remains ONLY on the pointer read (one write-once + * triple) — see resolvePointer. * * ── Generic by construction ────────────────────────────────────────────── * This module knows only the three native scopes; it knows no application @@ -62,6 +90,18 @@ const P = { // documents (one per entity) that live "in" that scope. const INDEX_SUBJECT = `${SHIM}:index`; +// --- pointer (store-root → doc-shim indirection) -------------------------- +// +// The pointer is a SINGLE well-known triple written ONCE into the store-root +// graph on the very first login, then IMMUTABLE. Its object is the NURI of the +// doc-shim (a `did:ng:o:...` repo) where all AccountRecords actually live. The +// store-root is NOT subscribable (no first-`State` barrier), but the pointer is +// the OLDEST triple in that graph and is write-once, so it is near-always synced +// on a cold read — and even a transient miss is bounded by a small guard +// (resolvePointer), NOT by an account-level retry. +const POINTER_SUBJECT = `${SHIM}:root`; +const POINTER_PRED = `${SHIM}:shimDoc`; + function accountSubject(id: string): string { // The id is UNTRUSTED and lands in an IRI position. Percent-encode it // (escapeIri) so no `>` / `"` / whitespace / control char can break out of @@ -110,7 +150,7 @@ function accountKey(id: string): string { /** Minimal session shape the registry needs — provided by the consumer. */ export interface RegistrySession { sessionId: string; - /** The shared wallet's private store id — the shim anchor. */ + /** The shared wallet's private store id — the pointer anchor. */ privateStoreId: string; /** The shared wallet's protected store id (native store). Optional: only the * scope resolvers need it; the shim only needs the private anchor. */ @@ -127,8 +167,11 @@ async function session(): Promise { return getStoreRegistryDeps().getSession(); } -/** The shim lives in the shared wallet's private store (always-known anchor). */ -async function anchorNuri(): Promise { +/** The pointer lives in the shared wallet's private STORE-ROOT graph (the only + * always-known-without-lookup anchor). NOT subscribable — hence the pointer is + * a write-once triple, and the actual account records live in the doc-shim it + * names (see rootNuri vs the doc-shim). */ +async function rootNuri(): Promise { const s = await session(); return `did:ng:${s.privateStoreId}`; } @@ -144,10 +187,23 @@ let cache: Map | null = null; // targeted resolve never forces a full shim scan. Both are cleared together. const accountCache = new Map(); +// The resolved doc-shim NURI for the current session (cached: the pointer read + +// barrier open happen once, then every account read reuses this doc). Cleared on +// resetRegistryCache / wallet switch. +let shimDocNuri: Nuri | null = null; +// De-dupe concurrent pointer-resolutions so a fresh page firing many parallel +// ensureAccount/resolveAccount calls opens the doc-shim exactly once. +let shimDocInFlight: Promise | null = null; + /** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ export function resetRegistryCache(): void { cache = null; accountCache.clear(); + shimDocNuri = null; + shimDocInFlight = null; + // The per-session migration guard is tied to the resolved doc-shim, which is + // dropped here — so a switched wallet (or a test) re-runs the legacy scan. + migratedInto.clear(); } // --- SPARQL result helpers ------------------------------------------------ @@ -171,7 +227,7 @@ function bindingValue(row: Record, key: string): stri * DETERMINISTIC resolution of an account's scope docs from a set of SPARQL * bindings (all bindings for ONE account subject). * - * ── Why this is load-bearing (the reconnection bug's root cause) ─────────── + * ── Why this is load-bearing (residual fork residue) ─────────────────────── * A corrupted shim can carry the SAME account subject with MULTIPLE values for a * scope predicate (e.g. 5 `shim:docPublic`) — the residue of past account FORKS * (each stray provision appended another doc NURI). A query then returns several @@ -187,6 +243,12 @@ function bindingValue(row: Record, key: string): stri * session-independent order: writer and reader converge on the SAME canonical doc * even on a wallet already corrupted by duplicates. (No creation timestamp is * recorded in the shim, so lexicographic-min is the available deterministic key.) + * + * With the barrier-authoritative doc-shim read, account FORKS no longer occur (a + * fresh page reads the doc-shim through its first-`State` barrier, so a cold 0 is + * definitive and never triggers a fork-provision). `canonicalDoc` is RETAINED to + * stay robust against the residue of PAST forks already persisted in a wallet, and + * to reconcile a benign pointer fork the same content-addressed way. */ function canonicalDoc(rows: Array>, key: string): Nuri { let chosen = ""; @@ -217,16 +279,196 @@ function recordFromRows( }; } -// --- shim load / account bootstrap ---------------------------------------- +// --- pointer resolution + doc-shim bootstrap ------------------------------ -/** Load all accounts from the shim into the cache. */ -export async function loadShim(): Promise> { - if (cache) return cache; +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +/** + * Read the pointer(s) from the store-root graph → the canonical doc-shim NURI, or + * `""` if no pointer exists yet. + * + * The store-root is NOT subscribable, so this read has no first-`State` barrier: + * a cold 0 is ambiguous. But the pointer is ONE write-once triple (the OLDEST in + * that graph), so it is near-always synced. The ONLY residual guard is a small + * bounded re-read here (NOT an account retry): a handful of quick re-reads of that + * single triple. It is bounded, benign, and — crucially — it can never re-provision + * an account or fork data; the worst it can do is take a couple extra reads to see a + * pointer that is still landing. The account records themselves are read + * authoritatively through the doc-shim barrier, never through this guard. + * + * If MULTIPLE pointers exist (a pointer fork: two devices each wrote a pointer to + * their own freshly-created doc-shim before either synced), reconcile to the + * canonical (lexicographically-smallest) doc-shim NURI — content-addressed and + * stable, so every device converges on the SAME doc-shim. A pointer fork is benign: + * no account data is lost (migration folds any non-canonical doc-shim forward). + */ +async function resolvePointer(): Promise { const s = await session(); - const anchor = await anchorNuri(); + const root = await rootNuri(); + // COLD-START heal: open the store-root repo before the anchored read, so a fresh + // wallet whose store-root isn't yet in `self.repos` resolves instead of throwing + // `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no + // barrier, so this open cannot make the read authoritative — the guard below does. + await ensureRepoOpen(root); + const query = ` + SELECT ?shimDoc WHERE { + GRAPH <${assertNuri(root)}> { + <${POINTER_SUBJECT}> <${POINTER_PRED}> ?shimDoc . + } + }`; + + // Micro-guard (POINTER only): a small bounded re-read to bridge the store-root + // sync-lag window on the ONE write-once pointer triple. Bounded, and it can only + // ever DELAY seeing an existing pointer — never provision, never fork. Uses the + // injected pointerGuard budget (defaults to a single read when unset, so unit + // fakes stay synchronous). NB this is NOT the deleted account-level provisionRetry. + const budget = getStoreRegistryDeps().pointerGuard; + const attempts = Math.max(1, budget.attempts ?? 1); + const baseMs = budget.baseMs ?? 150; + const maxStepMs = budget.maxStepMs ?? 2000; + + let step = baseMs; + for (let i = 0; i < attempts; i++) { + try { + const result = await sparqlQuery(s.sessionId, query, undefined, root, "resolvePointer"); + const doc = canonicalDoc(readBindings(result), "shimDoc"); + if (doc) return doc; + } catch (error) { + console.error("[storeRegistry] resolvePointer read failed:", error); + } + if (i < attempts - 1) { + await sleep(step); + step = Math.min(step * 2, maxStepMs); + } + } + return ""; +} + +/** Write the pointer (store-root → doc-shim), once, at first login. Idempotent in + * practice (only called when no pointer was found); a concurrent double-write is + * reconciled by canonicalDoc on read. */ +async function writePointer(doc: Nuri): Promise { + const s = await session(); + const root = await rootNuri(); + await ensureRepoOpen(root); + const update = ` + INSERT DATA { + GRAPH <${assertNuri(root)}> { + <${POINTER_SUBJECT}> <${POINTER_PRED}> <${assertNuri(doc)}> . + } + }`; + try { + await sparqlUpdate(s.sessionId, update, root, "writePointer"); + } catch (error) { + console.error("[storeRegistry] writePointer failed:", error); + } +} + +/** Create one graph document in the shared wallet's private store (→ a NURI). */ +async function createDoc(): Promise { + const s = await session(); + // crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store", + // store_repo=undefined → shared wallet's private store. + return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined); +} + +/** + * Resolve (or on first login, create) the doc-shim NURI for this session — the + * `did:ng:o:...` repo that holds every AccountRecord and IS subscribable. + * + * Steps (cached; runs at most once per session, concurrent callers share one): + * 1. Read the pointer from the store-root (resolvePointer). If present → that is + * the doc-shim; open it through its first-`State` BARRIER so subsequent account + * reads are AUTHORITATIVE, then MIGRATE any legacy store-root records into it. + * 2. No pointer → FIRST login (or a wallet from before the indirection): + * a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the + * session (`self.repos`) — so it is already open/synced in-session; + * b. publish the pointer (store-root → the new doc-shim), once; + * c. open it (barrier — trivially satisfied for a just-created in-session repo) + * and MIGRATE any legacy AccountRecords found in the store-root graph into it + * (see migrateLegacyRecords) — this is what guarantees NO existing account is + * lost when a pre-indirection wallet is opened for the first time. + * The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim); + * on first-login creation it is a no-op, so the pointer is published first. + */ +async function resolveShimDoc(): Promise { + if (shimDocNuri) return shimDocNuri; + if (shimDocInFlight) return shimDocInFlight; + + const p = (async (): Promise => { + const existing = await resolvePointer(); + if (existing) { + // Open the doc-shim through its first-`State` barrier BEFORE any account read, + // so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag. + await ensureRepoOpen(existing); + // A pointer that predates a migration still points at a doc-shim, but the LEGACY + // store-root records (from before the indirection) may not have been folded in. + // Migrate best-effort (idempotent) so no legacy account is ever stranded. + await migrateLegacyRecords(existing); + shimDocNuri = existing; + return existing; + } + + // FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish + // the pointer, then open (no-op barrier for a just-created repo) + migrate legacy. + const doc = await createDoc(); + await writePointer(doc); + await ensureRepoOpen(doc); + await migrateLegacyRecords(doc); + shimDocNuri = doc; + return doc; + })(); + + shimDocInFlight = p; + try { + return await p; + } finally { + shimDocInFlight = null; + } +} + +// --- migration (legacy store-root records → the doc-shim) ------------------ + +// Guard so the (best-effort, O(all-accounts)) legacy scan runs at most once per +// session per target doc-shim. Cleared with the rest via resetRegistryCache. +const migratedInto = new Set(); + +/** + * MIGRATION — fold any AccountRecords that live at the OLD location (the store-root + * graph, where the pre-indirection shim wrote them) into the new doc-shim. + * + * ── Why this exists (do NOT lose an existing account) ────────────────────── + * Before the indirection, `ensureAccount` wrote ` a Account ; id ; docPublic ; + * docProtected ; docPrivate` directly into the store-root graph. A wallet created + * under that scheme has its accounts there, not in a doc-shim. When such a wallet is + * first opened under the new scheme, resolveShimDoc creates/opens the doc-shim; this + * function reads the store-root graph best-effort and RE-INSERTS every legacy record + * into the doc-shim, so barrier-authoritative resolveAccount finds it — instead of + * seeing 0 in the (fresh) doc-shim and provisioning a NEW forked account. + * + * ── Behaviour (precise) ──────────────────────────────────────────────────── + * - Best-effort: a store-root read failure (RepoNotFound on a truly fresh wallet) + * is swallowed — there is simply nothing to migrate. + * - Copy, don't move: legacy triples are LEFT in the store-root (never deleted), so + * the migration is safe to re-run and an aborted migration never loses data. The + * doc-shim becomes the authoritative read location; the store-root copy is inert. + * - Idempotent: re-inserting the same ` ... docPublic ` triples is a no-op + * (RDF set semantics); and `migratedInto` skips the scan after the first run per + * session. Re-running after new legacy writes (there should be none post-migration) + * would simply re-copy them. + * - Runs once per session before the first authoritative account read, so a legacy + * account resolves on the very first resolveAccount after reconnect. + */ +async function migrateLegacyRecords(doc: Nuri): Promise { + if (migratedInto.has(doc)) return; + migratedInto.add(doc); + + const s = await session(); + const root = await rootNuri(); + // Read every legacy account record from the OLD store-root location. const query = ` SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE { - GRAPH <${assertNuri(anchor)}> { + GRAPH <${assertNuri(root)}> { ?acc a <${P.type}> ; <${P.id}> ?id ; <${P.docPublic}> ?docPublic ; @@ -234,13 +476,59 @@ export async function loadShim(): Promise> { <${P.docPrivate}> ?docPrivate . } }`; - const map = new Map(); - // COLD-START heal: open the anchor (private-store-root) repo before the shim scan, - // so an anchored read on a fresh wallet whose anchor repo isn't yet in `self.repos` - // resolves instead of throwing `RepoNotFound`. Idempotent; see resolveAccount. - await ensureRepoOpen(anchor); + let rows: Array> = []; try { - const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "loadShim"); + await ensureRepoOpen(root); + const result = await sparqlQuery(s.sessionId, query, undefined, root, "migrateLegacyRead"); + rows = readBindings(result); + } catch (error) { + // No legacy repo / nothing to read → nothing to migrate. Not an error. + console.error("[storeRegistry] migrateLegacyRecords read (best-effort) failed:", error); + return; + } + if (rows.length === 0) return; + + // Group by account subject, pick the canonical doc per scope (fold any legacy fork + // residue the SAME content-addressed way as resolveAccount), and re-insert into the + // doc-shim. Grouping needs the subject; the SELECT above doesn't bind it, so we key + // on the account id literal (stable per account) instead. + const byId = new Map>>(); + for (const row of rows) { + const id = bindingValue(row, "id"); + if (!id) continue; + const bucket = byId.get(id) ?? []; + bucket.push(row); + byId.set(id, bucket); + } + await ensureRepoOpen(doc); + for (const [id, group] of byId) { + const record = recordFromRows(group, id); + if (!record.docPublic && !record.docProtected && !record.docPrivate) continue; + await writeRecord(doc, record); + } +} + +// --- shim load / account bootstrap ---------------------------------------- + +/** Load all accounts from the shim (the doc-shim) into the cache. */ +export async function loadShim(): Promise> { + if (cache) return cache; + const s = await session(); + const doc = await resolveShimDoc(); + const query = ` + SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE { + ?acc a <${P.type}> ; + <${P.id}> ?id ; + <${P.docPublic}> ?docPublic ; + <${P.docProtected}> ?docProtected ; + <${P.docPrivate}> ?docPrivate . + }`; + const map = new Map(); + // The doc-shim is opened (first-`State` barrier) by resolveShimDoc, so this read is + // authoritative. + await ensureRepoOpen(doc); + try { + const result = await sparqlQuery(s.sessionId, query, undefined, doc, "loadShim"); // Group ALL bindings by account key first, then pick the CANONICAL doc per // scope (see recordFromRows / canonicalDoc). A single account subject may carry // duplicate scope-doc values (fork residue) → several bindings; grouping + @@ -271,9 +559,17 @@ export async function loadShim(): Promise> { /** * Resolve ONE account by its shim key with a BOUNDED query — O(1), independent * of the number of accounts in the shim. This is the HOT-PATH lookup: it hits - * the account record at its known subject (`accountSubject(id)`) directly, - * instead of scanning EVERY account like {@link loadShim}. Returns the account's - * record or `null` if it does not exist yet. + * the account record at its known subject (`accountSubject(id)`) directly in the + * doc-shim, instead of scanning EVERY account like {@link loadShim}. Returns the + * account's record or `null` if it does not exist yet. + * + * ── Barrier-AUTHORITATIVE (the reconnection fix) ──────────────────────────── + * The read targets the DOC-SHIM (`did:ng:o:...`), which resolveShimDoc opened + * through its first-`State` barrier. So a cold 0 rows here is DEFINITIVE ("account + * genuinely absent"), not ambiguous sync-lag — no account-level retry is needed or + * used. This is what replaced the old `resolveAccountReliably` / `provisionRetry` + * loop: the store-root ambiguity that forced the retry is gone once the read moves + * behind the doc-shim barrier. * * Cached per account (in `accountCache`); a hit skips the query entirely, so * repeated resolves of the same account are free. `resetRegistryCache` clears it. @@ -284,43 +580,27 @@ export async function resolveAccount(id: string): Promise if (cached) return cached; const s = await session(); - const anchor = await anchorNuri(); - // COLD-START heal (polyfill-era): the shim lives in the private-store-root graph - // (the anchor). An anchored `sparql_query` on a repo absent from the verifier's - // `self.repos` throws `RepoNotFound` — a HARD error here, unlike per-entity docs - // which silently read 0 (the private/store target resolves through - // `resolve_target_for_sparql`, which requires the repo loaded). Open the anchor - // ONCE before reading, idempotently — the SAME open-before-read guard - // `readScopeIndex` applies to its index doc. NB (see docs/nextgraph-current-state): - // `ensureRepoOpen` (→ `doc_subscribe`) can only OPEN/subscribe a repo the session - // ALREADY holds; it cannot pull a genuinely-absent repo (no JS primitive does). On - // the real broker the private-store repo is bootstrapped into `self.repos` at - // connect, so this is a defensive same-session open — it makes the anchor read/write - // robust to a not-yet-subscribed anchor without ever being able to hide a truly - // unbootstrapped store. No-op with the unit fake ng and when the repo is already - // open. See open-repo.ts. - await ensureRepoOpen(anchor); - // `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a - // trusted-shaped NURI → assertNuri. The query is bounded to this one subject. + const doc = await resolveShimDoc(); + // `subj` is already IRI-safe (accountSubject → escapeIri). The read is anchored to + // the doc-shim's default graph (opened through its barrier by resolveShimDoc), so + // it is authoritative. The query is bounded to this one subject. const subj = accountSubject(id); const query = ` SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE { - GRAPH <${assertNuri(anchor)}> { - <${subj}> a <${P.type}> ; - <${P.id}> ?id ; - <${P.docPublic}> ?docPublic ; - <${P.docProtected}> ?docProtected ; - <${P.docPrivate}> ?docPrivate . - } + <${subj}> a <${P.type}> ; + <${P.id}> ?id ; + <${P.docPublic}> ?docPublic ; + <${P.docProtected}> ?docProtected ; + <${P.docPrivate}> ?docPrivate . }`; try { - const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount"); + const result = await sparqlQuery(s.sessionId, query, undefined, doc, "resolveAccount"); const rows = readBindings(result); if (rows.length === 0) return null; // DETERMINISTIC: a corrupted shim may return SEVERAL bindings for this one // account subject (duplicate scope-doc values from past forks). Pick the // canonical (lexicographically-smallest) doc per scope so writer and reader - // always resolve the SAME docPublic — the fix for the reconnection bug. + // always resolve the SAME docPublic (robustness against PAST fork residue). const record = recordFromRows(rows, id); accountCache.set(key, record); return record; @@ -330,99 +610,51 @@ export async function resolveAccount(id: string): Promise } } -// --- anti-fork bounded retry ---------------------------------------------- - -const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); - -/** - * Resolve ONE account, RETRYING a bounded number of times while the shim reads 0 - * rows — the ANTI-FORK guard for the polyfill-era shim. - * - * ── The gap this bridges ────────────────────────────────────────────────── - * On a FRESH page over the same persistent wallet, the shim (in the private - * store's graph) may not yet be synced when the first read fires → 0 rows. Treating - * that transient 0 as "account genuinely absent" makes `ensureAccount` PROVISION a - * NEW set of scope docs — an account FORK, whose duplicate `docPublic` values are - * exactly what corrupts the shim and breaks reconnection (see {@link canonicalDoc}). - * The deterministic RUST barrier (`user_connect`) that would make a 0 definitive is - * NOT reachable from JS, so a BOUNDED retry (capped backoff) is the only available - * compensation: wait out the sync-lag window before concluding "new". - * - * NOT polling in the banned sense: it is a lib-internal, BOUNDED bootstrap wait for - * a real cold-read gap (a fixed budget from `provisionRetry`, not an open-ended - * "is it there yet?" loop) that stops the instant the shim resolves. A cache hit - * short-circuits it; once the account resolves it is cached, so this never runs on - * the hot path. - * - * The budget is the injected `provisionRetry` (see polyfill). It defaults to - * `attempts: 1` (a single read, NO retry) when the consumer leaves it unset — so - * the synchronous unit fakes stay fast and unchanged. Only the real-broker - * consumers (app + e2e) opt into a multi-attempt budget. A genuinely-new account - * (always 0) still resolves to `null` after the budget → the caller provisions once. - */ -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 first = await resolveAccount(id); - if (first) return first; - - const budget = getStoreRegistryDeps().provisionRetry; - const attempts = Math.max(1, budget.attempts ?? 1); - const baseMs = budget.baseMs ?? 150; - const maxStepMs = budget.maxStepMs ?? 2000; - - // attempts:1 (default, unit fakes) → no retry: the single 0 is authoritative. - // Real broker: retry with capped backoff before concluding "genuinely new". - // Bounded by `attempts` — never an infinite/open-ended loop. - let step = baseMs; - for (let i = 1; i < attempts; i++) { - await sleep(step); - const rec = await resolveAccount(id); - if (rec) return rec; - step = Math.min(step * 2, maxStepMs); - } - // Still 0 after the whole budget → account is genuinely new. Provision once. - return null; -} - /** All known accounts (from the shim). */ export async function allAccounts(): Promise { return [...(await loadShim()).values()]; } -/** Create one graph document in the shared wallet's private store (→ a NURI). */ -async function createDoc(): Promise { +/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the + * canonical always-safe shape — same convention as createEntityDoc). */ +async function writeRecord(doc: Nuri, record: AccountRecord): Promise { const s = await session(); - // crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store", - // store_repo=undefined → shared wallet's private store. - return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined); + const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`; + // `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position → + // escapeLiteral. The doc NURIs come from `ng` but are stored as literals here, so + // they are escaped as literals too (defence in depth). NO explicit `GRAPH <…>` + // wrapper: write the anchored DEFAULT graph (the `doc` anchor scopes it) — the + // canonical, always-safe shape the anchored default-graph read queries match. + const update = ` + INSERT DATA { + <${subj}> a <${P.type}> ; + <${P.id}> "${escapeLiteral(record.id)}" ; + <${P.docPublic}> "${escapeLiteral(record.docPublic)}" ; + <${P.docProtected}> "${escapeLiteral(record.docProtected)}" ; + <${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" . + }`; + try { + await sparqlUpdate(s.sessionId, update, doc, "writeRecord"); + } catch (error) { + console.error("[storeRegistry] writeRecord persist failed:", error); + } } /** * In-flight `ensureAccount` promises, keyed by account key — so CONCURRENT * `ensureAccount(id)` calls for the SAME account share ONE resolve-or-provision. * - * ── Why this is load-bearing (the reconnection FORK root cause) ───────────── + * ── Why this is load-bearing (concurrent-provision de-dup) ────────────────── * On a fresh page over the persistent wallet, SEVERAL independent callers hit - * `ensureAccount(A)` near-simultaneously: `watchShape('public')` and - * `watchShape('protected')` each resolve their doc set (`listMyEntityDocs` → - * `ensureAccount`) and subscribe their container (`scopeIndexDoc` → - * `ensureAccount`), plus the app's owned-events effect. Each runs BEFORE the shim - * has synced, so each `resolveAccountReliably` reads 0 rows and, independently, - * PROVISIONS a brand-new set of scope docs — an account FORK PER caller. Measured: - * a single new account accrued EIGHT distinct `docPublic`/`docProtected` values in - * one session. `canonicalDoc` then makes a fresh reader pick the lexicographic-min - * doc, which differs from the (cached, freshly-created) doc the WRITER wrote into → - * the reader lists an empty scope index → the home/participation reads come back - * empty (the reconnection bug's last layer). - * - * De-duping concurrent provisions collapses those N racing provisions into ONE: - * the first caller resolves-or-provisions; every concurrent caller awaits the SAME - * promise and gets the SAME record, so no duplicate docs are ever minted and writer - * and reader converge on one canonical scope doc. Not polling: a bounded in-memory - * promise map (mirrors open-repo.ts `inFlight`), cleared the instant it settles. + * `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container + * subscriptions, the app's owned-events effect). With the barrier-authoritative + * resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the + * account is GENUINELY new, N concurrent callers would still each see 0 and each + * provision a set of scope docs (an in-session fork). De-duping concurrent provisions + * collapses those N into ONE: the first caller resolves-or-provisions; every + * concurrent caller awaits the SAME promise and gets the SAME record. Not polling: a + * bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant + * it settles. */ const ensureInFlight = new Map>(); @@ -446,49 +678,22 @@ export async function ensureAccount(id: string): Promise { // 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. // - // ANTI-FORK: resolve via the BOUNDED retry, so a transient 0 rows (the shim not - // yet synced on a fresh page over the persistent wallet) is not mistaken for - // "account genuinely absent" → a fork. Only after the whole retry budget still - // reads 0 do we treat the account as new and provision. See resolveAccountReliably. - const existing = await resolveAccountReliably(id); + // Barrier-AUTHORITATIVE: resolveAccount reads the doc-shim behind its first-`State` + // barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY + // absent — not sync-lag. No account-level retry: the store-root ambiguity that + // forced the old provisionRetry loop is gone once the read moves behind the barrier. + const existing = await resolveAccount(id); if (existing) return existing; + const doc = await resolveShimDoc(); const [docPublic, docProtected, docPrivate] = await Promise.all([ createDoc(), createDoc(), createDoc(), ]); const record: AccountRecord = { id, docPublic, docProtected, docPrivate }; - - const s = await session(); - const anchor = await anchorNuri(); - // COLD-START heal: open the anchor (private-store-root) repo before PROVISIONING - // into it. The provision `sparqlUpdate` targets the private-store graph, which - // resolves through the same repo-loaded requirement — an unopened anchor repo on - // a fresh wallet would throw `RepoNotFound` and the account would never persist. - // Idempotent; a no-op once the repo is open (resolveAccountReliably already - // opened it on the read above in the common path). See open-repo.ts. - await ensureRepoOpen(anchor); - const subj = accountSubject(id); - // `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a - // trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL - // position → escapeLiteral. The doc NURIs come from `ng` but are stored as - // literals here, so they are escaped as literals too (defence in depth). - const update = ` - INSERT DATA { - GRAPH <${assertNuri(anchor)}> { - <${subj}> a <${P.type}> ; - <${P.id}> "${escapeLiteral(id)}" ; - <${P.docPublic}> "${escapeLiteral(docPublic)}" ; - <${P.docProtected}> "${escapeLiteral(docProtected)}" ; - <${P.docPrivate}> "${escapeLiteral(docPrivate)}" . - } - }`; - try { - await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount"); - } catch (error) { - console.error("[storeRegistry] ensureAccount persist failed:", error); - } + // Persist the record INTO the doc-shim (not the store-root anymore). + await writeRecord(doc, record); // Feed the per-account cache, and the full-shim cache if it is already loaded // (so allAccounts / the fan-out see the freshly-created account too). accountCache.set(key, record); @@ -582,12 +787,11 @@ const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox"); * This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document — * a real repo NURI from `docCreate`, stable across clients via the shim), NOT the * shared wallet's private-store root. Reason (perf + hygiene): the shim (the - * account→document trust root) lives in the private-store graph and is scanned on - * every `loadShim`; routing every inbox deposit into that SAME graph bloats it - * without bound (thousands of deposit triples across sessions), turning `loadShim` - * into a multi-second full-graph scan. A separate inbox document keeps the shim - * graph small and the deposits isolated. At migration this becomes the host's - * native per-document inbox and the resolution moves here. + * account→document trust root) is scanned on every `loadShim`; routing every inbox + * deposit into that SAME graph bloats it without bound (thousands of deposit triples + * across sessions). A separate inbox document keeps the shim graph small and the + * deposits isolated. At migration this becomes the host's native per-document inbox + * and the resolution moves here. */ export async function resolveInboxAnchor(): Promise { const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT); diff --git a/packages/client/test/access-log.test.ts b/packages/client/test/access-log.test.ts index 2a39bd9..34f1d68 100644 --- a/packages/client/test/access-log.test.ts +++ b/packages/client/test/access-log.test.ts @@ -47,7 +47,6 @@ function injectFake(debugAccessLog = false) { configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }), - provisionRetry: { attempts: 1 }, }); return ng; } diff --git a/packages/client/test/anti-fork.test.ts b/packages/client/test/anti-fork.test.ts index 11bd7f5..eed83e7 100644 --- a/packages/client/test/anti-fork.test.ts +++ b/packages/client/test/anti-fork.test.ts @@ -1,20 +1,27 @@ /** * anti-fork.test.ts — behavioral tests for the reconnection fix in the - * polyfill-era shim (src/store-registry.ts). Two guards, one file: + * polyfill-era shim (src/store-registry.ts), redesigned around the + * pointer → doc-shim indirection. Three groups, one file: * - * (1) DETERMINISTIC RESOLUTION — a shim whose account subject carries DUPLICATE - * scope-doc values (fork residue: several `shim:docPublic`) must resolve to - * the SAME canonical doc every time (lexicographically-smallest NURI), so the - * session that WROTE an entity and a fresh page that RESOLVES the doc never - * disagree. This is the root-cause fix for the reconnection bug. + * (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries + * DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must + * resolve to the SAME canonical doc every time (lexicographically-smallest + * NURI), so the session that WROTE an entity and a fresh page that RESOLVES + * the doc never disagree. Robustness against PAST fork residue. * - * (2) ANTI-FORK BOUNDED RETRY — a fresh page over a persistent wallet may read - * the shim as 0 rows while the private store is still syncing. A BOUNDED retry - * (capped backoff, from the injected `provisionRetry` budget) bridges that - * window before concluding "account genuinely new" and provisioning — - * preventing new fork residue. A genuinely-new account (always 0) is still - * provisioned exactly once. The budget defaults to `attempts: 1` (no retry) - * when unset, so the synchronous unit fakes stay single-read and unchanged. + * (2) BARRIER-AUTHORITATIVE RECONNECT (the core fix) — a fresh page over a + * persistent wallet resolves the SAME account through the doc-shim's + * first-`State` BARRIER, with NO account-level retry. The account records + * live in a subscribable doc-shim (`did:ng:o:...`) reached via a write-once + * POINTER triple in the store-root; opening the doc-shim makes a cold read + * authoritative, so a genuinely-present account is found on the first read + * and never re-provisioned (no fork). This replaced the deleted + * `resolveAccountReliably` / `provisionRetry` account retry. + * + * (3) MIGRATION — a wallet whose accounts were written at the OLD location (the + * store-root graph, pre-indirection) must have those records MIGRATED into + * the doc-shim at resolution, so a legacy account resolves (is READ, not + * re-provisioned) after reconnect. No existing account is lost. */ import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; @@ -41,13 +48,18 @@ afterAll(() => { }); const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" }; +const ROOT = "did:ng:PRIV-AF"; // --------------------------------------------------------------------------- -// Fake ng — in-memory quad store. Two variants: -// - makeFakeNg(): NO doc_subscribe → non-reactive path (no sync lag, no retry). -// - makeLaggyFakeNg({lag}): HAS doc_subscribe (so the anti-fork retry path -// fires) and can simulate a sync lag: the account SELECT returns 0 rows for the -// first `lag` reads, then the seeded data — modelling a shim not yet synced. +// Fake ng — in-memory quad store modelling the pointer → doc-shim indirection. +// +// - The POINTER (` `) lives in the store-root +// graph (keyed by GRAPH ). +// - AccountRecords live in the doc-shim (anchored default graph, keyed by the +// anchor arg = the doc-shim NURI). A LEGACY record may ALSO be seeded directly +// in the store-root graph (the pre-indirection location) to exercise migration. +// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once +// (the barrier). // --------------------------------------------------------------------------- interface Quad { g: string; s: string; p: string; o: string } @@ -95,10 +107,9 @@ function makeSparqlUpdate(quads: Quad[]) { }); } -/** Build the account-SELECT bindings from the quads (grouped per subject). Each - * (subject × docPublic × docProtected × docPrivate) combination is a binding, so - * a subject with DUPLICATE scope docs yields a cross-product of bindings — exactly - * what a corrupted shim returns. */ +/** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim, or + * the store-root for the migration read), grouped per subject. A subject with + * DUPLICATE scope docs yields a cross-product of bindings — a corrupted shim. */ function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) { const bySubject = new Map(); for (const q of quads) { @@ -130,19 +141,33 @@ function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: return bindings; } -/** Non-reactive fake ng (no doc_subscribe → no sync lag, no retry). */ +/** Reactive fake ng modelling the pointer → doc-shim indirection. `doc_subscribe` + * pushes a first `State` so the doc-shim barrier resolves synchronously. */ function makeFakeNg() { const quads: Quad[] = []; let docCounter = 0; - let accountQueryCount = 0; + // Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored + // to the doc-shim (a did:ng:o: NURI); the one-time legacy MIGRATION scan is anchored + // to the store-root (ROOT). Counting per-anchor lets a test assert "exactly one + // doc-shim account read" (no account RETRY) separately from the migration scan. + const accountReadsByAnchor = new Map(); const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`); const sparql_update = makeSparqlUpdate(quads); const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; + // Pointer SELECT (store-root). + if (query.includes("")) { + const bindings = quads + .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc") + .map((q) => ({ shimDoc: { value: q.o } })); + return { results: { bindings } }; + } + // Account SELECT — anchored either to the doc-shim (authoritative read) or, for + // the migration scan, to the store-root (legacy location). Same shape either way. if (query.includes("")) { - accountQueryCount++; - const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+/); + accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1); + const subjM = query.match(/<([^>]+)>\s+a\s+/); return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } }; } const bindings = quads @@ -150,40 +175,7 @@ function makeFakeNg() { .map((q) => ({ e: { value: q.o } })); return { results: { bindings } }; }); - return { doc_create, sparql_update, sparql_query, _quads: quads, getAccountQueryCount: () => accountQueryCount }; -} - -/** Fake ng with a SYNC-LAG simulation: the first `lag` account reads return 0 rows - * (shim not synced yet), then the seeded data appears — modelling a fresh page over - * a persistent wallet. Used with a fast `provisionRetry` budget to exercise the - * anti-fork retry. (Carries a `doc_subscribe` so it also passes as a reactive ng.) */ -function makeLaggyFakeNg(opts: { lag?: number } = {}) { - const quads: Quad[] = []; - let docCounter = 0; - let accountQueryCount = 0; - let lag = opts.lag ?? 0; - const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`); - const sparql_update = makeSparqlUpdate(quads); - const sparql_query = mock(async (...a: unknown[]) => { - const query = a[1] as string; - const anchor = a[3] as string | undefined; - if (query.includes("")) { - accountQueryCount++; - if (lag > 0) { lag--; return { results: { bindings: [] } }; } // sync not landed yet - const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+/); - return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } }; - } - const bindings = quads - .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains") - .map((q) => ({ e: { value: q.o } })); - return { results: { bindings } }; - }); - // doc_subscribe: immediately push a State (the sync barrier) so `ensureRepoOpen` - // resolves at once. `resolveAccount`/`ensureAccount` now open the anchor repo - // (open-repo.ts) before reading/writing the shim (the cold-start heal); the real - // broker pushes `TabInfo` then a first `State` on subscribe, and open-repo awaits - // that `State`. Model it: invoke the callback with a `State` AppResponse so the - // barrier is reached synchronously (no 8s fallback → the retry tests stay fast). + // Push a `State` on subscribe (the sync barrier) so ensureRepoOpen resolves at once. const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => { if (typeof cb === "function") cb({ V0: { State: {} } }); return () => {}; @@ -191,47 +183,52 @@ function makeLaggyFakeNg(opts: { lag?: number } = {}) { return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads, - getAccountQueryCount: () => accountQueryCount, + // Total account reads across all anchors. + getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0), + // Account reads anchored to a did:ng:o: doc-shim (excludes the store-root migration scan). + getDocShimAccountReads: () => + [...accountReadsByAnchor.entries()] + .filter(([g]) => g.startsWith("did:ng:o:")) + .reduce((a, [, n]) => a + n, 0), }; } function inject( fakeNg: unknown, - provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number }, + pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number }, ) { configure({ ng: fakeNg as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u) => u.trim().toLowerCase(), - // Default (unset) → attempts:1 (no retry). The retry tests pass a fast budget. - provisionRetry, + pointerGuard, }); resetRegistryCache(); resetOpenedRepos(); } // --------------------------------------------------------------------------- -// (1) Deterministic resolution +// (1) Deterministic resolution over fork residue (in the doc-shim) // --------------------------------------------------------------------------- -describe("deterministic resolution over a shim corrupted by fork residue", () => { +describe("deterministic resolution over a doc-shim corrupted by fork residue", () => { beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); }); it("(1a) a subject with MULTIPLE docPublic values always resolves the SAME canonical (lexicographically-smallest)", async () => { const fakeNg = makeFakeNg(); inject(fakeNg); - const anchor = "did:ng:PRIV-AF"; - // Same account subject with 5 duplicate docPublic values (fork residue), out of - // lexicographic order, plus one docProtected/docPrivate each. + const docShim = "did:ng:o:shimdoc"; + // Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim. + fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim }); const subj = "urn:ng-eventually:shim:account:dupuser"; const dupPublics = [ "did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-a", ]; - fakeNg._quads.push({ g: anchor, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" }); + fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" }); for (const p of dupPublics) - fakeNg._quads.push({ g: anchor, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p }); - fakeNg._quads.push({ g: anchor, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" }); - fakeNg._quads.push({ g: anchor, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" }); + fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p }); + fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" }); + fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" }); const r1 = await resolveAccount("dupuser"); resetRegistryCache(); @@ -240,9 +237,7 @@ describe("deterministic resolution over a shim corrupted by fork residue", () => // Canonical = lexicographically smallest → "did:ng:o:pub-a". expect(r1?.docPublic).toBe("did:ng:o:pub-a"); - // Stable across independent resolves... expect(r2?.docPublic).toBe(r1?.docPublic); - // ...and loadShim (full scan) agrees with resolveAccount (targeted). expect(viaShim?.docPublic).toBe(r1?.docPublic); expect(viaShim?.docProtected).toBe("did:ng:o:prot-1"); expect(viaShim?.docPrivate).toBe("did:ng:o:priv-1"); @@ -250,18 +245,21 @@ describe("deterministic resolution over a shim corrupted by fork residue", () => }); // --------------------------------------------------------------------------- -// (2) Anti-fork bounded retry +// (2) Barrier-authoritative reconnect — the core fix (no account retry) // --------------------------------------------------------------------------- -describe("ensureAccount: anti-fork bounded retry", () => { +describe("reconnect resolves the SAME account through the doc-shim barrier (no fork, no account retry)", () => { beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); }); - it("(2a) NO-FORK: account already in shim → reused, 0 new doc_create (non-reactive fake, single read)", async () => { + it("(2a) NO-FORK: account already in the doc-shim → reused, 0 new scope docs", async () => { const fakeNg = makeFakeNg(); inject(fakeNg); + // First login: provisions the account (1 doc-shim + 3 scope docs). const first = await ensureAccount("LauraBarrier"); - expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); + expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); + // Fresh page over the SAME persistent quads: reset all in-memory caches, keep the + // quads. Reconnect must find the SAME account via the doc-shim barrier, NO new docs. resetRegistryCache(); resetOpenedRepos(); const second = await ensureAccount("LauraBarrier"); @@ -269,56 +267,62 @@ describe("ensureAccount: anti-fork bounded retry", () => { expect(second.docPublic).toBe(first.docPublic); expect(second.docProtected).toBe(first.docProtected); expect(second.docPrivate).toBe(first.docPrivate); - expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); // still 3, not 6 + // Still 4 — no doc-shim re-created (pointer reused), no scope docs re-created. + expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); }); - it("(2b) SYNC LAG: shim reads 0 K times then the data → RETRY reuses, no fork", async () => { - // Seed an account into an initial (non-reactive) fake, capture its NURIs. + it("(2b) barrier-authoritative: a fresh session finds the persisted account on the FIRST read — single doc-shim read, NO retry", async () => { + // Seed session 1; capture the account NURIs + the persistent quads. const seed = makeFakeNg(); inject(seed); - const orig = await ensureAccount("LaggyUser"); - expect(seed.doc_create).toHaveBeenCalledTimes(3); + const orig = await ensureAccount("BarrierUser"); + expect(seed.doc_create).toHaveBeenCalledTimes(4); - // Fresh reactive session over the SAME quads, with a 3-read sync lag: the first - // 3 account reads return 0 rows (shim not synced), then the seeded data appears. - const reactive = makeLaggyFakeNg({ lag: 3 }); - reactive._quads.push(...seed._quads); - inject(reactive, { attempts: 8, baseMs: 1, maxStepMs: 2 }); // fast budget + // Fresh reactive session over the SAME persistent quads. The doc-shim pushes a + // `State` on subscribe → resolveShimDoc opens the barrier → the account read is + // authoritative on the FIRST attempt. Give a MULTI-attempt pointer guard to prove + // it is NOT used for the account. + const reconnect = makeFakeNg(); + reconnect._quads.push(...seed._quads); + inject(reconnect, { attempts: 8, baseMs: 1, maxStepMs: 2 }); - const resolved = await ensureAccount("LaggyUser"); + const resolved = await ensureAccount("BarrierUser"); - // Reused the SAME account — NO fork provisioning despite the initial 0 rows. expect(resolved.docPublic).toBe(orig.docPublic); expect(resolved.docProtected).toBe(orig.docProtected); expect(resolved.docPrivate).toBe(orig.docPrivate); - expect(reactive.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork - // It DID retry: more than one account read (1 initial + retries until data). - expect(reactive.getAccountQueryCount()).toBeGreaterThan(1); + expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork + // Barrier-authoritative: found on the FIRST doc-shim read (the store-root migration + // scan, which finds nothing to migrate here, is separate). + expect(reconnect.getDocShimAccountReads()).toBe(1); }); - it("(2c) GENUINELY NEW: shim always reads 0 → provisioned exactly once after the budget", async () => { - const reactive = makeLaggyFakeNg({ lag: 999 }); // never resolves → genuinely new - inject(reactive, { attempts: 5, baseMs: 1, maxStepMs: 2 }); + it("(2c) GENUINELY NEW: a cold doc-shim reads 0 → provisioned exactly once, no retry", async () => { + // Pointer + doc-shim exist but the doc-shim holds NO record for this account. + const fakeNg = makeFakeNg(); + inject(fakeNg, { attempts: 5, baseMs: 1, maxStepMs: 2 }); + const docShim = "did:ng:o:preexisting-shim"; + fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim }); const rec = await ensureAccount("BrandNewUser"); - // Provisioned exactly ONE set of scope docs. - expect(reactive.doc_create).toHaveBeenCalledTimes(3); + // Provisioned exactly ONE set of 3 scope docs (the doc-shim already existed). + expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docProtected).not.toBe(rec.docPublic); - // Retry was BOUNDED: exactly `attempts` account reads, then it gave up and provisioned. - expect(reactive.getAccountQueryCount()).toBe(5); + // Barrier-authoritative: exactly ONE doc-shim account read (the 0 is definitive). + expect(fakeNg.getDocShimAccountReads()).toBe(1); }); - it("(2d) default budget (unset → attempts:1): genuinely-new account → single read, no retry", async () => { + it("(2d) default budget (unset pointer guard): genuinely-new account → single doc-shim account read", async () => { const fakeNg = makeFakeNg(); - inject(fakeNg); // provisionRetry unset → attempts:1 (synchronous default) + inject(fakeNg); // pointerGuard unset → attempts:1 (synchronous default) const rec = await ensureAccount("SyncUser"); - expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); - // Exactly ONE account read — the default budget does not retry. - expect(fakeNg.getAccountQueryCount()).toBe(1); + // 1 doc-shim + 3 scope docs. + expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); + expect(fakeNg.getDocShimAccountReads()).toBe(1); expect(rec.docPublic).toMatch(/^did:ng:o:doc/); }); @@ -328,6 +332,66 @@ describe("ensureAccount: anti-fork bounded retry", () => { const a = await ensureAccount("SameUser"); const b = await ensureAccount("SameUser"); expect(b).toEqual(a); - expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); + expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs + }); +}); + +// --------------------------------------------------------------------------- +// (3) Migration — legacy store-root records folded into the doc-shim +// --------------------------------------------------------------------------- + +describe("migration: legacy store-root records are read (not re-provisioned) after reconnect", () => { + beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); }); + + it("(3a) a pre-indirection account (records in the store-root, no pointer) is migrated into a new doc-shim and resolves — no fork", async () => { + const fakeNg = makeFakeNg(); + inject(fakeNg); + // Seed a LEGACY account directly in the store-root graph (old location), NO pointer. + const subj = "urn:ng-eventually:shim:account:legacyuser"; + fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:id", o: "legacyuser" }); + fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docPublic", o: "did:ng:o:legacy-pub" }); + fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:legacy-prot" }); + fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:legacy-priv" }); + + const rec = await ensureAccount("legacyuser"); + + // The legacy account was READ (migrated), NOT re-provisioned: original NURIs come + // back, and NO scope docs were created (only the fresh doc-shim: 1 doc_create). + expect(rec.docPublic).toBe("did:ng:o:legacy-pub"); + expect(rec.docProtected).toBe("did:ng:o:legacy-prot"); + expect(rec.docPrivate).toBe("did:ng:o:legacy-priv"); + expect(fakeNg.doc_create).toHaveBeenCalledTimes(1); // only the new doc-shim + + // A pointer was written, and the record now also lives in the doc-shim. + const pointerRow = fakeNg._quads.find( + (q) => q.g === ROOT && q.p === "urn:ng-eventually:shim:shimDoc", + ); + expect(pointerRow?.o).toMatch(/^did:ng:o:doc/); + const inDocShim = fakeNg._quads.some( + (q) => q.g === pointerRow!.o && q.p === "urn:ng-eventually:shim:docPublic" && q.o === "did:ng:o:legacy-pub", + ); + expect(inDocShim).toBe(true); + }); + + it("(3b) a pointer that predates migration still folds legacy store-root records into its doc-shim", async () => { + const fakeNg = makeFakeNg(); + inject(fakeNg); + // A doc-shim + pointer already exist (empty doc-shim), records still only in the root. + const docShim = "did:ng:o:existing-shim"; + fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim }); + const subj = "urn:ng-eventually:shim:account:oldtimer"; + fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:id", o: "oldtimer" }); + fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docPublic", o: "did:ng:o:old-pub" }); + fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:old-prot" }); + fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:old-priv" }); + + const rec = await ensureAccount("oldtimer"); + + expect(rec.docPublic).toBe("did:ng:o:old-pub"); + expect(fakeNg.doc_create).toHaveBeenCalledTimes(0); // doc-shim already existed + const inDocShim = fakeNg._quads.some( + (q) => q.g === docShim && q.p === "urn:ng-eventually:shim:docPublic" && q.o === "did:ng:o:old-pub", + ); + expect(inDocShim).toBe(true); }); }); diff --git a/packages/client/test/cold-start-anchor.test.ts b/packages/client/test/cold-start-anchor.test.ts index a4b6135..05575f7 100644 --- a/packages/client/test/cold-start-anchor.test.ts +++ b/packages/client/test/cold-start-anchor.test.ts @@ -82,11 +82,19 @@ function makeColdAnchorNg() { const sparql_update = mock(async (_sid: string, query: string, anchor?: string) => { if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound"); - // Parse the shim INSERT (GRAPH { a ;

"o" … }). + // TWO shapes: the POINTER write uses `GRAPH ` (keyed by IRI); the account + // record write into the doc-shim has NO explicit GRAPH (keyed by the anchor arg). const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/); - if (!gm) return undefined; - const g = gm[1]!; - const body = gm[2]!; + let g: string; + let body: string; + if (gm) { + g = gm[1]!; + body = gm[2]!; + } else { + if (!anchor) return undefined; + g = anchor; + body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, ""); + } const sm = body.match(/<([^>]+)>/); if (!sm) return undefined; const s = sm[1]!; @@ -103,6 +111,13 @@ function makeColdAnchorNg() { const sparql_query = mock(async (_sid: string, query: string, _base: unknown, anchor?: string) => { if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound"); + // Pointer SELECT (store-root -> doc-shim). + if (query.includes("")) { + const bindings = quads + .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc") + .map((q) => ({ shimDoc: { value: q.o } })); + return { results: { bindings } }; + } const subjM = query.match( /<([^>]+)>\s+a\s+/, ); diff --git a/packages/client/test/discovery.test.ts b/packages/client/test/discovery.test.ts index a9e50dc..9a05fd1 100644 --- a/packages/client/test/discovery.test.ts +++ b/packages/client/test/discovery.test.ts @@ -119,11 +119,18 @@ function makeFakeNg() { const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; - // Shim account SELECT. Two shapes: the full scan (`?acc a `) and - // the TARGETED bounded resolve (` a `), which binds one - // subject — honour that subject filter so the bounded query is O(1)/exact. + // Pointer SELECT: ` ?shimDoc` in the store-root graph. + if (query.includes(`<${SHIM}:shimDoc>`)) { + const bindings = quads + .filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`) + .map((q) => ({ shimDoc: { value: q.o } })); + return { results: { bindings } }; + } + // Shim account SELECT (anchored to the doc-shim, no GRAPH wrapper). Two shapes: + // the full scan (`?acc a `) and the TARGETED bounded resolve (` a + // `) — honour that subject filter so the bounded query is O(1)/exact. if (query.includes(`<${SHIM}:id>`)) { - const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`)); + const subjM = query.match(new RegExp(`<([^>]+)>\\s+a\\s+<${SHIM}:Account>`)); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of quads) { @@ -188,8 +195,6 @@ 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); @@ -203,8 +208,8 @@ beforeEach(() => { test("submitToIndex creates the @index special account on first sight (3 docs)", async () => { await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" }); - // ensureAccount('@index') created its 3 scope docs. - expect(fake.doc_create).toHaveBeenCalledTimes(3); + // ensureAccount('@index') created its 3 scope docs + 1 doc-shim (first login). + expect(fake.doc_create).toHaveBeenCalledTimes(4); // The deposit landed in the @index public document (its inbox). const depositCall = fake.sparql_update.mock.calls.find((c) => (c[1] as string).includes(`${INBOX}:Deposit`), diff --git a/packages/client/test/inbox.test.ts b/packages/client/test/inbox.test.ts index b30cb92..082732e 100644 --- a/packages/client/test/inbox.test.ts +++ b/packages/client/test/inbox.test.ts @@ -153,7 +153,7 @@ function inject() { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. - configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); + configureStoreRegistry({ getSession: async () => SESSION }); setCurrentUser(null); return ng; } diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index 03298bb..e19b898 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -46,7 +46,7 @@ function inject() { }; configure({ ng: ng as any, useShape: (() => {}) as any }); // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. - configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim(), provisionRetry: { attempts: 1 } }); + configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); resetRegistryCache(); resetCaps(); setCurrentUser(null); diff --git a/packages/client/test/open-repo.test.ts b/packages/client/test/open-repo.test.ts index 785c1af..f984b39 100644 --- a/packages/client/test/open-repo.test.ts +++ b/packages/client/test/open-repo.test.ts @@ -86,7 +86,6 @@ function inject(ng: ReturnType) { configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u: string) => u, - provisionRetry: { attempts: 1 }, }); } @@ -151,7 +150,6 @@ describe("ensureRepoOpen", () => { configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u: string) => u, - provisionRetry: { attempts: 1 }, }); // Must not throw; nuri is added to opened Set (guard skips subscribe) diff --git a/packages/client/test/read-model.test.ts b/packages/client/test/read-model.test.ts index e2e8ed4..45a4035 100644 --- a/packages/client/test/read-model.test.ts +++ b/packages/client/test/read-model.test.ts @@ -35,8 +35,6 @@ 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; } @@ -99,8 +97,6 @@ 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 0065333..a50cec1 100644 --- a/packages/client/test/store-registry.test.ts +++ b/packages/client/test/store-registry.test.ts @@ -112,12 +112,18 @@ function makeFakeNg() { const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; + // Pointer SELECT: ` ?shimDoc` in the store-root graph. + if (query.includes("")) { + const bindings = quads + .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc") + .map((q) => ({ shimDoc: { value: q.o } })); + return { results: { bindings } }; + } if (query.includes("")) { - // Account SELECT, scoped to the anchor graph. Two shapes: the full scan - // (`?acc a `) and the TARGETED bounded resolve (` a - // `) which binds one subject — honour that subject so the bounded - // query returns exactly that account (or nothing). - const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+/); + // Account SELECT, anchored to the doc-shim's default graph (records live in the + // doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a `) + // and the TARGETED bounded resolve (` a `) — honour the subject. + const subjM = query.match(/<([^>]+)>\s+a\s+/); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of quads) { @@ -158,8 +164,6 @@ 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; @@ -170,22 +174,31 @@ beforeEach(() => { fake = inject(); }); -test("ensureAccount creates 3 scope docs and persists them to the shim", async () => { +test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => { const rec = await ensureAccount("Alice"); expect(rec.id).toBe("Alice"); - expect(fake.doc_create).toHaveBeenCalledTimes(3); + // 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs. + expect(fake.doc_create).toHaveBeenCalledTimes(4); expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docProtected).not.toBe(rec.docPublic); expect(rec.docPrivate).not.toBe(rec.docProtected); - // Persisted into the shim anchor graph (did:ng:PRIV). - expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV"); + // The pointer (store-root -> doc-shim) was written into the store-root graph. + const pointerWrite = fake.sparql_update.mock.calls.find( + (c) => (c[1] as string).includes(""), + ); + expect(pointerWrite?.[2]).toBe("did:ng:PRIV"); + // The account record was persisted into the doc-shim (a did:ng:o: repo), not the root. + const recordWrite = fake.sparql_update.mock.calls.find( + (c) => (c[1] as string).includes(""), + ); + expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/); }); test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => { const a = await ensureAccount("Alice"); const b = await ensureAccount("@alice"); expect(b).toEqual(a); - expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6 + expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7 }); test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => { @@ -202,8 +215,9 @@ test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 d ensureAccount("BOB"), ensureAccount("bob"), ]); - // Exactly ONE set of 3 docs was created — not 5×3. - expect(fake.doc_create).toHaveBeenCalledTimes(3); + // ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer + // resolution too) — 4 total, not 5×3. + expect(fake.doc_create).toHaveBeenCalledTimes(4); // Every caller got the SAME record (same docs), so writer/reader can never // disagree on the canonical scope doc. for (const r of results) expect(r).toEqual(results[0]!); @@ -236,8 +250,6 @@ 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"); @@ -384,10 +396,10 @@ test("normalizeId defaults to trim when not provided", async () => { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. - configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); + configureStoreRegistry({ getSession: async () => SESSION }); resetRegistryCache(); const a = await ensureAccount(" Ivy "); const b = await ensureAccount("Ivy"); // trimmed key matches expect(b).toEqual(a); - expect(ng.doc_create).toHaveBeenCalledTimes(3); + expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs }); diff --git a/packages/client/test/subscribe.test.ts b/packages/client/test/subscribe.test.ts index 3740840..72d2ed2 100644 --- a/packages/client/test/subscribe.test.ts +++ b/packages/client/test/subscribe.test.ts @@ -50,7 +50,7 @@ function inject(failFor?: Set) { const ng = makeFakeNg(failFor); configure({ ng: ng as any, useShape: (() => {}) as any }); // Synchronous fake store → no sync lag; disable the anti-fork retry backoff. - configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } }); + configureStoreRegistry({ getSession: async () => SESSION }); return ng; } diff --git a/packages/client/test/watch-shape.test.ts b/packages/client/test/watch-shape.test.ts index f02ab3b..53c6b1e 100644 --- a/packages/client/test/watch-shape.test.ts +++ b/packages/client/test/watch-shape.test.ts @@ -103,6 +103,15 @@ function makeFake(opts?: { holdState?: boolean }) { const p = m[1] ?? "urn:ng-eventually:shim:Account"; const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); quads.push({ g, s, p, o }); + // The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like + // the store-root: `doc_create` bootstrapped it into the session, so its barrier + // `State` is immediately available. Pre-release it so `holdState` (which gates the + // per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is + // published BEFORE the doc-shim barrier open (resolveShimDoc first-login order). + if (p === "urn:ng-eventually:shim:shimDoc") { + released.add(o); + for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } }); + } } // A write to a subscribed doc fires a Patch push (reactivity signal). for (const sub of subs) { @@ -114,9 +123,16 @@ function makeFake(opts?: { holdState?: boolean }) { const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; + // Pointer SELECT (store-root -> doc-shim). + if (query.includes("")) { + const bindings = quads + .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc") + .map((q) => ({ shimDoc: { value: q.o } })); + return { results: { bindings } }; + } if (query.includes("")) { const subjM = query.match( - /GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+/, + /<([^>]+)>\s+a\s+/, ); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>();