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
- The set proxy.
deepSignal's set handler (sdk/js/alien-deepsignals/src/deepSignal.ts, thedeletebranch) emits, for an OBJECT member,{ path: [...containerPath, syntheticId], op: "remove" }— aDeepPatchwith notype. For a LITERAL inside a multi-valued property it emits{ path: containerPath, op: "remove", type: "set", value }instead. - The synthetic id is
"<graph>|<subject>", minted byOrmSubscription.signalObjectPropGenerator, which also generates a subject IRI (<first 53 chars of the graph NURI>:q:<33 random bytes base64url>) when@idis"". deepPatchesToWasm(sdk/js/orm/src/connector/utils.ts) joins the path into the string/<graph>|<subject>.OrmSubscription.onSignalObjectUpdatebatches in a microtask (or intopendingPatchesinside a transaction) and callsng.graph_orm_update(subscriptionId, patches, session_id).- The binding.
graph_orm_update(sdk/js/lib-wasm/src/lib.rs) deserialisesOrmPatches—OrmPatch { op, valType, path, value }withOrmPatchOp::{add, remove}andOrmPatchType::set(engine/net/src/orm.rs) — intoAppRequest::new_orm_update. - The engine.
Verifier::orm_frontend_update→create_sparql_update_query_for_patches(engine/verifier/src/orm/graph/handle_frontend_update.rs): aremovewhose path resolves to a ROOT object (one segment, no predicate) callsSparqlBuilder::remove_objectand marks the tracked objectTrackedOrmObjectValidity::Invalid. The queries are then run throughprocess_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)), awaitsreadyPromise, schedulesclose()one second later, returnsdeepClone(signalObject). The result is detached: a plainSetof 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 makessignalObjectPropGeneratorthrow "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.