feat!: la famille ORM est publiee enveloppee, et le ng publie cesse de rendre des promesses
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# @ng-eventually/polyfill
|
||||
|
||||
One entry point. Most of what it publishes has the same signature as the future SDK —
|
||||
`ng`, `useShape`, `useShapeQuery`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
|
||||
`ng`, `useShape`, `useShapeQuery`, `getObjects`, `insertObject`, `docs`, `inbox`,
|
||||
`storeRegistry`, `readUnion` (+ types) -
|
||||
and is a drop-in for `@ng-org/web` / `@ng-org/orm`: as NextGraph matures it resolves to
|
||||
the real SDK (build alias removed) with no code change.
|
||||
|
||||
@@ -32,7 +33,10 @@ import {
|
||||
configure,
|
||||
} from "@ng-eventually/polyfill";
|
||||
|
||||
configure({ ng: realNg, useShape: realUseShape, ormSubscription, initNg, sharedWallet });
|
||||
configure({
|
||||
ng: realNg, useShape: realUseShape, ormSubscription, getObjects, insertObject,
|
||||
initNg, sharedWallet,
|
||||
});
|
||||
await ensureIdentity(); // who I am (returned), connection work awaited
|
||||
// A store is named by its reference — `doc_create` takes one, and the session is where
|
||||
// upstream gets it. Omit it and the document goes to the private store, as upstream does.
|
||||
@@ -45,6 +49,13 @@ await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
|
||||
// `configure` as `ormSubscription`), and hands back the shape's own objects: `@graph`,
|
||||
// `@id` and one field per predicate the shape DECLARES.
|
||||
const query = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
|
||||
// The ORM's one-shot read and its subscription-free write, at the SDK's own names and
|
||||
// signatures. Import them from HERE, never from `@ng-org/orm`: one wallet hosts every
|
||||
// identity, so the unwrapped read returns everybody's objects and the unwrapped write
|
||||
// reaches anybody's document. Wrapped, the read comes back filtered by what you hold and
|
||||
// the write is refused unless you own the document its `@graph` names.
|
||||
const notes = await getObjects(NoteShape, { graphs: "did:ng:i" });
|
||||
await insertObject(NoteShape, { "@id": "", "@graph": doc, title: "a note" });
|
||||
```
|
||||
|
||||
## Principle — the polyfill compensates, it never extends
|
||||
|
||||
@@ -163,6 +163,13 @@ computes a result and returns once (`sparql_query`,
|
||||
|
||||
In `@ng-eventually/polyfill` the one-shot read is exposed as:
|
||||
|
||||
- **`getObjects(shapeType, config)`** - the ORM's own one-shot read, by SHAPE, over the
|
||||
same `Scope` axes `useShapeQuery` takes plus `where` and `orderBy`
|
||||
([`../src/surface/orm.ts`](../src/surface/orm.ts)). This is the typed one, and the one
|
||||
to reach for first. What comes back is DETACHED - a clone - so mutating or deleting an
|
||||
item of it reaches nothing; and it is filtered by what you hold, so another identity's
|
||||
objects are not in it. Import it from here and never from `@ng-org/orm`: one wallet
|
||||
hosts every identity, and the unwrapped call returns everybody's objects.
|
||||
- **`docs.sparqlQuery(sid, query, base?, anchor?)`** — a raw anchored SPARQL query
|
||||
([`../src/surface/docs.ts`](../src/surface/docs.ts)). `anchor` = the document NURI to read; the
|
||||
anchor restricts the query to that one repo's graph.
|
||||
@@ -188,10 +195,22 @@ document model, so briefly:
|
||||
([`../src/surface/docs.ts`](../src/surface/docs.ts)) — mirrors `ng.doc_create`. **One document = one
|
||||
repo** (`did:ng:o:<RepoID>`); there is no separate `Document` type.
|
||||
- **Write into it:** `docs.sparqlUpdate(sid, query, anchor)` — a SPARQL
|
||||
`INSERT/DELETE` scoped to the anchor document's graph. Or, at the ORM layer, the ORM
|
||||
update primitives (`graph_orm_update`). A write is a **commit** on the document's
|
||||
`INSERT/DELETE` scoped to the anchor document's graph. Or, at the ORM layer,
|
||||
`insertObject(shapeType, object)` for one typed object with its `@graph` set
|
||||
([`../src/surface/orm.ts`](../src/surface/orm.ts)), or a mutation of the live set a
|
||||
shape query handed back - `set.add(...)`, `set.delete(...)`, assigning a property - which
|
||||
the ORM batches into `graph_orm_update`. A write is a **commit** on the document's
|
||||
branch — which is exactly what every `useShape` subscriber over that document is
|
||||
pushed.
|
||||
- **Remove an object:** delete it from the LIVE set (`set.delete(item)`, the item the set
|
||||
is holding - reached by iterating it, by `.first()` or by `.getBy(graph, subject)`).
|
||||
There is no `deleteObject` and no document deletion at any level; what the engine
|
||||
removes is the SHAPE's declared predicates on that subject, not the subject and not the
|
||||
document. Deleting an item of a `getObjects` result removes nothing: that result is a
|
||||
clone.
|
||||
- **Only the owner writes.** Holding a document's read key never grants a write -
|
||||
`insertObject` and the ORM's `graph_orm_update` are both refused otherwise, before the
|
||||
write is attempted.
|
||||
- **Writes target one document**, never "the union": a SPARQL update must name one
|
||||
document's graph (`resolve_target_for_sparql(update=true)` returns `InvalidTarget`
|
||||
for the union, `engine/verifier/src/request_processor.rs:275`).
|
||||
|
||||
@@ -1127,6 +1127,13 @@ const identity = new IdentityStore(
|
||||
// once. Nothing it opens starts before this, so it goes here rather than beside
|
||||
// the first read - through the PACKAGE's `initNg`, which is the call an
|
||||
// application makes.
|
||||
//
|
||||
// Since `1.0.0-dev.7` that call SUBSTITUTES the package's wrapped `ng` for the
|
||||
// one passed here, so the two `useShapeQuery` journeys below are now the proof
|
||||
// that `orm_start_graph` survives the wrapper - and they are the only thing that
|
||||
// is. A consumer wiring exactly this path got `orm_start_graph is not a function`
|
||||
// and a read pending for ever, while this suite stayed green precisely because
|
||||
// the ORM had been handed the raw SDK object here instead.
|
||||
initNg(realNg, session);
|
||||
sessionResolve(session);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ng-eventually/polyfill",
|
||||
"version": "1.0.0-dev.6",
|
||||
"version": "1.0.0-dev.7",
|
||||
"type": "module",
|
||||
"description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -85,6 +85,9 @@
|
||||
// NgLike `EventuallyConfig.ng`
|
||||
// UseShapeLike `EventuallyConfig.useShape`
|
||||
// OrmSubscriptionLike `EventuallyConfig.ormSubscription`
|
||||
// GetObjectsLike `EventuallyConfig.getObjects`
|
||||
// InsertObjectLike `EventuallyConfig.insertObject`
|
||||
// RdfOrmConfig `getObjects`
|
||||
//
|
||||
// `OrmSubscriptionHandle` - what that last one RETURNS - is deliberately NOT here. An
|
||||
// application hands the shape query in and reads the snapshot back; it never holds a
|
||||
@@ -99,10 +102,24 @@
|
||||
// takes the SDK's `Scope` now (re-exported below, from the SDK itself), and a store is
|
||||
// named by its reference. The three strings stay DEFINED in `model/types.ts` as
|
||||
// `StoreType`, where the library's own modules still tell the three apart.
|
||||
export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike, OrmSubscriptionLike } from "./model/types";
|
||||
export type {
|
||||
Nuri,
|
||||
NuriLike,
|
||||
PrincipalId,
|
||||
NgLike,
|
||||
UseShapeLike,
|
||||
OrmSubscriptionLike,
|
||||
GetObjectsLike,
|
||||
InsertObjectLike,
|
||||
} from "./model/types";
|
||||
export { useShape } from "./surface/use-shape";
|
||||
export { useShapeQuery } from "./surface/use-shape-query";
|
||||
export type { ShapeQuery, ShapeObservable } from "./surface/use-shape-query";
|
||||
// The ORM's one-shot read and its subscription-free write - WRAPPED, at upstream's own
|
||||
// names and signatures, never re-exported. `surface/orm.ts` says what each wrapper adds,
|
||||
// and which neighbours of theirs in `@ng-org/orm`'s entry are deliberately not here
|
||||
// (`RdfOrmSubscription`, `DiscreteOrmSubscription`, `ngSession`, `getRaw`).
|
||||
export { getObjects, insertObject } from "./surface/orm";
|
||||
export { init, initNg } from "./surface/lifecycle";
|
||||
export * as inbox from "./surface/inbox";
|
||||
export * as docs from "./surface/docs";
|
||||
@@ -129,6 +146,12 @@ export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
||||
// `subjects`) are upstream's to change.
|
||||
// @provenance Scope kind=passthrough level=3 ref=sdk/js/orm/src/types.ts:Scope
|
||||
export type { Scope } from "@ng-org/orm";
|
||||
// What `getObjects` takes: a `Scope` plus `where` and `orderBy`. It is re-exported from
|
||||
// the SDK's `/all` subpath because that is where the SDK puts it - the type the function
|
||||
// DECLARES is not exported beside the function, which is upstream's packaging and not a
|
||||
// choice here. Restating it would be how it drifts.
|
||||
// @provenance RdfOrmConfig kind=passthrough level=3 ref=sdk/js/orm/src/utilTypes.ts:OrmConfig - the published `@ng-org/orm@0.1.2-alpha.21` renames it `RdfOrmConfig` and re-parameterises it on the shape's type rather than its `ShapeType`, which the cited revision has not yet done
|
||||
export type { RdfOrmConfig } from "@ng-org/orm/all";
|
||||
// @provenance NG kind=passthrough level=2 ref=sdk/js/web/src/index.ts:NG — 88 members, all forwarded; the proxy adds none and removes none
|
||||
export type { NG } from "@ng-org/web";
|
||||
|
||||
@@ -188,7 +211,7 @@ export type { SharedWalletConfig } from "./shared-wallet/access-gate";
|
||||
|
||||
import type { NG } from "@ng-org/web";
|
||||
|
||||
import { makeNg } from "./surface/ng-proxy";
|
||||
import { wrappedNg } from "./surface/ng-proxy";
|
||||
|
||||
/**
|
||||
* SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`.
|
||||
@@ -198,4 +221,4 @@ import { makeNg } from "./surface/ng-proxy";
|
||||
* it forwards to: an application got no completion and no check on any of the 88 members.
|
||||
*/
|
||||
// @provenance ng kind=passthrough level=2 ref=sdk/js/web/src/index.ts:ng — a Proxy over the injected real `ng`; only `sparql_update` is overridden (the emulated write guard)
|
||||
export const ng: NG = makeNg();
|
||||
export const ng: NG = wrappedNg();
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
// any runtime `@ng-org` import). They describe what `configure` RECEIVES; nothing here
|
||||
// re-exports them.
|
||||
import type { Scope } from "@ng-org/orm";
|
||||
// `RdfOrmConfig` lives on the SDK's `/all` subpath rather than on its main entry - the
|
||||
// config type `getObjects` DECLARES is not exported beside the function that takes it.
|
||||
// Imported as a TYPE, so nothing of that subpath reaches the build.
|
||||
import type { RdfOrmConfig } from "@ng-org/orm/all";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
|
||||
/**
|
||||
@@ -126,6 +130,41 @@ export type OrmSubscriptionLike = <T extends BaseType>(
|
||||
scope: Scope,
|
||||
) => OrmSubscriptionHandle;
|
||||
|
||||
/**
|
||||
* Loose shape of `@ng-org/orm`'s `getObjects` - the SDK's ONE-SHOT read, the counterpart
|
||||
* of the two-way subscription {@link OrmSubscriptionLike} opens.
|
||||
*
|
||||
* The parameters are upstream's own, passed through rather than restated: a
|
||||
* {@link ShapeType} and the config `RdfOrmConfig` declares minus its two pagination
|
||||
* fields, which `getObjects` `Omit`s because a one-shot read has no pages. The return is
|
||||
* upstream's too - an ARRAY when the config asks for an `orderBy`, a `Set` otherwise -
|
||||
* and it is DETACHED: `structuredClone` of the raw signal object, so mutating an item of
|
||||
* it reaches nothing.
|
||||
*
|
||||
* An application injects the real one (`getObjects: getObjects`, from `@ng-org/orm`); it
|
||||
* is assignable to this as it stands, with no wrapper and no cast.
|
||||
*/
|
||||
// @provenance GetObjectsLike kind=invention level=none ref=none - nothing upstream is injected; the type exists only to describe what `configure` receives, here for the ORM's own one-shot read
|
||||
export type GetObjectsLike = <T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
config: Omit<RdfOrmConfig<T>, "pageSize" | "maxActivePages">,
|
||||
) => Promise<T[] | Set<T>>;
|
||||
|
||||
/**
|
||||
* Loose shape of `@ng-org/orm`'s `insertObject` - the SDK's write that needs no
|
||||
* subscription of its own.
|
||||
*
|
||||
* Upstream it opens a subscription scoped to NO document (`{ graphs: [] }`), adds the
|
||||
* object to that set and closes it, so the object must carry its `@graph`: the write has
|
||||
* no other way of knowing which document it lands in. It returns the object it inserted,
|
||||
* with a generated `@id` where one was left empty.
|
||||
*/
|
||||
// @provenance InsertObjectLike kind=invention level=none ref=none - same, for the ORM's own subscription-free write
|
||||
export type InsertObjectLike = <T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
object: T,
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* The stores that can carry an inbox. NOT `StoreType`: upstream only the public and
|
||||
* protected store repos get one — `new_store_default` attaches an inbox solely
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
NgLike,
|
||||
UseShapeLike,
|
||||
OrmSubscriptionLike,
|
||||
GetObjectsLike,
|
||||
InsertObjectLike,
|
||||
Nuri,
|
||||
NuriLike,
|
||||
PrincipalId,
|
||||
@@ -134,6 +136,23 @@ export interface EventuallyConfig {
|
||||
* reported as "there is nothing" is the one failure that surface exists to prevent.
|
||||
*/
|
||||
ormSubscription?: OrmSubscriptionLike;
|
||||
/**
|
||||
* The REAL `@ng-org/orm` `getObjects` - the SDK's one-shot read, which
|
||||
* {@link ../surface/orm}.getObjects wraps with the capability filter. Pass it as it
|
||||
* stands: `getObjects`.
|
||||
*
|
||||
* OPTIONAL for the same scope reason as {@link ormSubscription}: an application that
|
||||
* never calls it has nothing to inject, and every suite exercising another surface would
|
||||
* otherwise carry a stub for a call it does not make. Absent, the wrapper throws naming
|
||||
* this field rather than answering with an empty result.
|
||||
*/
|
||||
getObjects?: GetObjectsLike;
|
||||
/**
|
||||
* The REAL `@ng-org/orm` `insertObject` - the SDK's subscription-free write, which
|
||||
* {@link ../surface/orm}.insertObject wraps with the write guard. Pass it as it stands:
|
||||
* `insertObject`. Optional, and absent the wrapper throws naming this field.
|
||||
*/
|
||||
insertObject?: InsertObjectLike;
|
||||
/**
|
||||
* POINTER micro-guard budget — see {@link StoreRegistryDeps.pointerGuard}. Left unset
|
||||
* → a single read, which keeps the synchronous unit fakes fast.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -36,6 +36,9 @@ import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/boo
|
||||
import { connectedUser } from "../src/emulated-verifier/connect";
|
||||
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { share } from "../src/surface/inbox";
|
||||
import { insertObject } from "../src/surface/orm";
|
||||
import type { InsertObjectLike } from "../src/model/types";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
|
||||
import { readUnion } from "../src/surface/read-model";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
@@ -252,9 +255,20 @@ function makeFakeNg() {
|
||||
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
||||
}
|
||||
|
||||
/** The SDK's `insertObject`, stood in for - and a record of whether it was reached. */
|
||||
const ormInserts: unknown[][] = [];
|
||||
const fakeInsertObject: InsertObjectLike = <T extends BaseType>(
|
||||
shapeType: ShapeType<T>,
|
||||
object: T,
|
||||
): Promise<T> => {
|
||||
ormInserts.push([shapeType, object]);
|
||||
return Promise.resolve(object);
|
||||
};
|
||||
|
||||
function inject() {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
ormInserts.length = 0;
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any, insertObject: fakeInsertObject });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim().toLowerCase() });
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
@@ -785,3 +799,35 @@ test("a third party resolves another user's inbox, from the shim and not from a
|
||||
const bobView = await userInbox("alice", "protected");
|
||||
expect(bobView).toBe(aliceView);
|
||||
});
|
||||
|
||||
// The same asymmetry on the ORM's write, and it is the one an application is most
|
||||
// likely to meet: `insertObject` needs the document's `@graph`, which a recipient HAS -
|
||||
// the reference travelled with the key. Holding the key is what lets Bob read; it is
|
||||
// not what would let him write, and upstream it never was: `verify_permission` fires on
|
||||
// the commit and asks membership of the repo, never how the reader came by the key.
|
||||
//
|
||||
// Bob OBTAINS the capability the only way an application can - Alice deposits it, he
|
||||
// connects - so what he holds here is what he would really hold.
|
||||
test("a cap received in an inbox does not let the ORM insert into that document", async () => {
|
||||
inject();
|
||||
const { protDoc } = await aliceSetsUpHerDocuments();
|
||||
await userInbox("bob", "protected"); // Bob exists - `share` refuses a recipient nobody is
|
||||
|
||||
setCurrentUser("alice");
|
||||
await share(protDoc, "bob");
|
||||
setCurrentUser("bob");
|
||||
await connectedUser();
|
||||
expect(hasCap(protDoc)).toBe(true); // he reads it...
|
||||
|
||||
const shape = { shape: "urn:e2e:S", schema: {} } as ShapeType<BaseType>;
|
||||
// ...and the insert is refused before it is attempted.
|
||||
await expect(
|
||||
insertObject(shape, { "@id": "", "@graph": protDoc } as BaseType),
|
||||
).rejects.toThrow(/refused/i);
|
||||
expect(ormInserts).toHaveLength(0);
|
||||
|
||||
// Alice, who owns it, writes through the same call.
|
||||
setCurrentUser("alice");
|
||||
await insertObject(shape, { "@id": "", "@graph": protDoc } as BaseType);
|
||||
expect(ormInserts).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -516,9 +516,14 @@ test("nothing about the deferred service reaches the published surface", () => {
|
||||
"docChangeType",
|
||||
"docs",
|
||||
"ensureIdentity",
|
||||
// The ORM's one-shot read and its subscription-free write, WRAPPED (`surface/orm.ts`).
|
||||
// Neither reaches another identity's queue: the first is filtered by what the holder
|
||||
// holds, the second is refused unless the holder owns the document it names.
|
||||
"getObjects",
|
||||
"inbox",
|
||||
"init",
|
||||
"initNg",
|
||||
"insertObject",
|
||||
"ng",
|
||||
"readUnion",
|
||||
"storeRegistry",
|
||||
|
||||
@@ -89,3 +89,63 @@ test("write guard: passthrough when anchor is omitted (cannot scope the guard)",
|
||||
await proxy.sparql_update("sid", "INSERT DATA {}", undefined); // no anchor → passthrough
|
||||
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// --- the passthrough, against the shape the REAL `ng` actually has ----------
|
||||
|
||||
/**
|
||||
* A stand-in for `@ng-org/web`'s `ng`, faithful in the one way every fake above is not:
|
||||
* it is a PROXY, not a property bag.
|
||||
*
|
||||
* Read from the installed `@ng-org/web@0.1.2-alpha.14` (`dist/ngweb.js`, the `l(e = [])`
|
||||
* factory): every key - including `bind`, `length`, `name` - answers with a fresh
|
||||
* path-carrying CALLABLE, calling one posts a remote request named after the first path
|
||||
* segment and returns a PROMISE, and the reflection traps throw outright.
|
||||
*
|
||||
* Every other fake in this suite is a plain object, on which `bind` behaves. That is why
|
||||
* a passthrough that could not survive the real thing stayed green here for months.
|
||||
*/
|
||||
function asyncProxyNg(calls: string[]): Record<string, unknown> {
|
||||
const make = (path: string[]): unknown =>
|
||||
new Proxy(function (): void {} as object, {
|
||||
get: (_t, k) => make([...path, String(k)]),
|
||||
apply: (_t, _this, args: unknown[]) => {
|
||||
calls.push(`${path.join(".")}/${args.length}`);
|
||||
return Promise.resolve("ok");
|
||||
},
|
||||
has() { throw new Error('Async proxy does not support "has" handler.'); },
|
||||
getOwnPropertyDescriptor() { throw new Error('Async proxy does not support "getOwnPropertyDescriptor" handler.'); },
|
||||
ownKeys() { throw new Error('Async proxy does not support "ownKeys" handler.'); },
|
||||
});
|
||||
return make([]) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// THE regression. `Reflect.get(ng, prop).bind(ng)` looks like a passthrough and is not:
|
||||
// against the real `ng`, `bind` is just another key, so what came back was a PROMISE and
|
||||
// a caller got `<member> is not a function`. The library's own internals never go through
|
||||
// this proxy, so nothing here noticed until the ORM was handed it.
|
||||
test("passthrough: a member of the REAL (proxy-shaped) `ng` comes back callable", async () => {
|
||||
const calls: string[] = [];
|
||||
const ng = asyncProxyNg(calls);
|
||||
configure({ ng, useShape: () => undefined });
|
||||
const proxy = makeNg();
|
||||
|
||||
expect(typeof proxy.orm_start_graph).toBe("function");
|
||||
// Upstream's own arity and argument shapes (`index.d.ts:orm_start_graph`).
|
||||
await proxy.orm_start_graph!(["did:ng:i"], [], {}, "sid", {}, () => {});
|
||||
// ...and exactly ONE remote request went out, named after the member - not a `bind` one.
|
||||
expect(calls).toEqual(["orm_start_graph/6"]);
|
||||
});
|
||||
|
||||
// A plain-object `ng` still gets its receiver: forwarding must not cost what binding
|
||||
// bought. The unit fakes are all of this kind, and so is any `ng` that is a real object.
|
||||
test("passthrough: a plain-object `ng` keeps its receiver", async () => {
|
||||
const ng = {
|
||||
mark: "the real ng",
|
||||
whoAmI(this: { mark: string }) {
|
||||
return this.mark;
|
||||
},
|
||||
};
|
||||
configure({ ng, useShape: () => undefined });
|
||||
const proxy = makeNg() as unknown as { whoAmI: () => string };
|
||||
expect(proxy.whoAmI()).toBe("the real ng");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* The ORM family this package publishes - `getObjects`, `insertObject` - and the door
|
||||
* the rest of the ORM writes through, `ng.graph_orm_update`.
|
||||
*
|
||||
* -- What these prove, and why each case is here --------------------------
|
||||
* One shared wallet hosts every identity, so an UNWRAPPED ORM call is not merely
|
||||
* unfiltered, it is every identity's data and every identity's documents. The three
|
||||
* questions:
|
||||
*
|
||||
* (a) a READ comes back filtered by what the holder holds - a second identity's
|
||||
* objects are not in it, and the collection keeps its shape (a `Set` in is a
|
||||
* `Set` out, an ordered array stays an array);
|
||||
* (b) a WRITE is refused BEFORE it is attempted when the connected identity does not
|
||||
* own the document it names - and it is refused on the ORM's own door too, not
|
||||
* only at the call this package publishes, because a mutation of a live signal
|
||||
* object never passes through anything else;
|
||||
* (c) `initNg` hands the ORM this package's wrapped `ng`, which is what puts (b) on a
|
||||
* door the ORM actually knocks on.
|
||||
*
|
||||
* Every document here is OBTAINED through the published placement call under the
|
||||
* identity that owns it - never named by the test - so what one identity may do to
|
||||
* another's document is decided by the system rather than by a variable in this file.
|
||||
*/
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import { configure, getObjects, insertObject, initNg, storeRegistry } from "../src/index";
|
||||
import { makeNg } from "../src/surface/ng-proxy";
|
||||
import {
|
||||
configureStoreRegistry,
|
||||
resetCaps,
|
||||
resetConfig,
|
||||
resetStoreRegistry,
|
||||
setCurrentUser,
|
||||
} from "../src/shared-wallet/bootstrap";
|
||||
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
|
||||
import type { GetObjectsLike, InsertObjectLike, NgLike, Nuri, UseShapeLike } from "../src/model/types";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
// TYPE-only: no `@ng-org/orm` module is loaded at runtime here. What these two lines
|
||||
// check is the claim the published signatures MAKE - that the SDK's own functions are
|
||||
// assignable to the injection types as they stand, with no wrapper and no cast, so
|
||||
// `configure({ getObjects, insertObject })` is the whole of what a caller writes. A
|
||||
// signature that drifted from upstream's would fail to compile right here.
|
||||
import type { getObjects as SdkGetObjects, insertObject as SdkInsertObject } from "@ng-org/orm";
|
||||
type _TheSdkReadFits = typeof SdkGetObjects extends GetObjectsLike ? true : never;
|
||||
type _TheSdkWriteFits = typeof SdkInsertObject extends InsertObjectLike ? true : never;
|
||||
const _sdkFits: [_TheSdkReadFits, _TheSdkWriteFits] = [true, true];
|
||||
void _sdkFits;
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const SESSION = { sessionId: "sid-orm", privateStoreId: "PRIV-ORM" };
|
||||
|
||||
/**
|
||||
* A shape object, hand-written rather than generated: `rdf-orm build` turns a `.shex`
|
||||
* file into exactly this, and the generator is not what is under test. Nothing here
|
||||
* reads it - the injected fakes stand in for what would match it - so one predicate is
|
||||
* enough to make it a well-formed `ShapeType`.
|
||||
*/
|
||||
const NoteShape = {
|
||||
shape: "urn:orm-test:NoteShape",
|
||||
schema: {
|
||||
"urn:orm-test:NoteShape": {
|
||||
iri: "urn:orm-test:NoteShape",
|
||||
predicates: [
|
||||
{
|
||||
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||
readablePredicate: "@type",
|
||||
dataTypes: [{ valType: "iri", literals: ["urn:orm-test:Note"] }],
|
||||
minCardinality: 1,
|
||||
maxCardinality: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies ShapeType<BaseType>;
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** What the injected SDK calls saw, and what the read one will answer with. */
|
||||
interface OrmDouble {
|
||||
/** The objects the SDK's `getObjects` hands back, before this package filters them. */
|
||||
stock: BaseType[];
|
||||
/** Hand back an ordered ARRAY instead of a `Set` - what an `orderBy` config yields. */
|
||||
ordered: boolean;
|
||||
/** `[shapeType, object]` per call reaching the SDK's `insertObject`. */
|
||||
inserted: unknown[][];
|
||||
getObjects: GetObjectsLike;
|
||||
insertObject: InsertObjectLike;
|
||||
}
|
||||
|
||||
function makeOrmDouble(): OrmDouble {
|
||||
const d: OrmDouble = {
|
||||
stock: [],
|
||||
ordered: false,
|
||||
inserted: [],
|
||||
// The one cast in this file, and it is the SDK's own shape of the problem: upstream
|
||||
// `getObjects` returns `structuredClone(signalObject[RAW_KEY])`, untyped, and it is
|
||||
// `shapeType` that says the items are `T`. A fake stands where that cast stands.
|
||||
getObjects: <T extends BaseType>(): Promise<T[] | Set<T>> =>
|
||||
Promise.resolve(d.ordered ? ([...d.stock] as T[]) : (new Set(d.stock) as Set<T>)),
|
||||
insertObject: <T extends BaseType>(shapeType: ShapeType<T>, object: T): Promise<T> => {
|
||||
d.inserted.push([shapeType, object]);
|
||||
return Promise.resolve(object);
|
||||
},
|
||||
};
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stateful fake `ng` - the shim account records and the per-store index, which is
|
||||
* what makes a document created here really belong to the identity that created it
|
||||
* (`assertMayWrite` reads that index back). Copied in spirit from `app-surface.test.ts`;
|
||||
* nothing of the ORM goes through it except `graph_orm_update`, tested below.
|
||||
*/
|
||||
function makeFakeNg() {
|
||||
const quads: Quad[] = [];
|
||||
let created = 0;
|
||||
const doc_create = mock(async () => `did:ng:o:orm${++created}`);
|
||||
const graph_orm_update = mock(async (..._a: unknown[]) => undefined);
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
if (!anchor) return undefined;
|
||||
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||
const sm = body.match(/<([^>]+)>/);
|
||||
if (!sm) return undefined;
|
||||
const subj = sm[1]!;
|
||||
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||
while ((m = pairRe.exec(after)) !== null) {
|
||||
quads.push({ g: anchor, s: subj, p: m[1] ?? `${SHIM}:Account`, o: m[2] ?? m[3] ?? "" });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const sparql_query = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[3] as string | undefined;
|
||||
const byPred = (pred: string, v: string) => ({
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === anchor && q.p === pred)
|
||||
.map((q) => ({ [v]: { value: q.o } })),
|
||||
},
|
||||
});
|
||||
if (query.includes(`${SHIM}:shimDoc`)) return byPred(`${SHIM}:shimDoc`, "shimDoc");
|
||||
if (query.includes(`${SHIM}:readCap`)) return byPred(`${SHIM}:readCap`, "c");
|
||||
if (query.includes(`${SHIM}:contains`)) return byPred(`${SHIM}:contains`, "e");
|
||||
if (query.includes(`${SHIM}:id`)) {
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const only = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (only !== null && q.s !== only) continue;
|
||||
const rec = bySubject.get(q.s) ?? {};
|
||||
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
||||
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
||||
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
||||
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
||||
bySubject.set(q.s, rec);
|
||||
}
|
||||
return {
|
||||
results: {
|
||||
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
|
||||
id: { value: r.id! },
|
||||
docPublic: { value: r.docPublic ?? "" },
|
||||
docProtected: { value: r.docProtected ?? "" },
|
||||
docPrivate: { value: r.docPrivate ?? "" },
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { results: { bindings: [] } };
|
||||
});
|
||||
return { doc_create, sparql_update, sparql_query, graph_orm_update };
|
||||
}
|
||||
|
||||
function inject(orm?: OrmDouble) {
|
||||
const ng = makeFakeNg();
|
||||
configure({
|
||||
ng: ng as unknown as NgLike,
|
||||
useShape: (() => {}) as unknown as UseShapeLike,
|
||||
...(orm ? { getObjects: orm.getObjects, insertObject: orm.insertObject } : {}),
|
||||
});
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
/** A document that really belongs to `who` - created by them, through the published call. */
|
||||
async function documentOf(who: string): Promise<Nuri> {
|
||||
setCurrentUser(who);
|
||||
return storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
}
|
||||
|
||||
/** One ORM object placed in `doc`, as the ORM itself carries it. */
|
||||
function noteIn(doc: Nuri, id: string): BaseType {
|
||||
return { "@id": `${doc}:q:${id}`, "@graph": doc } as BaseType;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
// -- the read: what comes back is what the holder holds ---------------------
|
||||
|
||||
// THE rights case. The SDK answers with every identity's objects, because one wallet
|
||||
// holds every identity's documents; what the caller gets is only her own. Both
|
||||
// documents are obtained under their own owner, so "hers" and "his" are facts of the
|
||||
// system rather than labels this test applied.
|
||||
test("getObjects: a second identity's objects do not come back", async () => {
|
||||
const orm = makeOrmDouble();
|
||||
inject(orm);
|
||||
const hers = await documentOf("alice");
|
||||
const his = await documentOf("bob");
|
||||
orm.stock = [noteIn(hers, "a1"), noteIn(his, "b1"), noteIn(hers, "a2")];
|
||||
|
||||
setCurrentUser("alice");
|
||||
const read = await getObjects(NoteShape, { graphs: "did:ng:i" });
|
||||
expect(read).toBeInstanceOf(Set);
|
||||
expect([...(read as Set<BaseType>)].map((o) => o["@graph"])).toEqual([hers, hers]);
|
||||
|
||||
setCurrentUser("bob");
|
||||
expect([...((await getObjects(NoteShape, { graphs: "did:ng:i" })) as Set<BaseType>)]).toHaveLength(1);
|
||||
});
|
||||
|
||||
// The collection's SHAPE is upstream's answer to the config, not this package's to
|
||||
// choose: an `orderBy` makes the signal object an array, and an array filtered into a
|
||||
// Set would silently lose the ordering the caller asked for.
|
||||
test("getObjects: an ordered result stays an array, an unordered one stays a Set", async () => {
|
||||
const orm = makeOrmDouble();
|
||||
inject(orm);
|
||||
const hers = await documentOf("alice");
|
||||
orm.stock = [noteIn(hers, "a1")];
|
||||
setCurrentUser("alice");
|
||||
|
||||
orm.ordered = true;
|
||||
expect(Array.isArray(await getObjects(NoteShape, { graphs: "did:ng:i" }))).toBe(true);
|
||||
orm.ordered = false;
|
||||
expect(await getObjects(NoteShape, { graphs: "did:ng:i" })).toBeInstanceOf(Set);
|
||||
});
|
||||
|
||||
// Before the first cap exists the emulation is not in force, exactly as `useShape` and
|
||||
// `useShapeQuery` behave: a consumer that never touches caps keeps reading everything.
|
||||
test("getObjects: passthrough while no cap has been issued", async () => {
|
||||
const orm = makeOrmDouble();
|
||||
inject(orm);
|
||||
orm.stock = [noteIn("did:ng:o:someone" as Nuri, "x")];
|
||||
const read = await getObjects(NoteShape, { graphs: "did:ng:i" });
|
||||
expect([...(read as Set<BaseType>)]).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Identical signature means passed through: whatever config the caller wrote is the
|
||||
// config the SDK receives, with nothing interpreted on the way.
|
||||
test("getObjects: the shape and the config reach the SDK untouched", async () => {
|
||||
const orm = makeOrmDouble();
|
||||
let seen: unknown[] = [];
|
||||
const config = { graphs: ["did:ng:o:one"], subjects: ["urn:s"] };
|
||||
orm.getObjects = (<T extends BaseType>(...a: unknown[]): Promise<T[] | Set<T>> => {
|
||||
seen = a;
|
||||
return Promise.resolve(new Set<T>());
|
||||
}) as GetObjectsLike;
|
||||
inject(orm);
|
||||
await getObjects(NoteShape, config);
|
||||
expect(seen[0]).toBe(NoteShape);
|
||||
expect(seen[1]).toBe(config);
|
||||
});
|
||||
|
||||
// -- the write: refused before it is attempted ------------------------------
|
||||
|
||||
test("insertObject: an object in a document this identity does not own is REFUSED", async () => {
|
||||
const orm = makeOrmDouble();
|
||||
inject(orm);
|
||||
await documentOf("alice");
|
||||
const his = await documentOf("bob");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await expect(insertObject(NoteShape, noteIn(his, "a1"))).rejects.toThrow(/refused/i);
|
||||
// Not attempted, rather than attempted and caught - the difference between a caller
|
||||
// that knows what it holds and one that hoped.
|
||||
expect(orm.inserted).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("insertObject: the owner's own document goes through, object unchanged", async () => {
|
||||
const orm = makeOrmDouble();
|
||||
inject(orm);
|
||||
const hers = await documentOf("alice");
|
||||
setCurrentUser("alice");
|
||||
const note = noteIn(hers, "a1");
|
||||
expect(await insertObject(NoteShape, note)).toBe(note);
|
||||
expect(orm.inserted).toHaveLength(1);
|
||||
expect(orm.inserted[0]![1]).toBe(note);
|
||||
});
|
||||
|
||||
// An object naming no document is upstream's error to report, not ours to pre-empt:
|
||||
// the ORM throws "you must specify the @graph", and a refusal invented here would
|
||||
// answer a different question in a message nobody could act on.
|
||||
test("insertObject: an object with no @graph is forwarded, and upstream refuses it", async () => {
|
||||
const orm = makeOrmDouble();
|
||||
orm.insertObject = (() => Promise.reject(new Error("you must specify the @graph"))) as InsertObjectLike;
|
||||
inject(orm);
|
||||
await documentOf("alice");
|
||||
setCurrentUser("alice");
|
||||
await expect(insertObject(NoteShape, { "@id": "" } as BaseType)).rejects.toThrow(/@graph/);
|
||||
});
|
||||
|
||||
// -- neither call invents an answer when it was never injected --------------
|
||||
|
||||
test("both name the field to inject when configure() was not given them", async () => {
|
||||
inject();
|
||||
await expect(getObjects(NoteShape, { graphs: "did:ng:i" })).rejects.toThrow(/getObjects.*not injected/s);
|
||||
await expect(insertObject(NoteShape, { "@id": "" } as BaseType)).rejects.toThrow(
|
||||
/insertObject.*not injected/s,
|
||||
);
|
||||
});
|
||||
|
||||
// -- the ORM's own write door -----------------------------------------------
|
||||
|
||||
// The hole this closes: a mutation of a live signal object - `set.add(...)`,
|
||||
// `set.delete(...)` - never passes through `sparql_update`. It is batched into patches
|
||||
// and sent through `graph_orm_update`, so the guard on the other member saw no ORM
|
||||
// write at all. The path carries the document in its first segment,
|
||||
// `<graph>|<subject>`, which is the synthetic id the ORM itself minted.
|
||||
test("ng.graph_orm_update: a patch into somebody else's document is REFUSED", async () => {
|
||||
const ng = inject();
|
||||
await documentOf("alice");
|
||||
const his = await documentOf("bob");
|
||||
setCurrentUser("alice");
|
||||
const proxy = makeNg();
|
||||
await expect(
|
||||
proxy.graph_orm_update!("sub-1", [{ op: "remove", path: `/${his}|${his}:q:b1` }], "sid"),
|
||||
).rejects.toThrow(/refused/i);
|
||||
expect(ng.graph_orm_update).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("ng.graph_orm_update: the owner's own patches go through", async () => {
|
||||
const ng = inject();
|
||||
const hers = await documentOf("alice");
|
||||
setCurrentUser("alice");
|
||||
const proxy = makeNg();
|
||||
await proxy.graph_orm_update!(
|
||||
"sub-1",
|
||||
[
|
||||
{ op: "add", path: `/${hers}|${hers}:q:a1` },
|
||||
{ op: "add", path: `/${hers}|${hers}:q:a1/title`, value: "x" },
|
||||
],
|
||||
"sid",
|
||||
);
|
||||
expect(ng.graph_orm_update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ONE unauthorized document in the batch is enough: the patches of one flush are sent
|
||||
// together, so letting the call through because the first one was fine would write
|
||||
// every other one too.
|
||||
test("ng.graph_orm_update: one refused document refuses the whole batch", async () => {
|
||||
const ng = inject();
|
||||
const hers = await documentOf("alice");
|
||||
const his = await documentOf("bob");
|
||||
setCurrentUser("alice");
|
||||
const proxy = makeNg();
|
||||
await expect(
|
||||
proxy.graph_orm_update!(
|
||||
"sub-1",
|
||||
[{ op: "add", path: `/${hers}|${hers}:q:a1` }, { op: "add", path: `/${his}|${his}:q:b1` }],
|
||||
"sid",
|
||||
),
|
||||
).rejects.toThrow(/refused/i);
|
||||
expect(ng.graph_orm_update).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
// A patch naming no NextGraph reference is forwarded rather than refused. The emulation
|
||||
// cannot say whose document such a write lands in, and upstream does not have to guess:
|
||||
// `verify_permission` fires on the commit itself. Inventing a refusal the target does
|
||||
// not have would be the divergence.
|
||||
test("ng.graph_orm_update: a patch naming no document passes through", async () => {
|
||||
const ng = inject();
|
||||
await documentOf("alice");
|
||||
setCurrentUser("alice");
|
||||
const proxy = makeNg();
|
||||
await proxy.graph_orm_update!("sub-1", [{ op: "add", path: "/0/title" }], "sid");
|
||||
expect(ng.graph_orm_update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// -- and the door is one the ORM actually knocks on -------------------------
|
||||
|
||||
// The guard above only ever fires if the ORM is holding THIS package's `ng`. An
|
||||
// application hands `initNg` the raw SDK object - both consumers in this repository
|
||||
// did - so the substitution is what turns the guard from a claim into a mechanism.
|
||||
test("initNg hands the ORM this package's wrapped ng, not the argument", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const ng = makeFakeNg();
|
||||
const raw = { marker: "the raw SDK object" };
|
||||
configure({
|
||||
ng: ng as unknown as NgLike,
|
||||
useShape: (() => {}) as unknown as UseShapeLike,
|
||||
initNg: (...a: unknown[]) => void seen.push(...a),
|
||||
});
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
|
||||
initNg(raw, { session_id: "sid" });
|
||||
|
||||
expect(seen[0]).not.toBe(raw);
|
||||
// ...and what it IS forwards to the object `configure()` received, so nothing is taken
|
||||
// from the caller: the 88 members answer exactly as the real `ng`'s do.
|
||||
const handed = seen[0] as Record<string, unknown>;
|
||||
expect(typeof handed["graph_orm_update"]).toBe("function");
|
||||
expect(handed["doc_create"]).toBeDefined();
|
||||
// The session, and everything after it, passes straight through.
|
||||
expect(seen[1]).toEqual({ session_id: "sid" });
|
||||
});
|
||||
@@ -40,8 +40,20 @@ const TARGET_WORDS = new Set([
|
||||
// acts the target performs
|
||||
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
|
||||
"fetch", "init", "watch", "sparql", "ng", "orm", "type", "types",
|
||||
// `insert` is the ORM's own verb for adding an object without a subscription
|
||||
// (`sdk/js/orm/src/connector/insertObject.ts`), and SPARQL's for the query it builds.
|
||||
"insert",
|
||||
// RDF / SPARQL terms the engine's own query paths use
|
||||
"subject", "base", "schema", "connected", "identity", "identities",
|
||||
// `rdf` names the ORM half that speaks triples, as against the JSON-CRDT one - the two
|
||||
// subscription classes the SDK exports are `RdfOrmSubscription` and
|
||||
// `DiscreteOrmSubscription`, and the config the first one takes is `RdfOrmConfig`.
|
||||
"rdf",
|
||||
// `object` is the ORM's OWN noun for one shape-typed record: `getObjects`,
|
||||
// `insertObject`, `removeObject` and `signalObject` in `sdk/js/orm/src/`, and
|
||||
// `SparqlBuilder::remove_object` in the engine. It is also RDF's third triple position,
|
||||
// which the engine's query paths use for the same word.
|
||||
"object", "objects",
|
||||
// `publisher` is upstream's word for a pub/sub role on a topic (`as_publisher`,
|
||||
// `publisher_advert`, 126 occurrences in the engine). Our own "publish a document" is
|
||||
// banned as ambiguous, but that ban never reaches upstream's term — see the traps
|
||||
|
||||
Reference in New Issue
Block a user