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
+27
View File
@@ -26,6 +26,24 @@ export interface BrokenInboxOutcome {
readonly appeared: readonly string[];
}
/**
* What one anchored SPARQL SELECT answered — or how it failed.
*
* The three fields are kept apart deliberately. "Nothing came back" and "the call failed"
* are different answers, and a shape that folded them together would let a failure read as
* an empty index — the defect class this repository keeps finding. `raw` carries the answer
* BEFORE anything here decodes it, so a decoder that is wrong about the result's shape
* cannot pass its own blindness off as a query that returned nothing.
*/
export interface SelectOutcome {
/** The message the query rejected with, or `null` when it returned. */
readonly failed: string | null;
/** Whatever came back, rendered as JSON — the answer before any decoding of it. */
readonly raw: string;
/** The SELECT's bindings, decoded to plain `variable → value` rows. */
readonly rows: ReadonlyArray<Readonly<Record<string, string>>>;
}
/**
* The acts this application can perform — and ONLY acts an application can perform.
*
@@ -74,6 +92,15 @@ export interface IndexingBridge {
/** What a document literally holds, straight off `readUnion` — the write-form probe. */
readRaw(doc: string): Promise<UnionSubject[]>;
/**
* Run a SPARQL SELECT anchored on a document, through `docs.sparqlQuery`.
*
* An index is claimed to be an ORDINARY document, which means an ordinary query must
* reach it. Nothing in `src/` ever issues one — this layer composes SPARQL only to
* write — so this is the one act here that no code of this package performs, and it is
* on the bridge because the claim had never been measured against a broker.
*/
select(anchor: string, query: string): Promise<SelectOutcome>;
/** This identity's public documents. How an owner discovers a document it did not keep. */
listPublicDocs(): Promise<string[]>;
+64 -6
View File
@@ -2,7 +2,7 @@
* 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 ──────────────────────
* 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
@@ -15,7 +15,7 @@
* 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 ─────────────────────────────────
* 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
@@ -25,6 +25,7 @@
import {
configure,
docs,
ensureIdentity,
init,
readUnion,
@@ -36,9 +37,9 @@ 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 } from "./bridge";
import type { BrokenInboxOutcome, IndexingBridge, SelectOutcome } from "./bridge";
// ── bootstrap: the one polyfill-era call, then the SDK-shaped ones ──────────
// 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,
@@ -64,7 +65,7 @@ const sessionReady = new Promise<{ session_id: string }>((resolve) => {
);
});
// ── this application's state ───────────────────────────────────────────────
// this application's state
const state: { status: string; error: string | null; who: string } = {
status: "connecting",
@@ -134,7 +135,46 @@ async function publicDocsAfter(
}
}
// ── the acts ───────────────────────────────────────────────────────────────
/**
* 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,
@@ -185,6 +225,24 @@ const bridge: IndexingBridge = {
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];
+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) {