Files

316 lines
12 KiB
TypeScript

/**
* 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<void> {
// 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<NextGraphPort> {
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<string>,
budgetMs: number,
): Promise<readonly string[]> {
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<Record<string, string>> {
if (result === null || typeof result !== "object") return [];
const answered = result as {
results?: { bindings?: ReadonlyArray<Record<string, { value?: unknown } | undefined>> };
};
const bindings = answered.results?.bindings ?? [];
return bindings.map((binding) => {
const row: Record<string, string> = {};
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<string> {
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<string> {
const p = await probePort();
const doc = await p.createPublicDocument();
await p.addLiteralProperty(doc, doc, predicate, value);
return doc;
},
async addToConfigured(object: string): Promise<void> {
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<void> {
await ready().add(index, object);
},
async rebuildHandle(): Promise<void> {
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<IndexRow[]> {
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<UnionSubject[]> {
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<SelectOutcome> {
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<string[]> {
const docs: Nuri[] = await storeRegistry.listMyEntityDocs("public");
return [...docs];
},
async createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome> {
const p = await probePort();
const before = new Set<string>(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<void> => {
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;