feat!: la famille ORM est publiee enveloppee, et le ng publie cesse de rendre des promesses
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
import { getConfig, rememberHandOver } from "../shared-wallet/bootstrap";
|
||||
import { settleIdentity } from "../shared-wallet/access-gate";
|
||||
import { captureSession, expectSession } from "../shared-wallet/session";
|
||||
import { wrappedNg } from "./ng-proxy";
|
||||
|
||||
/**
|
||||
* Forwards to the real `@ng-org/web` `init`, once the identifier is in the address bar.
|
||||
@@ -90,10 +91,33 @@ export function init(...args: any[]): any {
|
||||
return settleIdentity().then(delegate);
|
||||
}
|
||||
|
||||
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
|
||||
// @provenance initNg kind=passthrough level=3 ref=sdk/js/orm/src/core.ts:initNg — upstream's own exported name for `initNgSignals`
|
||||
/**
|
||||
* Forwards to the real `@ng-org/orm` `initNg` (ORM signals) - with the `ng` in position 0
|
||||
* replaced by this package's own wrapped one.
|
||||
*
|
||||
* -- Why the substitution, and why it is invisible to the caller ------------
|
||||
* `initNg(ngImpl, session)` is how the ORM is told which `ng` to reach the engine
|
||||
* through: it resolves a module-level promise every subscription, every `insertObject`
|
||||
* and every batched patch awaits afterwards. So whatever is handed over HERE is the `ng`
|
||||
* every ORM write leaves by - and an ORM write leaves by `ng.graph_orm_update`, which is
|
||||
* where this package's write guard sits (`ng-proxy.ts`). Handing over the caller's raw
|
||||
* SDK object leaves that guard on a door nothing knocks on.
|
||||
*
|
||||
* It is the same argument `init`'s callback wrapping above rests on, and it lands the same
|
||||
* way: the invariant is carried by COMPOSITION rather than by an instruction a caller gets
|
||||
* to get wrong once. "Pass the package's `ng` to `initNg`" would be an ordering rule in
|
||||
* prose, unenforceable and silent when missed - and it WAS missed, by both consumers in
|
||||
* this repository, which is how the hole was found.
|
||||
*
|
||||
* Nothing is taken from the caller: `wrappedNg()` forwards every one of the 88 members to
|
||||
* the very object `configure()` received, and it is the same object `ng` publishes, so the
|
||||
* ORM now reaches the engine through exactly what the application does. The remaining
|
||||
* arguments and the return value pass straight through.
|
||||
*/
|
||||
// @provenance initNg kind=passthrough level=3 ref=sdk/js/orm/src/core.ts:initNg - upstream's own exported name for `initNgSignals`; the arguments and the return pass through, and the `ng` in position 0 is replaced by this package's wrapped one so the ORM's writes meet the emulated write guard
|
||||
export function initNg(...args: any[]): any {
|
||||
const f = getConfig().initNg;
|
||||
if (!f) throw new Error("[ng-eventually] initNg() not injected — pass it to configure()");
|
||||
return f(...args);
|
||||
const [, ...rest] = args;
|
||||
return f(wrappedNg(), ...rest);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,37 @@
|
||||
import type { NG } from "@ng-org/web";
|
||||
|
||||
import { getConfig, getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { assertMayWrite } from "../emulated-verifier/reach";
|
||||
import { isNuri } from "../model/nuri";
|
||||
import type { Nuri } from "../model/types";
|
||||
|
||||
/**
|
||||
* The documents a batch of ORM patches writes into.
|
||||
*
|
||||
* Every patch the ORM sends carries a `path` whose FIRST segment is the synthetic id of
|
||||
* the root object it touches - `<graph>|<subject>` - whether the patch adds a root
|
||||
* object, removes one, or updates a property several levels down
|
||||
* (`RdfOrmSubscription.deepPatchesToWasm` joins the path, and
|
||||
* `signalObjectPropGenerator` is what minted that id). So the document is readable from
|
||||
* the path alone, and it is the only thing in the batch this guard needs.
|
||||
*
|
||||
* A patch whose path names no NextGraph reference is left out rather than refused. The
|
||||
* emulation cannot say whose document such a write lands in - and upstream does not have
|
||||
* to guess, because `verify_permission` fires on the commit itself. Inventing a refusal
|
||||
* the target does not have would be the divergence, not the passthrough.
|
||||
*/
|
||||
function graphsWrittenBy(patches: unknown): Nuri[] {
|
||||
const out: Nuri[] = [];
|
||||
if (!Array.isArray(patches)) return out;
|
||||
for (const patch of patches) {
|
||||
const path = (patch as Record<string, unknown> | null)?.["path"];
|
||||
if (typeof path !== "string") continue;
|
||||
const graph = path.replace(/^\//, "").split("|")[0];
|
||||
if (graph !== undefined && isNuri(graph) && !out.includes(graph)) out.push(graph);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed `NG` — upstream's own type for `ng` (`NG = typeof NGModule`, 88 exported members,
|
||||
* installed `index.d.ts:136-231`). The proxy adds no member and removes none, so this is
|
||||
@@ -71,13 +100,79 @@ export function makeNg(): NG {
|
||||
};
|
||||
}
|
||||
|
||||
// graph_orm_update -> the SAME write guard, on the door the ORM actually uses.
|
||||
//
|
||||
// An ORM write never passes through `sparql_update`: a mutation of a live signal
|
||||
// object (`set.add(...)`, `set.delete(...)`, assigning a property) is batched by the
|
||||
// subscription and sent as PATCHES through this member instead. So the override
|
||||
// above saw no ORM add and no ORM delete at all - a rights hole that an
|
||||
// application reaches from the published surface, since `useShape` forwards
|
||||
// `add`/`delete`/`clear` to the underlying set and `useShapeQuery`'s `data` items
|
||||
// ARE that set's objects. It is closed here rather than at each of those
|
||||
// surfaces, because here is where every one of them converges.
|
||||
//
|
||||
// The predicate is OWNERSHIP (`assertMayWrite`), not the write-cap list the
|
||||
// `sparql_update` branch above consults: that list is decorative - `grantWrite`
|
||||
// has no production caller, so it never fires - and a guard that never fires
|
||||
// closes nothing. `sparql_update` is deliberately left on it all the same: this
|
||||
// library's own REGISTERS are written through that member, and a store document
|
||||
// is not owned in the authorship sense, so arming it there would refuse every
|
||||
// register write (`emulated-verifier/register-write.ts`, and the note on
|
||||
// `assertMayWrite`). The ORM's patches never carry a register.
|
||||
//
|
||||
// args = (subscription_id, patches, session_id).
|
||||
if (prop === "graph_orm_update") {
|
||||
return async (...args: unknown[]): Promise<unknown> => {
|
||||
for (const graph of graphsWrittenBy(args[1])) {
|
||||
await assertMayWrite(graph, "ng.graph_orm_update");
|
||||
}
|
||||
return ng[prop]!(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
|
||||
// here with their anticipated signatures, emulated for now.
|
||||
}
|
||||
|
||||
// Everything else: passthrough to the real SDK, unchanged.
|
||||
//
|
||||
// FORWARDED, never BOUND, and the difference is not a style choice. `@ng-org/web`'s
|
||||
// `ng` is ITSELF a Proxy - an "async proxy" whose `get` trap answers EVERY key with
|
||||
// a fresh path-carrying callable, and whose reflection traps throw outright
|
||||
// (`dist/ngweb.js`, the `l(e = [])` factory). So `real.bind(ng)` never reaches
|
||||
// `Function.prototype.bind`: `bind` is just another key, the trap hands back a
|
||||
// callable for the path `<member>.bind`, calling it posts a remote request named
|
||||
// after the MEMBER with the bind arguments, and what comes back is a PROMISE.
|
||||
//
|
||||
// The published `ng` therefore answered all 88 members with a promise instead of a
|
||||
// function, and a caller got the SDK's own `<member> is not a function`. What made
|
||||
// it invisible for months is that this library's internals never go through this
|
||||
// proxy - they call the injected `ng` directly - and every fake in the suite is a
|
||||
// plain object, on which `bind` behaves. It surfaced the moment the ORM was handed
|
||||
// this object (`surface/lifecycle.ts`): `orm_start_graph` was not a function, the
|
||||
// ORM catches that and only logs it, so a shape read simply stayed pending for
|
||||
// ever. MEASURED against the installed `@ng-org/web@0.1.2-alpha.14`.
|
||||
//
|
||||
// `Reflect.apply` is the passthrough that holds for both: it invokes the member
|
||||
// with the real `ng` as receiver, which a plain object needs and which the async
|
||||
// proxy ignores in favour of the path it already carries.
|
||||
const real = Reflect.get(ng, prop);
|
||||
return typeof real === "function" ? real.bind(ng) : real;
|
||||
if (typeof real !== "function") return real;
|
||||
return (...args: unknown[]): unknown => Reflect.apply(real, ng, args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* THE wrapped `ng` - one object for the whole package.
|
||||
*
|
||||
* `index.ts` publishes it and `surface/lifecycle.ts` hands it to the ORM, and those two
|
||||
* have to be the SAME object: a second proxy would behave identically today, and would be
|
||||
* a second place to notice the day one of them gains an override. It holds no state of its
|
||||
* own - every property is answered from `getConfig()` at access time - so it survives a
|
||||
* re-`configure()` and needs no reset.
|
||||
*/
|
||||
let wrapped: NG | null = null;
|
||||
export function wrappedNg(): NG {
|
||||
return (wrapped ??= makeNg());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user