feat!: useShapeQuery lit par la requete de forme du SDK

This commit is contained in:
Sylvain Duchesne
2026-09-21 11:41:31 +02:00
parent a5e553e65b
commit 77e78ec81d
29 changed files with 1190 additions and 971 deletions
+7 -60
View File
@@ -1,20 +1,12 @@
/**
* The page for the LIVE ORM measurement — the one thing `e2e/polyfill-entry.ts` cannot do.
* The page for the LIVE ORM measurement - what `e2e/run.ts` still does not measure.
*
* That harness injects a hand-written Set-like into `configure({ useShape })`, because the
* read filter is pure and does not need the real ORM to be exercised. What it therefore
* never touches is the only hop that reaches the broker: `ng.orm_start_graph`, the streamed
* method `OrmSubscription` opens and the one `bug_useshape-live-read-needs-a-newer-broker`
* is about. So this page injects the REAL `@ng-org/orm` — nothing else about the bootstrap
* differs — and the published `useShape` runs over a live subscription.
*
* ── The dependency, stated because it is not installed by default ──────────
* `@ng-org/orm` is an OPTIONAL PEER of this package (`package.json`,
* `peerDependenciesMeta`), so `bun install` does not fetch it and `bun.lock` records it
* under `optionalPeers` with no resolution. This page is the only thing in the repository
* that imports it: `e2e/run.ts` and its harness do not, so a checkout without it still
* builds and runs the 42-check suite. Here the bundle simply will not build, and the
* runner says so rather than reporting a broker verdict it did not measure.
* The 42-check harness injects the REAL `RdfOrmSubscription.getOrCreate` since
* `1.0.0-dev.6` (that is what `useShapeQuery` reads through), so a live ORM read is no
* longer unreachable from there. What stays here is the PUBLISHED `useShape` half: the
* bare `DeepSignalSet` an application gets from the ORM hook, and a `.delete()` on it
* reaching the document. That is a different surface from the snapshot `useShapeQuery`
* publishes, and it has no other measurement.
*
* ── What the framework adapter contributes, and why it is not here ─────────
* Upstream's `useShape` is React's (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`):
@@ -247,51 +239,6 @@ async function seededTripleCount(doc: Nuri, subject: string): Promise<number> {
}
},
/**
* CHARACTERISATION of the deployed broker's `orm_start_graph` ARITY.
*
* The failure the runner reports named a parameter - `config` - that the client of
* the day did not send. Two upstream arrangements could put one there, and they differ
* by POSITION:
* - config LAST, after the callback (upstream 2026-04-12 to 2026-04-21) — an
* unsupplied `config`, never a Function;
* - config at index 4, BEFORE the callback: the 5-argument call of the client of
* the day then lands its callback exactly there, which is what the error described.
*
* THE UPDATE ANSWERED THAT QUESTION. `@ng-org/web@0.1.2-alpha.14` declares six
* arguments with `config` at index 4 and routes the stream callback from index 5
* (`dist/ngweb.js`: `orm_start_graph: 5`), and `@ng-org/orm@0.1.2-alpha.21` sends six
* to match (`dist/connector/RdfOrmSubscription.js:159`). So the second arrangement was
* the right reading, and the ordinary `liveStart` path now exercises it.
*
* This is therefore kept as a FALLBACK, not as the live question: the runner only asks
* it when `liveStart` did not become ready, to say whether the engine still refuses on
* `config` when the client no longer can be blamed. It calls the SDK member directly
* rather than through the ORM. The app-side proxy takes index 5 as the stream callback,
* so the `{}` at index 4 is read as a config and nothing usable comes back; what is
* being read is only whether the start is refused.
*/
async liveStartWithConfig() {
const s = await sessionReady;
const doc = (await docs.docCreate(
s.session_id,
"Graph",
"data:graph",
"store",
undefined,
)) as Nuri;
// Called against the PUBLISHED declaration, which now has these six arguments, so the
// widening this used to need is gone: the arity is checked here rather than asserted.
// What remains unknowable at compile time is the DEPLOYED engine's arity, which is
// the question this asks.
try {
await realNg.orm_start_graph([doc], [], LIVE_SHAPE, s.session_id, {}, () => {});
return { doc, error: null };
} catch (e) {
return { doc, error: e instanceof Error ? e.message : String(e) };
}
},
/** The document's own answer: how many triples the seeded subject still has. */
async liveSeededTriples() {
if (!probe) return -1;
+17 -60
View File
@@ -2,43 +2,33 @@
* Does a live `useShape` read reach the DEPLOYED broker, and does deleting from the set it
* returns reach the document?
*
* Standalone, and deliberately outside `e2e/run.ts`: this is the one measurement that needs
* `@ng-org/orm`, an OPTIONAL PEER this package does not install (`bun.lock`,
* `optionalPeers`). Folding it into the 42-check suite would make that suite unbuildable on
* a checkout without it. Here, a missing ORM fails the build of this runner alone, with the
* reason said out loud — which is the honest outcome, because a measurement that did not
* run must not be reported as a verdict either way.
* Standalone, and deliberately outside `e2e/run.ts`: what it measures is the PUBLISHED
* `useShape` - the bare `DeepSignalSet` the ORM hook hands an application, and a
* `.delete()` on it reaching the document. `e2e/run.ts` exercises the other shape read,
* `useShapeQuery`, which since `1.0.0-dev.6` reads through the SDK's shape query too. Two
* surfaces, two runs.
*
* Run: `bun run e2e/orm-live-read.ts` from `packages/polyfill`.
*
* ── What it decides ────────────────────────────────────────────────────────
* -- What it decides ----------------------------------------
* The verdict is readable in one number: does `readyPromise` settle. It does on one path
* only — the arrival of an initial payload from the engine
* (`GraphOrmSubscription.ts:handleInitialResponse`) — so a refused start leaves it pending
* for ever, which is exactly why the failure is silent and why a bound, not an await, is
* what turns it into a report. Nothing here infers a verdict from a version string.
*
* ── What it found, 2026-09-16, and why the last row exists ─────────────────
* The read does NOT start, and the reason is not the one
* `bug_useshape-live-read-needs-a-newer-broker` names. That leaf's mechanism — a broker
* built before `orm_start` was renamed `orm_start_graph` — is REFUTED here: the method
* resolves, and the call fails INSIDE it, on an argument. The engine answers
* `Deserialization error of config Error: invalid type: JsValue(Function), expected any
* valid JSON value`.
* -- What it found, and the row that went with the answer ----------------
* On 2026-09-16 the read did not start: the engine answered `Deserialization error of
* config`, and a sixth row of this run established WHERE that `config` sits - position 4,
* before the callback, where the five-argument client of the day landed its callback. The
* deployment was AHEAD of the pinned client packages, not behind them.
*
* The last row establishes where that `config` sits, because the position is the whole
* diagnosis: calling the SDK member directly with `{}` in position 4 is ACCEPTED, and the
* engine then streams back. So the deployed `orm_start_graph` is
* `(graph_scope, subject_scope, shapeType, session_id, config, callback)`, and the
* five-argument call the published `@ng-org/orm` makes lands its callback in `config`.
* That arity is in no commit of `nextgraph-rs` at the read revision — the one window that
* had a `config` at all (2026-04-12 to 2026-04-21) put it LAST, after the callback. The
* deployment is therefore AHEAD of the client packages this repository pins, not behind
* them, which inverts what the leaf says to do about it.
*
* The run is EXPECTED TO EXIT NON-ZERO while that holds: it is a measurement of a
* deployment, not a gate on this package. Green again means the two ends agree, and the
* bug leaf can close.
* Updating them answered it: `@ng-org/web@0.1.2-alpha.14` declares six arguments with
* `config` at index 4 and `@ng-org/orm@0.1.2-alpha.21` sends six to match, and the run
* measured `6 passed, 0 failed` with the read ready in 16ms and the delete reaching the
* document. The characterisation row was retired with the question it answered: there is
* nothing left for it to find out, and a row that always prints the same sentence teaches
* whoever reads this report to skip it.
*/
import * as fs from "node:fs";
@@ -107,7 +97,6 @@ const CHECKS: readonly string[] = [
"the initial payload carries the seeded object, with its `@id` and `@graph`",
"`.delete()` on that set is accepted",
"the deletion reached the DOCUMENT: the seeded subject's triples are gone",
"[finding] the ARITY the deployed `orm_start_graph` expects",
];
type Check = { name: string; ok: boolean; detail?: string };
@@ -116,17 +105,6 @@ function check(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
}
/**
* A row that REPORTS rather than judges — `e2e/run.ts`'s convention for characterising the
* broker rather than asserting about this package. The last row is one: whichever arity the
* deployment expects, it is upstream's to change, so failing on it would only teach whoever
* reads the report to ignore this file.
*/
function record(name: string, detail: string): void {
results.push({ name, ok: true, detail });
console.log(` [INFO] ${name} — ${detail}`);
}
/** The bridge as the browser side of `frame.evaluate` sees it — no `any`, and no import
* the serialized callback could not carry across. */
type Bridge = Record<string, (...a: unknown[]) => unknown>;
@@ -261,27 +239,6 @@ async function main(): Promise<void> {
}
check(CHECKS[4]!, remaining === 0, `triples left for the seeded subject: ${remaining}`);
// Only worth asking when the published call did NOT start: it is the diagnosis of
// that failure, and a deployment that answers the published arity has nothing to
// characterise. Since `@ng-org/web@0.1.2-alpha.14` the published call is itself the
// six-argument one, so this asks whether the engine refuses a start the client got
// right - no longer whether the client was sending the wrong number.
if (readyMs === null) {
const withConfig = await sdk<{ doc: string; error: string | null }>(
frame,
"liveStartWithConfig",
);
record(
CHECKS[5]!,
withConfig.error === null
? "six arguments with `{}` in position 4 are ACCEPTED — the deployed " +
"`orm_start_graph` takes a `config` BEFORE the callback, which is what the " +
"published client now sends, so the refusal is not an arity mismatch"
: `six arguments were refused too: ${withConfig.error}`,
);
} else {
record(CHECKS[5]!, "not asked — the published six-argument call started");
}
} finally {
if (page) await closeQuietly("the ORM live-read page", () => page!.close());
if (ctx) await closeContext("orm-live", ctx);
+63 -40
View File
@@ -15,6 +15,11 @@
*/
import { ng as realNg, init as realInit } from "@ng-org/web";
// The REAL ORM. `useShapeQuery` reads through the SDK's own shape query since
// `1.0.0-dev.6`, so the harness has to hand it one: a fake Set-like is enough for the
// read FILTER (which is pure) and says nothing about a read that reaches the broker.
// `initNg` is what resolves the ORM's own session promise; nothing starts before it.
import { initNg as ormInitNg, RdfOrmSubscription } from "@ng-org/orm";
import {
configure,
docs,
@@ -26,6 +31,7 @@ import {
storeRegistry,
useShape as libUseShape,
useShapeQuery,
initNg,
} from "@ng-eventually/polyfill";
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
// application must not — but through the internal path, never the published entry.
@@ -60,13 +66,50 @@ import { isNuri } from "../src/model/nuri";
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
const EVERYTHING = { graphs: "did:ng:i" };
/**
* The shape the two `useShapeQuery` journeys read - built here rather than inline, since
* both of them need the same one and it is now handed to the SDK.
*
* Two predicates, both `minCardinality: 1`, because that is what
* `engine/verifier/src/orm/graph/query.rs:schema_shape_to_sparql` turns into REQUIRED
* triple patterns: a shape constraining nothing would match every subject in the dataset.
* The title is the second one, and it is what proves the real data landed rather than an
* empty object of the right type.
*/
function wsShape(cls: string) {
return {
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
{
iri: "urn:e2e:ws:title",
readablePredicate: "title",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ valType: "string" }],
},
],
},
},
} satisfies ShapeType<BaseType>;
}
const storeOfType = (t: "public" | "protected" | "private"): Promise<Nuri> =>
t === "public"
? storeRegistry.publicStore()
: t === "protected"
? storeRegistry.protectedStore()
: storeRegistry.privateStore();
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/polyfill";
import type { BaseType, Nuri, ShapeObservable, ShapeQuery, ShapeType } from "@ng-eventually/polyfill";
const { IdentityStore } = virtualUsers;
@@ -120,7 +163,12 @@ function fakeUseShape(_shape: unknown, _scope: unknown): any {
configure({
ng: realNg,
useShape: fakeUseShape,
// The SDK's own shape query, passed as it stands - no wrapper, no cast. This is what
// `useShapeQuery` reads through, so the journeys below run against the REAL broker
// rather than against anything this page composed.
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit,
initNg: ormInitNg,
// The harness already holds an open wallet (the suite imports it itself), so it never
// needs the gate's assisted import. These values exist only so the gate has something
// to render when the access-gate steps exercise it — they are never used to import.
@@ -794,9 +842,9 @@ const identity = new IdentityStore(
},
// ── useShapeQuery (reactive useQuery-shaped read) ────────────────────────
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. useShapeQuery
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
// to know which @type to keep — generic, no application domain.
// The shape is `wsShape(cls)` above: a hand-written `ShapeType`, two required
// predicates, generic and with no application domain. The read goes through the
// SDK's own shape query now, so these two journeys measure the REAL broker.
_wsProbes: {} as Record<
string,
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
@@ -828,23 +876,7 @@ const identity = new IdentityStore(
if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000));
}
const shape = {
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
],
},
},
};
const shape = wsShape(cls);
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
const unsub = obs.subscribe(() => {});
// First snapshot immediately after subscribe — the barrier is not yet crossed
@@ -866,8 +898,10 @@ const identity = new IdentityStore(
isSuccess: snap.isSuccess,
isError: snap.isError,
dataLen: snap.data.length,
// The seeded title, if the datum is present (proves the real data landed).
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
// The seeded title, if the datum is present (proves the real data landed). It is the
// shape's own readable predicate now, not a property bag: the objects come from the
// ORM.
titles: snap.data.map((d) => d.title).filter((v) => typeof v === "string"),
};
},
useShapeQueryStop(handle: string) {
@@ -884,23 +918,7 @@ const identity = new IdentityStore(
registryInternals.resetRegistryCache();
const id = "@ws-empty-" + handle;
setCurrentUser(id);
const shape = {
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
],
},
},
};
const shape = wsShape(cls);
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
const unsub = obs.subscribe(() => {});
const initial = obs.getSnapshot();
@@ -1105,6 +1123,11 @@ const identity = new IdentityStore(
(event: { session: BrokerSession }) => {
session = event.session;
state.status = "connected";
// The ORM reaches the SDK through a module-level promise of its own, resolved
// once. Nothing it opens starts before this, so it goes here rather than beside
// the first read - through the PACKAGE's `initNg`, which is the call an
// application makes.
initNg(realNg, session);
sessionResolve(session);
},
true,
+8 -6
View File
@@ -387,16 +387,18 @@ async function main(): Promise<void> {
});
// ── useShapeQuery (reactive useQuery-shaped read) ───────────────────────
// The real cycle: first subscription reads isPending (barrier not yet crossed),
// then after the broker sync it reaches isSuccess with the seeded datum present.
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
// distinction the announced `useShapeQuery` makes native, surfaced here from getSyncState).
// The real cycle, through the SDK's own shape query since `1.0.0-dev.6`: the first
// subscription reads isPending (the ORM's initial payload has not arrived), then the
// broker answers and it reaches isSuccess with the seeded datum present. A separate
// identity with nothing of that shape reaches isSuccess with data:[] - which over
// `did:ng:i` is the CAPABILITY FILTER doing the bounding, since the engine adds nothing
// to narrow the whole dataset by what the reader may open.
console.log("\n── useShapeQuery (reactive useQuery-shaped read) ──");
await step("useShapeQuery: first subscribe isPending → isSuccess with data present", async () => {
const h = "cyc" + Date.now();
const CLS = "urn:e2e:ws:Event";
const seed = await sdk<any>(frame, "useShapeQuerySeedAndSubscribe", h, CLS);
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
check("first snapshot after subscribe is isPending (no initial payload yet)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
// Event-driven: wait for the barrier to cross + the datum to land.
await frame.waitForFunction(
(hh) => {
@@ -423,7 +425,7 @@ async function main(): Promise<void> {
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h);
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
check("nothing of that shape: isSuccess, data:[] (answered-and-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
await sdk(frame, "useShapeQueryStop", h);
});