/** * 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**, * 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. * * ── 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 { escapeLiteral, escapeIri, assertNuri } from "./sparql"; 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`; 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 shim 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 shim lives in the shared wallet's private store (always-known anchor). */ async function anchorNuri(): 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(); /** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */ export function resetRegistryCache(): void { cache = null; accountCache.clear(); } // --- 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 ?? ""; } // --- shim load / account bootstrap ---------------------------------------- /** Load all accounts from the shim into the cache. */ export async function loadShim(): Promise> { if (cache) return cache; const s = await session(); const anchor = await anchorNuri(); const query = ` SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE { GRAPH <${assertNuri(anchor)}> { ?acc a <${P.type}> ; <${P.id}> ?id ; <${P.docPublic}> ?docPublic ; <${P.docProtected}> ?docProtected ; <${P.docPrivate}> ?docPrivate . } }`; const map = new Map(); try { const result = await sparqlQuery(s.sessionId, query, undefined, anchor); for (const row of readBindings(result)) { const id = bindingValue(row, "id"); if (!id) continue; const key = accountKey(id); const record: AccountRecord = { id, docPublic: bindingValue(row, "docPublic"), docProtected: bindingValue(row, "docProtected"), docPrivate: bindingValue(row, "docPrivate"), }; 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("[storeRegistry] 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, * instead of scanning EVERY account like {@link loadShim}. Returns the account's * record or `null` if it does not exist yet. * * 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 anchor = await anchorNuri(); // `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a // trusted-shaped NURI → assertNuri. 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 . } }`; try { const result = await sparqlQuery(s.sessionId, query, undefined, anchor); const rows = readBindings(result); if (rows.length === 0) return null; const row = rows[0]!; const record: AccountRecord = { id: bindingValue(row, "id") || id, docPublic: bindingValue(row, "docPublic"), docProtected: bindingValue(row, "docProtected"), docPrivate: bindingValue(row, "docPrivate"), }; accountCache.set(key, record); return record; } catch (error) { console.error("[storeRegistry] resolveAccount failed:", error); 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 { 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); } /** * Ensure an account exists in the shim, creating its 3 scope documents on * first sight. Idempotent — returns the existing record if already present. */ export async function ensureAccount(id: string): Promise { const key = accountKey(id); // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead // of a full-shim scan (loadShim). Off the read/write hot path entirely. const existing = await resolveAccount(id); if (existing) return existing; 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(); 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); } catch (error) { console.error("[storeRegistry] ensureAccount persist failed:", error); } // 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; } // --- 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) 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. */ 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, ); } catch (error) { console.error("[storeRegistry] 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[] = []; 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, ); for (const row of readBindings(res)) { const v = bindingValue(row, "e"); if (v) out.push(v); } } catch (error) { console.error("[storeRegistry] readScopeIndex read failed:", error); } 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 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)); }