fix: ng-helpers rejoint la surface dev.6, et son faux suit le contrat
This commit is contained in:
+17
-3
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user