278 lines
10 KiB
TypeScript
278 lines
10 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(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<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;
|
|
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<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 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().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<string> {
|
|
const p = readyPort();
|
|
const doc = await p.createPublicDocument();
|
|
await p.addLiteralProperty(doc, doc, predicate, value);
|
|
return doc;
|
|
},
|
|
|
|
async referConfigured(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().refer(index, object);
|
|
},
|
|
|
|
async referTo(index: string, object: string): Promise<void> {
|
|
await ready().refer(index, object);
|
|
},
|
|
|
|
async reconnect(): Promise<void> {
|
|
api = await indexing(readyPort());
|
|
},
|
|
|
|
async read(index: string): Promise<IndexEntry[]> {
|
|
return ready().read(index);
|
|
},
|
|
|
|
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 = readyPort();
|
|
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 = await indexing({
|
|
...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.createIndex(field);
|
|
} catch (e: unknown) {
|
|
rejected = String((e as Error)?.message ?? e);
|
|
}
|
|
|
|
return { rejected, returned, appeared: await publicDocsAfter(before, 15_000) };
|
|
},
|
|
};
|
|
|
|
window.__indexing = bridge;
|