Files
ng-eventually/packages/polyfill/e2e/polyfill-entry.ts
T
2026-09-24 18:42:32 +02:00

1308 lines
60 KiB
TypeScript

/**
* Polyfill e2e harness entry — the MINIMAL page loaded inside the broker iframe.
*
* It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/polyfill`),
* configures the polyfill session injection exactly the way a consumer does
* (`configure` + `configureStoreRegistry`), waits for the real broker to hand back
* a session, then exposes `window.__sdk`: a flat bag of async methods the
* Playwright test drives. This has ZERO application domain — no Festipod shapes,
* screens, or entities. It exercises the polyfill's OWN surface against the real
* broker.
*
* Why a bridge object of async methods (not calling the lib from Node): the real
* `ng` is browser-only (WASM + iframe RPC). Every SDK call must run INSIDE the
* broker iframe; Playwright reaches in via `frame.evaluate(() => window.__sdk.x())`.
*/
import { ng as realNg, init as realInit } from "@ng-org/web";
// The REAL ORM. `useShapeQuery` reads through the SDK's own shape query since
// `1.0.0-dev.6`, so the harness has to hand it one: a fake Set-like is enough for the
// read FILTER (which is pure) and says nothing about a read that reaches the broker.
// `initNg` is what resolves the ORM's own session promise; nothing starts before it.
import { initNg as ormInitNg, RdfOrmSubscription } from "@ng-org/orm";
import {
configure,
docs,
init,
subscribeDoc,
subscribeDocs,
inbox,
storeRegistry,
useShape as libUseShape,
useShapeQuery,
initNg,
} from "@ng-eventually/polyfill";
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
// application must not — but through the internal path, never the published entry.
// `storeRegistry` above is the app-facing slice; these are the shim internals.
import * as registryInternals from "../src/shared-wallet/account-registry";
// The shared `useShape` fake, the same one `bun test` injects. Type-only imports
// inside it, so nothing of `bun:test` reaches this bundle.
import { makeUseShapeHook } from "../test/use-shape-fake";
// The harness plays SEVERAL identities on one page — something no application does, and
// the reason `setCurrentUser` / `configureStoreRegistry` are no longer published. It
// reaches them by their internal path, like the rest of its machinery.
import {
adoptCurrentUser,
configureStoreRegistry,
setCurrentUser,
getCaps,
getCurrentUser,
resetCaps,
} from "../src/shared-wallet/bootstrap";
import { connectedUser } from "../src/emulated-verifier/connect";
import * as virtualUsers from "../src/shared-wallet/virtual-users";
import { ensureIdentity } from "@ng-eventually/polyfill";
// The harness narrows for its OWN assertions; a consumer never has to (the entries take
// plain strings and validate inside). Internal path, like the rest of its machinery.
import { isNuri } from "../src/model/nuri";
/**
* The published reference of one of the connected identity's three stores.
*
* Placement stopped being spelled as a word on the published surface in `1.0.0-dev.5`:
* an application names a store by what `privateStore()`, `protectedStore()` or
* `publicStore()` hands it. The journeys below still walk the three, so this is the step
* from one to the other - through the published calls.
*/
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
const EVERYTHING = { graphs: "did:ng:i" };
/**
* The shape the two `useShapeQuery` journeys read - built here rather than inline, since
* both of them need the same one and it is now handed to the SDK.
*
* Two predicates, both `minCardinality: 1`, because that is what
* `engine/verifier/src/orm/graph/query.rs:schema_shape_to_sparql` turns into REQUIRED
* triple patterns: a shape constraining nothing would match every subject in the dataset.
* The title is the second one, and it is what proves the real data landed rather than an
* empty object of the right type.
*/
function wsShape(cls: string) {
return {
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
{
iri: "urn:e2e:ws:title",
readablePredicate: "title",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ valType: "string" }],
},
],
},
},
} satisfies ShapeType<BaseType>;
}
const storeOfType = (t: "public" | "protected" | "private"): Promise<Nuri> =>
t === "public"
? storeRegistry.publicStore()
: t === "protected"
? storeRegistry.protectedStore()
: storeRegistry.privateStore();
import type { BaseType, Nuri, ShapeObservable, ShapeQuery, ShapeType } from "@ng-eventually/polyfill";
const { IdentityStore } = virtualUsers;
/**
* The Playwright boundary. Every NURI reaching this harness crosses the bridge as
* a plain `string` (Playwright serializes arguments), so it arrives untyped even
* though the library's `Nuri` is a template literal type. Narrow it here, loudly:
* a test that passes something which is not a NextGraph reference should fail with
* that message, not with a confusing downstream error. Never cast — a cast would
* re-open exactly the confusion the types exist to close.
*/
function asNuri(s: string): Nuri {
if (!isNuri(s)) throw new Error(`[e2e] not a NextGraph reference: ${JSON.stringify(s)}`);
return s;
}
/** Same, for an optional anchor. */
function asAnchor(s?: string): Nuri | undefined {
return s === undefined ? undefined : asNuri(s);
}
/** The reserved prefix: triples whose SUBJECT falls under it are this package's own
* machinery, not the document's data. `readUnion` dropped them for the caller; an
* application composing its own read drops them itself, and the contract says so. */
const RESERVED_SUBJECT_PREFIX = "urn:ng-eventually:";
/**
* Read ONE document with the published anchored query and group its rows per subject -
* what an application composes since `readUnion` was removed in `1.0.0-dev.8`.
*
* It THROWS, exactly like `docs.sparqlQuery` under it: a document that cannot be read is
* a failure, never an empty answer. Every caller below that wants to carry on past one
* says so with a `catch` of its own, which is where upstream leaves that decision too.
*/
async function readOneDocument(
sessionId: string,
docLike: string,
): Promise<Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }>> {
const doc = asNuri(docLike);
// Anchored -> default graph = this repo, so the body carries no `GRAPH <...>` wrapper.
const res: unknown = await docs.sparqlQuery(
sessionId,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
);
const rows = Array.isArray(res)
? (res as Array<Record<string, { value: string } | undefined>>)
: ((res as { results?: { bindings?: Array<Record<string, { value: string } | undefined>> } })
.results?.bindings ?? []);
const bySubject = new Map<string, { subject: string; graph: Nuri; props: Record<string, string[]> }>();
for (const row of rows) {
const subject = row["s"]?.value;
const p = row["p"]?.value;
const o = row["o"]?.value;
if (subject === undefined || !p || o === undefined) continue;
if (subject.startsWith(RESERVED_SUBJECT_PREFIX)) continue;
let entry = bySubject.get(subject);
if (!entry) {
entry = { subject, graph: doc, props: {} };
bySubject.set(subject, entry);
}
(entry.props[p] ??= []).push(o);
}
return [...bySubject.values()];
}
// ── The broker session, resolved once the iframe connects ──────────────────
interface BrokerSession {
session_id: string;
private_store_id: string;
protected_store_id: string;
public_store_id: string;
[k: string]: unknown;
}
let session: BrokerSession | null = null;
let sessionResolve!: (s: BrokerSession) => void;
const sessionReady = new Promise<BrokerSession>((r) => (sessionResolve = r));
// The injected `useShape`, so the lib's read-filtered view can be exercised WITHOUT a
// React tree. It is `test/use-shape-fake.ts` - the ONE fake in this repository that
// returns the shape `@ng-org/orm`'s hook really ships, read off the installed package's
// own `.d.ts`. This page used to carry a hand-written Set-like of its own, which is
// exactly why the capability filter could hand three members of that envelope back raw
// and still pass every gate: no fake anywhere said what the hook returns.
const shapeHook = makeUseShapeHook();
// ── Inject the real SDK + polyfill settings (the consumer bootstrap) ────────
configure({
ng: realNg,
useShape: shapeHook.hook,
// The SDK's own shape query, passed as it stands - no wrapper, no cast. This is what
// `useShapeQuery` reads through, so the journeys below run against the REAL broker
// rather than against anything this page composed.
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit,
initNg: ormInitNg,
// The harness already holds an open wallet (the suite imports it itself), so it never
// needs the gate's assisted import. These values exist only so the gate has something
// to render when the access-gate steps exercise it — they are never used to import.
sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" },
});
/**
* Who this page opens as — and why it has to open as somebody.
*
* The boot below goes through the POLYFILL's `init`, not the injected one, so that the
* 42 checks run over the ordering an application actually gets: settle the identity, THEN
* hand the page to the broker (`surface/lifecycle.ts`). A page with no identity would
* raise the barrier instead and never hand over, so the harness supplies one.
*
* Recorded with `adoptCurrentUser`, which names the identity and stops there — the
* session-free half, the same one the access gate settles with (`bootstrap.ts`). Naming it
* through `setCurrentUser` would FIRE the connection work: it costs the batch an account
* lookup and a broker round-trip for a boot identity nothing reads, and it registers a
* connection in flight for a user that does not exist. It used to fire nothing here for an
* incidental reason — the registry was still unwired at this line — and `configure` wires
* it now, so the intent is stated by the call instead of by the ordering. Every check that
* cares about identity sets its own anyway; this one is only what the page opened as.
*/
const BOOT_IDENTITY = "e2e-harness";
adoptCurrentUser(BOOT_IDENTITY);
// The harness keeps its OWN route to the session, substituted through the internal wiring
// path AFTER `configure` has pointed the registry at the package's. Not redundancy: the
// checks below tear a session down and start another (`session_stop` + `session_start`, the
// reconnection cold-start), and only this page knows about the second one — the package's
// holder is fed by `init()`'s callback, which the broker fires once per page.
configureStoreRegistry({
// The registry (+ subscribe/inbox/read-model) reach the session
// through this. It resolves once the broker connects.
getSession: async () => {
// Read the CURRENT session (mutable): a fresh session (session_stop+session_start
// for the reconnection cold-start test) swaps `session` in place, and the SDK must
// route reads/opens through the NEW session_id. Fall back to the first-connect
// promise until the initial session lands.
const s = session ?? (await sessionReady);
return {
sessionId: s.session_id,
privateStoreId: s.private_store_id,
protectedStoreId: s.protected_store_id,
publicStoreId: s.public_store_id,
};
},
// Identity normalization used as the shim key (lowercase, strip leading `@`).
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
// REAL broker: enable the POINTER micro-guard. The account records live in a
// subscribable doc-shim reached via a write-once pointer triple in the store-root;
// the account read is barrier-authoritative (no account retry). The only residual
// store-root sync-lag is the pointer read — this bounded guard re-reads JUST that
// one write-once triple on a cold reconnect (CONTRACT 2 non-fork). It can never
// provision or fork an account.
pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 },
});
// ── Bridge marshaling note ─────────────────────────────────────────────────
// Everything crossing frame.evaluate must be structured-cloneable. NURIs/strings/
// numbers/plain objects are fine. We never return functions or the raw `ng`.
const state: { status: string; error?: string } = { status: "connecting" };
// Identity store over the iframe's localStorage (the real VirtualUserStorage).
const identity = new IdentityStore(
typeof window !== "undefined" && window.localStorage ? window.localStorage : null,
);
// Expose the bridge immediately (status reflects connection progress).
(window as any).__sdk = {
status: () => state.status,
error: () => state.error ?? null,
sessionInfo: () =>
session
? {
session_id: session.session_id,
private_store_id: session.private_store_id,
protected_store_id: session.protected_store_id,
public_store_id: session.public_store_id,
}
: null,
/**
* Export the CURRENT wallet as a `.ngw` file, base64-encoded so it can cross the
* frame.evaluate bridge back to Node. Used by the reconnection cold-start test to
* re-import the SAME wallet into a CLEAN browser profile (empty local repo storage)
* — the only faithful way to force the broker-only cold-start (a fresh profile has
* no local IndexedDB copy of the repos to eagerly rehydrate).
*/
async exportWalletFile() {
const ws = await (realNg as any).get_wallets();
const walletName = Object.keys(ws ?? {})[0];
const file = await (realNg as any).wallet_get_file(walletName);
const bytes = file instanceof Uint8Array ? file : new Uint8Array(file);
let bin = "";
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
return { walletName, b64: btoa(bin), len: bytes.length };
},
/**
* THE ACCESS GATE, in a real browser — ported from the consumer's
* `barriere-acces-identifiant` feature, which the library took over with the flow.
*
* Unit tests pin the resolution ORDER (`test/access-gate.test.ts`); only a real DOM can
* pin the barrier itself: that it appears on a first access, that entering a value
* settles the identity normalized, and that it stands aside once the identity is known.
*
* That last one holds HERE because this page runs inside the broker iframe, and only
* there. Top-level the barrier shows on every load — a known identifier prefills the
* field instead of skipping the screen, since knowing who someone is says nothing about
* whether their browser still holds the wallet, and the broker's answer for someone who
* does not is a dead end with no way back. That side is the applicative suite's to walk
* (`e2e/notebook.ts`), with a real page and a real Back button.
*/
async accessGateFirstVisit(raw: string) {
setCurrentUser(null);
try { window.localStorage.removeItem("ng-eventually:identity"); } catch {}
// A first visit has no `?ng-id=` either, and the URL is the branch the gate consults
// FIRST — so clearing storage alone stopped describing a first visit the moment the
// boot started settling an identity (which writes the parameter, as every settling
// path must). Leaving it there would make this check pass for the wrong reason on a
// gate that had stopped asking at all.
try {
const withoutIdentity = new URL(window.location.href);
withoutIdentity.searchParams.delete("ng-id");
window.history.replaceState(null, "", withoutIdentity.toString());
} catch {}
const done = ensureIdentity();
const gate = document.querySelector('[data-ng-eventually="access-gate"]');
const root = gate?.shadowRoot ?? null;
const input = root?.querySelector("input") as HTMLInputElement | null;
const button = root?.querySelector("button.go") as HTMLButtonElement | null;
const shown = input !== null && button !== null;
const disabledWhenEmpty = button?.disabled ?? null;
if (input && button) {
input.value = raw;
input.dispatchEvent(new Event("input"));
button.click();
}
await done;
return {
shown,
disabledWhenEmpty,
identity: getCurrentUser(),
stillMounted: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
};
},
/** Inside the iframe, the barrier must stay away once an identity is known. */
async accessGateIdentityAlreadyKnown(known: string) {
setCurrentUser(known);
await ensureIdentity();
return {
shown: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
identity: getCurrentUser(),
};
},
// ── docs primitives ──────────────────────────────────────────────────────
async docCreate() {
const s = await sessionReady;
return docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
},
async sparqlUpdate(query: string, anchor?: string) {
const s = await sessionReady;
return docs.sparqlUpdate(s.session_id, query, asAnchor(anchor));
},
async sparqlQuery(query: string, anchor?: string) {
const s = await sessionReady;
return docs.sparqlQuery(s.session_id, query, undefined, asAnchor(anchor));
},
/**
* The load-bearing graph-behavior characterization against the REAL broker.
* fake-ng cannot verify any of this — it needs the broker's repo_graph_name
* overlay. This is the ground truth the lib's write/read comments cite.
*
* Anchored to doc D, it measures each write shape:
* (a) `INSERT DATA { <s> <p> o }` (NO GRAPH) → read anchored default graph
* (b) `INSERT DATA { GRAPH <D> { <s> <p> o } }` (explicit plain NURI) → read
* both the anchored default graph AND `GRAPH <D>` explicitly
* (c) anchorless `SELECT … WHERE { GRAPH ?g { … } }` over TWO docs D and D2 →
* does it see BOTH docs' graphs (the O(wallet) union scan)?
*/
async docRoundTrip() {
const s = await sessionReady;
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
const subj = "urn:e2e:s";
// (a) anchored default-graph write (no GRAPH) — the canonical shape the lib writes.
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${subj}> <urn:e2e:anchored> "yes" }`,
doc,
);
// (b) explicit `GRAPH <plainNuri>` write, anchored to the same doc.
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { GRAPH <${doc}> { <${subj}> <urn:e2e:explicitGraph> "yes" } }`,
doc,
);
// read back anchored default graph — both predicates expected if (b) round-trips.
const res: any = await docs.sparqlQuery(
s.session_id,
`SELECT ?p ?o WHERE { <${subj}> ?p ?o }`,
undefined,
doc,
);
const rows = Array.isArray(res) ? res : res?.results?.bindings ?? [];
const preds = rows.map((r: any) => r.p?.value).filter(Boolean);
// (b, cont.) read the explicit-graph triple back via its OWN named graph, to
// confirm `GRAPH <plainNuri>` when anchored resolves to the same repo graph.
const explicitNamed: any = await docs.sparqlQuery(
s.session_id,
`SELECT ?o WHERE { GRAPH <${doc}> { <${subj}> <urn:e2e:explicitGraph> ?o } }`,
undefined,
doc,
);
const enRows = Array.isArray(explicitNamed) ? explicitNamed : explicitNamed?.results?.bindings ?? [];
// (c) anchorless `GRAPH ?g` union scan across TWO docs. Create a second doc
// with a DISTINCT triple, then run an ANCHORLESS scan (anchor undefined →
// UserSite → local union). If it returns rows from BOTH graphs, the anchorless
// union spans every named graph in the session store — the O(wallet-size) cost
// the read path exists to avoid. Two docs is enough to demonstrate the union;
// it does not bloat the wallet.
let unionSpan: { sawDocA: boolean; sawDocB: boolean; graphCount: number } = {
sawDocA: false,
sawDocB: false,
graphCount: 0,
};
try {
const docB = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:s2> <urn:e2e:anchoredB> "yes" }`,
docB,
);
const union: any = await docs.sparqlQuery(
s.session_id,
`SELECT ?g ?s ?p WHERE { GRAPH ?g { ?s ?p ?o } }`,
undefined,
undefined, // ANCHORLESS → UserSite → local union of all named graphs
);
const uRows = Array.isArray(union) ? union : union?.results?.bindings ?? [];
const subjs = new Set(uRows.map((r: any) => r.s?.value).filter(Boolean));
const graphs = new Set(uRows.map((r: any) => r.g?.value).filter(Boolean));
unionSpan = {
sawDocA: subjs.has(subj),
sawDocB: subjs.has("urn:e2e:s2"),
graphCount: graphs.size,
};
} catch (e: any) {
unionSpan = { sawDocA: false, sawDocB: false, graphCount: -1 };
(unionSpan as any).error = String(e?.message ?? e);
}
return {
doc,
predicates: preds,
anchoredPresent: preds.includes("urn:e2e:anchored"),
explicitGraphPresent: preds.includes("urn:e2e:explicitGraph"),
explicitViaNamedGraph: enRows.length > 0,
unionSpan,
};
},
// ── the anchored document read ───────────────────────────────────────────
/**
* Create N docs, write a marker triple into each (anchored default graph), then read
* them ONE BY ONE with the published anchored query. Optionally inject a bad NURI, to
* show where the tolerance lives now: at THIS call site, as a visible `catch`, not
* inside a primitive. `readUnion` used to swallow it and answer with the rest, which
* is a policy upstream does not have - an anchored query on a repo the verifier does
* not know REJECTS (`resolve_target_for_sparql` -> `NgError::RepoNotFound`).
*/
async readDocsOneByOne(n: number, includeBad: boolean) {
const s = await sessionReady;
const docNuris: Nuri[] = [];
for (let i = 0; i < n; i++) {
const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:rm:${i}> <urn:e2e:idx> "${i}" }`,
d,
);
docNuris.push(d);
}
// Two SUBJECTS in the LAST document, so the probe can tell "one entry per document"
// from "one entry per subject". Until 2026-08-10 the read folded every triple of a
// document into one bag keyed by the document, and this step stayed green throughout
// because it only ever counted entries and never looked at what they were.
if (n > 0) {
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:rm:extra> <urn:e2e:idx> "extra" }`,
docNuris[n - 1]!,
);
}
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const subjects: Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }> = [];
const skipped: string[] = [];
for (const d of toRead) {
try {
subjects.push(...(await readOneDocument(s.session_id, d)));
} catch {
// THIS harness chooses to carry on. Another caller may choose not to - that is
// the point of the decision living here.
skipped.push(d);
}
}
return {
docNuris,
subjectCount: subjects.length,
// What each entry actually IS — the assertion the count could not make.
subjectIris: subjects.map((x) => x.subject),
graphs: subjects.map((x) => x.graph),
skipped,
subjects,
};
},
/**
* The possession gate on the anchored read: create a doc as owner O (whose keyring
* gets its cap), then read it as a DIFFERENT identity, which holds nothing. The
* stranger has the document's NURI in hand throughout: naming is not reading.
*
* The stranger is now REFUSED rather than handed an empty list - `readUnion` dropped
* an unreachable document from its batch, and there is no batch to drop it from.
*/
async anchoredReadCapGate() {
const s = await sessionReady;
resetCaps();
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate(s.session_id, `INSERT DATA { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc);
setCurrentUser("owner-O");
getCaps().open(doc, "protected");
setCurrentUser("someone-else");
let strangerRefused = false;
let strangerCount = -1;
try {
strangerCount = (await readOneDocument(s.session_id, doc)).length;
} catch {
strangerRefused = true;
}
setCurrentUser("owner-O");
const asOwner = await readOneDocument(s.session_id, doc);
resetCaps();
setCurrentUser(null);
return { strangerRefused, strangerCount, ownerCount: asOwner.length };
},
// ── reactivity (doc_subscribe) ───────────────────────────────────────────
// The test installs a counter; subscribeDoc pushes an initial state, then a
// push per subsequent write. We record every push and expose the count/log so
// the test can wait event-driven (waitForFunction on the counter), never timed.
_subs: {} as Record<string, { count: number; unsub: () => void }>,
async subscribeDocStart(handle: string) {
const s = await sessionReady;
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
const rec = { count: 0, unsub: () => {} };
(window as any).__sdk._subs[handle] = rec;
rec.unsub = subscribeDoc(doc, () => {
rec.count += 1;
});
return { doc };
},
subscribeCount(handle: string) {
return (window as any).__sdk._subs[handle]?.count ?? -1;
},
async writeTo(doc: string, marker: string) {
const s = await sessionReady;
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:sub:${marker}> <urn:e2e:m> "${marker}" }`,
asNuri(doc),
);
},
subscribeStop(handle: string) {
const rec = (window as any).__sdk._subs[handle];
if (rec) rec.unsub();
},
/**
* subscribeDocs isolation: subscribe to [goodDoc, deadDoc]. The dead doc's
* subscription fails in isolation; the good doc still fires on its write.
*/
_multiSub: { good: 0, bad: 0, unsub: () => {} },
async subscribeDocsStart() {
const s = await sessionReady;
const good = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
const bad = "did:ng:o:absent-doc-never-created";
const rec = { good: 0, bad: 0, unsub: () => {} };
(window as any).__sdk._multiSub = rec;
rec.unsub = subscribeDocs([good, bad], (nuri) => {
if (nuri === good) rec.good += 1;
else rec.bad += 1;
});
return { good, bad };
},
multiSubCounts() {
const r = (window as any).__sdk._multiSub;
return { good: r.good, bad: r.bad };
},
multiSubStop() {
(window as any).__sdk._multiSub.unsub();
},
// ── inbox ────────────────────────────────────────────────────────────────
/**
* `id` must be FRESH per run (run.ts stamps it). A user's inbox is stable over time —
* that is the point of it — so re-running against a reused id accumulates the previous
* runs' deposits on a persistent wallet, and the exact-count assertion drifts. The
* thing to make disposable is the user, not the inbox.
*/
async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) {
// The target must be that user's OWN inbox, not an arbitrary document: you may
// deposit into anyone's, you may only read your own. Establishing the identity
// FIRST is what makes `userInbox` resolve (and file) that user's inbox.
setCurrentUser(id);
const target = await registryInternals.userInbox(id, "protected");
await inbox.post(target, { payload: payloadA, from: null, ts: 1000 });
await inbox.post(target, { payload: payloadB, from: null, ts: 2000 });
const deposits = await inbox.read(target);
setCurrentUser(null);
return { target, deposits };
},
// watch (doc_subscribe-based) fires when a deposit lands.
_inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" },
/** `id` fresh per run, for the same reason as {@link inboxPostRead}. */
async inboxWatchStart(id: string) {
// Watching an inbox is READING it continuously, so the watcher stays connected
// for the whole probe — including across `inboxWatchDeposit`.
setCurrentUser(id);
const target = await registryInternals.userInbox(id, "protected");
const rec = { fires: 0, lastLen: -1, unsub: () => {}, target };
(window as any).__sdk._inboxWatch = rec;
rec.unsub = inbox.watch(target, (deposits) => {
rec.fires += 1;
rec.lastLen = deposits.length;
});
return { target };
},
async inboxWatchDeposit(payload: unknown) {
const rec = (window as any).__sdk._inboxWatch;
await inbox.post(rec.target, { payload, from: null });
},
inboxWatchState() {
const r = (window as any).__sdk._inboxWatch;
return { fires: r.fires, lastLen: r.lastLen };
},
inboxWatchStop() {
(window as any).__sdk._inboxWatch.unsub();
setCurrentUser(null);
},
// spoof guard: depositing as another principal throws.
async inboxSpoofGuard() {
// A REAL inbox, obtained from the system. It used to be a plain `docs.docCreate`
// document — a state the library never produces, and `inbox.post` now refuses it
// (a deposit is addressed to an inbox, never to a document). The step is about the
// `from` spoof guard; it should not also assert something the model forbids.
setCurrentUser("alice");
const target = await registryInternals.userInbox("alice", "protected");
let threw = false;
try {
await inbox.post(target, { payload: { x: 1 }, from: "bob" });
} catch {
threw = true;
}
// self + anonymous both allowed
let selfOk = true, anonOk = true;
try { await inbox.post(target, { payload: { x: 2 }, from: "alice" }); } catch { selfOk = false; }
try { await inbox.post(target, { payload: { x: 3 }, from: null }); } catch { anonOk = false; }
setCurrentUser(null);
return { spoofRejected: threw, selfOk, anonOk };
},
// ── store-registry ───────────────────────────────────────────────────────
async ensureAccountIdempotent(id: string) {
registryInternals.resetRegistryCache();
const first = await registryInternals.ensureAccount(id);
registryInternals.resetRegistryCache();
const second = await registryInternals.ensureAccount(id);
return {
firstDocs: [first.docPublic, first.docProtected, first.docPrivate],
secondDocs: [second.docPublic, second.docProtected, second.docPrivate],
same:
first.docPublic === second.docPublic &&
first.docProtected === second.docProtected &&
first.docPrivate === second.docPrivate,
};
},
async entityDocsBounded(idA: string, idB: string) {
registryInternals.resetRegistryCache();
// Each user creates its OWN documents: you act as one virtual user at a time,
// and the caps of what you create are filed under the identity you were acting
// as. Creating B's document while connected as A is not a thing the model has.
setCurrentUser(idA);
const dA1 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
const dA2 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
setCurrentUser(idB);
const dB1 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
setCurrentUser(idA);
let listA: string[] = [];
for (let i = 0; i < 12; i++) {
registryInternals.resetRegistryCache();
listA = await registryInternals.listMyEntityDocs(idA, "public");
if (listA.includes(dA1) && listA.includes(dA2)) break;
await new Promise((r) => setTimeout(r, 1000));
}
setCurrentUser(null);
return {
dA1, dA2, dB1,
listA,
hasA1: listA.includes(dA1),
hasA2: listA.includes(dA2),
leaksB: listA.includes(dB1),
};
},
/**
* RECONNECTION cold-start seed (phase 1, run in the FIRST session).
*
* Create a per-entity document under (id, scope) — which also appends its NURI to
* the account's scope-index document — and write a marker triple INTO the entity
* doc (anchored default graph, the canonical shape). Poll until listMyEntityDocs
* sees it, so the index append has actually landed on the broker before we tear
* the session down. Returns everything the FRESH session needs to re-find it
* purely from the persistent wallet: only `id` + `scope` are load-bearing (the
* fresh session re-resolves the account from the shim); entityNuri/marker are the
* expected values to assert against.
*/
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
registryInternals.resetRegistryCache();
const s = await sessionReady;
// Seed AS the user whose document this is — otherwise the cap of the created
// document is filed under nobody and the very session that created it is
// refused the write below.
setCurrentUser(id);
const entityNuri = await storeRegistry.createEntityDoc(await storeOfType(scope));
const marker = "recon-" + Date.now();
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:recon:subject> <urn:e2e:recon:marker> "${marker}" }`,
entityNuri,
);
// Wait until the index append + the triple are visible in THIS session (the
// session that created them — where the repos are already open), so we know the
// data is persisted before the fresh session tries to read it back.
let listed: string[] = [];
for (let i = 0; i < 15; i++) {
registryInternals.resetRegistryCache();
listed = await registryInternals.listMyEntityDocs(id, scope);
if (listed.includes(entityNuri)) break;
await new Promise((r) => setTimeout(r, 1000));
}
return { id, scope, entityNuri, marker, listedInSeed: listed };
},
/**
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
* account's entity docs of `scope` (listMyEntityDocs → readUserStore) and read each
* with its own anchored query, purely from the persistent wallet — nothing from
* phase 1's session state carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
* the broker-login bootstrap already opens the user's repos, so rawRowCount is
* non-zero even without the heal — this is a reconnection REGRESSION guard, not a
* fail-without-the-fix proof (see run.ts's reconnection step comment).
*/
async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) {
const s = session ?? (await sessionReady);
// A fresh session holds nothing in memory: connect AS the user so the caps are
// restored from the durable registers (own documents from the Store branches,
// received ones from the Links) before anything is read back.
setCurrentUser(id);
await connectedUser();
registryInternals.resetRegistryCache();
const listed = await registryInternals.listMyEntityDocs(id, scope);
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
// many rows the bare anchored query resolves for a not-yet-opened repo (the
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
// `readOneDocument`'s open step.
//
// Placed AFTER `listMyEntityDocs`, which is what restores the caps of the user's
// own documents from the Store branch. Before it, the boundary refuses the read
// and the probe would measure the guard rather than the open — a number that
// looks like the premise holding while proving nothing about it.
let rawRowCount = -1;
try {
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, asNuri(entityNuri));
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
} catch (e: any) {
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
}
// One anchored read per listed document, and the tolerance is HERE, visibly: a
// reconnect probe that lost one document to a broker hiccup still reports what the
// others held, because THIS caller decided so. The primitive rejects.
const subjects: Array<{ subject: string; graph: Nuri; props: Record<string, string[]> }> = [];
const unread: string[] = [];
for (const d of listed.length ? listed : [asNuri(entityNuri)]) {
try {
subjects.push(...(await readOneDocument(s.session_id, d)));
} catch {
unread.push(d);
}
}
const markers: string[] = [];
for (const subj of subjects) {
for (const vals of Object.values(subj.props)) {
for (const v of vals) markers.push(v);
}
}
return {
rawRowCount,
listed,
listedCount: listed.length,
foundEntity: listed.includes(asNuri(entityNuri)),
subjectCount: subjects.length,
unread,
markerPresent: markers.includes(marker),
markers,
};
},
/**
* NON-FORK contract probe. Resolve (or create on first sight) the account for
* `id` and return its THREE scope-document NURIs (docPublic/docProtected/
* docPrivate) exactly as ensureAccount records them in the shim. Run in session 1
* it PROVISIONS the account (first NURIs); run in a FRESH faithful reconnect
* session it must RE-RESOLVE the SAME account from the synced shim and return the
* SAME NURIs — never a second provisioning (an account fork). resetRegistryCache
* first so the resolve goes to the shim, not a same-session in-memory hit.
*/
async accountDocs(id: string) {
registryInternals.resetRegistryCache();
const rec = await registryInternals.ensureAccount(id);
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
},
// `resolveScopeGraph` stopped being published in `1.0.0-dev.5` — a store's graph holds
// its containment triples and nothing else, so the value it handed out matched nothing
// as a read scope upstream. The probe keeps measuring the mapping, at its internal path:
// a harness may do that, an application may not.
async scopeResolvers() {
const priv = await registryInternals.resolveScopeGraph("private");
const prot = await registryInternals.resolveScopeGraph("protected");
const pub = await registryInternals.resolveScopeGraph("public");
return { priv, prot, pub };
},
/**
* COLD-START anchor probe. Runs the EXACT shim SELECT the registry issues,
* anchored to `did:ng:${private_store_id}` (the shim anchor), as the very first
* thing in a fresh session — BEFORE anything opens that repo. Reports whether the
* raw anchored query threw `RepoNotFound` (the cold-start bug: the private-store
* repo not yet in `self.repos`) or returned rows. Uses the low-level docs
* primitive directly so nothing (open-repo, ensureAccount) opens the repo first.
*/
async shimAnchorProbe() {
const s = session ?? (await sessionReady);
const anchor = `did:ng:${s.private_store_id}`;
const query =
"SELECT ?acc WHERE { GRAPH <" +
anchor +
"> { ?acc a <urn:ng-eventually:shim:Account> } }";
try {
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, asNuri(anchor));
const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0);
return { threw: false, error: null, rows, anchor };
} catch (e: any) {
return { threw: true, error: String(e?.message ?? e), rows: -1, anchor };
}
},
/**
* COLD-START account provision. resetRegistryCache then ensureAccount(id) — the
* real bootstrap the app runs on first login. On a fresh wallet, if the anchor
* repo isn't open, resolveAccount's read AND ensureAccount's provision write both
* hit RepoNotFound; the account never persists. Returns the 3 scope docs (all
* truthy iff provisioning succeeded) so the runner can gate on real persistence.
*/
async coldEnsureAccount(id: string) {
registryInternals.resetRegistryCache();
try {
const rec = await registryInternals.ensureAccount(id);
return {
threw: false,
error: null,
docPublic: rec.docPublic,
docProtected: rec.docProtected,
docPrivate: rec.docPrivate,
};
} catch (e: any) {
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
}
},
/**
* VERIFY the provisioned account actually PERSISTED to the shim: resetRegistryCache
* then re-resolve the SAME id via a fresh anchored read. Returns whether the read
* threw + the resolved docs. After the fix, on a fresh wallet this returns the SAME
* docs coldEnsureAccount minted (real persistence, no RepoNotFound).
*/
async verifyShimPersisted(id: string) {
registryInternals.resetRegistryCache();
try {
const rec = await registryInternals.ensureAccount(id);
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
} catch (e: any) {
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
}
},
// ── useShapeQuery (reactive useQuery-shaped read) ────────────────────────
// The shape is `wsShape(cls)` above: a hand-written `ShapeType`, two required
// predicates, generic and with no application domain. The read goes through the
// SDK's own shape query now, so these two journeys measure the REAL broker.
_wsProbes: {} as Record<
string,
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
>,
/**
* REAL-BROKER cycle proof. Under a fresh identity, create ONE protected entity
* document carrying an rdf:type=<e2e class> triple, then open a `useShapeQuery` over
* (that class shape, the whole dataset). Capture the FIRST snapshot right after subscribe
* (must be isPending) so the caller can then wait event-driven for isSuccess with
* the seeded datum present. Returns the handle + the initial snapshot + the seed
* doc/type so the runner can assert the data landed.
*/
async useShapeQuerySeedAndSubscribe(handle: string, cls: string) {
registryInternals.resetRegistryCache();
const id = "@ws-" + handle;
setCurrentUser(id);
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
const s = await sessionReady;
// Seed the entity doc with the shape's type + a title (anchored default graph).
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${doc}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "seeded" }`,
doc,
);
// Wait until this session sees the index append (data persisted on the broker).
for (let i = 0; i < 15; i++) {
registryInternals.resetRegistryCache();
const listed = await registryInternals.listMyEntityDocs(id, "protected");
if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000));
}
const shape = wsShape(cls);
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
const unsub = obs.subscribe(() => {});
// First snapshot immediately after subscribe — the barrier is not yet crossed
// (docs opening via doc_subscribe), so this MUST be pending.
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
// NB: leave the current user SET for the probe's lifetime — useShapeQuery resolves
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
// keeps a stable identity. useShapeQueryStop clears it.
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
},
/** Current snapshot of a useShapeQuery probe (event-driven poll target). */
useShapeQuerySnapshot(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (!rec) return null;
const snap: ShapeQuery = rec.obs.getSnapshot();
return {
isPending: snap.isPending,
isSuccess: snap.isSuccess,
isError: snap.isError,
dataLen: snap.data.length,
// The seeded title, if the datum is present (proves the real data landed). It is the
// shape's own readable predicate now, not a property bag: the objects come from the
// ORM.
titles: snap.data.map((d) => d.title).filter((v) => typeof v === "string"),
};
},
useShapeQueryStop(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (rec) rec.unsub();
setCurrentUser(null);
},
/**
* NAMED-SCOPE seed. Under a fresh identity, create TWO entity documents carrying the
* shape's type - one PROTECTED, one PUBLIC - and seed each with a distinguishable
* title. Returns the owner and both references, which is all any of the three named
* subscriptions below is given.
*
* Why this exists at all: until `1.0.0-dev.9` every `useShapeQuery` journey in this
* suite read `{ graphs: "did:ng:i" }`, so the scope axis that NAMES a document was
* measured nowhere against the real broker - and the barrier that names introduced
* shipped with no live coverage. A suite that is green on a path it never walks is
* worse than one that is red.
*/
async useShapeQueryNamedSeed(cls: string) {
registryInternals.resetRegistryCache();
const owner = "@wsn-" + Date.now();
setCurrentUser(owner);
const s = await sessionReady;
const prot = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${prot}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "named-protected" }`,
prot,
);
// PUBLIC, so its ReadCap is exposed on its Header branch at creation - the one
// channel by which a bare reference alone opens a document, and the acquisition the
// barrier now performs on this call.
const pub = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${pub}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "named-public" }`,
pub,
);
setCurrentUser(null);
return { owner, prot, pub };
},
/**
* Subscribe a NAMED scope under `who` - the owner, or somebody holding nothing.
*
* The identity is set here and left set for the probe's lifetime, like the two
* journeys above: `useShapeQuery` asks the keyring about whoever is connected on every
* read, exactly as an application with one stable identity per page does.
* `useShapeQuerySnapshot` and `useShapeQueryStop` serve this probe too.
*/
useShapeQueryNamedSubscribe(handle: string, who: string, doc: string, cls: string) {
registryInternals.resetRegistryCache();
setCurrentUser(who);
const obs = useShapeQuery(wsShape(cls), { graphs: [doc] }) as ShapeObservable;
const unsub = obs.subscribe(() => {});
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
return {
initial: {
isPending: initial.isPending,
isSuccess: initial.isSuccess,
isError: initial.isError,
},
};
},
/** The error message a named scope published, so the runner can tell the refusal from
* any other failure rather than accepting `isError` as such. */
useShapeQueryError(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (!rec) return null;
const snap: ShapeQuery = rec.obs.getSnapshot();
return snap.error === undefined ? null : String((snap.error as Error)?.message ?? snap.error);
},
/**
* EMPTY-scope proof: a brand-new identity with NO entity docs at all. `useShapeQuery`
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
* the handle; poll useShapeQuerySnapshot for the transition.
*/
useShapeQueryEmptyStart(handle: string, cls: string) {
registryInternals.resetRegistryCache();
const id = "@ws-empty-" + handle;
setCurrentUser(id);
const shape = wsShape(cls);
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
const unsub = obs.subscribe(() => {});
const initial = obs.getSnapshot();
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
// Keep the identity set for the probe's lifetime (useShapeQueryStop clears it): a
// faithful "empty scope for a real identity", not "no identity at all".
return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } };
},
// ── caps / read-filter (in-memory cap model) ─────────────────────────────
// The read-filter over the injected useShape Set-like. Boundary note: the
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
// NOT yet enforce per-doc read caps here (one shared wallet reads everything).
// We test what the SDK enforces: the in-memory read-filtered VIEW, which after
// cap-surface is KEY POSSESSION — you read what your keyring holds, nothing else.
capsReadFilter() {
resetCaps();
shapeHook.items.length = 0;
shapeHook.items.push(
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
{ "@graph": "did:ng:o:unheld", "@id": "3", v: "unheld-item" },
);
// What the hook hands back is the SDK's result envelope, so the items are read off
// `data` - the member its own example destructures first, and one of the three that
// used to come back unfiltered.
const read = (): string[] =>
[
...(libUseShape(wsShape("urn:e2e:caps:Item"), EVERYTHING).data as Iterable<
{ v: string }
>),
].map((i) => i.v);
setCurrentUser("owner-O");
getCaps().open("did:ng:o:protdoc", "protected");
const link = getCaps().open("did:ng:o:pubdoc", "public");
const ownerView = read();
// A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with
// no round-trip to spend (see `read-filter.ts`). That a public store would serve
// the cap is proven on the read paths, not here.
setCurrentUser("stranger");
const strangerView = read();
// and the SDK's own handle is refused outright, whoever asks: it reads and writes
// with no capability filter at all.
let subscriptionRefused: string | null = null;
try {
void libUseShape(wsShape("urn:e2e:caps:Item"), EVERYTHING).subscription;
} catch (e) {
subscriptionRefused = e instanceof Error ? e.message : String(e);
}
getCaps().learn(link);
const strangerWithLinkView = read();
resetCaps();
shapeHook.items.length = 0;
setCurrentUser(null);
return { ownerView, strangerView, strangerWithLinkView, subscriptionRefused };
},
// MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07):
// - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice
// reads it". This one is WHY that suite exists: it was green here while the
// feature was unusable, because a harness can hand an inbox address across an
// identity boundary through a variable and an application cannot.
// - `capsShareCap` → "Alice's protected note stays shut until she gives Bob the key".
//
// What stays here is what an application does not do: primitives, characterisation,
// and the cold-start regressions.
// ── accounts (IdentityStore) ─────────────────────────────────────────────
identitySet(id: string) { return identity.set(id); },
identityGet() { return identity.get(); },
identityClear() { identity.clear(); return identity.get(); },
// ── CONTRACT: first-State barrier (doc_subscribe sync-point) ────────────
//
// The contract under test: the 1st event emitted by doc_subscribe is a `State`
// that marks the end of the initial broker sync. After that push:
// - if the doc has data → it MUST be present in the State (no "not yet synced")
// - if the doc is empty → it IS definitively empty (no later arrival)
//
// We drive this via the polyfill's subscribeDoc (which calls ng.doc_subscribe
// directly), but we capture the RAW AppResponse for each push so we can:
// (a) identify the event type (State vs Patch vs TabInfo vs other)
// (b) measure the time-to-first-State
// (c) correlate "State received" with the SPARQL content query result
//
// Bridge state for a single active first-State probe.
_stateProbe: null as null | {
doc: string;
startMs: number;
events: Array<{ typeKey: string; elapsedMs: number }>;
unsub: () => void;
},
/**
* PRESENCE probe — phase 1: write a triple into `doc` (same session, so the
* write is already committed on the broker before we subscribe). Returns the doc
* NURI so the test can hand it to stateProbeSubscribe. Split into write + subscribe
* so the test controls the ordering precisely.
*/
async stateProbeWrite(triple: { s: string; p: string; o: string }): Promise<string> {
const s = await sessionReady;
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <${triple.s}> <${triple.p}> "${triple.o}" }`,
doc,
);
return doc;
},
/**
* Subscribe to `doc` and record every raw event (type key + elapsed ms from
* subscribe call). The state probe slot is single-use per call; a previous probe
* is torn down first. `subscribeDoc` is the polyfill wrapper — it passes the raw
* AppResponse straight through to the callback.
*/
stateProbeSubscribe(doc: string): void {
// Tear down any prior probe.
const prev = (window as any).__sdk._stateProbe;
if (prev) { try { prev.unsub(); } catch { /* ignore */ } }
const probe = {
doc,
startMs: Date.now(),
events: [] as Array<{ typeKey: string; elapsedMs: number }>,
unsub: () => {},
};
(window as any).__sdk._stateProbe = probe;
probe.unsub = subscribeDoc(asNuri(doc), (resp: any) => {
const elapsedMs = Date.now() - probe.startMs;
// AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | …
let typeKey = "unknown";
try {
const v0 = resp?.V0 ?? resp?.v0 ?? resp;
if (v0 && typeof v0 === "object") {
const keys = Object.keys(v0);
typeKey = keys[0] ?? "empty";
} else {
typeKey = String(v0);
}
} catch { typeKey = "parse-error"; }
probe.events.push({ typeKey, elapsedMs });
});
},
/** Return the accumulated event log (snapshot — safe to call at any time). */
stateProbeEvents(): Array<{ typeKey: string; elapsedMs: number }> {
return (window as any).__sdk._stateProbe?.events ?? [];
},
/**
* Return the count of events with typeKey === `State` received so far.
* Used by waitForFunction to wait for the first State (not just any event —
* the broker pushes TabInfo first, State second).
*/
stateProbeStateCount(): number {
const events: Array<{ typeKey: string }> = (window as any).__sdk._stateProbe?.events ?? [];
return events.filter((e) => e.typeKey === "State").length;
},
/**
* After the first State has been received, run an anchored SPARQL query on the
* probe doc to read back its triples. Returns the raw rows so the test can assert
* presence/absence without re-querying from the test side.
*/
async stateProbeQuery(s_uri: string, p_uri: string): Promise<{ rows: number; found: boolean }> {
const s = await sessionReady;
const doc = (window as any).__sdk._stateProbe?.doc;
if (!doc) return { rows: -1, found: false };
try {
const res: any = await docs.sparqlQuery(
s.session_id,
`SELECT ?o WHERE { <${s_uri}> <${p_uri}> ?o }`,
undefined,
doc,
);
const rows = Array.isArray(res)
? res.length
: (res?.results?.bindings?.length ?? 0);
return { rows, found: rows > 0 };
} catch (e: any) {
return { rows: -2, found: false };
}
},
/** Stop the active probe's subscription. */
stateProbeStop(): void {
const probe = (window as any).__sdk._stateProbe;
if (probe) { try { probe.unsub(); } catch { /* ignore */ } }
(window as any).__sdk._stateProbe = null;
},
/**
* ABSENCE probe — subscribe to a doc NURI that was never written (non-existent).
* Returns the doc immediately after creating it (empty), then subscribes.
* The test waits for the first State, then verifies the doc is empty AND stays
* empty for a grace window (no late Patch arrival).
*/
async stateProbeEmptyDoc(): Promise<string> {
// Create a real doc so it's valid for doc_subscribe, but write NOTHING into it.
const s = await sessionReady;
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
// Subscribe immediately (no data written).
(window as any).__sdk.stateProbeSubscribe(doc);
return doc;
},
};
// ── Connect to the real broker ─────────────────────────────────────────────
// Mirrors ngSession.ts: register the init callback; the broker (this iframe is
// loaded by it) drives the connection and calls back with the session.
//
// Through the POLYFILL's `init`, not the injected `realInit` — even though `realInit` is
// what ends up being called (it is what `configure` injects, above). Calling it directly
// skipped the forwarder that settles the identity before delegating, so the 42 checks ran
// over an ordering no application has, and the defect that ordering exists to prevent —
// the hand-over happening before the identity reaches the address bar — could not have
// been caught here. What an application writes is this line.
(async () => {
try {
await init(
(event: { session: BrokerSession }) => {
session = event.session;
state.status = "connected";
// The ORM reaches the SDK through a module-level promise of its own, resolved
// once. Nothing it opens starts before this, so it goes here rather than beside
// the first read - through the PACKAGE's `initNg`, which is the call an
// application makes.
//
// Since `1.0.0-dev.7` that call SUBSTITUTES the package's wrapped `ng` for the
// one passed here, so the two `useShapeQuery` journeys below are now the proof
// that `orm_start_graph` survives the wrapper - and they are the only thing that
// is. A consumer wiring exactly this path got `orm_start_graph is not a function`
// and a read pending for ever, while this suite stayed green precisely because
// the ORM had been handed the raw SDK object here instead.
initNg(realNg, session);
sessionResolve(session);
},
true,
[],
);
} catch (e) {
state.status = "error";
state.error = e instanceof Error ? e.message : String(e);
}
})();