856 lines
39 KiB
TypeScript
856 lines
39 KiB
TypeScript
/**
|
||
* `@ng-helpers/indexing` against the REAL broker.
|
||
*
|
||
* What this suite is for
|
||
* The unit suite proves the indexing rules are consistent with a fake this repository
|
||
* wrote. It cannot prove NextGraph behaves the way that fake pretends, because the fake
|
||
* is the very thing in question. Two claims in particular had never met a broker:
|
||
*
|
||
* 1. **The write form.** An entry is a triple whose SUBJECT is another document — the
|
||
* indexed object — written into the index document's anchored default graph. The
|
||
* polyfill's own suites only ever write a document's own subject into itself, so
|
||
* nothing had ever asked oxigraph whether a FOREIGN subject survives the round trip.
|
||
* `publishObject` here writes the self-subject form with the same primitive, which
|
||
* makes it the control: if one round-trips and the other does not, the difference is
|
||
* the foreign subject and nothing else.
|
||
*
|
||
* 2. **A half-created index.** `create` creates a document, writes its descriptor,
|
||
* then opens its inbox. If the last step fails the caller gets an exception and no
|
||
* reference — but the document exists. The last journey injects that failure and
|
||
* asks the broker what was left behind.
|
||
*
|
||
* Two identities, and how the index reference reaches the second
|
||
* The whole point of an index is that STRANGERS contribute to it. So Bob must reach
|
||
* Alice's index — and he must reach it the way an application would, not through a
|
||
* variable in this file. An index is an ordinary document whose NURI an application
|
||
* references in its own source (`src/indexing.ts`), so Bob's page is CONFIGURED with it,
|
||
* through its URL: one build step earlier, that is a compiled-in constant. What must
|
||
* never happen — and does not happen here — is an inbox address crossing the identity
|
||
* boundary through a channel no deployment has.
|
||
*
|
||
* Waiting, because nothing here says when processing is done
|
||
* The two acts are `create` and `add`; what becomes of what is added is the business of
|
||
* the layer below, which applies a deposit when the owner's session is PUSHED one. There
|
||
* is no call that forces it, no receipt, and no `await` that covers it — by design. So a
|
||
* journey that has deposited WAITS for the index to hold the entry, reading it the way an
|
||
* application reads one (`settled`), and only then asserts. Before that wait existed the
|
||
* checks read the instant after the deposit and seven of them reported an index that was
|
||
* merely not written YET.
|
||
*
|
||
* The one claim that wait cannot carry: "an object carrying nothing for the field is not
|
||
* indexed". A deposit that is deliberately not indexed writes nothing, so no reading tells
|
||
* "examined and skipped" from "not examined yet", and there is nothing to wait for that is
|
||
* not a sleep. That journey reads straight away and its check is therefore weaker than its
|
||
* name — recorded here rather than hidden. What partly holds it up is order: that deposit
|
||
* precedes the hostile one, and the hostile one IS waited for, so by the end of the run the
|
||
* queue holding it has demonstrably been applied at least once.
|
||
*
|
||
* Reading a failure
|
||
* A named deadline, or a message `ng-e2e-helpers` recognises as a browser or frame
|
||
* failure, is the HOST. A failed check carrying an unexpected value is this code. The
|
||
* report says which, and the run is repeated rather than anything being loosened.
|
||
*/
|
||
|
||
import {
|
||
BROKER_ROUND_TRIP_MS,
|
||
NEW_PAGE_MS,
|
||
armSuiteDeadline,
|
||
browserTrouble,
|
||
closeContext,
|
||
closeQuietly,
|
||
declareSuite,
|
||
firstLine,
|
||
launchWatchedContext,
|
||
measured,
|
||
newPage,
|
||
setupBrokerPage,
|
||
within,
|
||
type Prerequisite,
|
||
type RunProfile,
|
||
} from "ng-e2e-helpers";
|
||
|
||
import { ENTRY_VALUE, INDEX_FIELD } from "../src/index";
|
||
import { WALLET, buildApp, mintRunWallet, serveApp } from "./harness-page";
|
||
|
||
/**
|
||
* The browser types, taken from the helpers that RETURN them rather than imported from
|
||
* `playwright` directly.
|
||
*
|
||
* `ng-e2e-helpers` declares Playwright a PEER dependency — the consumer owns the version,
|
||
* because browser binaries have to match the driver. Its files reach this repository as
|
||
* symlinks, so TypeScript resolves its `playwright` from where those files really live,
|
||
* and importing the driver here as well produced two structurally different copies of
|
||
* `BrowserContext`: a context this file had opened could not be handed back to the helper
|
||
* that opens contexts. Derived, there is exactly one set of these types — whichever copy
|
||
* the helpers speak — and a version skew can no longer express itself as a type error in
|
||
* code that is correct.
|
||
*/
|
||
type BrowserContext = Awaited<ReturnType<typeof launchWatchedContext>>;
|
||
type Page = Awaited<ReturnType<typeof newPage>>;
|
||
type Frame = Awaited<ReturnType<typeof setupBrokerPage>>;
|
||
|
||
// the domain this suite indexes by
|
||
//
|
||
// A date, so the suite exercises the case the package is built around: an index "by a
|
||
// date" is just an index whose field is a date predicate, and ISO-8601 sorts as a string.
|
||
const PUBLISHED_AT = "urn:ng-helpers-e2e:published-at";
|
||
/** A predicate an index is NOT built on — for the object that carries nothing usable. */
|
||
const UNRELATED = "urn:ng-helpers-e2e:unrelated";
|
||
|
||
// bounds
|
||
//
|
||
// Sized to be generous rather than tight. A bound exists to turn a hang into a named
|
||
// failure; sized to the median it would instead fail on a slow-but-healthy broker, which
|
||
// is the one thing it must never do. The wall clocks of the three reported runs are the
|
||
// measurement these should be re-sized from.
|
||
|
||
/** The bridge appearing on the page — a bundle evaluating, no broker involved. */
|
||
const BRIDGE_UP_MS = 60_000;
|
||
/** `ensureIdentity` + the session: an identity settled and the connection work run. */
|
||
const READY_MS = 180_000;
|
||
/** One sign-in: a page, the broker round trip, and the application booting behind it. */
|
||
const SIGN_IN_MS = NEW_PAGE_MS + BROKER_ROUND_TRIP_MS + READY_MS;
|
||
/** One call across the bridge. The slowest cross the broker once per deposit. */
|
||
const BRIDGE_MS = 4 * 60_000;
|
||
/**
|
||
* How often the index is READ while waiting for a deposit to have become an entry.
|
||
*
|
||
* Not a sleep standing in for the wait: it is the interval at which the CONDITION is asked,
|
||
* and the wait ends on the first reading that holds. `indexing-app.ts` polls its own store
|
||
* at the same interval, for the same reason.
|
||
*/
|
||
const SETTLE_POLL_MS = 500;
|
||
/**
|
||
* How long a deposit has to become an entry before the wait gives up and says what the
|
||
* index held instead.
|
||
*
|
||
* MEASURED on a green run (`E2E_TIMINGS=1`, 2026-08-21, one sample each): 0.6s for Bob's
|
||
* first deposit becoming an entry, 0.7s for the hostile one, and 0.0s for the entry
|
||
* reaching a stranger's own session — that last one had already converged by the time it
|
||
* was asked. The layer below is push-driven, so what is waited on is one push and one small
|
||
* write, not a broker crossing; that is why these are sub-second while a deposit or a
|
||
* publish measures 0.1–0.9s.
|
||
*
|
||
* Bounded at 60s ≈ 85x the slowest of them, generous on purpose. It must never fire on a
|
||
* slow-but-healthy broker, and it is what turns "the entry never came" into a named failure
|
||
* carrying the last reading — instead of a check that merely read too early, which is what
|
||
* the seven failures it was written for looked like.
|
||
*/
|
||
const SETTLE_MS = 60_000;
|
||
/**
|
||
* One journey. The longest holds two sign-ins' worth of work behind it.
|
||
*
|
||
* NOT the sum of the steps it encloses, and that is deliberate — the same arbitration
|
||
* `packages/polyfill/e2e/notebook.ts` records for its own: the longest journey here sums to
|
||
* 16 min of step bounds (four bridge calls), and a journey bounded above that would outlast
|
||
* the SUITE's own clock, so one hung journey would take the summary down with it. Every step
|
||
* inside a journey already carries a bound and names itself, so this catches only a hang in
|
||
* code no step wraps. What DOES have to hold is that this enclosure cannot fire BEFORE the
|
||
* settle point it now encloses, or a settle that gave up would report as an anonymous
|
||
* journey timeout: measured on a green run, the longest journey holding a settle takes 1.6s
|
||
* and the longest of all (a sign-in) 9.8s, so a journey that spends SETTLE_MS (60s) waiting
|
||
* still has more than eight minutes of this bound left — the settle is always the one that
|
||
* reports.
|
||
*/
|
||
const JOURNEY_MS = 10 * 60_000;
|
||
/** The whole run. A budget that cannot interrupt anything is not a budget. */
|
||
const SUITE_MS = 30 * 60_000;
|
||
|
||
// the report
|
||
|
||
let actors: BrowserContext | null = null;
|
||
|
||
const { check, journey, finish } = declareSuite({
|
||
label: "ng-helpers indexing e2e",
|
||
journeyBound: JOURNEY_MS,
|
||
diagnose: async () => (actors === null ? null : browserTrouble("actors", actors)),
|
||
journeys: [
|
||
{
|
||
name: "Alice signs in and creates an index",
|
||
checks: [
|
||
"Alice signs in and the application knows who she is",
|
||
"creating an index answers with a document reference",
|
||
"the index document declares the field it indexes by",
|
||
],
|
||
},
|
||
{
|
||
name: "Bob signs in configured with Alice's index, and publishes an object",
|
||
checks: [
|
||
"Bob signs in, configured with the index his application contributes to",
|
||
"Bob publishes a public object carrying the indexed field",
|
||
"Bob's object reads back carrying the value he wrote",
|
||
],
|
||
},
|
||
{
|
||
name: "Bob hands the index a reference, and it becomes an entry of Alice's index",
|
||
checks: [
|
||
"a stranger's deposit into the index's inbox reached its owner and became an entry",
|
||
"the entry is stored under Bob's object's own reference as its subject",
|
||
"the indexed value was read off Bob's object, and never travelled in his deposit",
|
||
],
|
||
},
|
||
{
|
||
name: "The index reads back, for its owner and for a stranger",
|
||
checks: [
|
||
"Alice reads exactly one entry, and it is Bob's object",
|
||
"Bob, who does not own the index, reads the same entry",
|
||
"connecting a second time changes nothing, and the index still holds one entry",
|
||
],
|
||
},
|
||
{
|
||
name: "An object carrying nothing for the field is not indexed",
|
||
checks: [
|
||
"the unrelated object is not indexed, and the index still holds exactly one entry",
|
||
],
|
||
},
|
||
{
|
||
name: "An index whose inbox cannot be opened leaves a document behind",
|
||
checks: [
|
||
"createIndex refuses when the inbox cannot be opened",
|
||
"a document was nevertheless created in the owner's public store",
|
||
"the leaked document carries a descriptor but accepts no deposit",
|
||
],
|
||
},
|
||
{
|
||
name: "A hostile value crosses the round trip as one inert literal",
|
||
checks: [
|
||
"the object reads back the hostile value byte for byte",
|
||
"the index holds it as one entry, and its own descriptor is untouched",
|
||
],
|
||
},
|
||
{
|
||
name: "The index answers an ordinary SPARQL query",
|
||
checks: [
|
||
"readUnion returns both entries and the index's own declaration",
|
||
"a SELECT for the entry predicate returns both entries, with their values",
|
||
"a SELECT of the index's own subject returns the field it declares",
|
||
"a stranger's SELECT returns the same entries",
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
/**
|
||
* Two readings of the same entries — the same objects, carrying the same values.
|
||
*
|
||
* Compared as a WHOLE: a query that answered with a subset, or with a value that changed
|
||
* shape crossing the round trip, is not the same answer as the document's own content.
|
||
*/
|
||
function sameEntries(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
|
||
if (a.size !== b.size) return false;
|
||
for (const [object, value] of a) {
|
||
if (b.get(object) !== value) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** The `?object`/`?value` rows of an entries SELECT, as the entries they claim to be. */
|
||
function entriesOf(rows: ReadonlyArray<Readonly<Record<string, string>>>): Map<string, string> {
|
||
const found = new Map<string, string>();
|
||
for (const row of rows) {
|
||
const object = row["object"];
|
||
const value = row["value"];
|
||
if (object !== undefined && value !== undefined) found.set(object, value);
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/** A named step that is both measured and bounded — `evaluate` carries no timeout of its own. */
|
||
function step<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
|
||
return measured(what, ms, (bound) => within(what, bound, task));
|
||
}
|
||
|
||
/**
|
||
* Read the index until it HOLDS what the checks about to run expect, and hand that reading
|
||
* back to them.
|
||
*
|
||
* ── Why the harness needs this at all ───────────────────────────────────────
|
||
* Nothing on this package's surface says "processing is done", deliberately: the two acts
|
||
* are `create` and `add`, and what becomes of what is added belongs to the layer below,
|
||
* which applies a deposit when the owner's session is PUSHED one. There is no call that
|
||
* forces it and no receipt to hold. So a check that reads the instant after a deposit reads
|
||
* too early — which is exactly what seven of these checks were reporting.
|
||
*
|
||
* ── A wait is not a retry of the assertion ──────────────────────────────────
|
||
* `holds` asks one thing only: has the deposit ARRIVED. The checks then assert their own
|
||
* claims ONCE, on the reading this returns. A loop that re-ran the checks until one of them
|
||
* passed would turn a flapping result into a green one and report the reading that happened
|
||
* to suit it.
|
||
*
|
||
* ── It reads the way an application reads ───────────────────────────────────
|
||
* `read` is an ordinary `readUnion` of an ordinary document, or an anchored SELECT — the
|
||
* only two ways anyone has of reading an index, the harness included. Nothing here reaches
|
||
* for a way to hurry the layer below, because a consumer has none either.
|
||
*
|
||
* On expiry it throws, naming what never arrived and WHAT THE INDEX HELD INSTEAD: the
|
||
* journey's remaining checks are then reported unreached with that reason, so the run still
|
||
* reports its thirty rows and the reason is the observation rather than a bare "timeout".
|
||
*/
|
||
async function settled<T>(
|
||
what: string,
|
||
read: () => Promise<T>,
|
||
holds: (seen: T) => boolean,
|
||
describe: (seen: T) => string,
|
||
): Promise<T> {
|
||
return measured(what, SETTLE_MS, async (bound) => {
|
||
const deadline = Date.now() + bound;
|
||
for (;;) {
|
||
const seen = await within(what, bound, read);
|
||
if (holds(seen)) return seen;
|
||
if (Date.now() >= deadline) {
|
||
throw new Error(
|
||
`[e2e] ${what}: nothing arrived within ${(bound / 1000).toFixed(0)}s — ` +
|
||
`the last reading was ${describe(seen)}`,
|
||
);
|
||
}
|
||
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
||
}
|
||
});
|
||
}
|
||
|
||
// an actor
|
||
|
||
interface Actor {
|
||
readonly id: string;
|
||
readonly frame: Frame;
|
||
readonly page: Page;
|
||
}
|
||
|
||
/**
|
||
* Sign an actor in, and wait for its application to be up.
|
||
*
|
||
* `?ng-id=` is the one channel that survives the broker round trip (the access gate's
|
||
* resolution order). `index` rides the same query string when the actor's deployment is
|
||
* built to contribute to one.
|
||
*/
|
||
async function signIn(
|
||
ctx: BrowserContext,
|
||
appUrl: string,
|
||
id: string,
|
||
index: string | null,
|
||
): Promise<Actor> {
|
||
const opened: { page: Page | null } = { page: null };
|
||
const query =
|
||
`?ng-id=${encodeURIComponent(id)}` +
|
||
(index === null ? "" : `&index=${encodeURIComponent(index)}`);
|
||
try {
|
||
return await measured(`${id}'s sign-in`, SIGN_IN_MS, (bound) =>
|
||
within(`${id} to sign in`, bound, async () => {
|
||
const page = await measured(`a page for ${id}`, NEW_PAGE_MS, () => newPage(id, ctx));
|
||
opened.page = page;
|
||
page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message));
|
||
page.on("console", (m) => {
|
||
if (m.type() === "error") console.error(`[${id} console]`, m.text());
|
||
});
|
||
const frame = await measured(`${id}'s broker round trip`, BROKER_ROUND_TRIP_MS, () =>
|
||
setupBrokerPage(page, `${appUrl}/${query}`, WALLET.password),
|
||
);
|
||
await waitReady(id, frame);
|
||
return { id, frame, page };
|
||
}),
|
||
);
|
||
} catch (e) {
|
||
if (opened.page !== null) {
|
||
await closeQuietly(`${id}'s abandoned sign-in page`, () => opened.page!.close());
|
||
}
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/** Wait for the application to be up, and say why with ITS reason when it is not. */
|
||
async function waitReady(id: string, frame: Frame): Promise<void> {
|
||
await step(`${id}'s application bundle`, BRIDGE_UP_MS, () =>
|
||
frame.waitForFunction(() => window.__indexing !== undefined, undefined, {
|
||
timeout: BRIDGE_UP_MS,
|
||
}),
|
||
);
|
||
await step(`${id}'s identity and session`, READY_MS, () =>
|
||
frame.waitForFunction(() => window.__indexing.status() !== "connecting", undefined, {
|
||
timeout: READY_MS,
|
||
}),
|
||
);
|
||
const status = await frame.evaluate(() => window.__indexing.status());
|
||
if (status !== "ready") {
|
||
const why = await frame.evaluate(() => window.__indexing.error());
|
||
throw new Error(`[e2e] ${id}'s application did not start (${status}): ${why ?? "no reason given"}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A journey cannot start without the actor it drives — reported as that, not discovered
|
||
* as a timeout on an innocent call.
|
||
*
|
||
* It takes a THUNK, not the actor: read eagerly, the value would be captured as it was
|
||
* before any sign-in happened, and every journey would report an actor that is standing
|
||
* right there as missing.
|
||
*/
|
||
function actorIsUp(id: string, actor: () => Actor | null): Prerequisite {
|
||
return () => (actor() === null ? `${id} never signed in` : null);
|
||
}
|
||
|
||
// the run
|
||
|
||
async function main(): Promise<void> {
|
||
armSuiteDeadline("ng-helpers indexing e2e", SUITE_MS, () =>
|
||
finish("the suite exceeded its wall clock"),
|
||
);
|
||
|
||
console.log("[e2e] building the application...");
|
||
buildApp();
|
||
|
||
// This run's own physical user, in a directory of its own — so another repository's
|
||
// suite can drive the same broker at the same time without either noticing.
|
||
console.log("[e2e] minting this run's wallet...");
|
||
const wallet: RunProfile = await mintRunWallet("the indexing suite (e2e/run.ts)");
|
||
|
||
const stamp = Date.now().toString(36);
|
||
const ALICE = `alice-${stamp}`;
|
||
const BOB = `bob-${stamp}`;
|
||
|
||
let ctx: BrowserContext | null = null;
|
||
let closeServer: (() => void) | null = null;
|
||
|
||
try {
|
||
const served = await serveApp();
|
||
closeServer = served.close;
|
||
console.log(`[e2e] application served at ${served.url}`);
|
||
|
||
ctx = await launchWatchedContext("actors", wallet.dir);
|
||
actors = ctx;
|
||
|
||
let alice: Actor | null = null;
|
||
let bob: Actor | null = null;
|
||
let index: string | null = null;
|
||
let bobsObject: string | null = null;
|
||
|
||
const aliceIsUp = actorIsUp(ALICE, () => alice);
|
||
const bobIsUp = actorIsUp(BOB, () => bob);
|
||
const indexExists: Prerequisite = () =>
|
||
index === null ? "Alice never created an index" : null;
|
||
|
||
await journey({
|
||
name: "Alice signs in and creates an index",
|
||
run: async () => {
|
||
alice = await signIn(ctx!, served.url, ALICE, null);
|
||
const who = await alice.frame.evaluate(() => window.__indexing.whoami());
|
||
check("Alice signs in and the application knows who she is", who.length > 0, `who=${who}`);
|
||
|
||
index = await step("Alice creating an index", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate((f) => window.__indexing.createIndex(f), PUBLISHED_AT),
|
||
);
|
||
check(
|
||
"creating an index answers with a document reference",
|
||
typeof index === "string" && index.startsWith("did:ng:"),
|
||
`index=${index}`,
|
||
);
|
||
|
||
// The descriptor's round trip — and the first thing the fake could have been
|
||
// lying about: the index document is found by an EXACT match on its own NURI as
|
||
// a subject, so a broker that returns a subject shaped differently breaks every
|
||
// read of every index.
|
||
const raw = await step("Alice reading the index document", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
||
);
|
||
const self = raw.find((s) => s.subject === index);
|
||
const declared = self?.props[INDEX_FIELD] ?? [];
|
||
check(
|
||
"the index document declares the field it indexes by",
|
||
declared.length === 1 && declared[0] === PUBLISHED_AT && self?.graph === index,
|
||
`subjects=${raw.length} self=${self === undefined ? "(not found)" : "found"} ` +
|
||
`graph=${self?.graph} declared=${JSON.stringify(declared)}`,
|
||
);
|
||
},
|
||
});
|
||
|
||
await journey({
|
||
name: "Bob signs in configured with Alice's index, and publishes an object",
|
||
needs: [indexExists],
|
||
run: async () => {
|
||
bob = await signIn(ctx!, served.url, BOB, index);
|
||
const configured = await bob.frame.evaluate(() => window.__indexing.configuredIndex());
|
||
check(
|
||
"Bob signs in, configured with the index his application contributes to",
|
||
configured === index,
|
||
`configured=${configured}`,
|
||
);
|
||
|
||
bobsObject = await step("Bob publishing an object", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate(
|
||
([p, v]) => window.__indexing.publishObject(p!, v!),
|
||
[PUBLISHED_AT, "2026-08-17T09:00:00Z"],
|
||
),
|
||
);
|
||
check(
|
||
"Bob publishes a public object carrying the indexed field",
|
||
typeof bobsObject === "string" && bobsObject.startsWith("did:ng:"),
|
||
`object=${bobsObject}`,
|
||
);
|
||
|
||
// The CONTROL for the write form: the same primitive, the document as its own
|
||
// subject. This is the shape the polyfill's own suites already exercise.
|
||
const raw = await step("Bob reading his own object", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate((d) => window.__indexing.readRaw(d), bobsObject!),
|
||
);
|
||
const self = raw.find((s) => s.subject === bobsObject);
|
||
check(
|
||
"Bob's object reads back carrying the value he wrote",
|
||
(self?.props[PUBLISHED_AT] ?? []).includes("2026-08-17T09:00:00Z"),
|
||
`subjects=${raw.length} props=${JSON.stringify(self?.props ?? {})}`,
|
||
);
|
||
},
|
||
});
|
||
|
||
await journey({
|
||
name: "Bob hands the index a reference, and it becomes an entry of Alice's index",
|
||
needs: [
|
||
aliceIsUp,
|
||
bobIsUp,
|
||
indexExists,
|
||
() => (bobsObject === null ? "Bob never published an object" : null),
|
||
],
|
||
run: async () => {
|
||
// Bob names his OWN object, and the index he was configured with. Nothing about
|
||
// the value travels: the deposit is the reference and nothing else.
|
||
await step("Bob depositing a reference", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), bobsObject!),
|
||
);
|
||
|
||
// NOBODY PROCESSES ANYTHING HERE, and nothing says when it is done: there is no
|
||
// such call on the surface, and no receipt. The reference becomes an entry because
|
||
// the layer below applies the inbox of the index Alice owns, when her session is
|
||
// pushed it — so the suite WAITS for the index to hold it, reading the document the
|
||
// way any application reads one.
|
||
//
|
||
// THE WRITE FORM, answered by the same reading. An entry is a triple whose subject
|
||
// is another document, written into this one's anchored default graph. "The write
|
||
// did not throw" is not the same claim as "oxigraph stored it": this reads it back.
|
||
const raw = await settled(
|
||
"Bob's deposit becoming an entry of Alice's index",
|
||
() => alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
||
(subjects) => subjects.some((s) => s.subject === bobsObject),
|
||
(subjects) => `subjects=${JSON.stringify(subjects.map((s) => s.subject))}`,
|
||
);
|
||
const entry = raw.find((s) => s.subject === bobsObject);
|
||
// The deposit is proven ARRIVED by its only possible effect: nobody but Alice
|
||
// reads that inbox, so an entry for Bob's object means his deposit crossed the
|
||
// identity boundary and her session found it. That the post did not throw is a
|
||
// weaker claim entirely — it says the call returned, and nothing more.
|
||
check(
|
||
"a stranger's deposit into the index's inbox reached its owner and became an entry",
|
||
entry !== undefined,
|
||
`from=${BOB} subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
|
||
);
|
||
check(
|
||
"the entry is stored under Bob's object's own reference as its subject",
|
||
entry !== undefined && Object.hasOwn(entry.props, ENTRY_VALUE),
|
||
`subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
|
||
);
|
||
// The value never travelled: a deposit is the reference and nothing else, so its
|
||
// presence here means whatever processed that inbox read it off Bob's object itself.
|
||
check(
|
||
"the indexed value was read off Bob's object, and never travelled in his deposit",
|
||
(entry?.props[ENTRY_VALUE] ?? []).includes("2026-08-17T09:00:00Z"),
|
||
`entry=${JSON.stringify(entry?.props ?? {})}`,
|
||
);
|
||
},
|
||
});
|
||
|
||
await journey({
|
||
name: "The index reads back, for its owner and for a stranger",
|
||
needs: [aliceIsUp, bobIsUp, indexExists],
|
||
run: async () => {
|
||
const mine = await step("Alice reading the index", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
||
);
|
||
check(
|
||
"Alice reads exactly one entry, and it is Bob's object",
|
||
mine.length === 1 && mine[0]?.object === bobsObject,
|
||
`entries=${JSON.stringify(mine)}`,
|
||
);
|
||
|
||
// A public index is read by whoever holds its reference — including someone who
|
||
// owns neither it nor anything in it. This is the act an application performs.
|
||
//
|
||
// Waited for on its OWN account: the journey above settled ALICE's session, and
|
||
// Bob's is a different one reading a document he does not own. "The owner sees it"
|
||
// and "a stranger sees it" are two arrivals, and only one of them has happened.
|
||
const theirs = await settled(
|
||
"the entry reaching a stranger's session",
|
||
() => bob!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
||
(rows) => rows.some((r) => r.object === bobsObject),
|
||
(rows) => `entries=${JSON.stringify(rows)}`,
|
||
);
|
||
check(
|
||
"Bob, who does not own the index, reads the same entry",
|
||
theirs.length === 1 && theirs[0]?.object === bobsObject,
|
||
`entries=${JSON.stringify(theirs)}`,
|
||
);
|
||
|
||
// Deposits are never retired, so every run sees every deposit again. Convergence
|
||
// is what makes that affordable.
|
||
await step("Alice loading her page a second time", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate(() => window.__indexing.rebuildHandle()),
|
||
);
|
||
const still = await step("Alice reading the index again", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
||
);
|
||
check(
|
||
"connecting a second time changes nothing, and the index still holds one entry",
|
||
still.length === 1 && still[0]?.object === bobsObject,
|
||
`entries=${JSON.stringify(still)}`,
|
||
);
|
||
},
|
||
});
|
||
|
||
await journey({
|
||
name: "An object carrying nothing for the field is not indexed",
|
||
needs: [aliceIsUp, bobIsUp, indexExists],
|
||
run: async () => {
|
||
// PRESENT but carrying nothing for the field — which is a different answer from
|
||
// an object that cannot be read at all, and the reason this object carries a
|
||
// predicate rather than being empty: an empty document reads exactly like an
|
||
// unreadable one, and resolves as `unresolved`, not `skipped`.
|
||
const other = await step("Bob publishing an unrelated object", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate(
|
||
([p, v]) => window.__indexing.publishObject(p!, v!),
|
||
[UNRELATED, "nothing to index by"],
|
||
),
|
||
);
|
||
await step("Bob depositing the unrelated reference", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), other),
|
||
);
|
||
|
||
// NOTHING TO WAIT FOR, and it is worth saying rather than dressing up. A deposit
|
||
// that is deliberately not indexed writes nothing, so the index holds afterwards
|
||
// exactly what it held before and no reading can tell "examined and skipped" from
|
||
// "not examined yet". A wait for the state the check expects would return on its
|
||
// first reading and prove nothing; a wait for a duration would be a sleep. So this
|
||
// reads straight away, and the check is a weaker claim than it reads as — see the
|
||
// suite's header. What DOES hold it: the deposit is applied before the hostile one
|
||
// below, and that one is waited for.
|
||
const entries = await step("Alice reading the index once more", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
|
||
);
|
||
check(
|
||
"the unrelated object is not indexed, and the index still holds exactly one entry",
|
||
entries.length === 1 && !entries.some((e) => e.object === other),
|
||
`entries=${JSON.stringify(entries)}`,
|
||
);
|
||
},
|
||
});
|
||
|
||
await journey({
|
||
name: "An index whose inbox cannot be opened leaves a document behind",
|
||
needs: [aliceIsUp],
|
||
run: async () => {
|
||
const outcome = await step("Alice creating an index whose inbox fails", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate(
|
||
(f) => window.__indexing.createIndexWithBrokenInbox(f),
|
||
PUBLISHED_AT,
|
||
),
|
||
);
|
||
check(
|
||
"createIndex refuses when the inbox cannot be opened",
|
||
outcome.rejected !== null && outcome.returned === null,
|
||
`rejected=${outcome.rejected} returned=${outcome.returned}`,
|
||
);
|
||
check(
|
||
"a document was nevertheless created in the owner's public store",
|
||
outcome.appeared.length === 1,
|
||
`appeared=${JSON.stringify(outcome.appeared)}`,
|
||
);
|
||
|
||
// What the leaked document IS: an index in every respect but the one that makes
|
||
// it usable. Alice found it in her own store — the only way anyone can, since
|
||
// `createIndex` threw its reference away.
|
||
const leaked = outcome.appeared[0];
|
||
if (leaked === undefined) {
|
||
check(
|
||
"the leaked document carries a descriptor but accepts no deposit",
|
||
false,
|
||
"no document appeared, so there was nothing to inspect",
|
||
);
|
||
return;
|
||
}
|
||
const raw = await step("Alice reading the leaked document", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), leaked),
|
||
);
|
||
const declares = (raw.find((s) => s.subject === leaked)?.props[INDEX_FIELD] ?? []).includes(
|
||
PUBLISHED_AT,
|
||
);
|
||
const refused = await step("Alice trying to deposit into it", BRIDGE_MS, async () => {
|
||
try {
|
||
await alice!.frame.evaluate(
|
||
([i, o]) => window.__indexing.addTo(i!, o!),
|
||
[leaked, bobsObject ?? leaked],
|
||
);
|
||
return null;
|
||
} catch (e) {
|
||
return firstLine(e);
|
||
}
|
||
});
|
||
check(
|
||
"the leaked document carries a descriptor but accepts no deposit",
|
||
declares && refused !== null,
|
||
`declares=${declares} deposit=${refused ?? "(accepted)"}`,
|
||
);
|
||
},
|
||
});
|
||
|
||
// LAST, deliberately: if the escaping below turned out not to hold, the damage would
|
||
// be to this index, and every check above has already been taken.
|
||
await journey({
|
||
name: "A hostile value crosses the round trip as one inert literal",
|
||
needs: [aliceIsUp, bobIsUp, indexExists],
|
||
run: async () => {
|
||
// `src/sparql.ts` carries this package's OWN escaping, because the polyfill
|
||
// publishes none. Until now it had only ever been judged by a fake whose SPARQL
|
||
// reader was written from the same assumptions — a pair that agrees with itself
|
||
// proves nothing about oxigraph. This value closes every construct the escaping
|
||
// is responsible for: the literal's own quote, a backslash, the whitespace
|
||
// escapes, and a complete injected UPDATE that would empty the index if the
|
||
// quote ever escaped its literal.
|
||
const hostile =
|
||
'a "quoted" part, a \\ backslash, a\nnewline, a\ttab, ' +
|
||
'" } ; DROP ALL ; INSERT DATA { <urn:ng-helpers-e2e:pwned> <urn:ng-helpers-e2e:pwned> "';
|
||
|
||
const object = await step("Bob publishing a hostile value", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate(
|
||
([p, v]) => window.__indexing.publishObject(p!, v!),
|
||
[PUBLISHED_AT, hostile],
|
||
),
|
||
);
|
||
const raw = await step("Bob reading the hostile object", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate((d) => window.__indexing.readRaw(d), object),
|
||
);
|
||
const stored = raw.find((s) => s.subject === object)?.props[PUBLISHED_AT] ?? [];
|
||
check(
|
||
"the object reads back the hostile value byte for byte",
|
||
stored.length === 1 && stored[0] === hostile,
|
||
`stored=${JSON.stringify(stored)}`,
|
||
);
|
||
|
||
await step("Bob depositing the hostile reference", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), object),
|
||
);
|
||
// Waited for as the first deposit was, and read RAW: the index must still declare
|
||
// its own field. An injected `DROP ALL` that had taken effect would show up exactly
|
||
// here, as a descriptor that is no longer there — and `read()` alone could not tell
|
||
// that apart from an ordinary failure.
|
||
const after = await settled(
|
||
"the hostile deposit becoming an entry",
|
||
() => alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
||
(subjects) => subjects.some((s) => s.subject === object),
|
||
(subjects) => `subjects=${JSON.stringify(subjects.map((s) => s.subject))}`,
|
||
);
|
||
const entry = after.find((s) => s.subject === object)?.props[ENTRY_VALUE] ?? [];
|
||
const descriptor = after.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? [];
|
||
check(
|
||
"the index holds it as one entry, and its own descriptor is untouched",
|
||
entry.length === 1 && entry[0] === hostile && descriptor.includes(PUBLISHED_AT),
|
||
`entry=${JSON.stringify(entry)} descriptor=${JSON.stringify(descriptor)}`,
|
||
);
|
||
},
|
||
});
|
||
|
||
// AFTER the hostile journey, and read-only: by here the index holds SEVERAL entries,
|
||
// which is the state the question is about — one entry cannot tell a query that
|
||
// returns everything from one that returns the first thing it finds.
|
||
//
|
||
// WHAT IS BEING ASKED. This package documents one way of reading an index
|
||
// (`readUnion`) and issues no query of its own, so "an index is an ordinary document
|
||
// anyone queries normally" has never been anything but plausible. These four checks
|
||
// are a measurement of that sentence, not a feature: an empty answer is a RESULT and
|
||
// is reported as one, and a rejection is reported apart from it, because "the query
|
||
// found nothing" and "the query failed" are the two answers this repository keeps
|
||
// finding folded into one.
|
||
await journey({
|
||
name: "The index answers an ordinary SPARQL query",
|
||
needs: [aliceIsUp, bobIsUp, indexExists],
|
||
run: async () => {
|
||
// The REFERENCE the queries below are judged against. "The SELECT came back with
|
||
// the entries" is only a claim if something independent says what the entries
|
||
// are — and it is what makes an empty answer below mean something instead of
|
||
// being indistinguishable from an index that holds nothing.
|
||
const raw = await step("Alice reading the index before querying it", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
||
);
|
||
const held = new Map<string, string>();
|
||
for (const subject of raw) {
|
||
const value = subject.props[ENTRY_VALUE] ?? [];
|
||
if (value.length === 1 && value[0] !== undefined) held.set(subject.subject, value[0]);
|
||
}
|
||
const declares = (raw.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? []).includes(
|
||
PUBLISHED_AT,
|
||
);
|
||
check(
|
||
"readUnion returns both entries and the index's own declaration",
|
||
held.size === 2 && declares,
|
||
`entries=${JSON.stringify([...held.keys()])} declares=${declares}`,
|
||
);
|
||
|
||
// The shape a reader would write, with nothing of this package in it: the entry
|
||
// predicate, and the anchored default graph the index document is.
|
||
const entriesQuery = `SELECT ?object ?value WHERE { ?object <${ENTRY_VALUE}> ?value }`;
|
||
const mine = await step("Alice querying the index for its entries", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate(
|
||
([a, q]) => window.__indexing.select(a!, q!),
|
||
[index!, entriesQuery],
|
||
),
|
||
);
|
||
const answered = entriesOf(mine.rows);
|
||
check(
|
||
"a SELECT for the entry predicate returns both entries, with their values",
|
||
mine.failed === null && sameEntries(held, answered),
|
||
`failed=${mine.failed} rows=${mine.rows.length} ` +
|
||
`objects=${JSON.stringify([...answered.keys()])} raw=${mine.raw}`,
|
||
);
|
||
|
||
// The index document's OWN subject, which is the other half of what an index
|
||
// holds — and the half a reader needs to know what the values mean.
|
||
const fieldQuery = `SELECT ?field WHERE { <${index!}> <${INDEX_FIELD}> ?field }`;
|
||
const declared = await step("Alice querying the index's declaration", BRIDGE_MS, () =>
|
||
alice!.frame.evaluate(([a, q]) => window.__indexing.select(a!, q!), [index!, fieldQuery]),
|
||
);
|
||
check(
|
||
"a SELECT of the index's own subject returns the field it declares",
|
||
declared.failed === null &&
|
||
declared.rows.length === 1 &&
|
||
declared.rows[0]?.["field"] === PUBLISHED_AT,
|
||
`failed=${declared.failed} rows=${declared.rows.length} raw=${declared.raw}`,
|
||
);
|
||
|
||
// The reader who matters: an index exists to be read by people who own neither it
|
||
// nor anything in it. `readUnion` already answers him (the journey above); whether
|
||
// a query does is a separate question, and it is the one an application asks.
|
||
const theirs = await step("Bob querying the index he does not own", BRIDGE_MS, () =>
|
||
bob!.frame.evaluate(
|
||
([a, q]) => window.__indexing.select(a!, q!),
|
||
[index!, entriesQuery],
|
||
),
|
||
);
|
||
const strangers = entriesOf(theirs.rows);
|
||
check(
|
||
"a stranger's SELECT returns the same entries",
|
||
theirs.failed === null && sameEntries(held, strangers),
|
||
`failed=${theirs.failed} rows=${theirs.rows.length} ` +
|
||
`objects=${JSON.stringify([...strangers.keys()])} raw=${theirs.raw}`,
|
||
);
|
||
},
|
||
});
|
||
} finally {
|
||
if (ctx !== null) await closeContext("actors", ctx);
|
||
if (closeServer !== null) {
|
||
await closeQuietly("the application server", async () => closeServer!());
|
||
}
|
||
wallet.discard();
|
||
}
|
||
|
||
finish(null);
|
||
}
|
||
|
||
void main().catch((e: unknown) => {
|
||
console.error("[e2e] fatal:", (e as Error)?.stack ?? e);
|
||
finish(firstLine(e));
|
||
});
|