feat!: la famille ORM est publiee enveloppee, et le ng publie cesse de rendre des promesses
This commit is contained in:
@@ -6,3 +6,4 @@
|
|||||||
## Raw markers (consolidate into blocks, then delete)
|
## Raw markers (consolidate into blocks, then delete)
|
||||||
- TOUCHED packages/polyfill/src/surface/use-shape-query.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
|
- TOUCHED packages/polyfill/src/surface/use-shape-query.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||||
- TOUCHED packages/polyfill/src/index.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
|
- TOUCHED packages/polyfill/src/index.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||||
|
- TOUCHED packages/polyfill/src/surface/orm.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ summary: The API @ng-eventually/polyfill exposes to an application — signature
|
|||||||
|
|
||||||
This package is a polyfill of NextGraph's SDK.
|
This package is a polyfill of NextGraph's SDK.
|
||||||
|
|
||||||
This package covers placement (creating an application's documents in one of its three stores), reading (a document's subjects, one-shot or reactive, by shape), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
|
This package covers placement (creating an application's documents in one of its three stores), reading (a document's subjects, one-shot or reactive, by shape), writing one shape-typed object, sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
|
||||||
|
|
||||||
### Deployment requirements
|
### Deployment requirements
|
||||||
|
|
||||||
@@ -17,6 +17,8 @@ An application using this package must:
|
|||||||
|
|
||||||
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
|
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
|
||||||
- pass `configure` the SDK's shape query (`ormSubscription: RdfOrmSubscription.getOrCreate`, from `@ng-org/orm`) and its `initNg`, and call this package's `initNg(ng, session)` once a session has arrived, if it uses `useShapeQuery`. That read goes through the SDK now, and the SDK is injected here rather than imported; without the injection the read publishes `isError` naming the field;
|
- pass `configure` the SDK's shape query (`ormSubscription: RdfOrmSubscription.getOrCreate`, from `@ng-org/orm`) and its `initNg`, and call this package's `initNg(ng, session)` once a session has arrived, if it uses `useShapeQuery`. That read goes through the SDK now, and the SDK is injected here rather than imported; without the injection the read publishes `isError` naming the field;
|
||||||
|
- pass `configure` the SDK's `getObjects` and `insertObject` (from `@ng-org/orm`, as they stand) if it calls the calls of the same name here. Same reason and same shape as the shape query: they are injected rather than imported, and without the injection each throws naming the field to pass;
|
||||||
|
- call this package's `initNg`, not the SDK's, whenever it uses anything of the ORM. That call decides which `ng` the ORM reaches the engine through, and this package substitutes its own - the same object `ng` publishes - so that an ORM write meets the same guard every other write meets. Calling `@ng-org/orm`'s `initNg` directly leaves ORM writes ungoverned;
|
||||||
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first;
|
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first;
|
||||||
- **declare this package exactly once**, so that everything in the application resolves to one copy of it — see the single-copy rule under `## Non-guarantees`, which is the one packaging mistake this surface cannot protect you from.
|
- **declare this package exactly once**, so that everything in the application resolves to one copy of it — see the single-copy rule under `## Non-guarantees`, which is the one packaging mistake this surface cannot protect you from.
|
||||||
|
|
||||||
@@ -35,6 +37,8 @@ export interface EventuallyConfig {
|
|||||||
// `RdfOrmSubscription.getOrCreate` from @ng-org/orm - the SDK's shape query, which
|
// `RdfOrmSubscription.getOrCreate` from @ng-org/orm - the SDK's shape query, which
|
||||||
// `useShapeQuery` reads through. Pass it as it stands; no wrapper, no cast.
|
// `useShapeQuery` reads through. Pass it as it stands; no wrapper, no cast.
|
||||||
ormSubscription?: OrmSubscriptionLike;
|
ormSubscription?: OrmSubscriptionLike;
|
||||||
|
getObjects?: GetObjectsLike; // `getObjects` from @ng-org/orm
|
||||||
|
insertObject?: InsertObjectLike; // `insertObject` from @ng-org/orm
|
||||||
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
|
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
|
||||||
debugAccessLog?: boolean;
|
debugAccessLog?: boolean;
|
||||||
init?: (...args: any[]) => any;
|
init?: (...args: any[]) => any;
|
||||||
@@ -80,6 +84,14 @@ export interface ShapeObservable<T extends BaseType = BaseType> {
|
|||||||
export interface ShapeQuery<T extends BaseType = BaseType> {
|
export interface ShapeQuery<T extends BaseType = BaseType> {
|
||||||
data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown;
|
data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown;
|
||||||
}
|
}
|
||||||
|
// The ORM's one-shot read, at the SDK's own name and signature. `config` is a `Scope`
|
||||||
|
// plus `where` and `orderBy`; the result is an ARRAY when it carries an `orderBy` and a
|
||||||
|
// `Set` otherwise, and it is a CLONE - mutating or deleting an item of it reaches nothing.
|
||||||
|
export async function getObjects<T extends BaseType>(
|
||||||
|
shapeType: ShapeType<T>,
|
||||||
|
config: Omit<RdfOrmConfig<T>, "pageSize" | "maxActivePages">,
|
||||||
|
): Promise<T[] | Set<T>>;
|
||||||
|
export type RdfOrmConfig<T> = Scope & { where?: ...; orderBy?: ... }; // the SDK's own
|
||||||
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||||
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||||
|
|
||||||
@@ -95,6 +107,12 @@ export const docs: {
|
|||||||
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -- writing one shape-typed object ---------------------------------------
|
||||||
|
// The SDK's own name and signature. The object MUST carry `@graph` - it is the only
|
||||||
|
// thing that says which document the write lands in - and what comes back is the object
|
||||||
|
// with its `@id` filled in where one was left empty.
|
||||||
|
export async function insertObject<T extends BaseType>(shapeType: ShapeType<T>, object: T): Promise<T>;
|
||||||
|
|
||||||
// ── inbox: giving to read, and depositing ────────────────────────────────
|
// ── inbox: giving to read, and depositing ────────────────────────────────
|
||||||
export const inbox: {
|
export const inbox: {
|
||||||
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
|
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
|
||||||
@@ -135,7 +153,11 @@ What was shared with you becomes readable after `ensureIdentity()`.
|
|||||||
|
|
||||||
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal is reported ONCE and then the deposit is gone: this package consumes what it claims, whether or not the arm succeeded. Until `1.0.0-dev.4` the same refusal came back at every arrival on that inbox and at every connection, for as long as the deposit sat there. The same now applies when the referenced document resolves but carries **nothing for the field its index declares** — a draft, say: the reference is discarded and reported, where before it sat in the queue and would have been indexed by a later drain once the document gained that field. Deposit the reference again after the document says what it needs to say. Carry your own data in an object with a `kind` of your own — `{ kind: "join", … }` is yours and stays yours — and never as a bare reference on its own.
|
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal is reported ONCE and then the deposit is gone: this package consumes what it claims, whether or not the arm succeeded. Until `1.0.0-dev.4` the same refusal came back at every arrival on that inbox and at every connection, for as long as the deposit sat there. The same now applies when the referenced document resolves but carries **nothing for the field its index declares** — a draft, say: the reference is discarded and reported, where before it sat in the queue and would have been indexed by a later drain once the document gained that field. Deposit the reference again after the document says what it needs to say. Carry your own data in an object with a `kind` of your own — `{ kind: "join", … }` is yours and stays yours — and never as a bare reference on its own.
|
||||||
|
|
||||||
Only a document's owner writes to it. Holding its read key never grants a write.
|
Only a document's owner writes to it. Holding its read key never grants a write. **Since `1.0.0-dev.7` that covers what you write through NextGraph's ORM too** - `insertObject`, and a mutation of the live set a shape query handed you (`set.add(...)`, `set.delete(...)`, assigning a property). It used to cover only `docs.sparqlUpdate`: an ORM write leaves by a different door, and nothing was standing at it. Refusal happens before the write is attempted, and one document you may not write refuses the whole batch the ORM was flushing.
|
||||||
|
|
||||||
|
**`getObjects(shapeType, config)` answers with what you hold, and nothing else.** Objects living in documents you have no key for are not in the result - the same rule the two shape reads follow, applied per document on what comes back. The collection keeps the shape the SDK gave it: an `orderBy` in the config yields an array, anything else a `Set`. What you get is a CLONE, detached from the database: changing an item of it changes nothing anywhere, and deleting an item of it removes nothing and reports nothing.
|
||||||
|
|
||||||
|
**`insertObject(shapeType, object)` needs the object's `@graph`**, and refuses a document you do not own. The `@graph` is the only thing that says which document the write lands in - upstream the insert subscribes to no document at all - and it is also what decides the refusal, so there is nothing extra to pass. An object with no `@graph` is forwarded and NextGraph refuses it in its own words.
|
||||||
|
|
||||||
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
|
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
|
||||||
|
|
||||||
@@ -193,8 +215,16 @@ Where a call must first find out whether something already exists — a document
|
|||||||
|
|
||||||
**Naming an identity proves nothing about who named it.** Any visitor may type any identifier at the barrier, or arrive on a URL that already carries one, and act as that identity — the wallet and its password are handed out on the barrier itself. The identifier is a choice of space, never a proof: anyone who knows one can act as it, so do not treat `ensureIdentity()`'s answer as an authenticated subject.
|
**Naming an identity proves nothing about who named it.** Any visitor may type any identifier at the barrier, or arrive on a URL that already carries one, and act as that identity — the wallet and its password are handed out on the barrier itself. The identifier is a choice of space, never a proof: anyone who knows one can act as it, so do not treat `ensureIdentity()`'s answer as an authenticated subject.
|
||||||
|
|
||||||
|
**The wrapped `ng` is a wrapper, and a wrapper can be wrong.** It forwards all 88 members to the object you gave `configure`, adding two refusals of its own and nothing else. Until `1.0.0-dev.7` it did not forward them at all - every member came back as a promise, and calling one failed with NextGraph's own `<member> is not a function`. The lesson to keep is that a member of `ng` that misbehaves is worth reporting here rather than worked around: nothing in this package's own code goes through that wrapper, so this surface is the only place such a defect shows.
|
||||||
|
|
||||||
**A shape query the engine refuses never finishes loading.** Both shape reads — `useShape` and `useShapeQuery` — open a subscription through NextGraph's ORM, and a start the engine refuses is caught there, written to the console and nothing more: the set stays empty, its readiness never settles, and the only trace is that one console line. `useShapeQuery` reports it as a `isPending` that does not end (see the guarantee above); `useShape` returns a set you cannot tell from an empty one. The cause is upstream and nothing in this package can work around it. This was a total outage until the client packages caught up with the deployed broker on 2026-09-21; it is the residual failure mode, not the ordinary case.
|
**A shape query the engine refuses never finishes loading.** Both shape reads — `useShape` and `useShapeQuery` — open a subscription through NextGraph's ORM, and a start the engine refuses is caught there, written to the console and nothing more: the set stays empty, its readiness never settles, and the only trace is that one console line. `useShapeQuery` reports it as a `isPending` that does not end (see the guarantee above); `useShape` returns a set you cannot tell from an empty one. The cause is upstream and nothing in this package can work around it. This was a total outage until the client packages caught up with the deployed broker on 2026-09-21; it is the residual failure mode, not the ordinary case.
|
||||||
|
|
||||||
|
**No object removal call, and no document deletion at all.** There is no `deleteObject` and no `removeDocument` at any level of NextGraph, so there is none here. You remove an object by deleting it from the LIVE set a shape query handed you - the item the set is holding, reached by iterating it, by `.first()` or by `.getBy(graph, subject)`. What that removes is the predicates your SHAPE declares on that subject: triples under a predicate the shape does not name survive, the subject survives, and the document survives. Deleting an item of a `getObjects` result removes nothing, because that result is a clone. `@ng-org/orm@0.1.2-alpha.21` ships a `removeObject(graphNuri, subjectIri)`; it is not published here, because it exists in the package and not in the source this package is verified against, and a symbol whose only evidence is what an npm build happens to export is not something to build a guarantee on.
|
||||||
|
|
||||||
|
**A refused ORM write does not always reach you as a failed call.** `insertObject` rejects, and you can await it. A mutation of a live set does not: NextGraph batches those into one call it does not await, so a refusal surfaces as an unhandled rejection in the console rather than as an error at the line that made the change - the write did not happen, and nothing in your code was told. Write into documents you own, and treat a document you merely hold the key for as read-only.
|
||||||
|
|
||||||
|
**Nothing of NextGraph's ORM is re-exported as it stands.** `getObjects` and `insertObject` are the two this package offers, wrapped. `RdfOrmSubscription`, `DiscreteOrmSubscription`, `ngSession`, `getRaw`, `watch` and `effect` are NOT published and importing them from `@ng-org/orm` defeats everything above: one wallet hosts every identity here, so an unwrapped read returns every identity's objects and an unwrapped write reaches any document. `RdfOrmSubscription` in particular pools its subscriptions on the shape and the scope alone, with no identity in the key - what makes that safe is the filtering this package does on the way out.
|
||||||
|
|
||||||
**No revocation.** `inbox.share` cannot be undone.
|
**No revocation.** `inbox.share` cannot be undone.
|
||||||
|
|
||||||
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
|
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
|
||||||
@@ -211,6 +241,8 @@ Where a call must first find out whether something already exists — a document
|
|||||||
|
|
||||||
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
|
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
|
||||||
|
|
||||||
|
**`getObjects` does not fetch a public store's key for you either**, for the same reason as the two shape reads below: it goes through the same ORM query and spends no round-trip asking. Read the document once by naming it (`readUnion`, `docs.sparqlQuery`) and it appears.
|
||||||
|
|
||||||
**Neither shape read fetches a public store's key for you.** A document in a public store gives up its key to whoever asks, but asking is a round-trip and neither `useShape` nor `useShapeQuery` spends one — since `1.0.0-dev.6` the second reads through the SDK and no longer opens a document of its own. So a public document you have read nowhere else does not appear in either. Read it once by naming it (`readUnion`, `docs.sparqlQuery`) and both pick it up: the key lands in what you hold, and that is a change both of them re-read on.
|
**Neither shape read fetches a public store's key for you.** A document in a public store gives up its key to whoever asks, but asking is a round-trip and neither `useShape` nor `useShapeQuery` spends one — since `1.0.0-dev.6` the second reads through the SDK and no longer opens a document of its own. So a public document you have read nowhere else does not appear in either. Read it once by naming it (`readUnion`, `docs.sparqlQuery`) and both pick it up: the key lands in what you hold, and that is a change both of them re-read on.
|
||||||
|
|
||||||
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
|
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
|
||||||
@@ -225,11 +257,19 @@ What each level means here, in this package's own terms:
|
|||||||
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
|
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
|
||||||
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
|
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
|
||||||
|
|
||||||
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.6`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.7`), which sorts *below* that version by construction - so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||||
|
|
||||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.6` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
|
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.7` is this package, `ng-e2e-helpers/v...` is the other one. A bare `v...` tag would say nothing about which surface it froze the day the two diverge - which is the day one of them takes a major and the other does not.
|
||||||
|
|
||||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.6`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.6`, the string you pinned, never the version it is heading for.
|
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything - but the next release very likely is a major. What exists today is `1.0.0-dev.7`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.7`, the string you pinned, never the version it is heading for.
|
||||||
|
|
||||||
|
`1.0.0-dev.7` is a **major**, and what makes it one is not the two calls it adds but the rule it starts enforcing. Symbol by symbol:
|
||||||
|
|
||||||
|
- **`getObjects` and `insertObject` are published**, at NextGraph's own names, parameters and returns, wrapping the SDK's. That part is a minor on its own: nothing existing moves. Import them from this package rather than from `@ng-org/orm` - which is the point, since the unwrapped ones bypass the capability filter and the shared-wallet identity boundary entirely. Both are injected through `configure` (`getObjects`, `insertObject`), like the shape query beside them.
|
||||||
|
- **An ORM write into a document you do not own is now refused**, where it used to succeed. This is the major. It applies to `insertObject` and to every mutation of a live set a shape query handed you, because both leave by a door that had no guard on it. The guarantee itself did not move - *only a document's owner writes to it* has been in this contract from the start - but the implementation only enforced it on `docs.sparqlUpdate`. Code that relied on writing through the ORM into somebody else's document was relying on a hole. Note that a live-set mutation is refused WITHOUT your call failing: see the non-guarantee above.
|
||||||
|
- **`initNg` must be this package's**, and it now substitutes the `ng` it hands NextGraph's ORM. An application that calls `@ng-org/orm`'s `initNg` directly still works and is still unguarded - which is exactly the state being left behind, so call this package's.
|
||||||
|
- **`RdfOrmConfig` joins the published types**, because `getObjects`'s signature names it. It is re-exported from the SDK's `/all` subpath, which is where the SDK puts it.
|
||||||
|
- **`ng` works.** Until this version the published `ng` answered every one of its members with a promise instead of the function, so `ng.<anything>(...)` failed with NextGraph's own `<member> is not a function`. If you passed it anywhere - and the surface tells you to use it instead of the one you gave `configure` - that is what you were hitting, including a `useShapeQuery` that never left `isPending` because NextGraph swallows the failure it gets from `orm_start_graph`. Nothing about the call changes; it simply answers now.
|
||||||
|
|
||||||
`1.0.0-dev.6` is a **major**, and all of it is one change: `useShapeQuery` reads through NextGraph's OWN shape query instead of a read this package built. Nothing was renamed and nothing was removed from the entry, but three things move under a caller:
|
`1.0.0-dev.6` is a **major**, and all of it is one change: `useShapeQuery` reads through NextGraph's OWN shape query instead of a read this package built. Nothing was renamed and nothing was removed from the entry, but three things move under a caller:
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -4,8 +4,8 @@ title: Publish the ORM family with the rights filter
|
|||||||
parent: T01
|
parent: T01
|
||||||
depends_on: [T01.b, T01.d]
|
depends_on: [T01.b, T01.d]
|
||||||
created: 2026-09-16
|
created: 2026-09-16
|
||||||
claimed_by: null
|
claimed_by: worker-T01.e
|
||||||
claimed_at: null
|
claimed_at: 2026-09-21
|
||||||
blocked: false
|
blocked: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,4 +39,20 @@ Note also: `OrmSubscription` was renamed `RdfOrmSubscription` in `@ng-org/orm@0.
|
|||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
|
- Read the two governing rules, T01.b's and T01.d's `## Result`, the parent's `## Learnings digest`, and `knowledge_what-the-orm-writes-and-deletes-upstream.md`. Baseline taken first: `bun test` 408/0, both `tsc` 0, lint 0 ERROR.
|
||||||
|
- Read the ORM family AT THE SOURCE and in the installed `@ng-org/orm@0.1.2-alpha.21`: `getObjects`, `insertObject`, `removeObject` (NEW, absent from the pinned `nextgraph-rs`), `RdfOrmSubscription`, `DiscreteOrmSubscription`, `ngSession`, plus `getRaw`/`watch`/`effect`. Decided each against the "nothing published as-is" rule; reasons recorded in `src/surface/orm.ts` and `docs/api-contract.md` section 6bis.
|
||||||
|
- Published `getObjects` and `insertObject` wrapped, at upstream's own names, parameters and returns, injected through `configure` like the shape query beside them. `getObjects`'s config parameter is `RdfOrmConfig` (alpha.21's widening of `Scope | string`), which forced publishing that type from the SDK's `/all` subpath.
|
||||||
|
- Closed the `graph_orm_update` rights hole in two pieces: the guard (an override in `ng-proxy.ts`, ownership via `assertMayWrite`, reading the document out of each patch's `path`), and the door it sits on (`initNg` now hands the ORM this package's wrapped `ng`, because both consumers in the repo hand it the raw SDK object).
|
||||||
|
- That substitution turned the e2e RED on the two `useShapeQuery` journeys; the reference run was green, so it was mine. Measured the cause: `makeNg()`'s passthrough did `Reflect.get(ng, prop).bind(ng)`, and `@ng-org/web`'s `ng` is itself a Proxy answering every key - `bind` included - with a path-carrying callable returning a Promise. The published `ng` therefore answered all 88 members with a promise. Forwarded with `Reflect.apply` instead; pinned by a test whose fake is shaped like the real proxy, and which was verified to FAIL on the old line.
|
||||||
|
- Contract leaf edited (Scope, Deployment requirements, Surface, Guarantees, Non-guarantees, Change policy), bumped to `1.0.0-dev.7` as a MAJOR under the leaf's own policy, and in `package.json`. `docs/api-contract.md` (new section 6bis, sections 2/3/14 and the appendix), `docs/internal-contract.md`, `README.md`, `docs/polyfill-reference.md` updated.
|
||||||
|
|
||||||
|
|
||||||
## Result
|
## Result
|
||||||
|
|
||||||
|
**Done.** `getObjects` and `insertObject` are published WRAPPED, at upstream's names, parameters and returns, injected through `configure`. The read is capability-filtered per `@graph` (collection shape preserved); the write takes `assertMayReach` + `assertMayWrite` BEFORE the call. `graph_orm_update` now carries the same ownership guard in `ng-proxy.ts`, and `initNg` substitutes this package's wrapped `ng` so the ORM knocks on that door - the rights hole is closed. NOT published, each with a checkable reason: `RdfOrmSubscription` (pooled with no identity in the key), `DiscreteOrmSubscription`, `ngSession`, `getRaw`, `watch`/`effect`, and `removeObject` (exists in alpha.21, absent from the pinned `nextgraph-rs`, so no citation resolves). `1.0.0-dev.7`, MAJOR: an ORM write into a document you do not own is now refused.
|
||||||
|
|
||||||
|
**Found and fixed, MEASURED.** `makeNg()`'s passthrough did `.bind(ng)`; the SDK's `ng` is itself a Proxy answering every key including `bind`, so the published `ng` returned a PROMISE for all 88 members - `orm_start_graph is not a function`, swallowed by the ORM, read pending for ever. Now `Reflect.apply`. The e2e proves it only since `initNg` routes the ORM through the wrapper.
|
||||||
|
|
||||||
|
**Gate.** `bun test` 424 pass / 0 fail; both `tsc` exit 0; lint 0 ERROR; `== Polyfill e2e summary: 42 passed, 0 failed, 42 total - batch 3.3 min, slowest cold sync 27s ==`.
|
||||||
|
|
||||||
|
**Follow-ups.** `examples/notebook` fails its OWN `tsc` on HEAD already (its shape literal needs `satisfies`). `discrete_orm_update` is unguarded. `removeObject` awaits a refreshed `nextgraph-rs`.
|
||||||
@@ -48,4 +48,5 @@ The polyfill's published surface has drifted from the SDK's: invented names, inv
|
|||||||
- T01.c - `useShapeQuery` takes the SDK's `Scope`, re-exported as a passthrough. Our three literals became `StoreType`, internal only: VERIFIED they are upstream's own `store_type`, so only the NAME was invented. A store is now named by its REFERENCE (`privateStore()` / `protectedStore()` / `publicStore()`), and `createEntityDoc(store?)` takes one, private when omitted, as `doc_create` does. `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` unpublished, still internal - T01.d removes the read path's last caller. VERIFIED: public and protected share one store id here, so the reference must be the index document. `1.0.0-dev.5`.
|
- T01.c - `useShapeQuery` takes the SDK's `Scope`, re-exported as a passthrough. Our three literals became `StoreType`, internal only: VERIFIED they are upstream's own `store_type`, so only the NAME was invented. A store is now named by its REFERENCE (`privateStore()` / `protectedStore()` / `publicStore()`), and `createEntityDoc(store?)` takes one, private when omitted, as `doc_create` does. `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` unpublished, still internal - T01.d removes the read path's last caller. VERIFIED: public and protected share one store id here, so the reference must be the index document. `1.0.0-dev.5`.
|
||||||
- Package refresh (2026-09-21) - `@ng-org/*` moved to web alpha.14 / orm alpha.21 / shex-orm alpha.9 / alien-deepsignals alpha.13. The live ORM read now STARTS against the deployed broker: `6 passed, 0 failed, 6 declared`, ready in 16ms, delete reached the document. `bug_useshape-live-read-needs-a-newer-broker` DELETED - the deployment was never behind, our packages were. T01.d's blocker LIFTS. Also surfaced: `OrmSubscription` is now `RdfOrmSubscription` (T01.e's proposal names the old one); `Scope.graphs` is REQUIRED and its `[]` means the EMPTY set while `subjects: []` means NO filtering - the two axes read `[]` in opposite directions, which T01.d must not get wrong; six e2e files imported `ng-e2e-helpers` without the polyfill declaring it, fixed as `workspace:*`.
|
- Package refresh (2026-09-21) - `@ng-org/*` moved to web alpha.14 / orm alpha.21 / shex-orm alpha.9 / alien-deepsignals alpha.13. The live ORM read now STARTS against the deployed broker: `6 passed, 0 failed, 6 declared`, ready in 16ms, delete reached the document. `bug_useshape-live-read-needs-a-newer-broker` DELETED - the deployment was never behind, our packages were. T01.d's blocker LIFTS. Also surfaced: `OrmSubscription` is now `RdfOrmSubscription` (T01.e's proposal names the old one); `Scope.graphs` is REQUIRED and its `[]` means the EMPTY set while `subjects: []` means NO filtering - the two axes read `[]` in opposite directions, which T01.d must not get wrong; six e2e files imported `ng-e2e-helpers` without the polyfill declaring it, fixed as `workspace:*`.
|
||||||
- T01.d - `useShapeQuery` reads through `RdfOrmSubscription.getOrCreate`, injected as `configure({ ormSubscription })`; absent, the read publishes `isError` naming it. Scope resolution, the `ensureReposOpen` barrier, `readUnion` and the `@type` filter are gone from that path; `graphs`/`subjects`/`did:ng:i` reach the engine verbatim. The observable, snapshot, state and capability filter stay ours. `isError` KEPT, wired to the one real failure (the query refusing to open). A start the ENGINE refuses is swallowed upstream, so it reads `isPending` for ever - recorded in code and contract, no timeout invented. `userStoreDoc` and `liveStartWithConfig` deleted. VERIFIED: `listMyEntityDocs` is NOT dead, its own suite and an e2e journey call it. The change silently broke `examples/notebook/`, repaired out of boundary and deliberately. `1.0.0-dev.6`, major, 42/42 e2e.
|
- T01.d - `useShapeQuery` reads through `RdfOrmSubscription.getOrCreate`, injected as `configure({ ormSubscription })`; absent, the read publishes `isError` naming it. Scope resolution, the `ensureReposOpen` barrier, `readUnion` and the `@type` filter are gone from that path; `graphs`/`subjects`/`did:ng:i` reach the engine verbatim. The observable, snapshot, state and capability filter stay ours. `isError` KEPT, wired to the one real failure (the query refusing to open). A start the ENGINE refuses is swallowed upstream, so it reads `isPending` for ever - recorded in code and contract, no timeout invented. `userStoreDoc` and `liveStartWithConfig` deleted. VERIFIED: `listMyEntityDocs` is NOT dead, its own suite and an e2e journey call it. The change silently broke `examples/notebook/`, repaired out of boundary and deliberately. `1.0.0-dev.6`, major, 42/42 e2e.
|
||||||
|
- T01.e - `getObjects` and `insertObject` published WRAPPED at upstream's names, parameters and returns. Read is capability-filtered per `@graph`; the write takes `assertMayReach` + `assertMayWrite` BEFORE the call, and `graph_orm_update` now carries the same ownership guard, so the ORM rights hole is closed. NOT published, each with a checkable reason: `RdfOrmSubscription` (pooled with no identity in the key), `DiscreteOrmSubscription`, `ngSession`, `getRaw`, `watch`/`effect`, `removeObject` (in alpha.21 but absent from the pinned `nextgraph-rs`, so no citation resolves). `1.0.0-dev.7`, MAJOR. ROOT CAUSE FOUND for the consumer's `orm_start_graph is not a function`: `makeNg()`'s passthrough did `.bind(ng)`, and the SDK's `ng` is an async proxy whose `get` trap answers EVERY key including `bind` with a path-carrying callable - so the published `ng` returned a PROMISE for all 88 members. Now `Reflect.apply`, pinned by `test/ng-proxy.test.ts`. The e2e only proves it since `initNg` routes the ORM through the wrapper - before, `polyfill-entry.ts` handed `initNg` the raw SDK object, which is why the suite stayed green while the documented path was dead.
|
||||||
|
|
||||||
|
|||||||
+70
-9
@@ -94,13 +94,14 @@ So an application's whole bootstrap is `configure({ ng, useShape, ormSubscriptio
|
|||||||
// lifecycle.ts:11 — settles the identity, wraps the callback, then forwards to the real
|
// lifecycle.ts:11 — settles the identity, wraps the callback, then forwards to the real
|
||||||
// @ng-org/web init injected at configure()
|
// @ng-org/web init injected at configure()
|
||||||
export function init(...args: any[]): any;
|
export function init(...args: any[]): any;
|
||||||
// lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure()
|
// lifecycle.ts:18 - forwards to the real @ng-org/orm initNg injected at configure(),
|
||||||
|
// with the `ng` in position 0 replaced by this package's wrapped one
|
||||||
export function initNg(...args: any[]): any;
|
export function initNg(...args: any[]): any;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
**PASSTHROUGH, VERIFIED at both levels — with one argument touched, deliberately.** `init`'s callback in position 0 is wrapped since 2026-08-12: the wrapper reads the event, keeps the session it carries (§ 1), and calls the caller's callback with that same event, unchanged. Everything else — the remaining arguments, the return value, what the callback observes — passes straight through, so an application's call site is what it would write against the real SDK. This is the only place the capture can sit: it is the only one that knows both what the caller asked and what the SDK will answer, and the alternative was every consumer re-implementing it (which is what it replaces). The real signatures forwarded to are:
|
**PASSTHROUGH, VERIFIED at both levels - with one argument touched in each, deliberately.** `init`'s callback in position 0 is wrapped since 2026-08-12: the wrapper reads the event, keeps the session it carries (§ 1), and calls the caller's callback with that same event, unchanged. Everything else - the remaining arguments, the return value, what the callback observes - passes straight through, so an application's call site is what it would write against the real SDK. This is the only place the capture can sit: it is the only one that knows both what the caller asked and what the SDK will answer, and the alternative was every consumer re-implementing it (which is what it replaces). The real signatures forwarded to are:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
|
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
|
||||||
@@ -120,7 +121,9 @@ export declare type Session = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Divergence: none in behaviour (pure forwarding), but the wrapper erases the parameter types. A consumer typing calls against the wrapper learns nothing it must unlearn — it just gets no compile-time help the real SDK would give.
|
`initNg`'s argument in position 0 is substituted since `1.0.0-dev.7`, and it is the same kind of move for the same reason. That argument is what the ORM reaches the engine through afterwards - every subscription, every `insertObject`, every batched patch awaits the promise this call resolves - and an ORM write leaves by `ng.graph_orm_update`, which is where the emulated write guard now sits (§ 3). Handing the caller's raw SDK object over leaves that guard on a door nothing knocks on, and "pass the package's `ng` here" would be an ordering rule in prose: unenforceable, silent when missed, and in fact missed by both consumers in this repository. What is substituted is the package's own `ng` - the same object the entry publishes, forwarding all 88 members to the very object `configure()` received - so the ORM now reaches the engine through exactly what the application does.
|
||||||
|
|
||||||
|
Divergence: none in behaviour (pure forwarding), but the wrappers erase the parameter types. A consumer typing calls against them learns nothing it must unlearn - it just gets no compile-time help the real SDK would give.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -166,7 +169,11 @@ export const ng: NG;
|
|||||||
export type { NG } from "@ng-org/web";
|
export type { NG } from "@ng-org/web";
|
||||||
```
|
```
|
||||||
|
|
||||||
`ng` is a `Proxy` (`ng-proxy.ts:26`) forwarding every property to the injected real `ng`, overriding exactly one thing: `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap). `session_start` is intercepted but is a pure passthrough today, with a TODO for shared-wallet credentials. (`login` was named here until 2026-08-14 but has not existed since 2026-08-03: `@ng-org/web` exposes no such method, and the proxy was FABRICATING it.)
|
`ng` is a `Proxy` (`ng-proxy.ts`) forwarding every property to the injected real `ng`, overriding two things: `sparql_update` (the emulated write-cap guard, rejecting a write when a write policy governs the anchored document and the current user lacks the cap) and, since `1.0.0-dev.7`, `graph_orm_update` (the ORM's own write door, guarded by OWNERSHIP). `session_start` is intercepted but is a pure passthrough today, with a TODO for shared-wallet credentials. (`login` was named here until 2026-08-14 but has not existed since 2026-08-03: `@ng-org/web` exposes no such method, and the proxy was FABRICATING it.)
|
||||||
|
|
||||||
|
There is exactly one instance of that proxy for the whole package (`wrappedNg()`), because two things now hold it: the entry publishes it, and `initNg` hands it to the ORM (§ 2).
|
||||||
|
|
||||||
|
**The passthrough itself was broken until `1.0.0-dev.7`, for every one of the 88 members, and nothing noticed.** It read `Reflect.get(ng, prop).bind(ng)`. `@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 (`dist/ngweb.js`, the `l(e = [])` factory) - so `.bind` never reached `Function.prototype.bind`: it was just another key, calling the result posted a remote request named after the MEMBER carrying the bind arguments, and what came back was a **Promise**. A caller therefore got the SDK's own `<member> is not a function`. Two things hid it: 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 `initNg` handed the ORM this object - `orm_start_graph` was not a function, the ORM catches that and only `console.error`s it, so a shape read stayed `isPending` for ever, which is the failure mode § 5 describes. MEASURED against `@ng-org/web@0.1.2-alpha.14`. The member is now FORWARDED (`Reflect.apply(real, ng, args)`), which gives a plain object the receiver it needs and lets the async proxy use the path it already carries; `test/ng-proxy.test.ts` pins it against a stand-in shaped like the real one.
|
||||||
|
|
||||||
### Target
|
### Target
|
||||||
|
|
||||||
@@ -175,7 +182,8 @@ export type { NG } from "@ng-org/web";
|
|||||||
The overrides:
|
The overrides:
|
||||||
|
|
||||||
- `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears.
|
- `session_start(wallet_name: string, user_id: any): Promise<any>` (`index.d.ts:276`) — target signature unchanged; only the emulated credential injection disappears.
|
||||||
- `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`index.d.ts:297`) — target signature unchanged. The native enforcement the guard stands in for is the engine's permission model (`verify_perm`, `engine/repo/src/commit.rs:897`), which today is **called only from tests** (its enclosing `Commit::verify` has no runtime caller — see `docs/nextgraph-current-state.md` § *Author-signature verification*). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's *behaviour* (a refused write) is target-shaped even though its *mechanism* (a JS-side check) is emulation. Known limit, documented in `README.md`: the guard fires only on this proxy, and the lib's own writers call the injected `ng` directly, so it is best-effort until cap-enforcement.
|
- `graph_orm_update(subscription_id: any, diff: any, session_id: any): Promise<void>` (`index.d.ts:104`) - target signature unchanged. **This is the door an ORM write actually uses**, and until `1.0.0-dev.7` nothing here guarded it: a mutation of a live signal object (`set.add(...)`, `set.delete(...)`, assigning a property) never passes through `sparql_update` at all, it is batched into patches and sent through this member. So the `sparql_update` guard saw no ORM add and no ORM delete - reachable from the published surface, since `useShape` forwards `add`/`delete`/`clear` to the underlying set and `useShapeQuery`'s `data` items ARE that set's objects. The guard reads the document out of each patch's `path`, whose first segment is the synthetic id `<graph>|<subject>` the ORM itself minted, and asks `assertMayWrite` - OWNERSHIP, the same predicate `docs.sparqlUpdate` asks, not the decorative write-cap list the `sparql_update` branch consults. A patch naming no NextGraph reference passes: the emulation cannot say whose document that is, and upstream does not have to guess because `verify_permission` fires on the commit itself.
|
||||||
|
- `sparql_update(session_id: any, sparql: string, nuri: any): Promise<any>` (`index.d.ts:297`) - target signature unchanged. It is deliberately left on the decorative write-cap list rather than moved to `assertMayWrite`: this library's own REGISTERS are written through this member, and a store document is not owned in the authorship sense, so arming it here would refuse every register write (`emulated-verifier/register-write.ts`). The native enforcement the guard stands in for is the engine's permission model (`verify_perm`, `engine/repo/src/commit.rs:897`), which today is **called only from tests** (its enclosing `Commit::verify` has no runtime caller - see `docs/nextgraph-current-state.md` § *Author-signature verification*). That absence says nothing about the target: write permissions are the engine's declared model, so the guard's *behaviour* (a refused write) is target-shaped even though its *mechanism* (a JS-side check) is emulation. Known limit, documented in `README.md`: the guard fires only on this proxy, and the lib's own writers call the injected `ng` directly, so it is best-effort until cap-enforcement.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -314,6 +322,50 @@ The anchored-read mechanics are level-1 VERIFIED: an anchor restricts the query
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 6bis. The ORM's one-shot read and subscription-free write - `getObjects` / `insertObject`
|
||||||
|
|
||||||
|
### Today — `@ng-eventually/polyfill`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// orm.ts - the SDK's own read, with the capability filter on what comes back
|
||||||
|
export async function getObjects<T extends BaseType>(
|
||||||
|
shapeType: ShapeType<T>,
|
||||||
|
config: Omit<RdfOrmConfig<T>, "pageSize" | "maxActivePages">,
|
||||||
|
): Promise<T[] | Set<T>>;
|
||||||
|
// orm.ts - the SDK's own write, with the ownership guard before it
|
||||||
|
export async function insertObject<T extends BaseType>(shapeType: ShapeType<T>, object: T): Promise<T>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**PASSTHROUGH (level 3, VERIFIED)** - both at upstream's own name, parameters and return, forwarding to the real functions injected at `configure()` (`getObjects`, `insertObject`; § 1). Published since `1.0.0-dev.7`; before that an application had to import them straight from `@ng-org/orm`, which is the problem rather than an inconvenience - an unwrapped ORM read returns EVERY identity's data out of the shared wallet with nothing signalling it, and an unwrapped ORM write reaches any document 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, applied per document, exactly where § 4 and § 5 apply it. The filter keeps the collection's shape, so a `Set` in is a `Set` out and an `orderBy`ed array stays an array; before the first cap is issued the emulation is not in force and everything passes through. A WRITE is NOT filtered, and the asymmetry is upstream's: 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 the same pair of guards `docs.sparqlUpdate` takes - `assertMayReach` then `assertMayWrite` - BEFORE the call, on the `@graph` the object carries. An object naming no document is forwarded untouched and the ORM's own refusal is heard (it throws without a `@graph`, because upstream the insert subscribes to no document and has no other way to know where the write lands).
|
||||||
|
|
||||||
|
**What comes back from `getObjects` is DETACHED** - `structuredClone` of the raw signal object - so mutating an item of it reaches nothing, and deleting an item of it removes nothing and reports nothing. Removal is a mutation of the LIVE set a subscription holds (§ 4), which leaves through `graph_orm_update` and is guarded there (§ 3).
|
||||||
|
|
||||||
|
**What is deliberately NOT published from this family**, each for a reason a reader can check:
|
||||||
|
|
||||||
|
- **`RdfOrmSubscription`** - an application never holds a subscription: it hands the shape query to `configure` and reads `useShapeQuery`'s snapshot back (§ 5, § 14). 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. Publishing it would open a surface and a hole in the same gesture.
|
||||||
|
- **`ngSession`** - a module-level PROMISE resolving to the SDK handle plus the SESSION. One physical session hosts every virtual identity here, so handing it out hands every identity the same one (§ 1, § 13). Wrapping a resolved value would also force a RUNTIME `@ng-org/orm` import into this package, which is what the injection discipline exists to avoid.
|
||||||
|
- **`getRaw`** - it returns the object BEHIND a signal, the same escape hatch the read filter refuses on a dunder key (`__raw__`): the unfiltered collection, every identity's items in it. There is no filtered form of it.
|
||||||
|
- **`watch` / `effect`** - `@ng-org/alien-deepsignals`' reactivity primitives, re-exported by the ORM's entry. They carry no document and grant nothing, so there is nothing to wrap.
|
||||||
|
- **`removeObject`** - **NOT YET RULED ON, and it is the one gap in this section.** It exists in the published `@ng-org/orm@0.1.2-alpha.21` (`removeObject(graphNuri, subjectIri)`, a `DELETE WHERE` on that subject, stated to have the same effect as `delete()` on a subscription object) and it does NOT exist at the `nextgraph-rs` revision this repository reads (`213338f`): no file, no symbol. So nothing in the source can be cited for it, and the project's own rule refuses a claim inferred from what an npm package happens to expose. It is left unpublished until the source carries it; an application removes an object by mutating the live set, which is the path § 4 publishes and § 3 guards.
|
||||||
|
|
||||||
|
### Target
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// level 3, VERIFIED - sdk/js/orm/src/connector/getObjects.ts:getObjects
|
||||||
|
export async function getObjects<T extends BaseType>(shapeType: ShapeType<T>, scope: Scope | string);
|
||||||
|
// level 3, VERIFIED - sdk/js/orm/src/connector/insertObject.ts:insertObject
|
||||||
|
export async function insertObject<T extends BaseType>(shapeType: ShapeType<T>, object: T);
|
||||||
|
```
|
||||||
|
|
||||||
|
**One difference between the cited revision and the published package, and the published one is what is mirrored here.** `getObjects`'s second parameter was `Scope | string` at `213338f`; `@ng-org/orm@0.1.2-alpha.21` widened it to the config `RdfOrmConfig` declares, minus the two pagination fields a one-shot read has no use for - a `Scope` plus `where` and `orderBy`. The wrapper takes the wider one, because that is what a consumer's TypeScript actually checks against and because narrowing it back to `Scope` would reject a `where` the SDK accepts. Both return types are inferred upstream rather than annotated; the published declaration says `Promise<T[] | Set<T>>` for the first and `Promise<T>` for the second.
|
||||||
|
|
||||||
|
At migration both wrappers disappear and the imports resolve to these two functions: same names, same arguments, same returns. What goes with them is the filtering and the guard, which the broker and the verifier then do.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 7. Raw document / SPARQL primitives — `docs.*`
|
## 7. Raw document / SPARQL primitives — `docs.*`
|
||||||
|
|
||||||
### Today — `@ng-eventually/polyfill` (namespace `docs`)
|
### Today — `@ng-eventually/polyfill` (namespace `docs`)
|
||||||
@@ -691,10 +743,16 @@ declare function user_disconnect(user_id: string): Promise<void>;
|
|||||||
```ts
|
```ts
|
||||||
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
||||||
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
||||||
|
export type { Scope } from "@ng-org/orm";
|
||||||
|
export type { RdfOrmConfig } from "@ng-org/orm/all";
|
||||||
export type { NG } from "@ng-org/web";
|
export type { NG } from "@ng-org/web";
|
||||||
```
|
```
|
||||||
|
|
||||||
**PASSTHROUGH (levels 2/3, VERIFIED)** — `ShapeType`/`BaseType` at `@ng-org/shex-orm` `dist/types.d.ts:5,12` (installed 0.1.2-alpha.8); `NG` at `index.d.ts:136`. At migration these imports point at the same packages directly; nothing changes for the consumer.
|
**PASSTHROUGH (levels 2/3, VERIFIED)** - `ShapeType`/`BaseType` at `@ng-org/shex-orm` `dist/types.d.ts:5,12`; `NG` at `index.d.ts:136`. At migration these imports point at the same packages directly; nothing changes for the consumer.
|
||||||
|
|
||||||
|
A type is erased at build, so re-exporting one adds no runtime `@ng-org` import and opens no path around the emulation - which is why the rule that nothing of the SDK is published as it stands is about VALUES, and these are the exception it does not reach.
|
||||||
|
|
||||||
|
`RdfOrmConfig` comes from the SDK's `/all` subpath rather than its main entry, and that is upstream's packaging rather than a choice here: the config type `getObjects` declares is not exported beside the function that takes it (§ 6bis). Restating it locally is how a type drifts, so it is re-exported from where the SDK puts it.
|
||||||
|
|
||||||
### The library's own model types — published by NAME since 2026-08-10
|
### The library's own model types — published by NAME since 2026-08-10
|
||||||
|
|
||||||
@@ -703,10 +761,13 @@ The entry used to say `export * from "./model/types"`, a blanket re-export publi
|
|||||||
> **A type is published only if a PUBLISHED SIGNATURE uses it.**
|
> **A type is published only if a PUBLISHED SIGNATURE uses it.**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike } from "./model/types";
|
export type {
|
||||||
|
Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike, OrmSubscriptionLike,
|
||||||
|
GetObjectsLike, InsertObjectLike,
|
||||||
|
} from "./model/types";
|
||||||
```
|
```
|
||||||
|
|
||||||
Each one's warrant: `Nuri` is what every reference-returning call returns and `NuriLike` what every entry accepts (§ 10, § 11); `Scope` left this list in `1.0.0-dev.5` — the read takes the SDK's own `Scope`, re-exported from `@ng-org/orm`, and a store is named by its reference (§ 5, § 12); `PrincipalId` is `ensureIdentity`'s return and a field of `Deposit`, `PostOptions` and `EventuallyConfig` (§ 2bis, § 9, § 1); `NgLike`, `UseShapeLike` and `OrmSubscriptionLike` type the three injected members of `EventuallyConfig` (§ 1). `OrmSubscriptionHandle` — what the last of them RETURNS — is deliberately not published: an application hands the shape query in and reads the snapshot back, and publishing the handle would offer a path around the capability filter and the shared-wallet identity boundary.
|
Each one's warrant: `Nuri` is what every reference-returning call returns and `NuriLike` what every entry accepts (§ 10, § 11); `Scope` left this list in `1.0.0-dev.5` - the read takes the SDK's own `Scope`, re-exported from `@ng-org/orm`, and a store is named by its reference (§ 5, § 12); `PrincipalId` is `ensureIdentity`'s return and a field of `Deposit`, `PostOptions` and `EventuallyConfig` (§ 2bis, § 9, § 1); `NgLike`, `UseShapeLike`, `OrmSubscriptionLike`, `GetObjectsLike` and `InsertObjectLike` type the five injected members of `EventuallyConfig` (§ 1), the last two since `1.0.0-dev.7` (§ 6bis). `OrmSubscriptionHandle` - what the last of them RETURNS - is deliberately not published: an application hands the shape query in and reads the snapshot back, and publishing the handle would offer a path around the capability filter and the shared-wallet identity boundary.
|
||||||
|
|
||||||
Two types the blanket export published are now internal, each because **nothing published names it**:
|
Two types the blanket export published are now internal, each because **nothing published names it**:
|
||||||
|
|
||||||
@@ -750,7 +811,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
|
|||||||
### `@ng-eventually/polyfill` — `src/index.ts` (the only entry since 2026-08-07)
|
### `@ng-eventually/polyfill` — `src/index.ts` (the only entry since 2026-08-07)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, NG, NgLike, Nuri, NuriLike, OrmSubscriptionLike, PrincipalId, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, useShapeQuery
|
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, GetObjectsLike, InsertObjectLike, NG, NgLike, Nuri, NuriLike, OrmSubscriptionLike, PrincipalId, RdfOrmConfig, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, getObjects, init, initNg, insertObject, ng, readUnion, subscribeDoc, subscribeDocs, useShape, useShapeQuery
|
||||||
docs: docCreate, sparqlQuery, sparqlUpdate
|
docs: docCreate, sparqlQuery, sparqlUpdate
|
||||||
inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch
|
inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch
|
||||||
storeRegistry: createEntityDoc, openDocumentInbox, privateStore, protectedStore, publicStore
|
storeRegistry: createEntityDoc, openDocumentInbox, privateStore, protectedStore, publicStore
|
||||||
|
|||||||
@@ -13,15 +13,17 @@
|
|||||||
## 1. The wrapped `ng` factory — `surface/ng-proxy.ts`
|
## 1. The wrapped `ng` factory — `surface/ng-proxy.ts`
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// ng-proxy.ts:10
|
export function makeNg(): NG;
|
||||||
export function makeNg(): Record<string, any>;
|
export function wrappedNg(): NG; // the memoised one - THE instance, since 1.0.0-dev.7
|
||||||
```
|
```
|
||||||
|
|
||||||
Builds the published `ng` Proxy (consumed once, `index.ts:61`): forwards every property to the injected real `ng`, overriding `login`/`session_start` (passthrough with a shared-wallet-credentials TODO) and `sparql_update` (the emulated write-cap guard).
|
Builds the `ng` Proxy: forwards every property to the injected real `ng`, overriding `session_start` (passthrough with a shared-wallet-credentials TODO), `sparql_update` (the emulated write-cap guard) and, since `1.0.0-dev.7`, `graph_orm_update` (the ORM's own write door - ownership, `assertMayWrite`).
|
||||||
|
|
||||||
- The factory itself is **NO COUNTERPART, by design** — the target has no "wrap the SDK" step; at migration `ng` IS `@ng-org/web`'s and `makeNg` is deleted.
|
- The factory itself is **NO COUNTERPART, by design** — the target has no "wrap the SDK" step; at migration `ng` IS `@ng-org/web`'s and `makeNg` is deleted.
|
||||||
- The `sparql_update` guard stands in for the engine's write-permission model (`verify_perm` inside `Commit::verify`, `engine/repo/src/commit.rs:892-899`) — same analysis as `docs/api-contract.md` § 3.
|
- `wrappedNg()` exists because the proxy now has **two** holders: `index.ts` publishes it, and `surface/lifecycle.ts` hands it to the ORM through `initNg`, so the two must be the same object. It keeps no state (every property is answered from `getConfig()` at access time), so it survives a re-`configure()` and needs no reset.
|
||||||
- **Defect — the `login` arm fabricates a member (see Findings F1).** `@ng-org/web` has no `login`: none among the exports of `index.d.ts` (re-verified), and no `fn login` in `sdk/js/lib-wasm/src/lib.rs`. The proxy nevertheless returns a function for `prop === "login"` (`ng-proxy.ts:16-22`), so `typeof ng.login === "function"` on the wrapper while the real SDK yields `undefined` — the one place the proxy adds a member, contradicting its own header and the surface contract's "adds no member and removes none" (§ 3). Calling it throws at runtime (`ng[prop]` is undefined). **ASSUMPTION with no provenance** — no target layer names a `login`.
|
- The `sparql_update` guard stands in for the engine's write-permission model (`verify_perm` inside `Commit::verify`, `engine/repo/src/commit.rs:892-899`) - same analysis as `docs/api-contract.md` § 3. It is deliberately left on the decorative write-cap list rather than on `assertMayWrite`: the library's own REGISTERS are written through this member (`emulated-verifier/register-write.ts`), and a store document is not owned in the authorship sense, so arming it there would refuse every register write. `graph_orm_update` has no such traffic, which is why the live predicate sits on it.
|
||||||
|
- ~~**Defect - the `login` arm fabricates a member (Findings F1).**~~ **FIXED 2026-08-03**, and this bullet had outlived it: `@ng-org/web` has no `login` - none among the exports of `index.d.ts`, no `fn login` in `sdk/js/lib-wasm/src/lib.rs` - and the arm that returned a function for `prop === "login"` was the one place the proxy added a member, against its own header and the surface contract's "adds no member and removes none" (§ 3). It is gone from `ng-proxy.ts`; the note stays as the record of what the proxy must never do again.
|
||||||
|
- **Defect - the passthrough could not survive the real `ng` (FIXED `1.0.0-dev.7`).** It was `Reflect.get(ng, prop).bind(ng)`, and `@ng-org/web`'s `ng` is itself a Proxy whose `get` trap answers every key - `bind` included - with a path-carrying callable returning a Promise. So the published `ng` handed back a promise for all 88 members and a caller got `<member> is not a function`. Invisible because the library's own internals bypass this proxy and every fake in the suite is a plain object. Now `Reflect.apply(real, ng, args)`; the stand-in in `test/ng-proxy.test.ts` is shaped like the real proxy, which is what makes the test one.
|
||||||
- Disappears at migration (the whole module).
|
- Disappears at migration (the whole module).
|
||||||
|
|
||||||
## 2. NURI internals — the unexported slice of `nuri.ts`, plus the minting point
|
## 2. NURI internals — the unexported slice of `nuri.ts`, plus the minting point
|
||||||
@@ -264,7 +266,7 @@ export function inspectOutbox(): void;
|
|||||||
|
|
||||||
## Appendix — full internal export inventory (for diffing)
|
## Appendix — full internal export inventory (for diffing)
|
||||||
|
|
||||||
Fully internal modules: `shared-wallet/access-log.ts` (`AccessOp`, `setAccessLog`, `enabled`, `activeIdentity`, `accessLogPrefix`, `logStage`, `shortNuri`, `logAccess`); `emulated-verifier/machinery.ts` (`MACHINERY_NS`, `isMachinerySubject`); `surface/ng-proxy.ts` (`makeNg`); `emulated-verifier/open-repo.ts` (`SyncState`, `setOpenTimeoutForTests`, `resetOpenedRepos`, `getSyncState`, `ensureRepoOpen`, `ensurePhysicalRepoOpen`, `ensureReposOpen`); `shared-wallet/outbox-log.ts` (`inspectOutbox`); `shared-wallet/physical.ts` (`physicalCreate`, `physicalQuery`, `physicalUpdate`); `emulated-verifier/reach.ts` (`declareInfrastructure`, `isInfrastructure`, `resetInfrastructure`, `mayReach`, `assertMayReach`, `mustNotAttempt`); `emulated-verifier/read-filter.ts` (`filterReadable`, `makeReadFilteredView`).
|
Fully internal modules: `shared-wallet/access-log.ts` (`AccessOp`, `setAccessLog`, `enabled`, `activeIdentity`, `accessLogPrefix`, `logStage`, `shortNuri`, `logAccess`); `emulated-verifier/machinery.ts` (`MACHINERY_NS`, `isMachinerySubject`); `surface/ng-proxy.ts` (`makeNg`, `wrappedNg`); `emulated-verifier/open-repo.ts` (`SyncState`, `setOpenTimeoutForTests`, `resetOpenedRepos`, `getSyncState`, `ensureRepoOpen`, `ensurePhysicalRepoOpen`, `ensureReposOpen`); `shared-wallet/outbox-log.ts` (`inspectOutbox`); `shared-wallet/physical.ts` (`physicalCreate`, `physicalQuery`, `physicalUpdate`); `emulated-verifier/reach.ts` (`declareInfrastructure`, `isInfrastructure`, `resetInfrastructure`, `mayReach`, `assertMayReach`, `mustNotAttempt`); `emulated-verifier/read-filter.ts` (`filterReadable`, `makeReadFilteredView`).
|
||||||
|
|
||||||
Internal slices of partially-published modules: `model/nuri.ts` (`targetOf`, `parseNuri`) and `emulated-verifier/caps.ts` (`mintCap`); `emulated-verifier/connect.ts` (`startConnect`); `subscribe.ts` (`subscribePhysicalDoc`); `shared-wallet/account-registry.ts` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`).
|
Internal slices of partially-published modules: `model/nuri.ts` (`targetOf`, `parseNuri`) and `emulated-verifier/caps.ts` (`mintCap`); `emulated-verifier/connect.ts` (`startConnect`); `subscribe.ts` (`subscribePhysicalDoc`); `shared-wallet/account-registry.ts` (`reservedAccount`, `resetRegistryCache`, `resolveAccount`, `ensureAccount`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`).
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# @ng-eventually/polyfill
|
# @ng-eventually/polyfill
|
||||||
|
|
||||||
One entry point. Most of what it publishes has the same signature as the future SDK —
|
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
|
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.
|
the real SDK (build alias removed) with no code change.
|
||||||
|
|
||||||
@@ -32,7 +33,10 @@ import {
|
|||||||
configure,
|
configure,
|
||||||
} from "@ng-eventually/polyfill";
|
} 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
|
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
|
// 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.
|
// 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`,
|
// `configure` as `ormSubscription`), and hands back the shape's own objects: `@graph`,
|
||||||
// `@id` and one field per predicate the shape DECLARES.
|
// `@id` and one field per predicate the shape DECLARES.
|
||||||
const query = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
|
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
|
## 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:
|
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
|
- **`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
|
([`../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.
|
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
|
([`../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.
|
repo** (`did:ng:o:<RepoID>`); there is no separate `Document` type.
|
||||||
- **Write into it:** `docs.sparqlUpdate(sid, query, anchor)` — a SPARQL
|
- **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
|
`INSERT/DELETE` scoped to the anchor document's graph. Or, at the ORM layer,
|
||||||
update primitives (`graph_orm_update`). A write is a **commit** on the document's
|
`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
|
branch — which is exactly what every `useShape` subscriber over that document is
|
||||||
pushed.
|
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
|
- **Writes target one document**, never "the union": a SPARQL update must name one
|
||||||
document's graph (`resolve_target_for_sparql(update=true)` returns `InvalidTarget`
|
document's graph (`resolve_target_for_sparql(update=true)` returns `InvalidTarget`
|
||||||
for the union, `engine/verifier/src/request_processor.rs:275`).
|
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
|
// 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
|
// the first read - through the PACKAGE's `initNg`, which is the call an
|
||||||
// application makes.
|
// 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);
|
initNg(realNg, session);
|
||||||
sessionResolve(session);
|
sessionResolve(session);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ng-eventually/polyfill",
|
"name": "@ng-eventually/polyfill",
|
||||||
"version": "1.0.0-dev.6",
|
"version": "1.0.0-dev.7",
|
||||||
"type": "module",
|
"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.",
|
"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",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -85,6 +85,9 @@
|
|||||||
// NgLike `EventuallyConfig.ng`
|
// NgLike `EventuallyConfig.ng`
|
||||||
// UseShapeLike `EventuallyConfig.useShape`
|
// UseShapeLike `EventuallyConfig.useShape`
|
||||||
// OrmSubscriptionLike `EventuallyConfig.ormSubscription`
|
// OrmSubscriptionLike `EventuallyConfig.ormSubscription`
|
||||||
|
// GetObjectsLike `EventuallyConfig.getObjects`
|
||||||
|
// InsertObjectLike `EventuallyConfig.insertObject`
|
||||||
|
// RdfOrmConfig `getObjects`
|
||||||
//
|
//
|
||||||
// `OrmSubscriptionHandle` - what that last one RETURNS - is deliberately NOT here. An
|
// `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
|
// 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
|
// 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
|
// 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.
|
// `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 { useShape } from "./surface/use-shape";
|
||||||
export { useShapeQuery } from "./surface/use-shape-query";
|
export { useShapeQuery } from "./surface/use-shape-query";
|
||||||
export type { ShapeQuery, ShapeObservable } 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 { init, initNg } from "./surface/lifecycle";
|
||||||
export * as inbox from "./surface/inbox";
|
export * as inbox from "./surface/inbox";
|
||||||
export * as docs from "./surface/docs";
|
export * as docs from "./surface/docs";
|
||||||
@@ -129,6 +146,12 @@ export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
|||||||
// `subjects`) are upstream's to change.
|
// `subjects`) are upstream's to change.
|
||||||
// @provenance Scope kind=passthrough level=3 ref=sdk/js/orm/src/types.ts:Scope
|
// @provenance Scope kind=passthrough level=3 ref=sdk/js/orm/src/types.ts:Scope
|
||||||
export type { Scope } from "@ng-org/orm";
|
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
|
// @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";
|
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 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`.
|
* 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.
|
* 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)
|
// @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
|
// any runtime `@ng-org` import). They describe what `configure` RECEIVES; nothing here
|
||||||
// re-exports them.
|
// re-exports them.
|
||||||
import type { Scope } from "@ng-org/orm";
|
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";
|
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +130,41 @@ export type OrmSubscriptionLike = <T extends BaseType>(
|
|||||||
scope: Scope,
|
scope: Scope,
|
||||||
) => OrmSubscriptionHandle;
|
) => 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
|
* 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
|
* protected store repos get one — `new_store_default` attaches an inbox solely
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type {
|
|||||||
NgLike,
|
NgLike,
|
||||||
UseShapeLike,
|
UseShapeLike,
|
||||||
OrmSubscriptionLike,
|
OrmSubscriptionLike,
|
||||||
|
GetObjectsLike,
|
||||||
|
InsertObjectLike,
|
||||||
Nuri,
|
Nuri,
|
||||||
NuriLike,
|
NuriLike,
|
||||||
PrincipalId,
|
PrincipalId,
|
||||||
@@ -134,6 +136,23 @@ export interface EventuallyConfig {
|
|||||||
* reported as "there is nothing" is the one failure that surface exists to prevent.
|
* reported as "there is nothing" is the one failure that surface exists to prevent.
|
||||||
*/
|
*/
|
||||||
ormSubscription?: OrmSubscriptionLike;
|
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
|
* POINTER micro-guard budget — see {@link StoreRegistryDeps.pointerGuard}. Left unset
|
||||||
* → a single read, which keeps the synchronous unit fakes fast.
|
* → a single read, which keeps the synchronous unit fakes fast.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import { getConfig, rememberHandOver } from "../shared-wallet/bootstrap";
|
import { getConfig, rememberHandOver } from "../shared-wallet/bootstrap";
|
||||||
import { settleIdentity } from "../shared-wallet/access-gate";
|
import { settleIdentity } from "../shared-wallet/access-gate";
|
||||||
import { captureSession, expectSession } from "../shared-wallet/session";
|
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.
|
* 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);
|
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 {
|
export function initNg(...args: any[]): any {
|
||||||
const f = getConfig().initNg;
|
const f = getConfig().initNg;
|
||||||
if (!f) throw new Error("[ng-eventually] initNg() not injected — pass it to configure()");
|
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 type { NG } from "@ng-org/web";
|
||||||
|
|
||||||
import { getConfig, getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
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";
|
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,
|
* 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
|
* 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
|
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
|
||||||
// here with their anticipated signatures, emulated for now.
|
// here with their anticipated signatures, emulated for now.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else: passthrough to the real SDK, unchanged.
|
// 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);
|
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 { connectedUser } from "../src/emulated-verifier/connect";
|
||||||
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||||
import { share } from "../src/surface/inbox";
|
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 { post, postToDocument, read as readInbox } from "../src/surface/inbox";
|
||||||
import { readUnion } from "../src/surface/read-model";
|
import { readUnion } from "../src/surface/read-model";
|
||||||
import { sparqlUpdate } from "../src/surface/docs";
|
import { sparqlUpdate } from "../src/surface/docs";
|
||||||
@@ -252,9 +255,20 @@ function makeFakeNg() {
|
|||||||
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
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() {
|
function inject() {
|
||||||
const ng = makeFakeNg();
|
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() });
|
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim().toLowerCase() });
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
resetCaps();
|
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");
|
const bobView = await userInbox("alice", "protected");
|
||||||
expect(bobView).toBe(aliceView);
|
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",
|
"docChangeType",
|
||||||
"docs",
|
"docs",
|
||||||
"ensureIdentity",
|
"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",
|
"inbox",
|
||||||
"init",
|
"init",
|
||||||
"initNg",
|
"initNg",
|
||||||
|
"insertObject",
|
||||||
"ng",
|
"ng",
|
||||||
"readUnion",
|
"readUnion",
|
||||||
"storeRegistry",
|
"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
|
await proxy.sparql_update("sid", "INSERT DATA {}", undefined); // no anchor → passthrough
|
||||||
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
|
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
|
// acts the target performs
|
||||||
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
|
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
|
||||||
"fetch", "init", "watch", "sparql", "ng", "orm", "type", "types",
|
"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
|
// RDF / SPARQL terms the engine's own query paths use
|
||||||
"subject", "base", "schema", "connected", "identity", "identities",
|
"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` 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
|
// `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
|
// banned as ambiguous, but that ban never reaches upstream's term — see the traps
|
||||||
|
|||||||
Reference in New Issue
Block a user