feat!: la famille ORM est publiee enveloppee, et le ng publie cesse de rendre des promesses

This commit is contained in:
Sylvain Duchesne
2026-09-21 12:38:17 +02:00
parent 2aac703784
commit d0ea043a5b
21 changed files with 1102 additions and 35 deletions
+171
View File
@@ -0,0 +1,171 @@
/**
* The ORM family this package offers an application - the SDK's one-shot read and its
* subscription-free write, each WRAPPED at the same name and the same signature.
*
* -- Why wrapped, and never re-exported ------------------------------------
* `export { getObjects } from "@ng-org/orm"` would compile and would look like the most
* faithful thing available. It is the opposite: it hands the caller a path straight to
* the SDK that SHORT-CIRCUITS the emulation - the capability filter, and above all the
* shared-wallet identity boundary, which is the whole of what this package protects. One
* wallet hosts every identity here, so an unwrapped ORM read returns EVERY identity's
* data with nothing signalling it. Everything offered from this family is therefore
* wrapped; a symbol that cannot be wrapped meaningfully is not published at all.
*
* -- What each wrapper adds, and where the target does the same thing ------
* A READ is filtered on what comes back. `emulated-verifier/read-filter.ts` keeps only
* items whose document - their `@graph` - the current holder holds a cap for, which is
* the emulation of what the broker bounds at delivery, applied per document, exactly
* where `surface/use-shape.ts` and `surface/use-shape-query.ts` apply it.
*
* A WRITE is NOT filtered, and the asymmetry is upstream's rather than ours: a write is
* bounded by write capability - `verify_permission`, reachable only from `Commit::verify`,
* so it fires on commits and never on reads - and nothing is hidden from the writer. So
* `insertObject` takes a GUARD BEFORE the write (`emulated-verifier/reach.ts`), the same
* pair `docs.sparqlUpdate` takes, and never a filter over what it returns.
*
* -- The second door the guard needs, and where it is ----------------------
* These two wrappers guard the calls an application MAKES. They do not, and cannot, guard
* a mutation of a live signal object - `set.add(...)`, `set.delete(...)` on what a shape
* query handed back - because that write is made by the ORM itself, batched into
* `ng.graph_orm_update`. That door is guarded where every other `ng` call is, in
* `surface/ng-proxy.ts`, and `surface/lifecycle.ts` is what makes sure the ORM is holding
* the wrapped `ng` when it knocks on it.
*
* -- What is deliberately NOT published from this family -------------------
* `@ng-org/orm`'s entry also exports `RdfOrmSubscription`, `DiscreteOrmSubscription`,
* `ngSession`, and `getRaw` / `watch` / `effect` re-exported from
* `@ng-org/alien-deepsignals`. None of them is published here, and each for a reason a
* later reader can check rather than a preference:
*
* - `RdfOrmSubscription` - an application never holds a subscription: it hands the
* shape query to `configure` and reads `useShapeQuery`'s snapshot back (see the note
* on `OrmSubscriptionHandle` in `model/types.ts`). And `getOrCreate` POOLS on a key
* built from the shape and the scope alone - no identity in it - so a second virtual
* user asking the same question is handed the FIRST one's live instance. What makes
* that safe here is the filter this package applies on the way out; publishing the
* handle would hand out the instance instead.
* - `DiscreteOrmSubscription` - a JSON-CRDT document. Its items carry no `@graph`, so
* the read filter has nothing to read, and its writes leave through
* `discrete_orm_update`, a third channel nothing here guards. Nothing in this package
* reads or writes a discrete document; publishing it would open a surface and a hole
* in the same gesture.
* - `ngSession` - a module-level PROMISE, not a function, and what it resolves to is
* the SDK handle plus the SESSION. Here one physical session hosts every virtual
* identity, so handing it out hands every identity the same one; the contract already
* says the session is the package's. Wrapping a resolved value would also force a
* RUNTIME `@ng-org/orm` import into this module, which is the one thing the injection
* discipline exists to avoid.
* - `getRaw` - it returns the object BEHIND a signal, which is the same escape hatch
* `read-filter.ts` refuses on a dunder key (`__raw__`): it hands back the unfiltered
* collection, every identity's items in it. There is no filtered form of it.
* - `watch` / `effect` - `@ng-org/alien-deepsignals`' reactivity primitives, not the
* ORM's. They carry no document and grant nothing, so there is nothing to wrap; an
* application that wants them declares that package.
*/
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { filterReadable } from "../emulated-verifier/read-filter";
import { assertMayReach, assertMayWrite } from "../emulated-verifier/reach";
import { isNuri } from "../model/nuri";
import type { GetObjectsLike, InsertObjectLike, Nuri } from "../model/types";
// The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org`
// import is added here). The provenance of what is PUBLISHED is recorded in `index.ts`.
import type { RdfOrmConfig } from "@ng-org/orm/all";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
/**
* The injected real function, or the error that says it was never handed over.
*
* Upstream an application imports the ORM; here it hands it to `configure`, like the
* `ng`, the `useShape` and the shape query beside it. Absent, there is nothing to call
* through to - and that has to be said out loud, naming the field to pass, rather than
* answered with an empty result.
*/
function injected<K extends "getObjects" | "insertObject">(
name: K,
): NonNullable<ReturnType<typeof getConfig>[K]> {
const f = getConfig()[name];
if (!f) {
throw new Error(
`[ng-eventually] ${name}: not injected. Pass \`${name}\` (from \`@ng-org/orm\`) to ` +
"configure() - this package wraps the SDK's own call and has none of its own to " +
"fall back on.",
);
}
return f;
}
/**
* The document an ORM object declares it lives in, or `null` when it names none.
*
* `@graph` is an untyped value on a property bag (`BaseType extends Record<string, any>`),
* so it is NARROWED here rather than cast - the same door `read-filter.ts` reads items
* through, and for the same reason: anything that is not a NextGraph reference names no
* document.
*/
function graphOf(object: unknown): Nuri | null {
const g = (object as Record<string, unknown> | null)?.["@graph"];
return typeof g === "string" && isNuri(g) ? g : null;
}
/**
* Read objects of one shape in one scope, ONCE - the SDK's own call, with the read
* filter applied to what comes back.
*
* Both parameters are upstream's, passed through untouched: the {@link ShapeType} and the
* config it declares minus the two pagination fields a one-shot read has no use for. What
* comes back is upstream's too, and DETACHED - a `structuredClone` of the raw signal
* object - so an ARRAY when the config asks for an `orderBy` and a `Set` otherwise, and
* mutating an item of it reaches nothing. Deleting an item of it removes nothing either,
* and reports nothing: removal is a mutation of the LIVE set a subscription holds.
*
* The filter keeps the shape of the collection it was given: a `Set` in is a `Set` out.
* Before the first cap is issued the emulation is not in force and everything passes
* through, exactly as in `surface/use-shape.ts`.
*/
// @provenance getObjects kind=passthrough level=3 ref=sdk/js/orm/src/connector/getObjects.ts:getObjects - forwards to the injected real function; the read-filtered result emulates the subset the broker will deliver. The cited revision takes `scope: Scope | string`; the published `@ng-org/orm@0.1.2-alpha.21` widened that parameter to the config below, which is what a consumer compiles against and therefore what is mirrored here
export async function getObjects<T extends BaseType>(
shapeType: ShapeType<T>,
config: Omit<RdfOrmConfig<T>, "pageSize" | "maxActivePages">,
): Promise<T[] | Set<T>> {
const read = injected("getObjects") as GetObjectsLike;
const objects = await read(shapeType, config);
const caps = getCaps();
// No cap issued yet -> the emulation is not in force and the result passes through.
if (!caps.isEnforcing()) return objects;
const kept = filterReadable(objects, caps);
return Array.isArray(objects) ? kept : new Set(kept);
}
/**
* Insert one ORM-typed object, without opening a subscription for it - the SDK's own
* call, behind the write guard.
*
* The object must carry its `@graph`: upstream the insert subscribes to NO document, so
* that field is the only thing that says where the write lands, and an object without one
* makes the ORM throw. It is also what this guard reads, which is why the two agree
* without either of them being bent to the other.
*
* Two questions are asked, and they are not the same one - the pair `docs.sparqlUpdate`
* asks. Reaching is POSSESSION of the document's read cap. Writing is OWNERSHIP: upstream
* the right to write is membership of the repo, and how you came by the read key bears on
* it not at all - a public store serves its read cap to whoever asks, and a cap deposited
* in your inbox is one someone gave you. Neither makes you a writer.
*
* The refusal happens BEFORE the call, so an unauthorized write is not attempted rather
* than attempted and caught. An object naming no document is forwarded untouched: there
* is nothing here to judge, and the ORM's own refusal is the one that should be heard.
*/
// @provenance insertObject kind=passthrough level=3 ref=sdk/js/orm/src/connector/insertObject.ts:insertObject - forwards to the injected real function; the guard before it emulates the write capability the target checks on the commit
export async function insertObject<T extends BaseType>(
shapeType: ShapeType<T>,
object: T,
): Promise<T> {
const write = injected("insertObject") as InsertObjectLike;
const graph = graphOf(object);
if (graph !== null) {
assertMayReach(graph, "insertObject");
await assertMayWrite(graph, "insertObject");
}
return write(shapeType, object);
}