313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
/**
|
|
* The page for the LIVE ORM measurement — the one thing `e2e/polyfill-entry.ts` cannot do.
|
|
*
|
|
* 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.
|
|
*
|
|
* ── 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`):
|
|
* it calls `OrmSubscription.getOrCreate(shape, scope)`, hands `signalObject` to
|
|
* `useDeepSignal`, and closes the subscription on unmount. The broker sees the first of
|
|
* those three and nothing else — `useDeepSignal` is re-render bookkeeping. So the hook
|
|
* injected below IS `getOrCreate(...).signalObject`, which is what a React render reaches
|
|
* the broker with, without a React tree to mount.
|
|
*/
|
|
|
|
import { ng as realNg, init as realInit } from "@ng-org/web";
|
|
import { initNg as ormInitNg, RdfOrmSubscription, type Scope as OrmScope } from "@ng-org/orm";
|
|
import { configure, docs, init, useShape as libUseShape } from "@ng-eventually/polyfill";
|
|
import type { BaseType, Nuri, ShapeType } from "@ng-eventually/polyfill";
|
|
import { adoptCurrentUser, configureStoreRegistry } from "../src/shared-wallet/bootstrap";
|
|
|
|
// ── the shape under measurement ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* The smallest shape that can be matched at all, written by hand rather than generated.
|
|
*
|
|
* `rdf-orm build` turns a `.shex` file into exactly this object — `{ schema, shape }`
|
|
* (`sdk/js/shex-orm/src/types.ts:ShapeType`) — and the generator is not what is under
|
|
* measurement here. 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 with none of them would match every subject in the document.
|
|
*/
|
|
const LIVE_SHAPE = {
|
|
shape: "did:ng:z:E2ELiveShape",
|
|
schema: {
|
|
"did:ng:z:E2ELiveShape": {
|
|
iri: "did:ng:z:E2ELiveShape",
|
|
predicates: [
|
|
{
|
|
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
|
readablePredicate: "@type",
|
|
dataTypes: [{ valType: "iri", literals: ["did:ng:z:E2ELive"] }],
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
},
|
|
{
|
|
iri: "did:ng:z:liveMark",
|
|
readablePredicate: "liveMark",
|
|
dataTypes: [{ valType: "string" }],
|
|
minCardinality: 1,
|
|
maxCardinality: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} satisfies ShapeType<BaseType>;
|
|
|
|
const TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
|
|
|
// ── the broker session ──────────────────────────────────────────────────────
|
|
|
|
interface BrokerSession {
|
|
session_id: string;
|
|
private_store_id: string;
|
|
protected_store_id: string;
|
|
public_store_id: string;
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
let session: BrokerSession | null = null;
|
|
let sessionResolve!: (s: BrokerSession) => void;
|
|
const sessionReady = new Promise<BrokerSession>((r) => (sessionResolve = r));
|
|
|
|
/**
|
|
* The injected hook — the real ORM's broker-facing half.
|
|
*
|
|
* It keeps the subscription aside so the probe can await `readyPromise`, which is the ORM's
|
|
* own answer to "did the initial payload arrive": it resolves on that path and on no other
|
|
* (`sdk/js/orm/src/connector/GraphOrmSubscription.ts:handleInitialResponse`), so a start
|
|
* the broker refused leaves it pending for ever — the silence this measurement is here to
|
|
* break.
|
|
*/
|
|
/**
|
|
* The handle this page keeps, DERIVED from `getOrCreate` rather than written by hand.
|
|
*
|
|
* `RdfOrmSubscription` (renamed from `OrmSubscription` in `@ng-org/orm@0.1.2-alpha.21`)
|
|
* now takes its config as a type parameter and `getOrCreate` returns a narrowed
|
|
* `RdfOrmSubscriptionFor<...>` - the paging methods are `Omit`ted when no `pageSize` is
|
|
* asked for. Naming the class directly would restate that narrowing and get it wrong the
|
|
* next time it moves; `ReturnType` asks the package what it actually hands back.
|
|
*/
|
|
type LiveSubscription = ReturnType<
|
|
typeof RdfOrmSubscription.getOrCreate<ShapeType<BaseType>, BaseType, OrmScope>
|
|
>;
|
|
|
|
let liveSubscription: LiveSubscription | null = null;
|
|
function realOrmUseShape(shapeType: unknown, scope: unknown): unknown {
|
|
const sub = RdfOrmSubscription.getOrCreate(shapeType as ShapeType<BaseType>, scope as OrmScope);
|
|
liveSubscription = sub;
|
|
return sub.signalObject;
|
|
}
|
|
|
|
configure({
|
|
ng: realNg,
|
|
useShape: realOrmUseShape,
|
|
init: realInit,
|
|
sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" },
|
|
});
|
|
adoptCurrentUser("e2e-orm-live");
|
|
configureStoreRegistry({
|
|
getSession: async () => {
|
|
const s = session ?? (await sessionReady);
|
|
return {
|
|
sessionId: s.session_id,
|
|
privateStoreId: s.private_store_id,
|
|
protectedStoreId: s.protected_store_id,
|
|
publicStoreId: s.public_store_id,
|
|
};
|
|
},
|
|
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
|
pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 },
|
|
});
|
|
|
|
// ── the probe ───────────────────────────────────────────────────────────────
|
|
|
|
const state: { status: string; error?: string } = { status: "connecting" };
|
|
|
|
interface LiveProbe {
|
|
doc: Nuri;
|
|
subject: string;
|
|
/** What the published `useShape` returned — a Set-like of the matched objects. */
|
|
view: Set<BaseType>;
|
|
startError: string | null;
|
|
readyMs: number | null;
|
|
deleteError: string | null;
|
|
}
|
|
let probe: LiveProbe | null = null;
|
|
|
|
/** Count the triples the document still holds for the seeded subject. */
|
|
async function seededTripleCount(doc: Nuri, subject: string): Promise<number> {
|
|
const s = await sessionReady;
|
|
const res = await docs.sparqlQuery(
|
|
s.session_id,
|
|
`SELECT ?p ?o WHERE { <${subject}> ?p ?o }`,
|
|
undefined,
|
|
doc,
|
|
);
|
|
const rows = Array.isArray(res)
|
|
? res
|
|
: ((res as { results?: { bindings?: unknown[] } })?.results?.bindings ?? []);
|
|
return rows.length;
|
|
}
|
|
|
|
(window as unknown as { __sdk: Record<string, unknown> }).__sdk = {
|
|
status: () => state.status,
|
|
error: () => state.error ?? null,
|
|
|
|
/**
|
|
* Seed a document with one shape-matching object, then open the live read through the
|
|
* PUBLISHED `useShape`.
|
|
*
|
|
* The subject IRI follows the ORM's own generator
|
|
* (`GraphOrmSubscription.ts:signalObjectPropGenerator`): the document NURI's first 53
|
|
* characters, then `:q:`, then a name. Nothing requires that form of a hand-written
|
|
* SPARQL insert, and using it removes one way for this measurement to differ from what
|
|
* the ORM itself would have written.
|
|
*/
|
|
async liveStart() {
|
|
const s = await sessionReady;
|
|
const doc = (await docs.docCreate(
|
|
s.session_id,
|
|
"Graph",
|
|
"data:graph",
|
|
"store",
|
|
undefined,
|
|
)) as Nuri;
|
|
const subject = `${doc.substring(0, 53)}:q:e2eLiveRead`;
|
|
await docs.sparqlUpdate(
|
|
s.session_id,
|
|
`INSERT DATA { <${subject}> <${TYPE_IRI}> <did:ng:z:E2ELive> ; <did:ng:z:liveMark> "measured" }`,
|
|
doc,
|
|
);
|
|
const seeded = await seededTripleCount(doc, subject);
|
|
|
|
// The ORM reaches the SDK through its own module-level promise, resolved once.
|
|
ormInitNg(realNg, s as unknown as Parameters<typeof ormInitNg>[1]);
|
|
|
|
let startError: string | null = null;
|
|
let view: Set<BaseType>;
|
|
const t0 = Date.now();
|
|
try {
|
|
view = libUseShape(LIVE_SHAPE, { graphs: [doc] }) as Set<BaseType>;
|
|
} catch (e) {
|
|
startError = e instanceof Error ? e.message : String(e);
|
|
probe = { doc, subject, view: new Set(), startError, readyMs: null, deleteError: null };
|
|
return { doc, subject, seeded, startError };
|
|
}
|
|
probe = { doc, subject, view, startError: null, readyMs: null, deleteError: null };
|
|
// Un-awaited on purpose: a refused start never resolves this, and the runner's own
|
|
// bound is what turns that silence into a verdict.
|
|
void liveSubscription?.readyPromise.then(() => {
|
|
if (probe) probe.readyMs = Date.now() - t0;
|
|
});
|
|
return { doc, subject, seeded, startError: null };
|
|
},
|
|
|
|
/** How many objects the published `useShape` view holds right now. */
|
|
liveCount: () => (probe ? probe.view.size : -1),
|
|
/** Milliseconds from the `useShape` call to `readyPromise`, or null while it is pending. */
|
|
liveReadyMs: () => probe?.readyMs ?? null,
|
|
liveItems: () =>
|
|
probe
|
|
? [...probe.view].map((i) => ({
|
|
id: i["@id"] as string,
|
|
graph: i["@graph"] as string,
|
|
mark: i.liveMark as string,
|
|
}))
|
|
: [],
|
|
|
|
/**
|
|
* Delete the seeded object through the set the hook returned — the gesture the consuming
|
|
* application reports as the shape for removal.
|
|
*/
|
|
liveDelete() {
|
|
if (!probe) return { deleted: false, error: "no probe" };
|
|
const item = [...probe.view][0];
|
|
if (item === undefined) return { deleted: false, error: "view is empty" };
|
|
try {
|
|
const deleted = probe.view.delete(item);
|
|
return { deleted, error: null };
|
|
} catch (e) {
|
|
const error = e instanceof Error ? e.message : String(e);
|
|
probe.deleteError = error;
|
|
return { deleted: false, error };
|
|
}
|
|
},
|
|
|
|
/**
|
|
* CHARACTERISATION of the deployed broker's `orm_start_graph` ARITY.
|
|
*
|
|
* The failure the runner reports names a parameter — `config` — that the ORM does not
|
|
* send and that `nextgraph-rs` at the read commit does not declare. 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 from `@ng-org/orm`
|
|
* then lands its callback exactly there, which is what the error describes.
|
|
*
|
|
* So this calls the SDK member directly with an empty config in position 4. It does not
|
|
* go through the ORM, which is fixed at five arguments — this is the one question the
|
|
* ORM cannot ask. The app-side proxy will mistake the `{}` for the stream callback, so
|
|
* nothing usable comes back; what is being read is only whether the engine still
|
|
* refuses on `config`.
|
|
*/
|
|
async liveStartWithConfig() {
|
|
const s = await sessionReady;
|
|
const doc = (await docs.docCreate(
|
|
s.session_id,
|
|
"Graph",
|
|
"data:graph",
|
|
"store",
|
|
undefined,
|
|
)) as Nuri;
|
|
// Six arguments against a declaration that has five: the declaration describes the
|
|
// arity of the PUBLISHED client, and the arity of the DEPLOYED engine is the question.
|
|
// Widened through `unknown`, never `any` — the call is deliberate, not unchecked.
|
|
const startGraph = realNg.orm_start_graph as unknown as (
|
|
...a: unknown[]
|
|
) => Promise<unknown>;
|
|
try {
|
|
await startGraph([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;
|
|
return seededTripleCount(probe.doc, probe.subject);
|
|
},
|
|
};
|
|
|
|
(async () => {
|
|
try {
|
|
await init(
|
|
(event: { session: BrokerSession }) => {
|
|
session = event.session;
|
|
state.status = "connected";
|
|
sessionResolve(session);
|
|
},
|
|
true,
|
|
[],
|
|
);
|
|
} catch (e) {
|
|
state.status = "error";
|
|
state.error = e instanceof Error ? e.message : String(e);
|
|
}
|
|
})();
|