fix: ng-helpers rejoint la surface dev.6, et son faux suit le contrat

This commit is contained in:
Sylvain Duchesne
2026-09-21 12:16:42 +02:00
parent e566c5b0b3
commit 71667ca14e
11 changed files with 482 additions and 90 deletions
+17 -3
View File
@@ -36,8 +36,24 @@ export interface BrokenInboxOutcome {
readonly rejected: string | null;
/** What `create` returned, on the impossible branch where it did not reject. */
readonly returned: string | null;
/** The documents that appeared in this identity's public store despite the failure. */
/**
* The index documents this identity did not hold before, and holds after the failure.
*
* It does NOT enumerate a store, because there is no longer such a call: the documents in
* a store are not askable for, so what is read instead is every index document this
* identity HOLDS, through the shape query. The difference matters when reading the
* check it feeds: what it establishes is that a document survived, not where it sits.
*/
readonly appeared: readonly string[];
/**
* Which of the read's three states that observation came out of -- `success`, or the
* state it gave up in.
*
* An empty `appeared` means "nothing turned up" only under `success`. Under `pending`
* or `error` it means nobody answered, and a report that folded the two together would
* let a read that never started pass for a leak that never happened.
*/
readonly observed: string;
}
/**
@@ -120,8 +136,6 @@ export interface IndexingBridge {
* 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[]>;
/**
* `create` with its inbox step made to fail — everything else real, against the
+193 -40
View File
@@ -14,11 +14,17 @@
* surface: `indexing(sessionId)`, the two acts on what it hands back, and the two IRIs.
* Nothing here makes an entry of a deposit, and nothing here can ask for one: that is
* the business of the layer below, and this page is where that shows or does not.
* Everything else here is the polyfill (`configure`, `ensureIdentity`, `init`,
* `readUnion`, `docs`, `storeRegistry`) — including reading the index, which is an
* Everything else here is the polyfill (`configure`, `ensureIdentity`, `init`, `initNg`,
* `ng`, `readUnion`, `docs`, `useShapeQuery`) -- including reading the index, which is an
* ordinary `readUnion` of an ordinary document. If something here is awkward, it is
* awkward for every consumer, which is the second reason to write it this way.
*
* This page WIRES THE ORM, because one thing here has no other door: the store enumeration it
* used to discover a leaked document left the engagement, and what replaces it is a shape
* query, which reads through NextGraph's own and therefore needs `ormSubscription`,
* `initNg` and a hand-written shape. An application that hands `configure` a stub gets
* none of it.
*
* The two things here no application does, and why they reach past the surface
* `createIndexWithBrokenInbox` injects a failure into the inbox step of `create`: the
* question it answers — does a failed `openInbox` leave a document behind? — cannot be
@@ -35,15 +41,21 @@ import {
docs,
ensureIdentity,
init,
initNg,
ng,
readUnion,
storeRegistry,
type Nuri,
useShapeQuery,
type Scope,
type ShapeObservable,
type ShapeQuery,
type ShapeType,
type UnionSubject,
} from "@ng-eventually/polyfill";
import { ng as realNg, init as realInit } from "@ng-org/web";
import { RdfOrmSubscription, initNg as ormInitNg } from "@ng-org/orm";
// The whole published surface of the package under test.
import { ENTRY_VALUE, indexing, type Indexing } from "../src/index";
import { ENTRY_VALUE, INDEX_FIELD, indexing, type Indexing } from "../src/index";
// NOT published, and reached only by the two probes above — see the header.
import { indexingOn } from "../src/indexing";
import type { NextGraphPort } from "../src/port";
@@ -55,10 +67,27 @@ import type { BrokenInboxOutcome, IndexRow, IndexingBridge, SelectOutcome } from
// `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.
//
// THE ORM IS WIRED HERE, and it is not optional dressing: `useShapeQuery` reads through
// NextGraph's own shape query, which arrives through `ormSubscription` and is started by
// `initNg`. Without them that read publishes `isError` naming the field it never got.
// `RdfOrmSubscription.getOrCreate` is passed as it stands -- no wrapper, no cast.
configure({
ng: realNg,
useShape: () => undefined, // this application reads through `readUnion`, not the ORM
// Required by `configure`, and `@ng-org/orm` publishes `useShape` only through its
// framework adapters (react / svelte / vue). This page has no framework, so there is
// nothing honest to hand over: it REFUSES instead of answering `undefined`, which a
// caller could not tell from an empty read-filtered view. Nothing here calls it, and
// the gap is declared in `usage_ng-helpers`'s `## Frictions`.
useShape: () => {
throw new Error(
"[e2e] useShape was never wired: this page has no framework adapter to give it, " +
"and it reads through readUnion and useShapeQuery instead",
);
},
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit,
initNg: ormInitNg,
sharedWallet: { fileUrl: "/wallet-never-served.ngw", password: "" },
});
@@ -96,6 +125,13 @@ async function boot(): Promise<void> {
// and the identity comes back. The application keeps it only to show it.
state.who = await ensureIdentity();
const session = await sessionReady;
// The ORM is started once the session exists and not before: `useShapeQuery` reads
// through it, and this package's own `initNg` is the one to CALL -- not the SDK's,
// which was handed to `configure` and is reached from there. The `ng` it is given is
// this package's too, the one the surface says to call instead of the one `configure`
// was passed. Measured: the argument makes no difference either way, because the call
// substitutes its own -- see `usage_ng-helpers`'s `## Frictions`.
initNg(ng, session);
// The session id, and nothing else — this application never names a port, and never
// awaits anything here: a handle reaches nothing. A reference this application hands
// an index becomes an entry because the layer below processes that index's inbox,
@@ -127,26 +163,125 @@ async function probePort(): Promise<NextGraphPort> {
}
/**
* Wait for a document to appear in this identity's public store.
* THE SHAPE AN INDEX DOCUMENT MATCHES, hand-written here because there is no other way
* to ask this question any more.
*
* 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.
* `storeRegistry.listMyEntityDocs` left the engagement, and what replaces it is the read
* itself: a shape over `{ graphs: "did:ng:i" }` brings back what this identity HOLDS of
* that shape. So the descriptor `create` writes -- one triple, on the index document's
* own subject -- is what makes an index findable, and it is declared here as the one
* predicate this shape names. A predicate a shape does not name is not projected, so
* `field` is both the match and the whole of what comes back.
*
* NOT A STORE LISTING, and the difference is recorded rather than glossed: `Scope` has
* two axes, neither of them a store. This finds an index document wherever it sits, and
* says nothing about which of the three stores holds it.
*/
async function publicDocsAfter(
before: ReadonlySet<string>,
type IndexDescriptor = {
"@id": string;
"@graph": string;
field: string;
};
const INDEX_SHAPE = "urn:ng-helpers-e2e:shape:index";
const IndexShape: ShapeType<IndexDescriptor> = {
shape: INDEX_SHAPE,
schema: {
[INDEX_SHAPE]: {
iri: INDEX_SHAPE,
predicates: [
{
iri: INDEX_FIELD,
readablePredicate: "field",
dataTypes: [{ valType: "string" }],
minCardinality: 1,
maxCardinality: 1,
},
],
},
},
};
/** Everything this identity holds, which is as close to "my documents" as `Scope` goes. */
const EVERYTHING_HELD: Scope = { graphs: "did:ng:i" };
/**
* Which of the three states a reactive read is in.
*
* Read BEFORE the data, always: an empty `data` under `isSuccess` means this scope holds
* nothing of that shape, and an empty `data` under anything else means nobody answered.
* The two must never be reported as one, which is the whole reason this is carried out
* to the report instead of being collapsed into an empty list.
*/
type LoadState = "pending" | "success" | "error";
function loadStateOf(snapshot: ShapeQuery<IndexDescriptor>): LoadState {
if (snapshot.isError) return "error";
if (snapshot.isSuccess) return "success";
return "pending";
}
/** The documents the snapshot's objects came out of -- one entry per index document. */
function documentsIn(snapshot: ShapeQuery<IndexDescriptor>): string[] {
return snapshot.data.map((descriptor) => descriptor["@graph"]);
}
/**
* Wait for the read to ANSWER -- to reach `isSuccess` or `isError`.
*
* A shape query the engine refuses stays `isPending` for ever: NextGraph catches that
* failure, logs it, and its readiness signal is only ever resolved. So a bound here is
* not impatience, it is the only way that failure becomes a named observation instead of
* a hang, and what it returns is the state it gave up in.
*/
async function answered(
held: ShapeObservable<IndexDescriptor>,
budgetMs: number,
): Promise<readonly string[]> {
): Promise<LoadState> {
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;
const state = loadStateOf(held.getSnapshot());
if (state !== "pending" || Date.now() >= deadline) return state;
await new Promise((r) => setTimeout(r, 500));
}
}
/**
* Wait for an index document this identity did not hold before to turn up.
*
* A document written a moment ago is not owed to be readable instantly, and this read is
* push-driven rather than polled -- so the loop asks the OBSERVABLE again (`refetch`)
* rather than issuing a fresh read, and it is bounded: an empty answer under `success` at
* the end is evidence, not a hang. An empty answer under any other state is neither, and
* that is why the state travels back with it.
*/
async function indexDocsAfter(
held: ShapeObservable<IndexDescriptor>,
before: ReadonlySet<string>,
budgetMs: number,
): Promise<{ appeared: readonly string[]; observed: LoadState }> {
const deadline = Date.now() + budgetMs;
for (;;) {
const snapshot = held.getSnapshot();
const observed = loadStateOf(snapshot);
const appeared =
observed === "success" ? documentsIn(snapshot).filter((d) => !before.has(d)) : [];
if (appeared.length > 0 || Date.now() >= deadline) return { appeared, observed };
await new Promise((r) => setTimeout(r, 500));
held.refetch();
}
}
/**
* How long the reactive read has, both to answer at all and to notice what turned up.
*
* It replaces the bound the withdrawn store enumeration was polled under, and it carries one
* more job than that one did: a shape query the engine refuses never finishes loading, so
* this is also what turns that failure into a reported state.
*/
const OBSERVE_MS = 15_000;
/**
* 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.
@@ -281,34 +416,52 @@ const bridge: IndexingBridge = {
}
},
async listPublicDocs(): Promise<string[]> {
const docs: Nuri[] = await storeRegistry.listMyEntityDocs("public");
return [...docs];
},
async createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome> {
const p = await probePort();
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 = indexingOn({
...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;
// The read that will notice the leak, opened BEFORE anything is created so that
// "what was already held" is a reading and not an assumption. It is subscribed
// because an observable with nobody listening has nowhere to push what arrives,
// and it is released at the end: the probe is its only caller.
const held = useShapeQuery(IndexShape, EVERYTHING_HELD);
const release = held.subscribe(() => {});
try {
returned = await broken.create(field);
} catch (e: unknown) {
rejected = String((e as Error)?.message ?? e);
}
// The opening read has to ANSWER first. A `before` taken from a read that never
// happened is empty, and an empty `before` would make the first document this
// identity has ever held look like something the failure left behind.
const opening = await answered(held, OBSERVE_MS);
const before = new Set<string>(
opening === "success" ? documentsIn(held.getSnapshot()) : [],
);
return { rejected, returned, appeared: await publicDocsAfter(before, 15_000) };
// 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 = indexingOn({
...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.create(field);
} catch (e: unknown) {
rejected = String((e as Error)?.message ?? e);
}
const after = await indexDocsAfter(held, before, OBSERVE_MS);
return {
rejected,
returned,
appeared: after.appeared,
observed: opening === "success" ? after.observed : `${opening} before the probe ran`,
};
} finally {
release();
}
},
};
+11 -6
View File
@@ -207,7 +207,7 @@ const { check, journey, finish } = declareSuite({
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",
"a document was nevertheless created, and its owner still holds it",
"the leaked document carries a descriptor but accepts no deposit",
],
},
@@ -654,15 +654,20 @@ async function main(): Promise<void> {
outcome.rejected !== null && outcome.returned === null,
`rejected=${outcome.rejected} returned=${outcome.returned}`,
);
// WHAT IS OBSERVED HERE, and what is not. There is no call that lists a store any
// more, so the leak is found by reading every index document Alice HOLDS and
// diffing: that establishes the document survived, not which store it sits in.
// `observed` travels with it because an empty answer only means "nothing turned
// up" under `success` -- under any other state it means nobody answered.
check(
"a document was nevertheless created in the owner's public store",
outcome.appeared.length === 1,
`appeared=${JSON.stringify(outcome.appeared)}`,
"a document was nevertheless created, and its owner still holds it",
outcome.appeared.length === 1 && outcome.observed === "success",
`appeared=${JSON.stringify(outcome.appeared)} observed=${outcome.observed}`,
);
// 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.
// it usable. Alice found it among the documents she holds -- the only way anyone
// can, since `createIndex` threw its reference away.
const leaked = outcome.appeared[0];
if (leaked === undefined) {
check(