Files
ng-eventually/packages/client/src/store-registry.ts
T
Sylvain Duchesne 70de7afa3c feat(logs): logs données restructurés — identité en préfixe, trace résolution/barrière, inspection outbox
Chemin données bas-niveau du polyfill rendu lisible pour diagnostiquer en session live :

- Format identité-en-premier : `[<identity>][polyfill] OP shortNuri (label)` ;
  console.error épars (store-registry, inbox) unifiés au même préfixe.
- Trace (derrière le flag debug) : issue de la barrière ensureRepoOpen
  (synced|timed-out + durée) et résultat sémantique de chaque étage de résolution
  (resolvePointer/canonicalDoc/resolveAccount/resolveShimDoc/readScopeIndex).
- outbox-log.ts (nouveau) : inspection read-only de l'outbox hors-ligne au
  démarrage de session ; console.warn si non vide (anomalie, toujours visible),
  sous flag si vide. Le comptage seul est fiable (payloads BARE opaques côté JS).

Pas de changement fonctionnel. bun test 126 pass ; tsc 0 erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-14 10:37:46 +02:00

840 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:<RepoID>`
* 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<RegistrySession> {
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<Nuri> {
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<string, AccountRecord> | 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<string, AccountRecord>();
// 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<Nuri> | 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<Record<string, { value: string }>> {
if (!result) return [];
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
if (anyRes.results?.bindings) return anyRes.results.bindings;
return [];
}
function bindingValue(row: Record<string, { value: string }>, 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<Record<string, { value: string }>>, key: string): Nuri {
let chosen = "";
const distinct = new Set<string>();
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<Record<string, { value: string }>>,
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<void> => 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<Nuri> {
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<void> {
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<Nuri> {
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<Nuri> {
if (shimDocNuri) return shimDocNuri;
if (shimDocInFlight) return shimDocInFlight;
const p = (async (): Promise<Nuri> => {
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<Map<string, AccountRecord>> {
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<string, AccountRecord>();
// 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<string, Array<Record<string, { value: string }>>>();
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<AccountRecord | null> {
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<AccountRecord[]> {
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<void> {
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<string, Promise<AccountRecord>>();
/**
* 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<AccountRecord> {
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<AccountRecord> => {
// 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<Nuri> {
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<Nuri[]> {
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<Nuri> {
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<Nuri> {
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<Nuri> {
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<Nuri> {
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 <plainNuri>`
// 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<Nuri[]> {
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<Nuri[]> {
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<Nuri> {
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<Nuri[]> {
const record = await ensureAccount(id);
return readScopeIndex(indexDocOf(record, scope));
}