/** * open-repo — cold-start repo opening for the ANCHORED read path (polyfill-era). * * ── The cold-start defect this heals ────────────────────────────────────── * The anchored read path (`surface/read-document.ts` `readDocumentSubjects`, * `shared-wallet/account-registry.ts` `readUserStore`) assumes the target repo is * already usable by the verifier — true within the session that CREATED the doc * (every `doc_create` opens it), but FALSE on a FRESH session over the same * persistent wallet (reconnection / new page / re-login): the repos are on the broker * and in the profile's cache, but this session has not synced them, so a persisted * document reads as empty. * * **The mechanism, corrected 2026-08-03.** This comment used to say the verifier * "silently returns 0 rows (never a `RepoNotFound`)" for a repo absent from * `self.repos`. That is FALSE at the source: `resolve_target_for_sparql` does * `self.repos.get(repo_id).ok_or(NgError::RepoNotFound)?` * (`engine/verifier/src/request_processor.rs:264,269`), which surfaces as a rejected * promise. Two things produce the 0 rows actually observed, and neither is silence in * the verifier: on a persistent profile `Verifier::load` repopulates `self.repos` from * user storage at construction (`engine/verifier/src/verifier.rs:535-560`), so the repo * is PRESENT but unsynced and the anchored query legitimately matches nothing; and this * library's own per-document read caught every error and returned `[]` * (`surface/read-model.ts`'s `readDoc`, until `1.0.0-dev.8` removed it along with the * batch it served), so anything that did throw reached the caller as emptiness anyway. * It no longer does: `surface/read-document.ts` lets the rejection through. The fix below is right; the diagnosis written beside it was not. * * The circularity that made this self-inflicted: `doc_subscribe` WOULD open the * repo, but the reactive layer only subscribes AFTER the listing produced NURIs — * and the listing (`readUserStore`) is itself an anchored read of a not-yet-open * index repo → 0 rows → nothing to subscribe → nothing ever opens. Verified fix * (adversarial pass): on a fresh session, `doc_subscribe()` THEN the * anchored re-read returns the data. So we OPEN the repo before the anchored read. * * ── How we open ─────────────────────────────────────────────────────────── * We reuse the existing per-document primitive {@link subscribeDoc} (the typed * wrapper over the platform's `doc_subscribe`) — NOT a parallel channel. On * subscribe the platform pushes `TabInfo` FIRST (~1-3ms) and then the initial * `State` (~2-3ms); the FIRST `State` is the sync BARRIER — after it, presence is * guaranteed and absence definitive (pinned empirically by CONTRACT 3 in `e2e/`). * So we await that first `State` specifically (identified via the `type` argument * {@link DocChangeType} `subscribeDoc` now surfaces), NOT the first push of any * kind — resolving on `TabInfo` would return before the real barrier. The * subscription is kept ALIVE for the whole session (that is what keeps the repo * open) — it is a bootstrap open, distinct from any reactive subscription a caller * later establishes for change signals. * * ── Idempotence / perf (once per session, no polling) ───────────────────── * The registry opens each repo at most ONCE per session: `opened` records completed * opens (a hit skips everything), `inFlight` de-dupes concurrent opens of the same * repo. A brand-new page / module instance starts with an empty registry; and when * the injected session id CHANGES within the same page (an in-page re-login / * `session_stop`+`session_start`, whose new verifier has an empty `self.repos`) the * registry auto-resets (`syncSession`) so repos are re-opened against the new session * rather than wrongly skipped as "already open". No polling: we wait on the first * `State` push, with a bounded fallback timeout so a missing push can't hang. * * ── Sync state (per-nuri, lib-internal) ─────────────────────────────────── * Each nuri carries an explicit sync state, readable via {@link getSyncState}: * `"syncing"` — subscribed, no `State` yet (barrier not reached); * `"synced"` — first `State` received (barrier reached — the TRUTH signal); * `"timed-out"` — the bounded fallback fired with NO `State` (open proceeded so * the read is never blocked, but this is NOT `"synced"`: a future * "ready" signal must not mistake a timeout for a real barrier); * `"unknown"` — never requested (or the fake-ng no-op path: no `State` semantics). * This distinction is the point — `"synced"` and `"timed-out"` are kept apart so a * later reactive readiness layer can trust `"synced"` and treat `"timed-out"` as * "opened best-effort, sync unconfirmed". * * ── Migration ───────────────────────────────────────────────────────────── * At the real multi-store migration this becomes "open the user's store repo by * cap" (a native broker fetch) done once at bootstrap; the anchored read then * resolves a same-session repo directly. Polyfill-era, removed with the shim. */ import { mustNotAttempt } from "./reach"; import { fetchReadCap } from "./public-store"; import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { resubscribeDocs, subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe"; import { logStage, shortNuri } from "../shared-wallet/access-log"; import type { Nuri } from "../model/types"; /** * The per-nuri bootstrap sync state (lib-internal). See the module header: * - `"syncing"` subscribed, first `State` not yet received; * - `"synced"` first `State` received — the real sync barrier (CONTRACT 3); * - `"timed-out"` fallback fired without a `State` — open proceeded, sync UNconfirmed; * `"unknown"` (from {@link getSyncState}) means "never requested / no `State` semantics". */ // @provenance SyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — the first `State` push IS the barrier; `timed-out` names the bounded fallback and is deliberately not `synced` export type SyncState = "syncing" | "synced" | "timed-out"; /** Repos whose bootstrap open has completed (first `State` received OR timed out). */ const opened = new Set(); /** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */ const inFlight = new Map>(); /** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */ const held = new Map(); /** Explicit per-nuri sync state — the barrier signal, distinct from `opened`. * `synced` and `timed-out` are NOT merged (see module header). */ const syncState = new Map(); /** The session id the current `opened`/`held` entries belong to. A change means a * new verifier session (fresh `self.repos`) → the registry must be invalidated. */ let boundSessionId: string | number | null = null; /** * Max wait (ms) for the initial-state push before proceeding with the read anyway. * The push normally lands quickly once the repo loads; the timeout only guards the * pathological case (a doc that never pushes), so a read is never blocked forever — * it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc). */ let OPEN_TIMEOUT_MS = 8000; /** * Override the bootstrap-open fallback timeout (ms). TEST-ONLY: the timed-out * branch (a doc that never pushes a `State`) is otherwise only reachable after the * 8s production wait, too slow for a unit test. Production never calls this — the * default stands. `resetOpenedRepos` restores the default. */ // @provenance setOpenTimeoutForTests kind=invention level=none ref=none — a test seam over the bounded fallback export function setOpenTimeoutForTests(ms: number): void { OPEN_TIMEOUT_MS = ms; } /** Reset the open registry (mainly for tests / a switched wallet). Tears down the * held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */ // @provenance resetOpenedRepos kind=invention level=none ref=none — per-session bookkeeping reset; upstream the verifier owns `self.repos` and nothing resets it from outside export function resetOpenedRepos(): void { for (const unsub of held.values()) { try { unsub(); } catch { /* ignore */ } } opened.clear(); inFlight.clear(); held.clear(); syncState.clear(); boundSessionId = null; OPEN_TIMEOUT_MS = 8000; // Whatever ELSE was still subscribed to a document - an inbox observation, an // application's own `subscribeDoc` - belongs to the session that just went away too. One real // subscription now serves all of them (`surface/subscribe.ts`), so dropping the bootstrap // ones above no longer closes those channels; they are re-opened against the new session, // with their barrier forgotten. Without this a bootstrap open would join a surviving // fan-out, be handed the PREVIOUS session's `State`, and call a repo the new session has // never synced "synced". After the held teardown, so a document nobody else follows is // simply released. resubscribeDocs(); } /** * The bootstrap sync state of `nuri` (lib-internal accessor; NOT a reactive * app-facing hook — that is a later phase). Returns `"unknown"` if the repo was * never opened via {@link ensureRepoOpen} (or was opened on the fake-ng no-op * path, which has no `State` semantics). Otherwise `"syncing"` (subscribed, no * `State` yet), `"synced"` (first `State` received — the barrier), or * `"timed-out"` (fallback fired without a `State`). `"synced"` and `"timed-out"` * are deliberately distinct — a later readiness signal must not confuse them. */ // @provenance getSyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — reports whether the first `State` arrived; upstream this is not observable from JS export function getSyncState(nuri: Nuri): SyncState | "unknown" { return syncState.get(nuri) ?? "unknown"; } /** * Invalidate the registry if the active session id changed since it was populated. * A new session id means a new verifier with an EMPTY `self.repos`, so entries from * the previous session must NOT suppress re-opening under the new one. Tolerant: if * the session can't be resolved, keep the current registry (best effort). */ async function syncSession(): Promise { let sid: string | number | null = null; try { sid = (await getStoreRegistryDeps().getSession()).sessionId; } catch { return; // no session deps wired (unit fake path) — nothing to invalidate against } if (boundSessionId !== null && boundSessionId !== sid) resetOpenedRepos(); boundSessionId = sid; } /** * Ensure `nuri`'s repo is OPEN in the current session before an anchored read, * so the cold-start (fresh session, same persistent wallet) resolves it instead * of returning 0 rows. Opens via {@link subscribeDoc} and awaits the first `State` * push — the sync barrier (bounded fallback marks the nuri `"timed-out"`, not * `"synced"`). Idempotent: a repo already opened (or in flight) is not re-opened. * * Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g. * the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged. * Never throws; a failed open just leaves the read to behave as it did before. * * `holderKey` names WHOSE possession decides, when it is not the connected identity's — * the emulated inbox processor opening an inbox repo for its owner during someone else's * session (`emulated-verifier/inbox-processor.ts`). The public-store fetch is then skipped * on purpose: it files what it obtains for whoever is CONNECTED, so asking on behalf of * another holder would put a cap in the wrong ring, and a caller on that path has already * established what its holder holds. */ // @provenance ensureRepoOpen kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — a real subscription held for the session — upstream `doc_subscribe` is what brings a repo into `self.repos`, which `resolve_target_for_sparql` then requires export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise { if (!nuri) return; // A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on // the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK // before deciding whether we may touch it, or the answer would be "no" purely for // want of asking, and a bare reference to a public document would never suffice. // Memoised and inert once the cap is held (see public-store.ts). if (holderKey === undefined) await fetchReadCap(nuri); // RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and // pulls its state. A user that holds no cap for it has no business asking. // (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.) if (mustNotAttempt(nuri, holderKey)) return; return openRepoUnguarded(nuri); } /** * The unguarded core. Exported for ONE importer — `shared-wallet/physical.ts` — and * for nobody else; neither entry point re-exports it. The `Unguarded` suffix is the * warning, and the single importer is what keeps it honest. */ // @provenance openRepoUnguarded kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_subscribe — the same open without the reach guard; internal export async function openRepoUnguarded(nuri: Nuri): Promise { // Drop the registry if the session changed (in-page re-login → fresh verifier). await syncSession(); if (opened.has(nuri)) return; const pending = inFlight.get(nuri); if (pending) return pending; // No reactive primitive on the injected ng (fake-ng unit suite): nothing to open, // no `State` to await → preserve the old immediate-resolve behaviour so `bun test` // does not regress. No sync state is recorded (getSyncState → "unknown"): the fake // path has no barrier semantics, and claiming "synced" here would be a lie. const ng = getConfig().ng as { doc_subscribe?: unknown }; if (typeof ng.doc_subscribe !== "function") { opened.add(nuri); return; } // Subscribed, first `State` not yet seen. syncState.set(nuri, "syncing"); // Barrier clock: how long the subscribe→first-State (or fallback) round-trip // took, surfaced on the BARRIER trace line below — the most important line in // the whole low-level data-path trace: it distinguishes a genuine absence // (`synced` → a 0-row read means it) from a not-yet-synced read (`timed-out`). const barrierStartedAt = Date.now(); const p = (async () => { await new Promise((resolve) => { let settled = false; // Resolve on the first `State` (the sync BARRIER), marking the nuri "synced". const onState = (): void => { if (settled) return; settled = true; syncState.set(nuri, "synced"); logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)"); resolve(); }; // Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT // "synced". A genuinely-absent doc reads 0 rows anyway (same as before, never // a hang); the distinct state keeps a future "ready" signal from lying. const onTimeout = (): void => { if (settled) return; settled = true; syncState.set(nuri, "timed-out"); logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)"); resolve(); }; // The bootstrap subscription is kept ALIVE for the session — holding it open // is the whole point. We wait for the FIRST `State` event specifically (the // barrier), NOT any push: the platform pushes `TabInfo` before `State`, and // resolving on `TabInfo` would return before the real sync barrier. // Unguarded on purpose: the caller already decided. `ensureRepoOpen` applied // rule 2 above; `ensurePhysicalRepoOpen` is the machinery's door and is not // subject to the boundary at all (see physical.ts). const unsub = subscribeDocUnguarded(nuri, (_r, type) => { if (type === "State") onState(); }); held.set(nuri, unsub); setTimeout(onTimeout, OPEN_TIMEOUT_MS); }); opened.add(nuri); inFlight.delete(nuri); })(); inFlight.set(nuri, p); return p; }