70de7afa3c
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
840 lines
38 KiB
TypeScript
840 lines
38 KiB
TypeScript
/**
|
||
* 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));
|
||
}
|