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
+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();
}
},
};