Files
ng-eventually/.project/concepts/app-contract/knowledge_what-the-orm-writes-and-deletes-upstream.md
T

7.4 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-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

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.