/** * 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(polyfillPort(...))` exactly as an * application would. * * It reaches nothing private. Every import below is a published entry — of the polyfill * (`configure`, `ensureIdentity`, `init`, `readUnion`, `storeRegistry`) or of this * package (`indexing`, `polyfillPort`). If something here is awkward, it is awkward for * every consumer, which is the second reason to write it this way. * * The one thing here no application does * `createIndexWithBrokenInbox` injects a failure into the inbox step of `createIndex`. * That is a probe, it is named for what it is, and it exists because the question it * answers — does a failed `openInbox` leave a document behind? — cannot be reached from * outside: nothing a caller controls makes a real `openDocumentInbox` fail on demand. * Everything around the injection is real, including the broker and the document. */ 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"; import { indexing, polyfillPort } from "../src/index"; import type { IndexEntry, Indexing, NextGraphPort } from "../src/index"; import type { BrokenInboxOutcome, 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 // `polyfillPort` takes a session id, 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; let port: NextGraphPort | 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; port = polyfillPort({ sessionId: session.session_id }); // One await, and curation is part of it: obtaining the handle processes the inboxes // of the indexes this identity owns and leaves them watched. This application never // curates anything, and has nothing to call if it wanted to. api = await indexing(port); 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; } function readyPort(): NextGraphPort { if (port === null) { throw new Error(`[e2e] the application is not ready (${state.status}): ${state.error ?? "still connecting"}`); } return port; } /** * 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 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().createIndex(field); }, /** * Publish a public document carrying one value for one predicate. * * It goes through the SAME primitive the curator writes an entry 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 = readyPort(); const doc = await p.createPublicDocument(); await p.addLiteralProperty(doc, doc, predicate, value); return doc; }, async referConfigured(object: string): Promise { const index = configuredIndex(); if (index === null) { throw new Error("[e2e] this application was not configured with an index reference"); } await ready().refer(index, object); }, async referTo(index: string, object: string): Promise { await ready().refer(index, object); }, async reconnect(): Promise { api = await indexing(readyPort()); }, async read(index: string): Promise { return ready().read(index); }, 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 = readyPort(); 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 = await indexing({ ...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.createIndex(field); } catch (e: unknown) { rejected = String((e as Error)?.message ?? e); } return { rejected, returned, appeared: await publicDocsAfter(before, 15_000) }; }, }; window.__indexing = bridge;