feat!: la lecture quitte le contrat, un index se lit comme un document
This commit is contained in:
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user