docs: ce que l'ORM offre pour ecrire et supprimer, et la vraie cause du useShape mort
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
type: overview
|
||||
summary: What an application may rely on from @ng-eventually/polyfill, and what it will have to delete
|
||||
triggers:
|
||||
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit, IRI, urn, namespace, vocabulary, ontology, predicate]
|
||||
keywords: [polyfill, sdk, surface, contract, publish, published, entry, export, migration, unlearn, consumer, app-facing, inbox, deposit, IRI, urn, namespace, vocabulary, ontology, predicate, orm, useShape, getObjects, insertObject, OrmSubscription, ngSession]
|
||||
paths:
|
||||
- "packages/polyfill/src/index.ts"
|
||||
- "packages/polyfill/src/surface/**"
|
||||
@@ -40,5 +40,6 @@ This repo is the **provider** of `polyfill-surface`; consuming applications live
|
||||
- `knowledge_what-an-app-deletes-at-migration` — the two fates a published symbol can have.
|
||||
- `knowledge_what-an-iri-says-upstream` — the same two fates for a written *triple*: NextGraph's own vocabulary, ours, and which of our IRIs must outlive the migration.
|
||||
- `knowledge_what-a-read-scope-is-upstream` — what the target offers for reading many objects at once, and the one question it cannot express.
|
||||
- `knowledge_what-the-orm-writes-and-deletes-upstream` — the writing half: removal is a set mutation wired end to end, and an ORM write never passes through `sparql_update`.
|
||||
- `knowledge_what-an-inbox-is-upstream` — what the target actually offers around inboxes, and where a choice of ours begins.
|
||||
- `knowledge_who-processes-an-inbox` — the division of labour along a deposit's path: what this package applies, what it merely delivers, and what a layer above owes itself.
|
||||
|
||||
@@ -1,52 +1,46 @@
|
||||
---
|
||||
type: bug
|
||||
summary: A live read through the published `useShape` never starts against a broker deployed before `orm_start` was renamed `orm_start_graph` — it stays empty and pending forever, with one console error as the only trace
|
||||
summary: A live read through the published `useShape` never starts against the deployed broker — its `orm_start_graph` expects a `config` argument the published `@ng-org/orm` does not send, so the subscription stays empty and pending for ever with one console error as the only trace
|
||||
severity: major
|
||||
opened: 2026-08-17
|
||||
last_checked: 2026-08-17
|
||||
last_checked: 2026-09-16
|
||||
---
|
||||
|
||||
# `useShape` opens no live read against the deployed broker
|
||||
|
||||
The published `useShape` is a passthrough to `@ng-org/orm`'s hook (`packages/polyfill/src/surface/use-shape.ts`) — this package adds the read filter and forwards. Against the broker deployed today, the subscription that hook opens **never starts**: its set stays empty, its readiness never settles, no engine update ever arrives, and the only trace is one `console.error`. An application sees "this scope holds nothing", indefinitely, which is the exact confusion the rest of the read surface is built to prevent.
|
||||
|
||||
The cause is upstream, in NextGraph. **Nothing in this package can work around it** — the failure is a name lookup two hops below our surface.
|
||||
The cause is upstream, in the disagreement between the deployed broker and the published client packages. **Nothing in this package can work around it.**
|
||||
|
||||
## The mechanism — one method name, kept in three places by hand
|
||||
## The mechanism — an ARGUMENT, not a name
|
||||
|
||||
VERIFIED 2026-08-17 by reading `/home/sylvain/projects/nextgraph/nextgraph-rs` at `213338f6`.
|
||||
MEASURED 2026-09-16 against the real broker by `packages/polyfill/e2e/orm-live-read.ts`, which drives the published `useShape` over the real `@ng-org/orm`. The engine answers:
|
||||
|
||||
A live ORM read is one call, and it is streamed: the engine pushes into a callback rather than answering once. Nothing in the transport infers that — each hop looks the method up in a **hand-maintained table** mapping method name to the index of its callback argument, and a method missing from the table takes the plain request/response branch instead.
|
||||
```
|
||||
Deserialization error of config Error: invalid type: JsValue(Function), expected any valid JSON value
|
||||
```
|
||||
|
||||
1. `sdk/js/orm/src/connector/GraphOrmSubscription.ts` — the `OrmSubscription` constructor is the only place a live graph read begins: `ng.orm_start_graph(scope.graphs, scope.subjects, shapeType, session.session_id, this.onBackendMessage)`. Five arguments, callback last. `useShape` reaches it through `OrmSubscription.getOrCreate` (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`).
|
||||
2. `sdk/js/web/src/index.ts` — the application-side `ng` proxy. `streamed_api` must contain the method, or `rpc` posts the arguments verbatim to the parent window, callback included. A function is not structured-cloneable, so that post throws.
|
||||
3. `sdk/js/api-web/main.ts` — the broker-side proxy, an independent copy of the same table. Same fallback into `myWorker.postMessage`, same throw.
|
||||
A live ORM read is one call and it is streamed: `OrmSubscription`'s constructor issues `ng.orm_start_graph(graphs, subjects, shapeType, session_id, onBackendMessage)` — **five** arguments, callback last — and the engine pushes into that callback rather than answering once. What comes back instead names a parameter, `config`, that the client never sent.
|
||||
|
||||
The hops between the two tables carry no table of their own and cannot repair a mismatch: `infra/ngnet/auth/src/main.ts` forwards `{method, args, streamed, port}` unchanged, and `engine/broker/auth/src/App.svelte` re-appends a callback **only** when `streamed` is true.
|
||||
The same measurement establishes where that parameter sits, by calling the SDK member directly with `{}` in position 4: **six arguments are ACCEPTED**, and the engine then streams back. So the deployed signature is `(graph_scope, subject_scope, shapeType, session_id, config, callback)`, and the published client's five-argument call lands its **callback** exactly where `config` is expected — which is precisely what the error describes.
|
||||
|
||||
**The name changed.** Both tables keyed the method as `orm_start` until commit `d5ecd0fd` (2026-01-16, "refactor discrete orm for yjs") renamed it to `orm_start_graph` in both files at once — while the ORM had been calling `orm_start_graph`. A build made before that commit therefore does not recognise the method a live read needs.
|
||||
**The direction is the opposite of what this leaf said until 2026-09-16.** The earlier reading — a broker built before `orm_start` was renamed `orm_start_graph` (upstream `d5ecd0fd`, 2026-01-16) — is **refuted**: the name resolves, and the call fails *inside* the function, on an argument. That arity exists in **no** commit of `nextgraph-rs` at `213338f6`: the one window that carried a `config` at all (`c38aa371` 2026-04-12 → `7c8abd0a` 2026-04-21, *"rm unused config param"*) put it **last**, after the callback, where a five-argument call would leave it merely absent. The deployment is therefore built from a source **ahead of** that checkout, and it is the published `@ng-org/orm@0.1.2-alpha.19` / `@ng-org/web@0.1.2-alpha.13` this package pins that are behind it.
|
||||
|
||||
**The application side is already correct**, and that is what isolates the defect. This package pins `@ng-org/web@0.1.2-alpha.13` (`packages/polyfill/package.json`), which is the version at `213338f6` — the rename is in it. `@ng-org/api-web` is `private: true` and has never been version-bumped past `0.1.2`: it is **not published**, it ships only inside the broker build. Its table is whatever the operator last deployed, and no consumer can pin it.
|
||||
|
||||
**Why it fails in silence:** `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, and `resolveReady()` is called on one path only — the arrival of initial data. A rejected start leaves the readiness promise pending forever and the set permanently empty. This is the same upstream property already recorded in `docs/api-contract.md` (a failed read *is* an eternal pending upstream), reached here through a different door.
|
||||
**Why it fails in silence:** `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, and `resolveReady()` is called on one path only — the arrival of initial data (`GraphOrmSubscription.ts:handleInitialResponse`). A rejected start leaves the readiness promise pending for ever and the set permanently empty. This is the same upstream property already recorded in `docs/api-contract.md` (a failed read *is* an eternal pending upstream), reached here through a different door.
|
||||
|
||||
## What is affected, and what is not
|
||||
|
||||
- **Affected:** the published `useShape`, and only it.
|
||||
- **Not affected:** `watchShape`, `subscribeDoc`, `subscribeDocs`, `readUnion`. These open one `doc_subscribe` per document, and `doc_subscribe: 2` has been in both tables continuously under a stable name. `watchShape` is built on that path, not on the ORM subscription — it is a working reactive read today.
|
||||
- **Affected:** the published `useShape`, and only it. By the same argument, `insertObject` and `getObjects` — both built on `OrmSubscription` — cannot start either, so **no ORM write or deletion can be exercised against this deployment**.
|
||||
- **Not affected:** `watchShape`, `subscribeDoc`, `subscribeDocs`, `readUnion`. These open one `doc_subscribe` per document, and `doc_subscribe: 2` has been in the method tables continuously under a stable name and arity. `watchShape` is built on that path, not on the ORM subscription — it is a working reactive read today.
|
||||
|
||||
That is why the library's own suites and the reference application do not show it: nothing in this package calls `useShape` internally.
|
||||
|
||||
## Not verified
|
||||
|
||||
The deployed broker's table was **not read**. `https://nextgraph.net/redir/` returns a page containing none of the protocol markers, so it is not the bundle that holds it, and probing further was out of mandate. What is established is the mechanism and its exact precondition — a broker built before `d5ecd0fd` — not that the running deployment sits before it. The symptom was reported by the consuming application, not reproduced here.
|
||||
|
||||
## What to do
|
||||
|
||||
- **Do not build on live `useShape`.** Use `watchShape` for a reactive read, `readUnion` for a one-shot one. This is stated in the contract's `## Non-guarantees` so a consumer meets it without reading this leaf.
|
||||
- **The fix is not ours.** It is a NextGraph deployment: rebuild and redeploy the broker from a source at or after `d5ecd0fd`. Nothing to change in this repository, and nothing to open upstream from here — report it, do not patch around it.
|
||||
- **To check whether it is closed:** open a live `useShape` against the deployment and watch for an initial payload. A permanently empty set with one `console.error` naming `orm_start_graph` means the table is still stale.
|
||||
- **The fix is not ours, and it is an ALIGNMENT, not an upgrade in one direction.** Either the deployment rolls to a build whose `orm_start_graph` matches the published packages, or `@ng-org/orm` / `@ng-org/web` are republished against the deployed signature. Report it; do not patch around it, and do not pass a `config` argument from here — a client that guesses an unpublished arity breaks the day the two ends agree.
|
||||
- **To check whether it is closed:** `bun run e2e/orm-live-read.ts` from `packages/polyfill` (it needs `@ng-org/orm`, an optional peer `bun install` does not fetch). It exits non-zero while this is open and names what the engine expected. Green means the two ends agree and this leaf can go.
|
||||
|
||||
## The shape worth remembering
|
||||
|
||||
A method name duplicated across three independently-deployed artifacts, with a **silent** fallback when they disagree, is a defect that cannot be caught by any one repository's tests. Renaming it in the two tables in one commit fixed the source; it did not fix anything already running. Anything this package routes through a streamed method inherits the same exposure — that is the reason `subscribeDocs` composing per-document `doc_subscribe` is worth more than it looks.
|
||||
A method **signature** — name and arity together — duplicated across three independently-deployed artifacts, with a **silent** failure when they disagree, is a defect no single repository's tests can catch. Pinning the client packages does not help: the broker-side half (`@ng-org/api-web`, `private: true`, shipped only inside the broker build) is pinnable by nobody, so the two ends drift in either direction and the symptom is identical both ways. Anything this package routes through a streamed method inherits the same exposure — which is the reason `subscribeDocs` composing per-document `doc_subscribe` is worth more than it looks.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: What the target offers for writing and removing — deletion is a SET MUTATION wired end to end, it removes the shape's predicates rather than the subject, and the ORM's writes never pass through `sparql_update`
|
||||
last_checked: 2026-09-16
|
||||
---
|
||||
|
||||
# What the ORM writes and deletes upstream
|
||||
|
||||
Read at the source in `nextgraph-rs` (`213338f697df15afc086c042f4bab1bffd01aa04`), with `@ng-org/orm@0.1.2-alpha.19`, `@ng-org/web@0.1.2-alpha.13` and `@ng-org/alien-deepsignals@0.1.2-alpha.11` installed in `packages/polyfill`. Every claim points at a symbol rather than a line, so `git grep` settles any doubt after the code moves. Companion to [[knowledge_what-a-read-scope-is-upstream]], which answers the reading half.
|
||||
|
||||
## Removing an object is a set mutation, and LEVEL 3 answers
|
||||
|
||||
`sdk/js/orm/README.md` teaches it as one line — `dogs.delete(aDog)` — on the `DeepSignalSet` a subscription hands back. There is no `deleteObject` symbol and no deletion file: `sdk/js/orm/src/core.ts` exports exactly `initNg`, `ngSession`, `OrmSubscription`, `DiscreteOrmSubscription`, `insertObject`, `getObjects`, plus `getRaw`/`watch`/`effect` re-exported from `@ng-org/alien-deepsignals`. That absence is **not** the finding, and per [[no-divergence-from-nextgraph]] it would prove nothing on its own — the finding is that the set mutation is fully implemented, so the cascade stops at level 3 and there is nothing to invent.
|
||||
|
||||
## The path, hop by hop
|
||||
|
||||
1. **The set proxy.** `deepSignal`'s set handler (`sdk/js/alien-deepsignals/src/deepSignal.ts`, the `delete` branch) emits, for an OBJECT member, `{ path: [...containerPath, syntheticId], op: "remove" }` — a `DeepPatch` with no `type`. For a LITERAL inside a multi-valued property it emits `{ path: containerPath, op: "remove", type: "set", value }` instead.
|
||||
2. **The synthetic id is `"<graph>|<subject>"`**, minted by `OrmSubscription.signalObjectPropGenerator`, which also generates a subject IRI (`<first 53 chars of the graph NURI>:q:<33 random bytes base64url>`) when `@id` is `""`.
|
||||
3. **`deepPatchesToWasm`** (`sdk/js/orm/src/connector/utils.ts`) joins the path into the string `/<graph>|<subject>`.
|
||||
4. **`OrmSubscription.onSignalObjectUpdate`** batches in a microtask (or into `pendingPatches` inside a transaction) and calls `ng.graph_orm_update(subscriptionId, patches, session_id)`.
|
||||
5. **The binding.** `graph_orm_update` (`sdk/js/lib-wasm/src/lib.rs`) deserialises `OrmPatches` — `OrmPatch { op, valType, path, value }` with `OrmPatchOp::{add, remove}` and `OrmPatchType::set` (`engine/net/src/orm.rs`) — into `AppRequest::new_orm_update`.
|
||||
6. **The engine.** `Verifier::orm_frontend_update` → `create_sparql_update_query_for_patches` (`engine/verifier/src/orm/graph/handle_frontend_update.rs`): a `remove` whose path resolves to a ROOT object (one segment, no predicate) calls `SparqlBuilder::remove_object` and marks the tracked object `TrackedOrmObjectValidity::Invalid`. The queries are then run through `process_sparql_update`, anchored on the graph taken from the first patch's path.
|
||||
|
||||
**The trap that makes a delete a no-op.** The patch is emitted only when `target.delete(rawValue)` returned true **and** a synthetic id is known for that object. So the object passed must be the one the set is holding — reached by iterating it, by `.first()` or by `.getBy(graph, subject)`. `getObjects` returns a `deepClone`, so deleting an item of ITS result removes nothing and reports nothing.
|
||||
|
||||
## `remove_object` deletes the SHAPE's predicates, not the subject and not the document
|
||||
|
||||
`SparqlBuilder::remove_object` loops the shape's own `predicates` and emits one `DELETE { GRAPH <g> { <s> <p> ?o } } WHERE { … }` per predicate. Triples on the same subject under a predicate the shape does not declare **survive**, and so does the document: nothing on this path deletes a document, and the ORM offers no call that does.
|
||||
|
||||
Removing a child from a parent's multi-valued object property is a different arm — `remove_link`, a `DELETE DATA` of the one triple that ties parent to child. The README states the consequence plainly: only that tie goes, the child's own quads stay.
|
||||
|
||||
## `getObjects` and `insertObject` — the exact surface
|
||||
|
||||
```ts
|
||||
getObjects<T extends BaseType>(shapeType: ShapeType<T>, scope: Scope | string): Promise<DeepSignalSet<T>>
|
||||
insertObject<T extends BaseType>(shapeType: ShapeType<T>, object: T): Promise<void>
|
||||
```
|
||||
|
||||
Both return types are **inferred**, not annotated, and both functions are thin wrappers over `OrmSubscription`.
|
||||
|
||||
- **`getObjects`** (`sdk/js/orm/src/connector/getObjects.ts`) — `getOrCreate(shapeType, normalizeScope(scope))`, awaits `readyPromise`, schedules `close()` one second later, returns `deepClone(signalObject)`. The result is **detached**: a plain `Set` of plain objects, no two-way binding, no reactivity. `docs/api-contract.md` § 6 records this accurately — checked against the source, not re-derived.
|
||||
- **`insertObject`** (`sdk/js/orm/src/connector/insertObject.ts`) — opens a subscription scoped to `{ graphs: [] }` (deliberately subscribing to no document), awaits ready, `signalObject.add(object)`, `close()`. The object **must** carry `@graph`: a root object without one makes `signalObjectPropGenerator` throw *"When adding new root orm objects, you must specify the @graph"*. `@id: ""` means "generate one".
|
||||
|
||||
## The three remaining symbols
|
||||
|
||||
**`OrmSubscription`** — a class, and the machinery every other read and write goes through. Private constructor; `static getOrCreate(shapeType, scope)` pooled on `"<shape>|<graphs>|<subjects>"` with refcounting and a 500 ms close delay. Instance surface: `signalObject: DeepSignalSet<T>`, `readyPromise`, `close()`, `beginTransaction()`, `commitTransaction()`, `inTransaction`, `shapeType`, `scope`. It hands a caller nothing the target will not hand them — a shape, a scope and a set — and `useShape`, `getObjects` and `insertObject` are all built on it.
|
||||
|
||||
**`DiscreteOrmSubscription`** — the same class shape for JSON-CRDT documents. `static getOrCreate(documentId: string)`; `signalObject` is `DeepSignal<DiscreteArray | DiscreteObject> | undefined` (a getter, undefined before the initial payload); it reaches the engine through `orm_start_discrete` and `discrete_orm_update`. Its unit of subscription is **one document id**, never a `Scope`.
|
||||
|
||||
**`ngSession`** — not a function and not a class: a module-level `Promise<{ ng: NG; session: Session }>` (`sdk/js/orm/src/connector/initNg.ts`), resolved exactly once by `initNg`. Every subscription awaits it before touching the engine. Its value is the SDK handle plus the session object.
|
||||
|
||||
## A write never passes through `sparql_update`
|
||||
|
||||
`graph_orm_update` is its own SDK member, distinct from `sparql_update`. So a guard implemented by overriding `ng.sparql_update` — which is what `packages/polyfill/src/surface/ng-proxy.ts` does — **does not see an ORM write at all**, neither an add nor a delete. The read-filtered view (`packages/polyfill/src/emulated-verifier/read-filter.ts`) is symmetrical by design: it forwards `add`, `delete` and `clear` to the underlying set untouched, because those yield no items to leak.
|
||||
|
||||
That is a fact about where a guard can sit, not a proposal for one.
|
||||
|
||||
## Live behaviour against the deployed broker
|
||||
|
||||
Verified by measurement, not by reading: a live ORM read still does not start against the deployment, and the reason is **not** the one previously recorded. See [[bug_useshape-live-read-needs-a-newer-broker]], and `packages/polyfill/e2e/orm-live-read.ts`, which is the measurement.
|
||||
@@ -17,7 +17,7 @@ vocabulary:
|
||||
|
||||
# e2e-harness — the suites, and what they can and cannot tell you
|
||||
|
||||
Three suites drive real browsers against the real broker and the real wallet application. They are the only place the emulation meets what it emulates, so a green unit suite says nothing about them.
|
||||
Four runners drive real browsers against the real broker and the real wallet application — three suites that judge this package, and one measurement that judges the deployment. They are the only place the emulation meets what it emulates, so a green unit suite says nothing about them.
|
||||
|
||||
They are also expensive and, on a machine whose network moves, unreliable in ways that look exactly like product defects. Most of what this concept holds exists so an agent does not spend hours diagnosing a product bug that is a transport failure — or, worse, trust a green run that measured nothing.
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ summary: Which suite answers which question, what a batch costs, and why two run
|
||||
|
||||
**The reactivity suite** isolates document subscription.
|
||||
|
||||
A unit suite cannot replace any of them, and none of them replaces the unit suite: they are slow, they depend on live external services, and they cannot enumerate a case space.
|
||||
**The ORM live-read measurement** asks one question of the deployment rather than of this package: does a live `useShape` read actually start. It is the only runner that imports `@ng-org/orm` — an optional peer `bun install` does not fetch — which is why it stands apart from the polyfill suite instead of inside it: a checkout without the ORM must still be able to run the other three. It is EXPECTED to exit non-zero while the deployed broker and the published client packages disagree on `orm_start_graph` (see `app-contract/bug_useshape-live-read-needs-a-newer-broker`); green means they agree.
|
||||
|
||||
A unit suite cannot replace any of the first three, and none of them replaces the unit suite: they are slow, they depend on live external services, and they cannot enumerate a case space.
|
||||
|
||||
## What a batch costs, and why
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
id: T01.b
|
||||
title: What the ORM offers for writing and deleting, and what the three remaining symbols are
|
||||
parent: T01
|
||||
depends_on: []
|
||||
created: 2026-09-16
|
||||
claimed_by: claude-f93872b5
|
||||
claimed_at: 2026-09-16T15:25:00Z
|
||||
blocked: false
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
**Reconnaissance, not implementation.** Piece 2 of the parent task rests on a fact nobody has established: upstream's shape for **deletion**. Specifying a wrapper before knowing it is how this project has invented before. This child establishes the facts and proposes the arbitrations; T01.e implements against its `## Result`.
|
||||
|
||||
**Read the target at the source.** `/home/sylvain/projects/nextgraph/nextgraph-rs` is a sibling checkout, at `213338f6` when this was planned — the commit the existing knowledge leaves were read at. Record in the leaf you write the commit you actually read.
|
||||
|
||||
**The three questions:**
|
||||
|
||||
1. **Deletion.** What does the target offer to delete an object? Starting facts, verified at planning: `sdk/js/orm/src/connector/` holds `getObjects.ts` and `insertObject.ts` and **no deletion file**; `sdk/js/orm/src/core.ts` exports exactly `initNg`, `ngSession`, `OrmSubscription`, `DiscreteOrmSubscription`, `insertObject`, `getObjects`, plus `getRaw` / `watch` / `effect` re-exported from `@ng-org/alien-deepsignals` and the types. Run the cascade of `.project/rules/nextgraph/no-divergence-from-nextgraph.md` — level 3 first, then level 2 (`sdk/js/lib-wasm`, `sdk/js/web`), then the engine's model — and report **which level answers**, with symbols. A shape the target *declares* and constructs nowhere is `declared-not-wired` and is the strongest thing to align on; an **absent implementation is not evidence** the target will not have one, and must not be reported as one.
|
||||
2. **`getObjects` and `insertObject`.** Exact signature, parameters and return of each, precisely enough that a wrapper can be published at an identical signature without translating anything. `docs/api-contract.md` § 6 already records `getObjects` as level 3 VERIFIED (`sdk/js/orm/src/connector/getObjects.ts:23`) — check it rather than re-deriving it, and correct it if it has drifted.
|
||||
3. **`OrmSubscription`, `DiscreteOrmSubscription`, `ngSession`.** What each one is, what it hands the caller, and whether an application coding against this package needs it. **Propose** an arbitration for each — publish as-is / publish under what name / do not publish — with the evidence. Do not implement. Weigh it against the rule's own tell: *a symbol that makes the caller handle what the target will never hand it* (a document's key, a store id, its own identity) is a divergence even when upstream exports it.
|
||||
|
||||
**Established facts — cite them, do not re-derive them from `nextgraph-rs`:** `.project/concepts/app-contract/knowledge_what-a-read-scope-is-upstream.md` (the `Scope` type and its two axes; a `graphs` entry is one document's graph; `did:ng:i` means the whole local dataset with no permission narrowing; the graph filter is literal; `OrmSubscription.readyPromise` distinguishes not-ready from ready-and-empty at level 3 while the React `useShape` adapter drops it; `OrmConfig`'s richness is real and unreachable), `knowledge_what-an-iri-says-upstream.md`, `knowledge_what-an-inbox-is-upstream.md`.
|
||||
|
||||
**The problem being served is a rights hole, not a missing convenience.** Today an application must import these straight from `@ng-org/orm`, and then receives **every identity's data** out of the shared wallet with nothing signalling it. The filter that closes it is `makeReadFilteredView` (`packages/polyfill/src/emulated-verifier/read-filter.ts`, annotated `kind=aligned level=1 ref=engine/verifier/src/verifier.rs:load_repo_from_read_cap`), applied per document on what comes back — where the target bounds it. For each candidate wrapper, report **how** that filter would have to apply, or why it cannot. A write path is the hard case: the filter is a *read* emulation, and `packages/polyfill/src/surface/ng-proxy.ts` already overrides `sparql_update` with an emulated write guard — read it before concluding.
|
||||
|
||||
**Deliverable.** A `knowledge_` leaf under `.project/concepts/app-contract/`, written by invoking `/concept document` rather than by writing the file directly. Every claim points at a **symbol**, never a line number. The arbitration proposals and every unresolved question go in this task's `## Result`, where T01.e will read them.
|
||||
|
||||
**Out of scope:** no edit under `packages/`, no contract edit, no version bump. If a fact you establish changes what the published contract says, name it in `## Result` and leave the edit to the child that owns it.
|
||||
|
||||
**Pass condition for this child** (it changes no code, so the implementation gate does not apply): `python3 ~/.claude/skills/concept/lint.py` → 0 ERROR; the leaf names the commit it was read at; every target-side claim carries a symbol reference; each of the three questions has either an answer or an explicitly stated open question.
|
||||
|
||||
**Resolved since planning (2026-09-16).** Add one question: verify `useShape` LIVE against the real broker. `bug_useshape-live-read-needs-a-newer-broker` may be stale — the owner reports the deployed broker generally tracks NextGraph's source, and the rename landed 2026-01-16. Do not conclude from "generally": measure it. If it works, close the bug leaf and the blocker recorded in T01.d dissolves.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-09-16T15:25Z — claimed. Baselines taken BEFORE any edit: `bun test` 397 pass / 0 fail; `tsc -p packages/polyfill/tsconfig.json` exit 0; `tsc -p packages/polyfill/e2e/tsconfig.json` exit 0; `concept/lint.py` 0 ERROR (13 WARNING). `nextgraph-rs` read at `213338f697df15afc086c042f4bab1bffd01aa04` (clean but for one untracked engine test fixture).
|
||||
- 2026-09-16T15:30Z — questions 1-3 settled by reading the target. Deletion answers at LEVEL 3 and is wired end to end (`DeepSignalSet.delete` -> `OrmPatch{op:remove}` -> `graph_orm_update` -> `Verifier::orm_frontend_update` -> `SparqlBuilder::remove_object`). Full chain recorded in the knowledge leaf.
|
||||
- 2026-09-16T15:30Z — question 4: e2e baseline run started (`bun run test:e2e`) before adding anything.
|
||||
|
||||
## Result
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: T01.b
|
||||
title: What the ORM offers for writing and deleting, and what the three remaining symbols are
|
||||
parent: T01
|
||||
depends_on: []
|
||||
created: 2026-09-16
|
||||
claimed_by: null
|
||||
claimed_at: null
|
||||
blocked: false
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
**Reconnaissance, not implementation.** Piece 2 of the parent task rests on a fact nobody has established: upstream's shape for **deletion**. Specifying a wrapper before knowing it is how this project has invented before. This child establishes the facts and proposes the arbitrations; T01.e implements against its `## Result
|
||||
|
||||
**Done:** reconnaissance only, no product code. Findings: `.project/concepts/app-contract/knowledge_what-the-orm-writes-and-deletes-upstream.md` (`nextgraph-rs` read at `213338f697df15afc086c042f4bab1bffd01aa04`).
|
||||
|
||||
1. **Deletion — LEVEL 3 answers and is fully wired.** `DeepSignalSet.delete` → `{op:"remove", path:"/<graph>|<subject>"}` → `deepPatchesToWasm` → `ng.graph_orm_update` → `Verifier::orm_frontend_update` → `SparqlBuilder::remove_object`, which deletes only the SHAPE's declared predicates. No document deletion exists at any level. Nothing to invent.
|
||||
2. **`getObjects(shapeType, scope: Scope|string)`** returns a detached `deepClone` (mutations reach nothing); **`insertObject(shapeType, object)`** throws without `@graph`. `docs/api-contract.md` §6 checked — accurate, no drift.
|
||||
3. **Proposals (owner decides):** publish `OrmSubscription` as-is — the read filter applies to `signalObject` exactly as `surface/use-shape.ts` already does. Do NOT publish `DiscreteOrmSubscription` (JSON-CRDT, no `@graph`, so a per-document filter has nothing to read) nor `ngSession` (hands the caller the SDK handle and the session — the rule's own tell).
|
||||
4. **`useShape` live: MEASURED, still broken, and the bug leaf's mechanism was FALSE.** The name resolves; the deployed `orm_start_graph` takes `config` BEFORE the callback, so the published five-argument client lands its callback there. The deployment is AHEAD of the pinned `@ng-org/*`, not behind. Bug leaf rewritten, still open/major — T01.d's blocker STANDS.
|
||||
|
||||
**Open question for the owner:** the consumer reports `ngSet.delete()` "works again", yet no ORM subscription can start against this deployment. Which broker were they on?
|
||||
|
||||
**Surprises:** ORM writes go through `graph_orm_update`, NOT `ng.sparql_update` — so `surface/ng-proxy.ts`'s emulated write guard never sees an ORM add or delete (VERIFIED). `@ng-org/orm` is an optional peer with no lock entry; only the new runner imports it.
|
||||
|
||||
**Files:** `packages/polyfill/e2e/orm-live-read.ts`, `packages/polyfill/e2e/orm-live-entry.ts` (new); `.project/concepts/app-contract/{knowledge_what-the-orm-writes-and-deletes-upstream.md,bug_useshape-live-read-needs-a-newer-broker.md,_overview.md}`; `.project/concepts/e2e-harness/{_overview.md,knowledge_what-each-suite-judges.md}`; `_debt.md` reconciled and deleted.
|
||||
|
||||
**Gate:** `bun test` → 397 pass, 0 fail. `tsc -p packages/polyfill/tsconfig.json` → exit 0. `tsc -p packages/polyfill/e2e/tsconfig.json` → exit 0. `concept/lint.py` → 0 ERROR. e2e: `══ Polyfill e2e summary: 42 passed, 0 failed, 42 total — batch 4.0 min, slowest cold sync 30s ══` (unchanged) and `══ ORM live-read summary: 2 passed, 4 failed, 6 declared — run 1.7 min ══` (the new measurement; non-zero exit is its verdict on the deployment).
|
||||
|
||||
**Follow-ups:** add `@ng-org/orm` to `packages/polyfill` devDependencies so the new runner survives a clean `bun install`; `docs/api-contract.md` says nothing about deletion or `graph_orm_update` — T01.e's call.
|
||||
@@ -44,3 +44,4 @@ The polyfill's published surface has drifted from the SDK's: invented names, inv
|
||||
## Learnings digest
|
||||
|
||||
- T01.a — the taxonomy gains `announced-not-written`: its `ref` points at the `.md` in THIS repo where the statement is on record, never a source file, plus `announced-by=<who>@<date>`. Upstream citations now resolve-check only when the sibling checkout is present; announced records always. Two latent defects fell out: the test's closed set was missing `reserved-name`, and the rule said "five kinds" over six rows. All 92 existing refs already resolved.
|
||||
- T01.b — deletion is answered at level 3 and fully wired: `DeepSignalSet.delete` -> a remove patch -> `graph_orm_update` -> `SparqlBuilder::remove_object`, which removes the SHAPE's predicates, not the subject; no document deletion exists at any level. `getObjects` returns a DETACHED clone; `insertObject` throws without `@graph`. Proposals: publish `OrmSubscription` as-is (the read filter applies to `signalObject`), publish NEITHER `DiscreteOrmSubscription` (JSON-CRDT, no `@graph`, nothing for a per-document filter to read) NOR `ngSession` (hands out the SDK handle and the session). MEASURED: `useShape` is still broken live, and the bug leaf's mechanism was FALSE - the deployment is AHEAD of our pinned `@ng-org/*`, its `orm_start_graph` takes `config` BEFORE the callback, so the five-argument client lands its callback in the wrong slot. T01.d's blocker STANDS. Also VERIFIED: ORM writes bypass `sparql_update` entirely, so the emulated write guard never sees an ORM add or delete.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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, OrmSubscription, 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.
|
||||
*/
|
||||
let liveSubscription: OrmSubscription<BaseType> | null = null;
|
||||
function realOrmUseShape(shapeType: unknown, scope: unknown): unknown {
|
||||
const sub = OrmSubscription.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);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Run: `bun run e2e/orm-live-read.ts` from `packages/polyfill`.
|
||||
*
|
||||
* ── 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`.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
armSuiteDeadline,
|
||||
closeContext,
|
||||
closeQuietly,
|
||||
launchWatchedContext,
|
||||
newPage,
|
||||
serveOnEphemeralPort,
|
||||
setupBrokerPage,
|
||||
within,
|
||||
type BrowserContext,
|
||||
type Frame,
|
||||
type Page,
|
||||
} from "ng-e2e-helpers";
|
||||
import { WALLET, mintBatchWallet } from "./harness-page";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ENTRY = path.resolve(__dirname, "orm-live-entry.ts");
|
||||
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "orm-live-entry.js");
|
||||
|
||||
// ── bounds ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `bun build` is a local bundle; a minute is already ten times what it takes. */
|
||||
const BUILD_MS = 60_000;
|
||||
/**
|
||||
* The whole measurement. Five checks against one fresh wallet, where the expensive parts
|
||||
* are the broker login and one document creation — the 42-check suite pays both and
|
||||
* measures 3.4 min in total, so eight minutes here is the point past which this has
|
||||
* stopped being a measurement of anything.
|
||||
*/
|
||||
const SUITE_DEADLINE_MS = 8 * 60 * 1000;
|
||||
/** One `__sdk` bridge call. `frame.evaluate()` has no deadline of its own. */
|
||||
const BRIDGE_MS = 2 * 60 * 1000;
|
||||
/**
|
||||
* How long a live read may take to deliver its initial payload.
|
||||
*
|
||||
* NOT yet computed from a measured latency of this path, and it cannot be while the start
|
||||
* is refused: nothing has ever settled here to measure. It is sized against the duration
|
||||
* the step before it is allowed — the broker login, bounded at 45s in `ng-e2e-helpers` and
|
||||
* measured at a few seconds — so a read that has not answered in 60s has not answered
|
||||
* because it was slow. The first run that settles prints `ready in Nms`; set this to a
|
||||
* generous multiple of that figure then, and say so here.
|
||||
*/
|
||||
const READY_MS = 60_000;
|
||||
/**
|
||||
* How long the delete may take to reach the document. Same status, and the write it waits
|
||||
* on is the one `e2e/run.ts` measures elsewhere at a second or two against this broker.
|
||||
*/
|
||||
const DELETE_MS = 30_000;
|
||||
|
||||
// ── what this run reports ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every check, declared here so the run's total is known BEFORE the browser launches — the
|
||||
* convention `e2e/notebook.ts` states at length: a run that dies in its setup still has a
|
||||
* total to be compared against, so a difference between two runs is always a real one.
|
||||
*/
|
||||
const CHECKS: readonly string[] = [
|
||||
"the document accepted the seed (two triples for one subject)",
|
||||
"the live read STARTS: `useShape` returns a set and the subscription reports ready",
|
||||
"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 };
|
||||
const results: Check[] = [];
|
||||
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>;
|
||||
declare global {
|
||||
interface Window {
|
||||
__sdk?: Bridge;
|
||||
}
|
||||
}
|
||||
|
||||
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||
return within(`__sdk.${method}() in the broker iframe`, BRIDGE_MS, () =>
|
||||
frame.evaluate(
|
||||
([m, a]) => window.__sdk![m as string]!(...(a as unknown[])),
|
||||
[method, args] as const,
|
||||
),
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
function buildBundle(): void {
|
||||
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
|
||||
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
|
||||
stdio: "pipe",
|
||||
cwd: path.resolve(__dirname, ".."),
|
||||
timeout: BUILD_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function serve(): Promise<{ url: string; close: () => void }> {
|
||||
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
|
||||
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually ORM live read</title></head><body><div id="root"></div><script type="module" src="/orm-live-entry.js"></script></body></html>`;
|
||||
return serveOnEphemeralPort((req, res) => {
|
||||
if (req.url === "/orm-live-entry.js") {
|
||||
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
||||
res.end(bundle);
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
armSuiteDeadline("the ORM live-read measurement", SUITE_DEADLINE_MS);
|
||||
const started = Date.now();
|
||||
|
||||
console.log("[orm-live] building the page bundle...");
|
||||
try {
|
||||
buildBundle();
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[orm-live] the bundle did not build. `@ng-org/orm` is an OPTIONAL PEER of this " +
|
||||
"package and `bun install` does not fetch it — install it in packages/polyfill " +
|
||||
"before running this measurement. NOTHING was measured, so nothing is concluded.",
|
||||
);
|
||||
console.error(String((e as { stderr?: Buffer })?.stderr ?? e));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("[orm-live] minting this run's wallet...");
|
||||
const wallet = await mintBatchWallet("the ORM live-read measurement (e2e/orm-live-read.ts)");
|
||||
const { url, close: closeServer } = await serve();
|
||||
console.log(`[orm-live] page served at ${url}`);
|
||||
|
||||
let ctx: BrowserContext | null = null;
|
||||
let page: Page | null = null;
|
||||
try {
|
||||
ctx = await launchWatchedContext("orm-live", wallet.dir);
|
||||
page = await newPage("the ORM live-read page", ctx);
|
||||
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error") console.error("[iframe console]", m.text());
|
||||
});
|
||||
|
||||
const frame = await setupBrokerPage(page, url, WALLET.password);
|
||||
await frame.waitForFunction(() => window.__sdk !== undefined, { timeout: 30_000 });
|
||||
await frame.waitForFunction(() => window.__sdk!.status!() === "connected", {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
const start = await sdk<{
|
||||
doc: string;
|
||||
subject: string;
|
||||
seeded: number;
|
||||
startError: string | null;
|
||||
}>(frame, "liveStart");
|
||||
check(CHECKS[0]!, start.seeded === 2, `doc=${start.doc} triples=${start.seeded}`);
|
||||
|
||||
// The verdict. A refused start never settles `readyPromise`, so this wait IS the
|
||||
// measurement — and the bound is what turns "for ever" into a reported failure.
|
||||
let readyMs: number | null = null;
|
||||
try {
|
||||
await frame.waitForFunction(() => window.__sdk!.liveReadyMs!() !== null, {
|
||||
timeout: READY_MS,
|
||||
});
|
||||
readyMs = await sdk<number | null>(frame, "liveReadyMs");
|
||||
} catch {
|
||||
readyMs = null;
|
||||
}
|
||||
check(
|
||||
CHECKS[1]!,
|
||||
start.startError === null && readyMs !== null,
|
||||
start.startError !== null
|
||||
? `useShape threw: ${start.startError}`
|
||||
: readyMs === null
|
||||
? `readyPromise still pending after ${READY_MS / 1000}s — the engine refused the ` +
|
||||
"start; the next row says what it expected instead"
|
||||
: `ready in ${readyMs}ms`,
|
||||
);
|
||||
|
||||
const items = await sdk<Array<{ id: string; graph: string; mark: string }>>(frame, "liveItems");
|
||||
const seen = items[0];
|
||||
check(
|
||||
CHECKS[2]!,
|
||||
items.length === 1 && seen?.id === start.subject && seen?.graph === start.doc,
|
||||
`items=${JSON.stringify(items)}`,
|
||||
);
|
||||
|
||||
const del = await sdk<{ deleted: boolean; error: string | null }>(frame, "liveDelete");
|
||||
check(CHECKS[3]!, del.deleted === true && del.error === null, `deleted=${del.deleted} error=${del.error}`);
|
||||
|
||||
let remaining = await sdk<number>(frame, "liveSeededTriples");
|
||||
try {
|
||||
await within("the deletion reaching the document", DELETE_MS, async () => {
|
||||
const deadline = Date.now() + DELETE_MS;
|
||||
while (remaining > 0 && Date.now() < deadline) {
|
||||
await page!.waitForTimeout(1000);
|
||||
remaining = await sdk<number>(frame, "liveSeededTriples");
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* the count below is the verdict either way */
|
||||
}
|
||||
check(CHECKS[4]!, remaining === 0, `triples left for the seeded subject: ${remaining}`);
|
||||
|
||||
// Only worth asking when the five-argument call did NOT start: it is the diagnosis of
|
||||
// that failure, and a deployment that answers the published arity has nothing to
|
||||
// characterise.
|
||||
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, so the published " +
|
||||
"client's five-argument call puts its callback there"
|
||||
: `six arguments were refused too: ${withConfig.error}`,
|
||||
);
|
||||
} else {
|
||||
record(CHECKS[5]!, "not asked — the published five-argument call started");
|
||||
}
|
||||
} finally {
|
||||
if (page) await closeQuietly("the ORM live-read page", () => page!.close());
|
||||
if (ctx) await closeContext("orm-live", ctx);
|
||||
closeServer();
|
||||
wallet.discard();
|
||||
}
|
||||
|
||||
const passed = results.filter((r) => r.ok).length;
|
||||
const failed = results.length - passed;
|
||||
console.log(
|
||||
`\n══ ORM live-read summary: ${passed} passed, ${failed} failed, ${CHECKS.length} declared ` +
|
||||
`— run ${((Date.now() - started) / 60000).toFixed(1)} min ══`,
|
||||
);
|
||||
if (failed > 0 || results.length !== CHECKS.length) {
|
||||
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("[orm-live] fatal:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user