Files
ng-eventually/packages/client/e2e/sdk-entry.ts
T
Sylvain Duchesne 5e91771da6 fix(client): résolution de compte barrière-autoritative — fin du fork à la reconnexion
Bug: à la reconnexion, resolveAccount lisait le shim depuis le store-root
(did🆖${privateStoreId}), NON abonnable → pas de barrière first-State → un "0 rows"
à froid est ambigu → le retry (resolveAccountReliably/provisionRetry) échoue → nouveau
compte provisionné → FORK → données du compte invisibles.

Cause NextGraph (vérifiée nextgraph-rs): "trouvable-sans-lookup" (store-root) et
"abonnable" (did:ng:o:<RepoID aléatoire>) sont DISJOINTS — pas de doc à la fois
devinable et attendable → une résolution shim purement barrière est impossible.

Fix (indirection pointeur → doc-shim abonnable):
- Les AccountRecord migrent dans un doc-shim doc_create'd (did:ng:o:..., a une barrière).
- Un pointeur écrit-une-fois dans le store-root (<shim:root> <shim:shimDoc> <docShim>)
  le nomme. resolveShimDoc lit le pointeur → ensureRepoOpen(docShim) [barrière] → lecture
  de compte AUTORITATIVE (cold 0 = absent pour de vrai). Retry de compte SUPPRIMÉ.
- Micro-garde résiduel (pointerGuard, ex-provisionRetry) sur le SEUL triple pointeur
  écrit-une-fois; ne peut jamais forker un compte; fork de pointeur réconcilié au
  doc-shim canonique (lexicographiquement-min), sans perte.
- Migration: migrateLegacyRecords copie (pas déplace) les comptes de l'ancien store-root
  vers le doc-shim avant toute conclusion "absent"; idempotent; wallet neuf → no-op.

Tests: unit 128/128, e2e réel 42/42 (CONTRACT 2 = non-fork du compte à la reconnexion),
red-before/green-after prouvé. Docs: nextgraph-current-state (antagonisme + indirection),
simulation, migration-guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:46:16 +02:00

951 lines
41 KiB
TypeScript

/**
* SDK e2e harness entry — the MINIMAL page loaded inside the broker iframe.
*
* It imports the REAL `@ng-org/web` `ng` + this package (`@ng-eventually/client`),
* 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";
import { configure, configureStoreRegistry, setCurrentUser, getCaps, resetCaps } from "@ng-eventually/client/polyfill";
import {
docs,
subscribeDoc,
subscribeDocs,
readModel,
inbox,
discovery,
storeRegistry,
useShape as libUseShape,
watchShape,
accounts,
} from "@ng-eventually/client";
import type { ShapeObservable, ShapeQuery } from "@ng-eventually/client";
const { IdentityStore } = accounts;
// ── 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));
// A minimal in-memory Set-like, so the lib's read-filtered `useShape` view can be
// exercised WITHOUT the real ORM (`@ng-org/orm` is not installed here, and the
// read-filter is pure — it wraps whatever Set-like the injected useShape returns).
// The injected useShape receives `(shapeType, scope)`; we ignore both and return
// the items array captured by the test through window.__sdk.caps.seedSet().
let injectedSetItems: any[] = [];
function fakeUseShape(_shape: unknown, _scope: unknown): any {
const items = () => injectedSetItems;
return {
get size() { return items().length; },
[Symbol.iterator]() { return items()[Symbol.iterator](); },
forEach(cb: (v: unknown) => void) { items().forEach(cb); },
add(item: any) { injectedSetItems.push(item); },
};
}
// ── Inject the real SDK + polyfill settings (the consumer bootstrap) ────────
configure({
ng: realNg,
useShape: fakeUseShape,
init: realInit,
});
configureStoreRegistry({
// The registry (+ subscribe/inbox/discovery/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 AccountStorage).
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 };
},
// ── 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, anchor);
},
async sparqlQuery(query: string, anchor?: string) {
const s = await sessionReady;
return docs.sparqlQuery(s.session_id, query, undefined, 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,
};
},
// ── read-model ───────────────────────────────────────────────────────────
/**
* Create N docs, write a marker triple into each (anchored default graph), then
* readUnion over them → one subject per doc. Optionally inject a bad NURI to
* prove per-doc tolerance (the bad one is skipped, the batch survives).
*/
async readUnionOverDocs(n: number, includeBad: boolean) {
const s = await sessionReady;
const docNuris: string[] = [];
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);
}
const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const subjects = await readModel.readUnion(toRead);
return { docNuris, subjectCount: subjects.length, subjects };
},
/**
* readUnion cap gate: create a doc, mark it protected for owner O, set the
* current user to a DIFFERENT identity, and readUnion → the doc is dropped.
*/
async readUnionCapGate() {
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);
getCaps().open(doc, "protected", "owner-O");
setCurrentUser("someone-else");
const asStranger = await readModel.readUnion([doc]);
setCurrentUser("owner-O");
const asOwner = await readModel.readUnion([doc]);
resetCaps();
setCurrentUser(null);
return { strangerCount: asStranger.length, 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}" }`,
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 ────────────────────────────────────────────────────────────────
async inboxPostRead(payloadA: unknown, payloadB: unknown) {
const s = await sessionReady;
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
setCurrentUser("inbox-user");
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: "" },
async inboxWatchStart() {
const s = await sessionReady;
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
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;
setCurrentUser("watcher");
await inbox.post(rec.target, { payload, from: null });
setCurrentUser(null);
},
inboxWatchState() {
const r = (window as any).__sdk._inboxWatch;
return { fires: r.fires, lastLen: r.lastLen };
},
inboxWatchStop() {
(window as any).__sdk._inboxWatch.unsub();
},
// spoof guard: depositing as another principal throws.
async inboxSpoofGuard() {
const s = await sessionReady;
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
setCurrentUser("alice");
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 };
},
// ── discovery index ──────────────────────────────────────────────────────
async discoverySubmitRead(ref: unknown) {
setCurrentUser("publisher");
await discovery.submitToIndex(ref);
setCurrentUser(null);
const entries = await discovery.readIndex();
return { entries };
},
_discWatch: { fires: 0, lastLen: -1, unsub: () => {} },
discoveryWatchStart() {
const rec = { fires: 0, lastLen: -1, unsub: () => {} };
(window as any).__sdk._discWatch = rec;
rec.unsub = discovery.watchIndex((entries) => {
rec.fires += 1;
rec.lastLen = entries.length;
});
},
async discoverySubmit(ref: unknown) {
setCurrentUser("publisher2");
await discovery.submitToIndex(ref);
setCurrentUser(null);
},
discoveryWatchState() {
const r = (window as any).__sdk._discWatch;
return { fires: r.fires, lastLen: r.lastLen };
},
discoveryWatchStop() {
(window as any).__sdk._discWatch.unsub();
},
// reserved @index account isolation: a real user named "index"/"@index" resolves
// to a DIFFERENT account than the reserved index owner.
async discoveryIndexIsolation() {
const userIndex = await storeRegistry.ensureAccount("@index");
const reserved = await storeRegistry.ensureAccount(discovery.INDEX_ACCOUNT);
return {
userIndexDoc: userIndex.docPublic,
reservedDoc: reserved.docPublic,
disjoint: userIndex.docPublic !== reserved.docPublic,
};
},
// ── store-registry ───────────────────────────────────────────────────────
async ensureAccountIdempotent(id: string) {
storeRegistry.resetRegistryCache();
const first = await storeRegistry.ensureAccount(id);
storeRegistry.resetRegistryCache();
const second = await storeRegistry.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) {
storeRegistry.resetRegistryCache();
const dA1 = await storeRegistry.createEntityDoc(idA, "public");
const dA2 = await storeRegistry.createEntityDoc(idA, "public");
const dB1 = await storeRegistry.createEntityDoc(idB, "public");
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
let listA: string[] = [];
for (let i = 0; i < 12; i++) {
storeRegistry.resetRegistryCache();
listA = await storeRegistry.listMyEntityDocs(idA, "public");
if (listA.includes(dA1) && listA.includes(dA2)) break;
await new Promise((r) => setTimeout(r, 1000));
}
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") {
storeRegistry.resetRegistryCache();
const s = await sessionReady;
const entityNuri = await storeRegistry.createEntityDoc(id, 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++) {
storeRegistry.resetRegistryCache();
listed = await storeRegistry.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 → readScopeIndex) and readUnion
* them, 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) {
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open at all, first
// thing in the fresh session — 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 readUnion's open step.
const s = session ?? (await sessionReady);
let rawRowCount = -1;
try {
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, entityNuri);
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
} catch (e: any) {
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
}
storeRegistry.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, scope);
const subjects = await readModel.readUnion(listed.length ? listed : [entityNuri]);
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(entityNuri),
subjectCount: subjects.length,
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) {
storeRegistry.resetRegistryCache();
const rec = await storeRegistry.ensureAccount(id);
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
},
async scopeResolvers() {
const priv = await storeRegistry.resolveScopeGraph("private");
const prot = await storeRegistry.resolveScopeGraph("protected");
const pub = await storeRegistry.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, 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) {
storeRegistry.resetRegistryCache();
try {
const rec = await storeRegistry.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) {
storeRegistry.resetRegistryCache();
try {
const rec = await storeRegistry.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: "" };
}
},
// ── watchShape (reactive useQuery-shaped read) ───────────────────────────
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
// to know which @type to keep — generic, no application domain.
_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 `watchShape` over
* (that class shape, "protected"). 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 watchShapeSeedAndSubscribe(handle: string, cls: string) {
storeRegistry.resetRegistryCache();
const id = "@ws-" + handle;
setCurrentUser(id);
const doc = await storeRegistry.createEntityDoc(id, "protected");
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++) {
storeRegistry.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000));
}
const shape = {
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" }],
},
],
},
},
};
const obs = watchShape(shape, "protected") 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 — watchShape resolves
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
// keeps a stable identity. watchShapeStop clears it.
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
},
/** Current snapshot of a watchShape probe (event-driven poll target). */
watchShapeSnapshot(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).
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
};
},
watchShapeStop(handle: string) {
const rec = (window as any).__sdk._wsProbes[handle];
if (rec) rec.unsub();
setCurrentUser(null);
},
/**
* EMPTY-scope proof: a brand-new identity with NO entity docs of `scope`. watchShape
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
* the handle; poll watchShapeSnapshot for the transition.
*/
watchShapeEmptyStart(handle: string, cls: string) {
storeRegistry.resetRegistryCache();
const id = "@ws-empty-" + handle;
setCurrentUser(id);
const shape = {
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" }],
},
],
},
},
};
const obs = watchShape(shape, "protected") 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 (watchShapeStop 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.
capsReadFilter() {
resetCaps();
injectedSetItems = [
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
{ "@graph": "did:ng:o:ungoverned", "@id": "3", v: "ungoverned-item" },
];
getCaps().open("did:ng:o:protdoc", "protected", "owner-O");
getCaps().makePublic("did:ng:o:pubdoc");
// as owner-O
setCurrentUser("owner-O");
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
// as a stranger
setCurrentUser("stranger");
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
resetCaps();
injectedSetItems = [];
setCurrentUser(null);
return { ownerView, strangerView };
},
capsDirectedGrant() {
resetCaps();
injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }];
getCaps().open("did:ng:o:sharedoc", "protected", "owner-O");
setCurrentUser("friend");
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
getCaps().grantRead("did:ng:o:sharedoc", "friend");
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
resetCaps();
injectedSetItems = [];
setCurrentUser(null);
return { before, after };
},
// ── 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(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.
(async () => {
try {
await (realInit as any)(
(event: any) => {
session = event.session as BrokerSession;
state.status = "connected";
sessionResolve(session);
},
true,
[],
);
} catch (e: any) {
state.status = "error";
state.error = String(e?.message ?? e);
}
})();