Files
ng-eventually/.project/concepts/app-contract/knowledge_what-the-orm-writes-and-deletes-upstream.md
T
Sylvain Duchesne 7bcb52a5fd fix: retire la refutation de where/orderBy/pageSize, lue dans le mauvais arbre
Deux feuilles affirmaient que la richesse de la config ORM est perdue au
niveau 1 - "no caller can reach any of it. The richness is real and
unreachable". La preuve invoquee etait le `json!({})` de
`Verifier::start_orm` a 213338f6, et elle ne porte pas cet enonce.

Ce qui est verifie a 213338f6 : `OrmConfig` declare where_, order_by,
select, page_size et max_active_pages ; `from_json` les analyse tous les
cinq et refuse un pageSize sans orderBy ; schema_shape_to_sparql et le
pagineur les consomment. La machinerie est implementee. Il manque un
seul saut, a l'ENTREE : start_orm ne prend aucun argument de config, et
son TODO voisin nomme le maillon absent, "use json in libwasm context".

Or ce maillon existe dans le build : @ng-org/web@0.1.2-alpha.14 publie
orm_start_graph avec SIX parametres, config en position 4, quand l'arbre
cite en declare CINQ, et @ng-org/orm@0.1.2-alpha.21 l'appelle avec six
en passant tout son objet d'options. Lus dans les artefacts publies.

Donc la config quitte le niveau 3 et est acceptee au niveau 2 aux
versions installees. Ce que le moteur derriere ce binding en fait n'est
etabli par rien d'atteignable, et c'est dit ainsi.
2026-09-24 15:05:22 +02:00

10 KiB

type, summary, last_checked
type summary last_checked
knowledge 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` 2026-09-24

What the ORM writes and deletes upstream

Read at the source in nextgraph-rs (213338f697df15afc086c042f4bab1bffd01aa04), which declares @ng-org/orm@0.1.2-alpha.18, @ng-org/web@0.1.2-alpha.13, @ng-org/shex-orm@0.1.2-alpha.8 and @ng-org/alien-deepsignals@0.1.2-alpha.11. packages/polyfill installs @ng-org/orm@0.1.2-alpha.21, @ng-org/web@0.1.2-alpha.14, @ng-org/shex-orm@0.1.2-alpha.9 and @ng-org/alien-deepsignals@0.1.2-alpha.13 - the published packages are AHEAD of the source this repo cites, so where the two disagree both are named below. That gap is checked rather than remembered: the ANCHOR in packages/polyfill/test/provenance.test.ts holds it once and goes red from either end. 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 at any version: sdk/js/orm/src/core.ts at the cited commit exports exactly initNg, ngSession, OrmSubscription, DiscreteOrmSubscription, insertObject, getObjects, plus getRaw/watch/effect re-exported from @ng-org/alien-deepsignals. The installed @ng-org/orm@0.1.2-alpha.21 renames OrmSubscription to RdfOrmSubscription and adds one export the source does not carry, removeObject(graphNuri, subjectIri): Promise<void> - see the note at the end of this section. 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.

removeObject exists in the package and not in the source. Its own documentation declares a wider effect than the set mutation: DELETE WHERE { GRAPH <graphNuri> { <subjectIri> ?p ?o } }, every predicate of the subject rather than the shape's own, while claiming in the same comment to have "the same effect" as a delete() on a subscription's object. The two cannot both hold, and nothing in the cited source settles it. It stays unpublished here for the reason contract_polyfill-surface records: a symbol whose only evidence is an npm build is not something to build a guarantee on.

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 (RdfOrmSubscription in alpha.21), 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 (RdfOrmSubscription in alpha.21) 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

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, RdfOrmSubscription in alpha.21 — 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. In alpha.21 the second argument is no longer a bare Scope but an RdfOrmConfig (Scope & { where?, orderBy?, pageSize?, maxActivePages? }), readyPromise resolves to the subscription data rather than to nothing, and the instance also carries mode, isReady, addChangeListener, removeChangeListener, nextPage and previousPage. How far that config travels is answered in knowledge_what-a-read-scope-is-upstream: the shipped binding takes it at index 4 and the shipped ORM sends it there, while what the engine behind that binding does with it is settled by nothing reachable. The json!({}) in 213338f6's Verifier::start_orm does NOT settle it, and reading it as "level 1 discards the richness" - the claim this leaf used to carry - is inference from an absent implementation. 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 STARTS against the deployment, and the delete reaches the document. packages/polyfill/e2e/orm-live-read.ts is the measurement - 5 passed, 0 failed on 2026-09-21, the read ready in 13ms, the seeded subject's triples gone after .delete().

The read was blocked until 2026-09-21, and the recorded reason was wrong twice over. It was not that the broker needed to catch up: the DEPLOYMENT was ahead and the pinned client packages were behind, orm_start_graph having gained a config parameter at index 4, before the callback, which the five-argument client of the day filled with its callback. Updating the packages answered it. The bug leaf that carried the wrong mechanism was deleted with the question it described.