/** * The application the end-to-end suite drives — written the way a consumer of * `@ng-helpers/indexing` writes one, and nothing more. * * Why an application and not a bag of library calls * The 80 unit tests in `test/` run against a fake this repository wrote. They prove the * indexing RULES are consistent; they cannot prove that NextGraph does what the fake * pretends, because the fake is the thing being asked. This page closes that gap by * putting the real broker underneath: it imports `@ng-eventually/polyfill` for real, * crosses the real broker, and calls `indexing(sessionId)` exactly as an application * would. * * What this application takes from `@ng-helpers/indexing` is its WHOLE published * surface: `indexing(sessionId)`, the two acts on what it hands back, and the two IRIs. * Nothing here makes an entry of a deposit, and nothing here can ask for one: that is * the business of the layer below, and this page is where that shows or does not. * Everything else here is the polyfill (`configure`, `ensureIdentity`, `init`, * `readUnion`, `docs`, `storeRegistry`) — including reading the index, which is an * ordinary `readUnion` of an ordinary document. If something here is awkward, it is * awkward for every consumer, which is the second reason to write it this way. * * The two things here no application does, and why they reach past the surface * `createIndexWithBrokenInbox` injects a failure into the inbox step of `create`: the * question it answers — does a failed `openInbox` leave a document behind? — cannot be * reached from outside, because nothing a caller controls makes a real * `openDocumentInbox` fail on demand. And `publishObject` writes through the very * primitive `create` declares an index's field with, which is what makes it the CONTROL * for the write-form question. Both therefore build the package's INTERNAL port, and * both are named for what they are. Everything around them is real, including the * broker. */ import { configure, docs, ensureIdentity, init, readUnion, storeRegistry, type Nuri, type UnionSubject, } from "@ng-eventually/polyfill"; import { ng as realNg, init as realInit } from "@ng-org/web"; // The whole published surface of the package under test. import { ENTRY_VALUE, indexing, type Indexing } from "../src/index"; // NOT published, and reached only by the two probes above — see the header. import { indexingOn } from "../src/indexing"; import type { NextGraphPort } from "../src/port"; import { polyfillPort } from "../src/polyfill-adapter"; import type { BrokenInboxOutcome, IndexRow, IndexingBridge, SelectOutcome } from "./bridge"; // bootstrap: the one polyfill-era call, then the SDK-shaped ones // // `sharedWallet` is declared because the access gate wants somewhere to point when it // has to render, and never used: this suite always enters through the broker's redirect, // where the wallet is already open in the run's profile. Nothing is served at that path. configure({ ng: realNg, useShape: () => undefined, // this application reads through `readUnion`, not the ORM init: realInit, sharedWallet: { fileUrl: "/wallet-never-served.ngw", password: "" }, }); // The library's `init`, not the injected one: it settles the identity BEFORE handing the // page to the broker, so the round-trip leaves with `?ng-id=` in the address it carries. // The callback is this application's own business — it keeps the session because // `indexing(sessionId)` takes one, exactly as the real SDK's primitives do. const sessionReady = new Promise<{ session_id: string }>((resolve) => { init( (event: { status: string; session?: { session_id: string } }) => { if (event.status === "loggedin" && event.session) resolve(event.session); }, true, [], ); }); // this application's state const state: { status: string; error: string | null; who: string } = { status: "connecting", error: null, who: "", }; let api: Indexing | null = null; /** The index this deployment contributes to, read off its own configuration. */ function configuredIndex(): string | null { return new URLSearchParams(window.location.search).get("index"); } async function boot(): Promise { // One await, and it covers everything: the identity settles, the connection work runs, // and the identity comes back. The application keeps it only to show it. state.who = await ensureIdentity(); const session = await sessionReady; // The session id, and nothing else — this application never names a port, and never // awaits anything here: a handle reaches nothing. A reference this application hands // an index becomes an entry because the layer below processes that index's inbox, // and there is nothing to call, schedule or configure for it. api = indexing(session.session_id); state.status = "ready"; } void boot().catch((e: unknown) => { state.status = "failed"; state.error = String((e as Error)?.message ?? e); }); /** The library, once the page is up. Throws with the boot's own reason if it is not. */ function ready(): Indexing { if (api === null) { throw new Error(`[e2e] the application is not ready (${state.status}): ${state.error ?? "still connecting"}`); } return api; } /** * The package's INTERNAL port, for the two probes that need one. Never used by an act * this application performs as an application — see the header for why each needs it. */ async function probePort(): Promise { const session = await sessionReady; return polyfillPort({ sessionId: session.session_id }); } /** * Wait for a document to appear in this identity's public store. * * A store listing is a read like any other, and a document written a moment ago is not * owed to be in it instantly. Polling is therefore what an owner would actually do, and * it is bounded: an empty answer at the end is evidence, not a hang. */ async function publicDocsAfter( before: ReadonlySet, budgetMs: number, ): Promise { const deadline = Date.now() + budgetMs; let appeared: readonly string[] = []; for (;;) { const now = await storeRegistry.listMyEntityDocs("public"); appeared = now.filter((d) => !before.has(d)); if (appeared.length > 0 || Date.now() >= deadline) return appeared; await new Promise((r) => setTimeout(r, 500)); } } /** * How much of an answer a report carries. Bounded so a report stays one, generous enough * that the answer is readable rather than merely counted. */ const RAW_LIMIT = 2000; /** Whatever came back, as JSON — `undefined` and a value that will not render included, * because both of those are answers too and a report that hides them is worth nothing. */ function render(result: unknown): string { let text: string; try { text = JSON.stringify(result) ?? String(result); } catch (e: unknown) { text = `(did not render: ${String((e as Error)?.message ?? e)})`; } return text.length <= RAW_LIMIT ? text : `${text.slice(0, RAW_LIMIT)}…(${text.length} chars)`; } /** * The SELECT's bindings, in the shape the SPARQL results JSON specifies — the same * `results.bindings` the polyfill itself reads out of this very call (`surface/inbox.ts`, * `surface/read-model.ts`). A binding whose term carries no string `value` is dropped * rather than guessed at; `raw` beside it is what keeps that honest. */ function compare(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } function rowsOf(result: unknown): Array> { if (result === null || typeof result !== "object") return []; const answered = result as { results?: { bindings?: ReadonlyArray> }; }; const bindings = answered.results?.bindings ?? []; return bindings.map((binding) => { const row: Record = {}; for (const [variable, term] of Object.entries(binding)) { if (term !== undefined && typeof term.value === "string") row[variable] = term.value; } return row; }); } // the acts const bridge: IndexingBridge = { status: () => state.status, error: () => state.error, whoami: () => state.who, configuredIndex, async createIndex(field: string): Promise { return ready().create(field); }, /** * Publish a public document carrying one value for one predicate. * * It goes through the SAME primitive `create` declares an index's field with * (`addLiteralProperty`), with the document as its own subject. That makes it the * CONTROL for the write-form question: if this round-trips and an index entry does * not, the difference is the foreign subject and nothing else. */ async publishObject(predicate: string, value: string): Promise { const p = await probePort(); const doc = await p.createPublicDocument(); await p.addLiteralProperty(doc, doc, predicate, value); return doc; }, async addToConfigured(object: string): Promise { const index = configuredIndex(); if (index === null) { throw new Error("[e2e] this application was not configured with an index reference"); } await ready().add(index, object); }, async addTo(index: string, object: string): Promise { await ready().add(index, object); }, async rebuildHandle(): Promise { const session = await sessionReady; api = indexing(session.session_id); }, /** * The index read BY THIS APPLICATION, with nothing the package publishes but * `ENTRY_VALUE` — the claim "an index is an ordinary document" performed rather than * repeated. `readUnion([index])` is the same call this page makes on any other * document; the index's own subject is the one that is not an entry, told apart by * being the document itself. */ async read(index: string): Promise { const subjects = await readUnion([index]); const rows: IndexRow[] = []; for (const subject of subjects) { if (subject.subject === index) continue; for (const value of subject.props[ENTRY_VALUE] ?? []) { rows.push({ object: subject.subject, value }); } } // Ordered by value, ties broken on the object, so two readers of the same document // see the same order. The package used to do this; an application does it in four // lines, and gets to choose differently. rows.sort((a, b) => a.value === b.value ? compare(a.object, b.object) : compare(a.value, b.value), ); return rows; }, async readRaw(doc: string): Promise { return readUnion([doc]); }, /** * A SPARQL SELECT anchored on a document, through the polyfill's published `docs`. * * The session id is the one this application already holds — the same one every write * of this layer is made with. Nothing is caught and rethrown: a rejection is REPORTED, * because "the query failed" is a different answer from "the query found nothing" and * the whole point of this probe is to tell them apart. */ async select(anchor: string, query: string): Promise { const session = await sessionReady; try { const result = await docs.sparqlQuery(session.session_id, query, undefined, anchor); return { failed: null, raw: render(result), rows: rowsOf(result) }; } catch (e: unknown) { return { failed: String((e as Error)?.message ?? e), raw: "(the query rejected)", rows: [] }; } }, async listPublicDocs(): Promise { const docs: Nuri[] = await storeRegistry.listMyEntityDocs("public"); return [...docs]; }, async createIndexWithBrokenInbox(field: string): Promise { const p = await probePort(); const before = new Set(await storeRegistry.listMyEntityDocs("public")); // Everything real except the inbox step. The failure is injected at the exact moment // the question is about: after the document exists and carries its descriptor, before // anyone can deposit into it. const broken = indexingOn({ ...p, openInbox: async (): Promise => { throw new Error("[e2e] injected: the inbox could not be opened"); }, }); let rejected: string | null = null; let returned: string | null = null; try { returned = await broken.create(field); } catch (e: unknown) { rejected = String((e as Error)?.message ?? e); } return { rejected, returned, appeared: await publicDocsAfter(before, 15_000) }; }, }; window.__indexing = bridge;