feat!: la lecture quitte le contrat, un index se lit comme un document

This commit is contained in:
Sylvain Duchesne
2026-08-21 15:49:59 +02:00
parent ebaae15baf
commit c2f9ff4674
5 changed files with 270 additions and 70 deletions
+129 -9
View File
@@ -1,7 +1,7 @@
/**
* `@ng-helpers/indexing` against the REAL broker.
*
* ── What this suite is for ─────────────────────────────────────────────────
* 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:
@@ -19,7 +19,7 @@
* 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 ─────────
* 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
@@ -28,7 +28,7 @@
* never happen — and does not happen here — is an inbox address crossing the identity
* boundary through a channel no deployment has.
*
* ── Reading a failure ──────────────────────────────────────────────────────
* 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.
@@ -72,7 +72,7 @@ type BrowserContext = Awaited<ReturnType<typeof launchWatchedContext>>;
type Page = Awaited<ReturnType<typeof newPage>>;
type Frame = Awaited<ReturnType<typeof setupBrokerPage>>;
// ── the domain this suite indexes by ───────────────────────────────────────
// 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.
@@ -80,7 +80,7 @@ const PUBLISHED_AT = "urn:ng-helpers-e2e:published-at";
/** A predicate an index does NOT curate on — for the object that carries nothing usable. */
const UNRELATED = "urn:ng-helpers-e2e:unrelated";
// ── bounds ─────────────────────────────────────────────────────────────────
// 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
@@ -100,7 +100,7 @@ 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 ─────────────────────────────────────────────────────────────
// the report
let actors: BrowserContext | null = null;
@@ -138,7 +138,7 @@ const { check, journey, finish } = declareSuite({
checks: [
"Alice reads exactly one entry, and it is Bob's object",
"Bob, who does not own the index, reads the same entry",
"curating a second time changes nothing, and the index still holds one entry",
"connecting a second time changes nothing, and the index still holds one entry",
],
},
{
@@ -162,15 +162,49 @@ const { check, journey, finish } = declareSuite({
"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));
}
// ── an actor ───────────────────────────────────────────────────────────────
// an actor
interface Actor {
readonly id: string;
@@ -250,7 +284,7 @@ function actorIsUp(id: string, actor: () => Actor | null): Prerequisite {
return () => (actor() === null ? `${id} never signed in` : null);
}
// ── the run ────────────────────────────────────────────────────────────────
// the run
async function main(): Promise<void> {
armSuiteDeadline("ng-helpers indexing e2e", SUITE_MS, () =>
@@ -602,6 +636,92 @@ async function main(): Promise<void> {
);
},
});
// 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) {