/** * storeRegistry — resolves (account, scope) → document NURI. * * Stopgap / polyfill-era. Emulates the target infrastructure — where each * user owns their own public/protected/private stores — on top of one shared * wallet. It creates one document per (account × scope) inside that shared * wallet (via the `docs.docCreate` primitive), so the `scope` * (`public|protected|private`) is a logical attribute tracked here, not a * 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**. 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 * reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every * device converges on the SAME doc-shim. 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 * entity kind. The consumer maps its entities to a scope and calls * `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native * scope. No application domain here. * * ── What disappears at migration ───────────────────────────────────────── * At the real multi-store migration the shim vanishes entirely: `(account, * scope)` maps to the user's REAL store NURI instead of a document in the * shared wallet, `docCreate` targets the real per-user store, and the * per-scope index document (the store-container emulation) is replaced by the * store itself. The consumer-facing surface (`createEntityDoc`, * `listEntityDocs`, resolvers) is designed to survive that swap unchanged. * * All NextGraph I/O routes through the T01.a `docs` primitive (real injected * `ng`), so this module imports **no** `@ng-org` package. */ import { docCreate, sparqlUpdate, sparqlQuery } from "./docs"; import { getStoreRegistryDeps } from "./polyfill"; import { ensureRepoOpen } from "./open-repo"; import { escapeLiteral, escapeIri, assertNuri } from "./sparql"; import { accessLogPrefix, logStage, shortNuri } from "./access-log"; import type { Nuri, Scope } from "./types"; // --- sharedWalletShim model ---------------------------------------------- /** One account's three scope-document NURIs, as recorded in the shim. */ export interface AccountRecord { id: string; docPublic: Nuri; docProtected: Nuri; docPrivate: Nuri; } const SHIM = "urn:ng-eventually:shim"; const P = { type: `${SHIM}:Account`, id: `${SHIM}:id`, docPublic: `${SHIM}:docPublic`, docProtected: `${SHIM}:docProtected`, docPrivate: `${SHIM}:docPrivate`, contains: `${SHIM}:contains`, // scope-index → entity document NURI } as const; // Fixed subject of the per-(account×scope) index document. The index doc plays // the role of the future store-container: it lists the NURIs of the entity // 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 // the `<...>` and inject triples into the shim graph (the account→doc trust // root). accountKey() runs first so the subject stays stable per shim key. return `${SHIM}:account:${escapeIri(accountKey(id))}`; } // --- reserved accounts ----------------------------------------------------- // // Some accounts are internal to the lib (e.g. the discovery index owner) and // must NOT collide with any user-chosen id. A reserved account is created // via {@link reservedAccount}, which marks the name with a sentinel PREFIX that // `normalizeId` (consumer-injected) can never produce: it strips a leading // `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved // keys therefore live in a disjoint namespace from every normalized id — // a real user named "index"/"@index" can never resolve to the reserved // `reservedAccount("index")` account. const RESERVED_PREFIX = "\u0000reserved:"; /** * Wrap an internal account name so it occupies a key that no user input can * produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount} * (and the other registry calls) instead of a bare id. */ export function reservedAccount(name: string): string { return `${RESERVED_PREFIX}${name}`; } /** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */ function isReserved(id: string): boolean { return id.startsWith(RESERVED_PREFIX); } /** * The shim/cache key for an account. Reserved accounts bypass `normalizeId` * entirely and key on their sentinel-prefixed name, so they cannot collide with * a normalized id; everyone else normalizes as usual. */ function accountKey(id: string): string { return isReserved(id) ? id : normalize(id); } // --- session / normalization access (injected by the consumer) ------------ /** Minimal session shape the registry needs — provided by the consumer. */ export interface RegistrySession { sessionId: string; /** 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. */ protectedStoreId?: string; /** The shared wallet's public store id (native store). Optional. */ publicStoreId?: string; } function normalize(id: string): string { return getStoreRegistryDeps().normalizeId(id); } async function session(): Promise { return getStoreRegistryDeps().getSession(); } /** 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}`; } // --- cache ---------------------------------------------------------------- // In-memory cache of the FULL shim (all accounts), keyed by account key. Set // only once loadShim() has read every account — used by the all-accounts paths. let cache: Map | null = null; // Per-account cache, keyed by account key. Populated by the TARGETED resolver // (resolveAccount) and by loadShim(). Independent of `cache` so a single // 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; } // --- SPARQL result helpers ------------------------------------------------ /** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */ function readBindings(result: unknown): Array> { if (!result) return []; const anyRes = result as { results?: { bindings?: Array> }; }; if (Array.isArray(result)) return result as Array>; if (anyRes.results?.bindings) return anyRes.results.bindings; return []; } function bindingValue(row: Record, key: string): string { return row[key]?.value ?? ""; } /** * 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 (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 * bindings (the cross-product of the duplicate values). Picking `rows[0]` is * NON-DETERMINISTIC (binding order is not stable across sessions), so the session * that WROTE an entity into one docPublic and a later fresh page that RESOLVED a * DIFFERENT docPublic would disagree → the anchored `readScopeIndex` returns 0 → * the home reads empty. When both happen to pick the same doc, it "works". * * The fix: for each scope field, collect EVERY distinct value across the bindings * and choose the SAME one every time — the lexicographically-smallest NURI. NURIs * are content-addressed and stable, so lexicographic order is a total, stable, * 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 = ""; const distinct = new Set(); for (const row of rows) { const v = bindingValue(row, key); if (!v) continue; distinct.add(v); if (chosen === "" || v < chosen) chosen = v; } // Stage trace: which doc got picked, and out of how many DISTINCT candidate // values — >1 flags residual fork residue (see the module doc above) even // when resolution still converges correctly on the canonical (smallest) one. logStage( "canonicalDoc(" + key + ") → " + (chosen ? shortNuri(chosen) : "none") + " (" + distinct.size + (distinct.size === 1 ? " candidate)" : " candidates)"), ); return chosen; } /** Build an AccountRecord by picking the canonical (lexicographically-smallest) * doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */ function recordFromRows( rows: Array>, fallbackId: string, ): AccountRecord { let id = ""; for (const row of rows) { const v = bindingValue(row, "id"); if (v) { id = v; break; } } return { id: id || fallbackId, docPublic: canonicalDoc(rows, "docPublic"), docProtected: canonicalDoc(rows, "docProtected"), docPrivate: canonicalDoc(rows, "docPrivate"), }; } // --- pointer resolution + doc-shim bootstrap ------------------------------ 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. */ async function resolvePointer(): Promise { const s = await session(); 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) { logStage("resolvePointer → 1 target: " + shortNuri(doc)); return doc; } } catch (error) { console.error(accessLogPrefix() + " resolvePointer failed:", error); } if (i < attempts - 1) { await sleep(step); step = Math.min(step * 2, maxStepMs); } } logStage("resolvePointer → 0 targets"); 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(accessLogPrefix() + " 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. * 2. No pointer → FIRST login: * 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). * 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); shimDocNuri = existing; logStage("resolveShimDoc → " + shortNuri(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). const doc = await createDoc(); await writePointer(doc); await ensureRepoOpen(doc); shimDocNuri = doc; logStage("resolveShimDoc → " + shortNuri(doc)); return doc; })(); shimDocInFlight = p; try { return await p; } finally { shimDocInFlight = null; } } // --- 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 + // canonical selection makes loadShim resolve the SAME doc the targeted // resolveAccount does, so full-scan and hot-path readers never disagree. const byKey = new Map>>(); for (const row of readBindings(result)) { const id = bindingValue(row, "id"); if (!id) continue; const key = accountKey(id); const bucket = byKey.get(key) ?? []; bucket.push(row); byKey.set(key, bucket); } for (const [key, rows] of byKey) { const record = recordFromRows(rows, rows[0] ? bindingValue(rows[0], "id") : key); map.set(key, record); // Feed the per-account cache too, so a subsequent targeted resolve is free. accountCache.set(key, record); } } catch (error) { console.error(accessLogPrefix() + " loadShim failed:", error); } cache = map; return map; } /** * 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 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. */ export async function resolveAccount(id: string): Promise { const key = accountKey(id); const cached = accountCache.get(key); if (cached) return cached; const s = await session(); 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 { <${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, doc, "resolveAccount"); const rows = readBindings(result); if (rows.length === 0) { logStage("resolveAccount(" + key + ") → null"); 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 (robustness against PAST fork residue). const record = recordFromRows(rows, id); accountCache.set(key, record); logStage("resolveAccount(" + key + ") → 1 record"); return record; } catch (error) { console.error(accessLogPrefix() + " resolveAccount failed:", error); return null; } } /** All known accounts (from the shim). */ export async function allAccounts(): Promise { return [...(await loadShim()).values()]; } /** 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(); 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(accessLogPrefix() + " 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 (concurrent-provision de-dup) ────────────────── * On a fresh page over the persistent wallet, SEVERAL independent callers hit * `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>(); /** * Ensure an account exists in the shim, creating its 3 scope documents on * first sight. Idempotent — returns the existing record if already present. * Concurrency-safe: concurrent calls for the same account share one provision * (see {@link ensureInFlight}) so a fresh page never FORKS the account. */ export async function ensureAccount(id: string): Promise { const key = accountKey(id); // A completed provision/resolve is cached → no query, no fork risk. const cached = accountCache.get(key); if (cached) return cached; // A concurrent provision for the SAME account is already running → await it, // instead of racing a second (forking) provision. This is the anti-fork guard. const pending = ensureInFlight.get(key); if (pending) return pending; const p = (async (): 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. // // 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 }; // 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); cache?.set(key, record); return record; })(); ensureInFlight.set(key, p); try { return await p; } finally { ensureInFlight.delete(key); } } // --- resolvers ------------------------------------------------------------ /** The index document NURI of an account for a scope (the store-container). */ function indexDocOf(record: AccountRecord, scope: Scope): Nuri { return scope === "public" ? record.docPublic : scope === "protected" ? record.docProtected : record.docPrivate; } /** * NURI of the document where `id` writes GROUPED entities of `scope` (a single * per-scope index document, for entities that need no per-entity document / no * inbox). For per-entity scopes use {@link createEntityDoc} instead. */ export async function resolveWriteGraph(id: string, scope: Scope): Promise { const record = await ensureAccount(id); return indexDocOf(record, scope); } /** NURIs of every account's document for `scope` (read fan-out). */ export async function resolveReadGraphs(scope: Scope): Promise { const accounts = await allAccounts(); return accounts.map((a) => indexDocOf(a, scope)); } // --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ---------- // // The consumer asks by SCOPE ("give me the graph to write/read entities of // scope X", "give me the inbox anchor") and NEVER constructs a `did:ng:${…}` // store NURI itself. The lib owns the physical placement — which is the whole // point of the SDK boundary. In THIS polyfill the placement is the shared // wallet's native stores (Axis A, per the two-axes doctrine in // docs/simulation.md): a scope maps to a native store NURI resolved from the // injected session. `public` currently co-locates with `protected` because // `doc_create`/ORM cannot target a non-private/protected native store today // (the SDK blocker recorded in migration-guide.md); at migration each scope // resolves to the user's REAL per-scope store and this mapping changes here, // in the lib, with no consumer change. /** The native store NURI backing `scope`, resolved from the injected session. * Requires `protectedStoreId` on the session for the non-private scopes. */ async function scopeStoreNuri(scope: Scope): Promise { const s = await session(); if (scope === "private") return `did:ng:${s.privateStoreId}`; // public + protected → the protected native store (see note above). Falls // back to the private store if the session didn't carry a protected id. const store = s.protectedStoreId ?? s.privateStoreId; return `did:ng:${store}`; } /** * The graph NURI where the current session WRITES entities of `scope`, and * whose repo `useShape` must subscribe to read them back. SDK-shaped: the * consumer passes a logical scope and gets an opaque graph NURI — it holds no * store-id and builds no NURI. Use the returned value as both the read scope * (`useShape(shape, nuri)`) and the `@graph` write target. */ export async function resolveScopeGraph(scope: Scope): Promise { return scopeStoreNuri(scope); } /** * The reserved account that OWNS the shared registration-inbox document. Like the * discovery index's special account, it lives in the reserved namespace (no user * can produce this key) and only HOSTS a document — its `public` scope document is * the inbox anchor. Disappears at migration (native per-document inboxes). */ const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox"); /** * The inbox anchor NURI for the current session (where emulated inbox deposits * physically land). SDK-shaped: the consumer never resolves a store itself. * * 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) 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); return record.docPublic; } // --- per-entity documents + per-scope index ------------------------------- /** * Create a dedicated document for ONE entity — mirrors the target, where each * such entity is its own document/repo (addressable, future inbox). The new * document's NURI is appended to the account's scope index document (the * store-container). Returns the entity document NURI (use it as `@graph`). */ export async function createEntityDoc(id: string, scope: Scope): Promise { const record = await ensureAccount(id); const indexDoc = indexDocOf(record, scope); const entityNuri = await createDoc(); const s = await session(); try { await sparqlUpdate( s.sessionId, // NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the // `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the // anchored default-graph read queries (readScopeIndex below, same as // read-model.ts). Not a round-trip necessity on the current broker: the e2e // harness (`packages/client/e2e/`) verified an anchored `GRAPH ` // write ALSO round-trips here (same repo graph, no phantom graph); no-GRAPH // is kept as a simplicity/safety convention. entityNuri is a NURI stored as // a literal → escapeLiteral. `INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`, indexDoc, "createEntityDoc", ); } catch (error) { console.error(accessLogPrefix() + " createEntityDoc index append failed:", error); } return entityNuri; } /** Read the entity-document NURIs contained in ONE scope index document. */ async function readScopeIndex(indexDoc: Nuri): Promise { const s = await session(); const out: Nuri[] = []; // COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the // scope-index repo (public OR protected — the protected one carries participations // and is the one that most often reads empty) is not yet in `self.repos`, so this // anchored read would return 0 NURIs → nothing gets listed → nothing gets // subscribed (the self-inflicted circularity). Open/subscribe the index repo ONCE // before reading it. Idempotent per session; no-op with the unit fake ng. See // open-repo.ts. await ensureRepoOpen(indexDoc); try { const res = await sparqlQuery( s.sessionId, // NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see // the note in createEntityDoc). The `indexDoc` anchor scopes the query. `SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`, undefined, indexDoc, "readScopeIndex", ); for (const row of readBindings(res)) { const v = bindingValue(row, "e"); if (v) out.push(v); } } catch (error) { console.error(accessLogPrefix() + " readScopeIndex failed:", error); } logStage("readScopeIndex(" + shortNuri(indexDoc) + ") → " + out.length + " entities"); return out; } /** * Every entity document NURI of `scope`, across all accounts — the read * fan-out for per-entity scopes. Reads each account's scope index document and * unions the contained NURIs. Use as `useShape(shape, { graphs })`. * * NOTE (read-by-need): this ALL-ACCOUNTS fan-out contradicts the read-by-need * model (docs/read-model.md) — it opens/syncs other accounts' possibly-unsynced * docs, which HANGS. Prefer {@link listMyEntityDocs} (my own account's scope * docs) for "my entities", and the discovery index for "all public events". * Retained for callers that legitimately need every account (tests). */ export async function listEntityDocs(scope: Scope): Promise { const accounts = await allAccounts(); const out: Nuri[] = []; for (const a of accounts) { out.push(...(await readScopeIndex(indexDocOf(a, scope)))); } return out; } /** * The scope-INDEX document NURI of ONE account (`id`) for `scope` — the * store-container document that LISTS the account's per-entity document NURIs * (what {@link listMyEntityDocs} reads). Exposed so a reactive reader * ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the * entity-doc set when the index changes (a new entity created appends a NURI * here). Idempotent via `ensureAccount`'s cache. At migration this becomes the * user's real per-scope store NURI (the container the store itself provides). */ export async function scopeIndexDoc(id: string, scope: Scope): Promise { const record = await ensureAccount(id); return indexDocOf(record, scope); } /** * The entity-document NURIs of `scope` belonging to ONE account (`id`) — * the read-by-need path for one account's own entities. Bounded to a SINGLE * account: it resolves only that account's scope index doc (via `ensureAccount`) * and reads the contained NURIs — NO cross-account fan-out, so it never touches * another account's unsynced docs. This is the helper a consumer application uses * for its own my-entities path, instead of the all-accounts `listEntityDocs`. */ export async function listMyEntityDocs(id: string, scope: Scope): Promise { const record = await ensureAccount(id); return readScopeIndex(indexDocOf(record, scope)); }