feat!: useShapeQuery lit par la requete de forme du SDK

This commit is contained in:
Sylvain Duchesne
2026-09-21 11:41:31 +02:00
parent a5e553e65b
commit 77e78ec81d
29 changed files with 1190 additions and 971 deletions
@@ -16,6 +16,7 @@ This package covers placement (creating an application's documents in one of its
An application using this package must: 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;
- 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.
@@ -31,6 +32,9 @@ export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig { export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web ng: NgLike; // the `ng` object from @ng-org/web
useShape: UseShapeLike; // `useShape` from @ng-org/orm useShape: UseShapeLike; // `useShape` from @ng-org/orm
// `RdfOrmSubscription.getOrCreate` from @ng-org/orm - the SDK's shape query, which
// `useShapeQuery` reads through. Pass it as it stands; no wrapper, no cast.
ormSubscription?: OrmSubscriptionLike;
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? } sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
debugAccessLog?: boolean; debugAccessLog?: boolean;
init?: (...args: any[]) => any; init?: (...args: any[]) => any;
@@ -61,15 +65,19 @@ export const storeRegistry: { // no identity parameter — a session is
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>; export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> } export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
// TWO positional arguments — the same pair `useShape` takes, and the same `Scope`. // TWO positional arguments — the same pair `useShape` takes, both of them the SDK's own
// `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in. // types. `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in, and
export function useShapeQuery<T = UnionSubject>(shapeType: unknown, scope: Scope): ShapeObservable<T>; // its items are the SHAPE's objects: `@id`, `@graph` and the shape's readable predicates.
export interface ShapeObservable<T = UnionSubject> { export function useShapeQuery<T extends BaseType = BaseType>(
shapeType: ShapeType<T>,
scope: Scope,
): ShapeObservable<T>;
export interface ShapeObservable<T extends BaseType = BaseType> {
getSnapshot(): ShapeQuery<T>; // stable reference until the state changes getSnapshot(): ShapeQuery<T>; // stable reference until the state changes
subscribe(onChange: () => void): () => void; // returns its own unsubscribe subscribe(onChange: () => void): () => void; // returns its own unsubscribe
refetch(): void; // re-resolve and re-read now; never polls refetch(): void; // ask again now; never polls
} }
export interface ShapeQuery<T = UnionSubject> { 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;
} }
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe; export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
@@ -157,9 +165,11 @@ Where a call must first find out whether something already exists — a document
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about. **`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
**A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too. **A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds nothing of that shape — the distinction the surface exists for, and the one thing it will never do is publish that snapshot for a read that did not happen. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
**A read is scoped by document and by subject, never by store.** `Scope` has two axes and there is no third: `graphs` names one document's graph per entry — `did:ng:i` or `""` for the whole local dataset, `[]` or `undefined` for no objects at all — and `subjects` filters subject IRIs literally, with no prefix or hierarchy matching. There is no call that lists the documents in a store, and naming a store in `graphs` is not the question you meant: a store's graph holds its containment triples and nothing of the shape you asked for, so it answers empty rather than raising. That is the target's own shape, not a narrowing this package chose. **Which failure lands in `isError` changed in `1.0.0-dev.6`, and one of them now stays PENDING.** The read goes through the SDK's own shape query, so what `isError` carries is the query failing to open: a shape the ORM cannot subscribe, or a `configure` that was never given `ormSubscription`. A start the underlying engine REFUSES does not reach it: NextGraph catches that failure and logs it, and its readiness signal is only ever resolved, never rejected, so such a read stays `isPending` indefinitely. The guarantee above is unaffected — you are still never told "there is nothing" for "nobody answered" — but an interface that handles only `isError` will show its loading state where it used to show an error. Handle a `isPending` that does not end.
**A read is scoped by document and by subject, never by store.** `Scope` has two axes and there is no third: `graphs` names one document's graph per entry — `did:ng:i` or `""` for the whole local dataset, `[]` or `undefined` for no objects at all — and `subjects` filters subject IRIs literally, with no prefix or hierarchy matching. There is no call that lists the documents in a store, and naming a store in `graphs` is not the question you meant: a store's graph holds its containment triples and nothing of the shape you asked for, so it answers empty rather than raising. That is the target's own shape, not a narrowing this package chose — and since `1.0.0-dev.6` nothing is translated on the way: the `Scope` object you write is the object the SDK receives.
**A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page. **A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
@@ -183,7 +193,7 @@ 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.
**No live read through `useShape` against a deployed broker.** The reactive subscription it opens is dispatched by method name at two hops below this package, and a broker deployed before that name last changed does not recognise it: the read never starts, its set stays empty, its readiness never settles, and one console error is the only trace — indistinguishable from "this scope is empty". The cause is upstream in NextGraph and the remedy is a broker redeployment; nothing in this package can work around it. Use `useShapeQuery` for a reactive read and `readUnion` for a one-shot one — neither goes through that path. **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 revocation.** `inbox.share` cannot be undone. **No revocation.** `inbox.share` cannot be undone.
@@ -201,6 +211,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.
**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.
## Change policy ## Change policy
@@ -209,17 +221,25 @@ Where a call must first find out whether something already exists — a document
What each level means here, in this package's own terms: What each level means here, in this package's own terms:
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession`, `inbox.materialize`, `storeRegistry.listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` all left this way), **or** a published symbol is renamed (`watchShape` became `useShapeQuery`), **or** an existing call narrows: it now throws where it returned (`ensureIdentity` rejects rather than resolve on an incomplete restore), or it reports a state you did not have to handle before (`useShapeQuery` publishing `isError` where it used to publish a synced-empty snapshot). A signature change a caller must react to counts; one that only accepts more than before does not. - **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession`, `inbox.materialize`, `storeRegistry.listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` all left this way), **or** a published symbol is renamed (`watchShape` became `useShapeQuery`), **or** an existing call narrows: it now throws where it returned (`ensureIdentity` rejects rather than resolve on an incomplete restore), it reports a state you did not have to handle before (`useShapeQuery` publishing `isError` where it used to publish a synced-empty snapshot), or what it HANDS BACK changes shape (`useShapeQuery`'s `data` carrying the shape's own objects where it carried property bags). A signature change a caller must react to counts; one that only accepts more than before does not.
- **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.5`), 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.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.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.5` 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.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.
`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.5`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.5`, 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.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-dev.5` is the largest **major** of the line so far: it renames one call, changes the parameter of two, and removes three. Every change is one step toward the surface NextGraph will ship, so none of them is reversible by asking. Symbol by symbol, with what replaces it: `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:
- **`data` items are the shape's objects, not property bags.** `ShapeQuery<T>`'s `T` is now the shape's type: each item carries `@id`, `@graph` and one field per predicate the shape DECLARES, under that predicate's readable name. `note.props["urn:notebook:title"]?.[0]` becomes `note.title`, and `note.graph` becomes `note["@graph"]`. **A predicate your shape does not name is not projected**, so a shape written only to match — an `rdf:type` constraint and nothing else — now comes back with no fields at all: declare the ones you read. `readUnion` is unchanged and still returns `UnionSubject`.
- **`shapeType` is typed.** It was `unknown`; it is `ShapeType<T>` (the SDK's own, re-exported here), so the snapshot is typed without a cast — and a hand-written shape object must now satisfy that type, which means `readablePredicate`, `minCardinality` and `maxCardinality` on every predicate.
- **`configure` takes one more thing.** `ormSubscription: RdfOrmSubscription.getOrCreate`, plus `initNg` and a call to this package's `initNg(ng, session)` once the session has arrived. Optional in the type, required in practice for this read: without it `useShapeQuery` publishes `isError` naming the field.
And one guarantee narrows, which is the part to read twice: a shape query the engine REFUSES no longer reaches `isError` — it stays `isPending` for ever, because NextGraph swallows that failure. See `## Guarantees`.
`1.0.0-dev.5` before it was the largest **major** of the line: it renames one call, changes the parameter of two, and removes three. Every change is one step toward the surface NextGraph will ship, so none of them is reversible by asking. Symbol by symbol, with what replaces it:
- **`watchShape(shapeType, scope)` → `useShapeQuery(shapeType, scope)`.** The name is the target's own; `watchShape` appears nowhere in NextGraph and spends a verb the ORM already uses for signal observation. Everything you read back is unchanged — the same `ShapeObservable`, the same `ShapeQuery` snapshot, the same three states. - **`watchShape(shapeType, scope)` → `useShapeQuery(shapeType, scope)`.** The name is the target's own; `watchShape` appears nowhere in NextGraph and spends a verb the ORM already uses for signal observation. Everything you read back is unchanged — the same `ShapeObservable`, the same `ShapeQuery` snapshot, the same three states.
- **`Scope` is no longer `"public" | "protected" | "private"`.** The published `Scope` is now the SDK's own, `{ graphs?: string[] | string; subjects?: string[] }`, re-exported from `@ng-org/orm`, and it is what `useShapeQuery` takes. `watchShape(Shape, "protected")` becomes `useShapeQuery(Shape, { graphs: "did:ng:i" })` for everything you hold, or `useShapeQuery(Shape, { graphs: [doc1, doc2] })` for named documents. **There is no translation for "the documents in my protected store"**: that question is not expressible against the target, and the three strings were this package's own way of asking it. - **`Scope` is no longer `"public" | "protected" | "private"`.** The published `Scope` is now the SDK's own, `{ graphs?: string[] | string; subjects?: string[] }`, re-exported from `@ng-org/orm`, and it is what `useShapeQuery` takes. `watchShape(Shape, "protected")` becomes `useShapeQuery(Shape, { graphs: "did:ng:i" })` for everything you hold, or `useShapeQuery(Shape, { graphs: [doc1, doc2] })` for named documents. **There is no translation for "the documents in my protected store"**: that question is not expressible against the target, and the three strings were this package's own way of asking it.
@@ -33,4 +33,22 @@ So this change takes the one reactive read that works and puts it on the path th
## Progress ## Progress
- Blocker confirmed LIFTED before writing anything: the deleted bug leaf's premise was the pinned packages, and `RdfOrmSubscription.getOrCreate` is reachable at `@ng-org/orm@0.1.2-alpha.21`.
- Read the doctrine first (`surface-mirrors-the-sdk`, `no-divergence-from-nextgraph`, `knowledge_what-a-read-scope-is-upstream`), then the SDK's installed typings to establish what the shape query offers: `readyPromise` (pending vs ready-and-empty), `signalObject`, `addChangeListener`, `close`.
- Established, by reading `dist/connector/RdfOrmSubscription.js`, that `orm_start_graph` failing is caught and only `console.error`'d and that `readyPromise` is built with `resolve` alone. So the ONLY real error channel on this path is the query refusing to open; `isError` is wired to that and the rest is recorded rather than invented (no timeout was added).
- Chose injection over a runtime import: the package never hard-imports the SDK, and `useShape` is React's hook, unusable from an observable. `configure({ ormSubscription })` mirrors the `ng` / `useShape` fields beside it, and `RdfOrmSubscription.getOrCreate` is assignable to it with no wrapper (probed with `tsc` before committing to the type).
- Rewrote both suites against a new `test/orm-fake.ts` - a small engine honouring the two scope axes the way `schema_shape_to_sparql` does, so the scope tests still prove something. Nothing deleted: 7 -> 11 and 12 -> 15 tests.
- The e2e harness now injects the REAL ORM, so the two `useShapeQuery` journeys measure the deployed broker. First run died in the broker login (a transient before any assertion); the second passed 42/42.
## Result ## Result
**Done.** `useShapeQuery` reads through the SDK's shape query (`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; `graphs`/`subjects`/`did:ng:i` reach the engine verbatim. The observable, snapshot, state tracking and capability filter stay ours. `data` items are the shape's own objects and `shapeType` is `ShapeType<T>`. **`isError` stays**, 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` deleted; `liveStartWithConfig` retired. `1.0.0-dev.6`, major.
**Files.** `packages/polyfill/` src (7), test (4, incl. new `orm-fake.ts`), e2e (4), `package.json`, `README.md`; `docs/{api-contract,internal-contract}.md`; the contract leaf. Out of boundary, deliberately: `examples/notebook/` + `bun.lock` - the change silently broke the reference consumer.
**Gate.** `bun test` 408 pass / 0 fail; both `tsc` exit 0; lint 0 ERROR; `42 passed, 0 failed, 42 total - batch 3.3 min`.
**Surprises.** VERIFIED: `listMyEntityDocs` is NOT dead - its own suite and an e2e journey call it. VERIFIED: `bun.lock` was stale before this; `bun install` synced it.
**Follow-ups.** `readUnion` keeps two `index-deposit.ts` callers (T01.f). `knowledge_what-the-orm-writes-and-deletes-upstream.md` links the deleted bug leaf. `watchShape` survives as a stale name in ~10 src comments.
@@ -47,4 +47,5 @@ The polyfill's published surface has drifted from the SDK's: invented names, inv
- T01.b — deletion is answered at level 3 and fully wired: `DeepSignalSet.delete` -> a remove patch -> `graph_orm_update` -> `SparqlBuilder::remove_object`, which removes the SHAPE's predicates, not the subject; no document deletion exists at any level. `getObjects` returns a DETACHED clone; `insertObject` throws without `@graph`. Proposals: publish `OrmSubscription` as-is (the read filter applies to `signalObject`), publish NEITHER `DiscreteOrmSubscription` (JSON-CRDT, no `@graph`, nothing for a per-document filter to read) NOR `ngSession` (hands out the SDK handle and the session). MEASURED: `useShape` is still broken live, and the bug leaf's mechanism was FALSE - the deployment is AHEAD of our pinned `@ng-org/*`, its `orm_start_graph` takes `config` BEFORE the callback, so the five-argument client lands its callback in the wrong slot. T01.d's blocker STANDS. Also VERIFIED: ORM writes bypass `sparql_update` entirely, so the emulated write guard never sees an ORM add or delete. - T01.b — deletion is answered at level 3 and fully wired: `DeepSignalSet.delete` -> a remove patch -> `graph_orm_update` -> `SparqlBuilder::remove_object`, which removes the SHAPE's predicates, not the subject; no document deletion exists at any level. `getObjects` returns a DETACHED clone; `insertObject` throws without `@graph`. Proposals: publish `OrmSubscription` as-is (the read filter applies to `signalObject`), publish NEITHER `DiscreteOrmSubscription` (JSON-CRDT, no `@graph`, nothing for a per-document filter to read) NOR `ngSession` (hands out the SDK handle and the session). MEASURED: `useShape` is still broken live, and the bug leaf's mechanism was FALSE - the deployment is AHEAD of our pinned `@ng-org/*`, its `orm_start_graph` takes `config` BEFORE the callback, so the five-argument client lands its callback in the wrong slot. T01.d's blocker STANDS. Also VERIFIED: ORM writes bypass `sparql_update` entirely, so the emulated write guard never sees an ORM add or delete.
- 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.
+18 -10
View File
@@ -14,14 +14,18 @@
"name": "@ng-eventually/example-notebook", "name": "@ng-eventually/example-notebook",
"dependencies": { "dependencies": {
"@ng-eventually/polyfill": "workspace:*", "@ng-eventually/polyfill": "workspace:*",
"@ng-org/web": "0.1.2-alpha.13", "@ng-org/orm": "0.1.2-alpha.21",
"@ng-org/web": "0.1.2-alpha.14",
}, },
}, },
"packages/ng-e2e-helpers": { "packages/ng-e2e-helpers": {
"name": "ng-e2e-helpers", "name": "ng-e2e-helpers",
"version": "0.0.0", "version": "1.0.0-dev.2",
"bin": {
"ng-mint-wallet": "./bin/mint-wallet.ts",
},
"devDependencies": { "devDependencies": {
"@ng-org/web": "0.1.2-alpha.13", "@ng-org/web": "0.1.2-alpha.14",
"playwright": "^1.61.1", "playwright": "^1.61.1",
}, },
"peerDependencies": { "peerDependencies": {
@@ -31,11 +35,13 @@
}, },
"packages/polyfill": { "packages/polyfill": {
"name": "@ng-eventually/polyfill", "name": "@ng-eventually/polyfill",
"version": "0.0.0", "version": "1.0.0-dev.5",
"devDependencies": { "devDependencies": {
"@ng-org/alien-deepsignals": "0.1.2-alpha.11", "@ng-org/alien-deepsignals": "0.1.2-alpha.13",
"@ng-org/shex-orm": "0.1.2-alpha.8", "@ng-org/orm": "0.1.2-alpha.21",
"@ng-org/web": "0.1.2-alpha.13", "@ng-org/shex-orm": "0.1.2-alpha.9",
"@ng-org/web": "0.1.2-alpha.14",
"ng-e2e-helpers": "workspace:*",
}, },
"peerDependencies": { "peerDependencies": {
"@ng-org/alien-deepsignals": "*", "@ng-org/alien-deepsignals": "*",
@@ -60,11 +66,13 @@
"@ng-eventually/polyfill": ["@ng-eventually/polyfill@workspace:packages/polyfill"], "@ng-eventually/polyfill": ["@ng-eventually/polyfill@workspace:packages/polyfill"],
"@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.11", "", { "dependencies": { "alien-signals": "2.0.8" } }, "sha512-nPgqOrheAda/pW5FHgSb45SrSZWuyMyEVqO683ijEsVPpD105bngfh92PPfcRoRnFzGSoKXa3CfuqUHi2+qVIQ=="], "@ng-org/alien-deepsignals": ["@ng-org/alien-deepsignals@0.1.2-alpha.13", "", { "dependencies": { "alien-signals": "^2.0.7" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-yOEh/J+9IrCo8WDXTdZLxRcO9IHAczrUA4pjSD4BaZzmWhnCFE/amy/0PsAdh/yePurBrtQj3tG/y+hReM15Ew=="],
"@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.8", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "1.0.0-alpha.28", "commander": "14.0.3", "dts-dom": "3.6.0", "ejs": "3.1.10", "fs-extra": "10.1.0", "jsonld2graphobject": "0.0.5", "loading-cli": "1.1.2", "prettier": "3.9.5" }, "peerDependencies": { "typescript": "5.9.3" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-jogxiLfUtI3WkuF5O3PPwRBRWylpapk/JlvElGUqZnEavXXlEMmEGbK3VwqMUsbp1pweNA0sEKIFLIk3Lzt2Gg=="], "@ng-org/orm": ["@ng-org/orm@0.1.2-alpha.21", "", { "dependencies": { "@ng-org/alien-deepsignals": "0.1.2-alpha.13" }, "peerDependencies": { "react": "^19.0.0 || ^18.0.0", "svelte": "^5.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["react", "svelte", "vue"] }, "sha512-+Dtt9jEa+277wonExxqC1BHqVwvWhXeXM51zcGg2w+Mc6C1F74qaXMGYZT3CcWCQISGEMOWV3n2movP3yQ8xJA=="],
"@ng-org/web": ["@ng-org/web@0.1.2-alpha.13", "", { "dependencies": { "async-proxy": "0.4.1" } }, "sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ=="], "@ng-org/shex-orm": ["@ng-org/shex-orm@0.1.2-alpha.9", "", { "dependencies": { "@ldo/traverser-shexj": "1.0.0-alpha.28", "@ldo/type-traverser": "1.0.0-alpha.28", "@shexjs/parser": "^1.0.0-alpha.24", "commander": "^14.0.1", "dts-dom": "~3.6.0", "ejs": "^3.1.8", "fs-extra": "^10.1.0", "jsonld2graphobject": "^0.0.5", "loading-cli": "^1.1.0", "prettier": "^3.0.3" }, "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0" }, "bin": { "rdf-orm": "dist/cli.js" } }, "sha512-M69oIHrcjl24xKXIaiZjZ64rJvispAPIqn73asLPR73GWpEMvbiot/EN0vDkcfrbRy3cMEql36A9e1Nwv2csOA=="],
"@ng-org/web": ["@ng-org/web@0.1.2-alpha.14", "", { "dependencies": { "async-proxy": "^0.4.1" } }, "sha512-FPQ7kyngMhAXK/WJtZ6pkoEJwGcau6GaBH2nhjjYorMq7OG//I+zwghat90FsIeMrfYsps0+5Y0UrRqfxNdUXA=="],
"@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="], "@rdfjs/types": ["@rdfjs/types@1.1.2", "", { "dependencies": { "@types/node": "26.1.1" } }, "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg=="],
+21 -13
View File
@@ -48,6 +48,7 @@ The prose labels above map onto the annotation's closed `kind` set, and where th
export interface EventuallyConfig { export interface EventuallyConfig {
ng: NgLike; // the REAL @ng-org/web ng ng: NgLike; // the REAL @ng-org/web ng
useShape: UseShapeLike; // the REAL @ng-org/orm useShape useShape: UseShapeLike; // the REAL @ng-org/orm useShape
ormSubscription?: OrmSubscriptionLike; // the REAL @ng-org/orm RdfOrmSubscription.getOrCreate
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number }; pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
sharedWallet?: SharedWalletConfig; // the gate's, § 2bis sharedWallet?: SharedWalletConfig; // the gate's, § 2bis
debugAccessLog?: boolean; debugAccessLog?: boolean;
@@ -75,7 +76,9 @@ export function configure(c: EventuallyConfig): void;
Both remain substitutable through `configureStoreRegistry` (`shared-wallet/bootstrap.ts`), which the published entry does not re-export: the unit suites have no browser and the e2e harness holds a session the broker handed it directly, and neither is an application. Both remain substitutable through `configureStoreRegistry` (`shared-wallet/bootstrap.ts`), which the published entry does not re-export: the unit suites have no browser and the e2e harness holds a session the broker handed it directly, and neither is an application.
So an application's whole bootstrap is `configure({ ng, useShape, init, sharedWallet })` plus `await ensureIdentity()` — and the second of those keeps its call site after migration. **One field ARRIVED in `1.0.0-dev.6`: `ormSubscription`.** `useShapeQuery` reads through the SDK's own shape query now (§ 5), and the SDK is injected here rather than imported — so the query has to arrive the same way the `ng` and the `useShape` beside it do. Pass `RdfOrmSubscription.getOrCreate` as it stands; it is assignable to `OrmSubscriptionLike` with no wrapper. It is OPTIONAL because an application that never calls `useShapeQuery` has nothing to give, and `useShapeQuery` without it publishes `isError` naming the field rather than an empty set.
So an application's whole bootstrap is `configure({ ng, useShape, ormSubscription, init, sharedWallet })` plus `await ensureIdentity()` — and the second of those keeps its call site after migration.
### Target ### Target
@@ -224,7 +227,7 @@ Divergence to note: the wrapper types everything `unknown`, losing the generic `
```ts ```ts
// use-shape-query.ts — ShapeQuery // use-shape-query.ts — ShapeQuery
export interface ShapeQuery<T = UnionSubject> { export interface ShapeQuery<T extends BaseType = BaseType> {
data: T[]; data: T[];
isPending: boolean; isPending: boolean;
isSuccess: boolean; isSuccess: boolean;
@@ -232,22 +235,27 @@ export interface ShapeQuery<T = UnionSubject> {
error: unknown; error: unknown;
} }
// use-shape-query.ts — ShapeObservable // use-shape-query.ts — ShapeObservable
export interface ShapeObservable<T = UnionSubject> { export interface ShapeObservable<T extends BaseType = BaseType> {
getSnapshot(): ShapeQuery<T>; getSnapshot(): ShapeQuery<T>;
subscribe(onChange: () => void): () => void; subscribe(onChange: () => void): () => void;
refetch(): void; refetch(): void;
} }
// use-shape-query.ts — useShapeQuery. `Scope` here is the SDK's own // use-shape-query.ts — useShapeQuery. Both parameters are the SDK's own:
// (`sdk/js/orm/src/types.ts:Scope`), re-exported by the entry — NOT a store scope. // `ShapeType` (`sdk/js/shex-orm/src/types.ts`) and `Scope` (`sdk/js/orm/src/types.ts`),
export function useShapeQuery<T = UnionSubject>( // both re-exported by the entry. `Scope` is NOT a store scope.
shapeType: unknown, export function useShapeQuery<T extends BaseType = BaseType>(
shapeType: ShapeType<T>,
scope: Scope, scope: Scope,
): ShapeObservable<T>; ): ShapeObservable<T>;
``` ```
**The name and the scope, since `1.0.0-dev.5`.** It was `watchShape(shapeType, "public" | "protected" | "private")`. `watchShape` has ZERO occurrences in `nextgraph-rs` and spends `watch`, a verb the ORM already spends on signal observation; the target's own name is `useShapeQuery`, announced by NextGraph's developers and on record in `.project/rules/nextgraph/surface-mirrors-the-sdk.md`, which is what the symbol's `announced-not-written` annotation cites. The three literals were this package's own and named the scope of a READ, which the SDK answers on two axes instead: `graphs` (one document's graph per entry, `did:ng:i` or `""` for the whole dataset, `[]` or `undefined` for no objects at all) and `subjects`. Only the whole-dataset reading still needs translating — it is served by enumerating the identity's own documents across its three stores — and that translation goes when the read queries the store itself. **What it reads THROUGH, since `1.0.0-dev.6`: the SDK's own shape query.** `RdfOrmSubscription.getOrCreate(shapeType, scope)`, injected at `configure` as `ormSubscription` (§ 1). Four steps went with the change and they were one mechanism: resolving the logical scope to a document set (`listMyEntityDocs`), opening those documents through `ensureReposOpen` for a sync barrier, `readUnion` over them, and filtering the union by the shape's `@type` IRI. All four existed because the parameter had to be TRANSLATED; once it is upstream's own `Scope`, the question goes to the thing that answers it, and `graphs` / `subjects` / `did:ng:i` are read by the engine literally. It does NOT delegate to the SDK's `useShape` — that is React's hook and its `DeepSignalSet` cannot carry the request state, which is the whole reason this symbol exists. What is taken from the SDK is the QUERY; the observable, the snapshot and the state tracking stay here, and so does the capability filter (`emulated-verifier/read-filter.ts`, applied per `@graph` on what comes back, exactly where § 4 applies it).
**A scope that did not answer is `isError`, never `isSuccess` with `data: []` — since 2026-08-17.** Step 1 of the pipeline asks `listMyEntityDocs` which documents are mine in this scope. That failure used to be caught and logged, and the empty set flowed on: a barrier over zero documents is trivially reached, so the surface published `{ data: [], isPending: false, isSuccess: true }` — byte for byte the synced-but-empty snapshot, which means the one distinction this module exists for was the one it destroyed. An application rendered "you have created nothing" for "the store did not answer". `listMyEntityDocs` had stopped handing out that reading the day before (see § 12); catching it here put it straight back one floor up. It now travels the LOAD-STATE channel, which is where "this is not an answer" already lives on this surface, and which an application must already read to tell pending from empty — so the third state costs it no new vocabulary. **Consequences a caller sees.** `data` items are now the SHAPE's own objects — `@id`, `@graph` and the shape's readable predicates — where they were `UnionSubject` property bags; `shapeType` is typed `ShapeType<T>` instead of `unknown`, so `T` follows the shape and the snapshot is typed without a cast; and the read no longer opens a document of its own, so a public-store document whose cap has not been fetched by some other read does not appear until it has been (the same limit § 4 carries, and the same `CapRegistry.onChange` recovery).
**The name and the scope, since `1.0.0-dev.5`.** It was `watchShape(shapeType, "public" | "protected" | "private")`. `watchShape` has ZERO occurrences in `nextgraph-rs` and spends `watch`, a verb the ORM already spends on signal observation; the target's own name is `useShapeQuery`, announced by NextGraph's developers and on record in `.project/rules/nextgraph/surface-mirrors-the-sdk.md`, which is what the symbol's `announced-not-written` annotation cites. The three literals were this package's own and named the scope of a READ, which the SDK answers on two axes instead: `graphs` (one document's graph per entry, `did:ng:i` or `""` for the whole dataset, `[]` or `undefined` for no objects at all) and `subjects`. Nothing is translated any more: the `Scope` object a caller writes is the object the SDK receives.
**"There is nothing" and "I could not find out" are still two different answers — and the second one moved.** Until `1.0.0-dev.6` the failure this surface had to keep apart was its own first step: `listMyEntityDocs` not answering, caught and logged, the empty set flowing on, and `{ data: [], isPending: false, isSuccess: true }` published — byte for byte the synced-but-empty snapshot, so an application rendered "you have created nothing" for "the store did not answer". That step is gone with the pipeline. What can fail now is opening the shape query, and that is what `isError` carries: a shape the ORM cannot subscribe, or a `configure()` that was never given the query. A start the ENGINE refuses does NOT reach `isError`, and that is upstream's doing rather than a choice made here — `orm_start_graph` is awaited inside a `catch` that only logs, and the readiness promise is built with `resolve` alone — so such a read stays `isPending` for ever. The distinction holds either way (an application is never told "there is nothing" for "nobody answered"), but an interface that only handles `isError` will show a spinner where it used to show an error. `isError` stays published because this package enriches the SDK in its announced direction rather than stopping at what it exposes today; the day the ORM reports a failed start, that is where it lands.
**`data` survives an error rather than emptying.** A one-shot call rejects and is done; an observable has already handed a list to a subscriber that rendered it, and cannot un-emit. Collapsing `data` to `[]` on failure would put the empty answer back in the one field a view actually paints, for exactly the case that must never read as empty — so `data` keeps the last read that ANSWERED, `isSuccess` stays false so that array is never offered as a reply to the question that just failed, and the subscriber is notified so a view gating on the load state can say so. Before any answer there is nothing to keep and `data` is `[]`, published under `isError` and never under `isSuccess`. **`data` survives an error rather than emptying.** A one-shot call rejects and is done; an observable has already handed a list to a subscriber that rendered it, and cannot un-emit. Collapsing `data` to `[]` on failure would put the empty answer back in the one field a view actually paints, for exactly the case that must never read as empty — so `data` keeps the last read that ANSWERED, `isSuccess` stays false so that array is never offered as a reply to the question that just failed, and the subscriber is notified so a view gating on the load state can say so. Before any answer there is nothing to keep and `data` is `[]`, published under `isError` and never under `isSuccess`.
@@ -611,7 +619,7 @@ export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
// resolveWriteGraph — it returned the very reference the three accessors return. // resolveWriteGraph — it returned the very reference the three accessors return.
// NOT published — internal, kept here because the target rulings below still cover them. // NOT published — internal, kept here because the target rulings below still cover them.
// userStoreDoc, userInbox, documentInboxAddress, isOwnInbox, myInboxes, // userInbox, documentInboxAddress, isOwnInbox, myInboxes,
// addLink, readLinks, resolveAccount, ensureAccount, reservedAccount, // addLink, readLinks, resolveAccount, ensureAccount, reservedAccount,
// resetRegistryCache, and the VirtualUserRecord type. // resetRegistryCache, and the VirtualUserRecord type.
// `RegistrySession` joined them on 2026-08-12: it was published for ONE reason — a consumer // `RegistrySession` joined them on 2026-08-12: it was published for ONE reason — a consumer
@@ -629,7 +637,7 @@ export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter is already gone from the published call (2026-08-10); expect `createEntityDoc(scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping. - **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter is already gone from the published call (2026-08-10); expect `createEntityDoc(scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
- **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on the store; the caps come back by replaying the Store branch (`AddRepo::verify` → `load_repo_from_read_cap`). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read. - **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on the store; the caps come back by replaying the Store branch (`AddRepo::verify` → `load_repo_from_read_cap`). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read.
- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `sdk/js/web/src/index.ts:Session`, installed `index.d.ts:265-273`). The store IS the container; the per-scope index document disappears. - **`resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** (`userStoreDoc` stood beside them and was REMOVED in `1.0.0-dev.6`: it handed out the scope-index document so a reactive reader could follow it, and `useShapeQuery` reading through the SDK's shape query left it with no caller.) The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `sdk/js/web/src/index.ts:Session`, installed `index.d.ts:265-273`). The store IS the container; the per-scope index document disappears.
- **`userInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged. - **`userInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.
- **`openDocumentInbox(doc)` / `documentInboxAddress(doc)` → level 1, VERIFIED support, no exerciser.** Every `Repo` carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); `AddInboxCapV0` is keyed by `repo_id` with no is-store restriction (`engine/repo/src/types.rs:1973`; applied at `engine/verifier/src/verifier.rs:1920-1928`); but no code path creates one for a plain document (`doc_create` → `new_repo_default` → `Store::create_repo_default` → `create_repo_with_keys`, which builds the `Repo` with `inbox: None` — `engine/verifier/src/verifier.rs:3004`, `engine/repo/src/store.rs:264,284,691`) and no level-2/3 API exposes any of it. So: the *capability* is engine-verified; the *functions* are invented surface; and the **address publication is a real, deliberate divergence** (upstream transmits addresses, never publishes them — § 9), with the ownership guard compensating our design, not mirroring an upstream rule. - **`openDocumentInbox(doc)` / `documentInboxAddress(doc)` → level 1, VERIFIED support, no exerciser.** Every `Repo` carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`); `AddInboxCapV0` is keyed by `repo_id` with no is-store restriction (`engine/repo/src/types.rs:1973`; applied at `engine/verifier/src/verifier.rs:1920-1928`); but no code path creates one for a plain document (`doc_create` → `new_repo_default` → `Store::create_repo_default` → `create_repo_with_keys`, which builds the `Repo` with `inbox: None` — `engine/verifier/src/verifier.rs:3004`, `engine/repo/src/store.rs:264,284,691`) and no level-2/3 API exposes any of it. So: the *capability* is engine-verified; the *functions* are invented surface; and the **address publication is a real, deliberate divergence** (upstream transmits addresses, never publishes them — § 9), with the ownership guard compensating our design, not mirroring an upstream rule.
- **`addLink(cap)` / `readLinks()` → annotated `declared-not-wired` (level 1).** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:AddLinkV0` — *"so that a user can share with all its device a new Link they received"*, external repos only). **Verified 2026-08-16, and this is the load-bearing fact:** the type is DEFINED, its `CommitVerifier::verify` is a no-op `Ok(())` (`engine/verifier/src/commits/mod.rs`), and **nothing in the engine constructs one** — the only other occurrences are the `CommitBodyV0::AddLink` enum variant and two match arms classifying body kinds. Its sibling `AddRepoV0` *is* constructed (`Verifier::send_add_repo_to_store`), which is why the Store-branch register next door is annotated `aligned` and this one is not. A maintainer read that absence and concluded the function was this library's own invention; it is not — an absent implementation says nothing about the target. Upstream this filing happens inside the verifier when it processes the inbox; the future SDK most likely never exposes these as calls, so consumers should not code against them (§ 15). - **`addLink(cap)` / `readLinks()` → annotated `declared-not-wired` (level 1).** The emulated `AddLink { read_cap }` register (`engine/repo/src/types.rs:AddLinkV0` — *"so that a user can share with all its device a new Link they received"*, external repos only). **Verified 2026-08-16, and this is the load-bearing fact:** the type is DEFINED, its `CommitVerifier::verify` is a no-op `Ok(())` (`engine/verifier/src/commits/mod.rs`), and **nothing in the engine constructs one** — the only other occurrences are the `CommitBodyV0::AddLink` enum variant and two match arms classifying body kinds. Its sibling `AddRepoV0` *is* constructed (`Verifier::send_add_repo_to_store`), which is why the Store-branch register next door is annotated `aligned` and this one is not. A maintainer read that absence and concluded the function was this library's own invention; it is not — an absent implementation says nothing about the target. Upstream this filing happens inside the verifier when it processes the inbox; the future SDK most likely never exposes these as calls, so consumers should not code against them (§ 15).
@@ -698,7 +706,7 @@ The entry used to say `export * from "./model/types"`, a blanket re-export publi
export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike } from "./model/types"; export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike } 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` and `UseShapeLike` type the two injected objects in `EventuallyConfig` (§ 1). 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.
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**:
@@ -742,7 +750,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, 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, 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
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
+5 -5
View File
@@ -169,11 +169,11 @@ export async function ensureAccount(id: string): Promise<VirtualUserRecord>;
### 9b. Scope-index resolution ### 9b. Scope-index resolution
```ts ```ts
// store-registry.ts:1079 // REMOVED in `1.0.0-dev.6`
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>; // export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri>;
``` ```
The scope-INDEX document of one account (what `watchShape` subscribes to for container changes). Same resolution as the published `resolveWriteGraph`, without doubling as a write target. The scope-INDEX document of one account, which the reactive shape read subscribed to for container changes. That read goes through the SDK's own shape query since `1.0.0-dev.6`, which follows the data rather than a container this package maintains, so the function lost its only caller and went with it. The ruling below is kept for the record, because the question it answers is the one the target closes.
- **Level 2, VERIFIED counterpart with the indirection removed**: in the target the container IS the store, and its id is on the session (`Session.private_store_id | protected_store_id | public_store_id`, `index.d.ts:264-272`); the listing a subscriber watches is the store's `ldp:contains` graph, written natively by `doc_create` (`request_processor.rs:706-708`). The function's question survives; the per-account parameter and the index-document indirection disappear. - **Level 2, VERIFIED counterpart with the indirection removed**: in the target the container IS the store, and its id is on the session (`Session.private_store_id | protected_store_id | public_store_id`, `index.d.ts:264-272`); the listing a subscriber watches is the store's `ldp:contains` graph, written natively by `doc_create` (`request_processor.rs:706-708`). The function's question survives; the per-account parameter and the index-document indirection disappear.
@@ -249,7 +249,7 @@ export function inspectOutbox(): void;
**F3 — incomplete citation in `subscribe.ts`.** `subscribe.ts:31` cites the ORM fan-out abort as "`initialize.rs:125-128`" with no path. The file is `engine/verifier/src/orm/graph/initialize.rs`; lines 125-128 are the graph loop calling `self.open_for_target(&nuri.target, true).await?` — verified, the `?` propagates `RepoNotFound` and aborts the whole subscription. Substance correct; the bare filename is unfindable without this note. **F3 — incomplete citation in `subscribe.ts`.** `subscribe.ts:31` cites the ORM fan-out abort as "`initialize.rs:125-128`" with no path. The file is `engine/verifier/src/orm/graph/initialize.rs`; lines 125-128 are the graph loop calling `self.open_for_target(&nuri.target, true).await?` — verified, the `?` propagates `RepoNotFound` and aborts the whole subscription. Substance correct; the bare filename is unfindable without this note.
**F4 — `docs/api-contract.md` lags the `surface/placement.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `userStoreDoc`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `virtualUsers.*` on the SDK entry — since the split (`index.ts` routes through `surface/placement.ts`) those are internal. That file is being edited concurrently; noted here, deliberately not fixed by this document. **F4 — `docs/api-contract.md` lags the `surface/placement.ts` split.** Its § 12 and appendix still list `resolveAccount`, `ensureAccount`, `isOwnInbox`, `myInboxes`, `addLink`, `readLinks`, `reservedAccount`, `resetRegistryCache` as the SDK entry's `storeRegistry` namespace, and § 13/§ 15 place `virtualUsers.*` on the SDK entry — since the split (`index.ts` routes through `surface/placement.ts`) those are internal. That file is being edited concurrently; noted here, deliberately not fixed by this document.
**F5 — `reservedAccount`'s collision guarantee is asserted about code the lib does not own.** `store-registry.ts:200-206` states the injected `normalizeId` "strips a leading `@`, trims, and lowercases, so a NUL prefix is unreachable" — that describes ONE consumer's normalizer, not a contract; the lib's own default is `id.trim()` (`shared-wallet/bootstrap.ts`), which passes U+0000 through. The reserved namespace is disjoint only if every consumer's normalizer keeps it so. Either document the requirement on `StoreRegistryDeps.normalizeId`, or reject NUL-prefixed raw ids at `accountKey`. **F5 — `reservedAccount`'s collision guarantee is asserted about code the lib does not own.** `store-registry.ts:200-206` states the injected `normalizeId` "strips a leading `@`, trims, and lowercases, so a NUL prefix is unreachable" — that describes ONE consumer's normalizer, not a contract; the lib's own default is `id.trim()` (`shared-wallet/bootstrap.ts`), which passes U+0000 through. The reserved namespace is disjoint only if every consumer's normalizer keeps it so. Either document the requirement on `StoreRegistryDeps.normalizeId`, or reject NUL-prefixed raw ids at `accountKey`.
@@ -266,6 +266,6 @@ export function inspectOutbox(): void;
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`); `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`, `userStoreDoc`, `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`).
Modules with no internal exports (everything they export is published): `types.ts`, `docs.ts`, `inbox.ts`, `surface/read-model.ts`, `shared-wallet/virtualUsers.ts`, `emulated-verifier/caps.ts`, `sparql.ts`, `lifecycle.ts`, `surface/use-shape.ts`, `surface/watch-shape.ts`, `surface/placement.ts`, and the entry point. Modules with no internal exports (everything they export is published): `types.ts`, `docs.ts`, `inbox.ts`, `surface/read-model.ts`, `shared-wallet/virtualUsers.ts`, `emulated-verifier/caps.ts`, `sparql.ts`, `lifecycle.ts`, `surface/use-shape.ts`, `surface/watch-shape.ts`, `surface/placement.ts`, and the entry point.
+37 -5
View File
@@ -37,6 +37,7 @@ import {
readUnion, readUnion,
storeRegistry, storeRegistry,
subscribeDoc, subscribeDoc,
initNg as initNgSignals,
useShapeQuery, useShapeQuery,
type Nuri, type Nuri,
type ShapeObservable, type ShapeObservable,
@@ -44,6 +45,10 @@ import {
configure, configure,
} from "@ng-eventually/polyfill"; } from "@ng-eventually/polyfill";
import { ng as realNg, init as realInit } from "@ng-org/web"; import { ng as realNg, init as realInit } from "@ng-org/web";
// The SDK's shape query, which `useShapeQuery` reads through since `1.0.0-dev.6`, and
// the call that hands the ORM its session. Both are handed to `configure` below, the
// same way the `ng` is: this package never imports the SDK itself.
import { initNg as ormInitNg, RdfOrmSubscription } from "@ng-org/orm";
// --- the domain, such as it is --------------------------------------------- // --- the domain, such as it is ---------------------------------------------
@@ -91,6 +96,23 @@ const NoteShape = {
maxCardinality: 1, maxCardinality: 1,
dataTypes: [{ literals: [NOTE], valType: "iri" }], dataTypes: [{ literals: [NOTE], valType: "iri" }],
}, },
// The two fields a note actually carries. A shape declares what comes BACK as
// well as what matches: a predicate the shape does not name is not projected onto
// the object, so leaving these out would return notes with no title and no body.
{
iri: TITLE,
readablePredicate: "title",
minCardinality: 1,
maxCardinality: 1,
dataTypes: [{ valType: "string" }],
},
{
iri: BODY,
readablePredicate: "body",
minCardinality: 1,
maxCardinality: 1,
dataTypes: [{ valType: "string" }],
},
], ],
}, },
}, },
@@ -104,8 +126,10 @@ const NoteShape = {
configure({ configure({
ng: realNg, ng: realNg,
useShape: (() => {}) as never, // `useShapeQuery` and `readUnion` do not go through the ORM hook useShape: (() => {}) as never, // this app reads through `useShapeQuery`, never the hook
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit, init: realInit,
initNg: ormInitNg,
sharedWallet: { sharedWallet: {
fileUrl: "/shared-wallet.ngw", fileUrl: "/shared-wallet.ngw",
password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "", password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "",
@@ -125,7 +149,13 @@ configure({
// is only what this app itself reads. // is only what this app itself reads.
const sessionReady = new Promise<{ session_id: string }>((resolve) => { const sessionReady = new Promise<{ session_id: string }>((resolve) => {
init((event: { status: string; session?: { session_id: string } }) => { init((event: { status: string; session?: { session_id: string } }) => {
if (event.status === "loggedin" && event.session) resolve(event.session); if (event.status === "loggedin" && event.session) {
// The ORM reaches the SDK through a module-level promise of its own; nothing it
// opens starts before this. Through the library's `initNg`, which is the SDK-shaped
// call and survives migration.
initNgSignals(realNg, event.session);
resolve(event.session);
}
}, true, []); }, true, []);
}); });
@@ -185,9 +215,11 @@ function currentNotes(): Note[] {
return noteQuery() return noteQuery()
.getSnapshot() .getSnapshot()
.data.map((s) => ({ .data.map((s) => ({
doc: s.graph, // The read hands back the SHAPE's own objects now, not property bags: `@graph` is
title: s.props[TITLE]?.[0] ?? "(sans titre)", // the document a note lives in, and each field is the predicate's readable name.
body: s.props[BODY]?.[0] ?? "", doc: s["@graph"] as Nuri,
title: typeof s.title === "string" ? s.title : "(sans titre)",
body: typeof s.body === "string" ? s.body : "",
})); }));
} }
+2 -1
View File
@@ -5,6 +5,7 @@
"description": "A minimal application written against @ng-eventually/polyfill \u2014 the library's usage example, and what the e2e suite drives.", "description": "A minimal application written against @ng-eventually/polyfill \u2014 the library's usage example, and what the e2e suite drives.",
"dependencies": { "dependencies": {
"@ng-eventually/polyfill": "workspace:*", "@ng-eventually/polyfill": "workspace:*",
"@ng-org/web": "0.1.2-alpha.13" "@ng-org/orm": "0.1.2-alpha.21",
"@ng-org/web": "0.1.2-alpha.14"
} }
} }
+4 -1
View File
@@ -32,7 +32,7 @@ import {
configure, configure,
} from "@ng-eventually/polyfill"; } from "@ng-eventually/polyfill";
configure({ ng: realNg, useShape: realUseShape, getSession, sharedWallet }); configure({ ng: realNg, useShape: realUseShape, ormSubscription, 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.
@@ -41,6 +41,9 @@ await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
// Reading is by SHAPE, over the SDK's own scope. `did:ng:i` is the whole local dataset; // Reading is by SHAPE, over the SDK's own scope. `did:ng:i` is the whole local dataset;
// naming documents in `graphs` reads exactly those. There is no "list my documents" call // naming documents in `graphs` reads exactly those. There is no "list my documents" call
// and no way to scope a read by store — neither exists upstream. // and no way to scope a read by store — neither exists upstream.
// It reads through the SDK's own shape query (`RdfOrmSubscription.getOrCreate`, passed to
// `configure` as `ormSubscription`), and hands back the shape's own objects: `@graph`,
// `@id` and one field per predicate the shape DECLARES.
const query = useShapeQuery(NoteShape, { graphs: "did:ng:i" }); const query = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
``` ```
+7 -60
View File
@@ -1,20 +1,12 @@
/** /**
* The page for the LIVE ORM measurement — the one thing `e2e/polyfill-entry.ts` cannot do. * The page for the LIVE ORM measurement - what `e2e/run.ts` still does not measure.
* *
* That harness injects a hand-written Set-like into `configure({ useShape })`, because the * The 42-check harness injects the REAL `RdfOrmSubscription.getOrCreate` since
* read filter is pure and does not need the real ORM to be exercised. What it therefore * `1.0.0-dev.6` (that is what `useShapeQuery` reads through), so a live ORM read is no
* never touches is the only hop that reaches the broker: `ng.orm_start_graph`, the streamed * longer unreachable from there. What stays here is the PUBLISHED `useShape` half: the
* method `OrmSubscription` opens and the one `bug_useshape-live-read-needs-a-newer-broker` * bare `DeepSignalSet` an application gets from the ORM hook, and a `.delete()` on it
* is about. So this page injects the REAL `@ng-org/orm` — nothing else about the bootstrap * reaching the document. That is a different surface from the snapshot `useShapeQuery`
* differs — and the published `useShape` runs over a live subscription. * publishes, and it has no other measurement.
*
* ── The dependency, stated because it is not installed by default ──────────
* `@ng-org/orm` is an OPTIONAL PEER of this package (`package.json`,
* `peerDependenciesMeta`), so `bun install` does not fetch it and `bun.lock` records it
* under `optionalPeers` with no resolution. This page is the only thing in the repository
* that imports it: `e2e/run.ts` and its harness do not, so a checkout without it still
* builds and runs the 42-check suite. Here the bundle simply will not build, and the
* runner says so rather than reporting a broker verdict it did not measure.
* *
* ── What the framework adapter contributes, and why it is not here ───────── * ── What the framework adapter contributes, and why it is not here ─────────
* Upstream's `useShape` is React's (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`): * Upstream's `useShape` is React's (`sdk/js/orm/src/frontendAdapters/react/useShape.ts`):
@@ -247,51 +239,6 @@ async function seededTripleCount(doc: Nuri, subject: string): Promise<number> {
} }
}, },
/**
* CHARACTERISATION of the deployed broker's `orm_start_graph` ARITY.
*
* The failure the runner reports named a parameter - `config` - that the client of
* the day did not send. Two upstream arrangements could put one there, and they differ
* by POSITION:
* - config LAST, after the callback (upstream 2026-04-12 to 2026-04-21) — an
* unsupplied `config`, never a Function;
* - config at index 4, BEFORE the callback: the 5-argument call of the client of
* the day then lands its callback exactly there, which is what the error described.
*
* THE UPDATE ANSWERED THAT QUESTION. `@ng-org/web@0.1.2-alpha.14` declares six
* arguments with `config` at index 4 and routes the stream callback from index 5
* (`dist/ngweb.js`: `orm_start_graph: 5`), and `@ng-org/orm@0.1.2-alpha.21` sends six
* to match (`dist/connector/RdfOrmSubscription.js:159`). So the second arrangement was
* the right reading, and the ordinary `liveStart` path now exercises it.
*
* This is therefore kept as a FALLBACK, not as the live question: the runner only asks
* it when `liveStart` did not become ready, to say whether the engine still refuses on
* `config` when the client no longer can be blamed. It calls the SDK member directly
* rather than through the ORM. The app-side proxy takes index 5 as the stream callback,
* so the `{}` at index 4 is read as a config and nothing usable comes back; what is
* being read is only whether the start is refused.
*/
async liveStartWithConfig() {
const s = await sessionReady;
const doc = (await docs.docCreate(
s.session_id,
"Graph",
"data:graph",
"store",
undefined,
)) as Nuri;
// Called against the PUBLISHED declaration, which now has these six arguments, so the
// widening this used to need is gone: the arity is checked here rather than asserted.
// What remains unknowable at compile time is the DEPLOYED engine's arity, which is
// the question this asks.
try {
await realNg.orm_start_graph([doc], [], LIVE_SHAPE, s.session_id, {}, () => {});
return { doc, error: null };
} catch (e) {
return { doc, error: e instanceof Error ? e.message : String(e) };
}
},
/** The document's own answer: how many triples the seeded subject still has. */ /** The document's own answer: how many triples the seeded subject still has. */
async liveSeededTriples() { async liveSeededTriples() {
if (!probe) return -1; if (!probe) return -1;
+17 -60
View File
@@ -2,43 +2,33 @@
* Does a live `useShape` read reach the DEPLOYED broker, and does deleting from the set it * Does a live `useShape` read reach the DEPLOYED broker, and does deleting from the set it
* returns reach the document? * returns reach the document?
* *
* Standalone, and deliberately outside `e2e/run.ts`: this is the one measurement that needs * Standalone, and deliberately outside `e2e/run.ts`: what it measures is the PUBLISHED
* `@ng-org/orm`, an OPTIONAL PEER this package does not install (`bun.lock`, * `useShape` - the bare `DeepSignalSet` the ORM hook hands an application, and a
* `optionalPeers`). Folding it into the 42-check suite would make that suite unbuildable on * `.delete()` on it reaching the document. `e2e/run.ts` exercises the other shape read,
* a checkout without it. Here, a missing ORM fails the build of this runner alone, with the * `useShapeQuery`, which since `1.0.0-dev.6` reads through the SDK's shape query too. Two
* reason said out loud — which is the honest outcome, because a measurement that did not * surfaces, two runs.
* run must not be reported as a verdict either way.
* *
* Run: `bun run e2e/orm-live-read.ts` from `packages/polyfill`. * Run: `bun run e2e/orm-live-read.ts` from `packages/polyfill`.
* *
* ── What it decides ──────────────────────────────────────────────────────── * -- What it decides ----------------------------------------
* The verdict is readable in one number: does `readyPromise` settle. It does on one path * The verdict is readable in one number: does `readyPromise` settle. It does on one path
* only — the arrival of an initial payload from the engine * only — the arrival of an initial payload from the engine
* (`GraphOrmSubscription.ts:handleInitialResponse`) — so a refused start leaves it pending * (`GraphOrmSubscription.ts:handleInitialResponse`) — so a refused start leaves it pending
* for ever, which is exactly why the failure is silent and why a bound, not an await, is * for ever, which is exactly why the failure is silent and why a bound, not an await, is
* what turns it into a report. Nothing here infers a verdict from a version string. * what turns it into a report. Nothing here infers a verdict from a version string.
* *
* ── What it found, 2026-09-16, and why the last row exists ───────────────── * -- What it found, and the row that went with the answer ----------------
* The read does NOT start, and the reason is not the one * On 2026-09-16 the read did not start: the engine answered `Deserialization error of
* `bug_useshape-live-read-needs-a-newer-broker` names. That leaf's mechanism — a broker * config`, and a sixth row of this run established WHERE that `config` sits - position 4,
* built before `orm_start` was renamed `orm_start_graph` — is REFUTED here: the method * before the callback, where the five-argument client of the day landed its callback. The
* resolves, and the call fails INSIDE it, on an argument. The engine answers * deployment was AHEAD of the pinned client packages, not behind them.
* `Deserialization error of config Error: invalid type: JsValue(Function), expected any
* valid JSON value`.
* *
* The last row establishes where that `config` sits, because the position is the whole * Updating them answered it: `@ng-org/web@0.1.2-alpha.14` declares six arguments with
* diagnosis: calling the SDK member directly with `{}` in position 4 is ACCEPTED, and the * `config` at index 4 and `@ng-org/orm@0.1.2-alpha.21` sends six to match, and the run
* engine then streams back. So the deployed `orm_start_graph` is * measured `6 passed, 0 failed` with the read ready in 16ms and the delete reaching the
* `(graph_scope, subject_scope, shapeType, session_id, config, callback)`, and the * document. The characterisation row was retired with the question it answered: there is
* five-argument call the published `@ng-org/orm` makes lands its callback in `config`. * nothing left for it to find out, and a row that always prints the same sentence teaches
* That arity is in no commit of `nextgraph-rs` at the read revision — the one window that * whoever reads this report to skip it.
* had a `config` at all (2026-04-12 to 2026-04-21) put it LAST, after the callback. The
* deployment is therefore AHEAD of the client packages this repository pins, not behind
* them, which inverts what the leaf says to do about it.
*
* The run is EXPECTED TO EXIT NON-ZERO while that holds: it is a measurement of a
* deployment, not a gate on this package. Green again means the two ends agree, and the
* bug leaf can close.
*/ */
import * as fs from "node:fs"; import * as fs from "node:fs";
@@ -107,7 +97,6 @@ const CHECKS: readonly string[] = [
"the initial payload carries the seeded object, with its `@id` and `@graph`", "the initial payload carries the seeded object, with its `@id` and `@graph`",
"`.delete()` on that set is accepted", "`.delete()` on that set is accepted",
"the deletion reached the DOCUMENT: the seeded subject's triples are gone", "the deletion reached the DOCUMENT: the seeded subject's triples are gone",
"[finding] the ARITY the deployed `orm_start_graph` expects",
]; ];
type Check = { name: string; ok: boolean; detail?: string }; type Check = { name: string; ok: boolean; detail?: string };
@@ -116,17 +105,6 @@ function check(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail }); results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`); console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
} }
/**
* A row that REPORTS rather than judges — `e2e/run.ts`'s convention for characterising the
* broker rather than asserting about this package. The last row is one: whichever arity the
* deployment expects, it is upstream's to change, so failing on it would only teach whoever
* reads the report to ignore this file.
*/
function record(name: string, detail: string): void {
results.push({ name, ok: true, detail });
console.log(` [INFO] ${name} — ${detail}`);
}
/** The bridge as the browser side of `frame.evaluate` sees it — no `any`, and no import /** The bridge as the browser side of `frame.evaluate` sees it — no `any`, and no import
* the serialized callback could not carry across. */ * the serialized callback could not carry across. */
type Bridge = Record<string, (...a: unknown[]) => unknown>; type Bridge = Record<string, (...a: unknown[]) => unknown>;
@@ -261,27 +239,6 @@ async function main(): Promise<void> {
} }
check(CHECKS[4]!, remaining === 0, `triples left for the seeded subject: ${remaining}`); check(CHECKS[4]!, remaining === 0, `triples left for the seeded subject: ${remaining}`);
// Only worth asking when the published call did NOT start: it is the diagnosis of
// that failure, and a deployment that answers the published arity has nothing to
// characterise. Since `@ng-org/web@0.1.2-alpha.14` the published call is itself the
// six-argument one, so this asks whether the engine refuses a start the client got
// right - no longer whether the client was sending the wrong number.
if (readyMs === null) {
const withConfig = await sdk<{ doc: string; error: string | null }>(
frame,
"liveStartWithConfig",
);
record(
CHECKS[5]!,
withConfig.error === null
? "six arguments with `{}` in position 4 are ACCEPTED — the deployed " +
"`orm_start_graph` takes a `config` BEFORE the callback, which is what the " +
"published client now sends, so the refusal is not an arity mismatch"
: `six arguments were refused too: ${withConfig.error}`,
);
} else {
record(CHECKS[5]!, "not asked — the published six-argument call started");
}
} finally { } finally {
if (page) await closeQuietly("the ORM live-read page", () => page!.close()); if (page) await closeQuietly("the ORM live-read page", () => page!.close());
if (ctx) await closeContext("orm-live", ctx); if (ctx) await closeContext("orm-live", ctx);
+63 -40
View File
@@ -15,6 +15,11 @@
*/ */
import { ng as realNg, init as realInit } from "@ng-org/web"; import { ng as realNg, init as realInit } from "@ng-org/web";
// The REAL ORM. `useShapeQuery` reads through the SDK's own shape query since
// `1.0.0-dev.6`, so the harness has to hand it one: a fake Set-like is enough for the
// read FILTER (which is pure) and says nothing about a read that reaches the broker.
// `initNg` is what resolves the ORM's own session promise; nothing starts before it.
import { initNg as ormInitNg, RdfOrmSubscription } from "@ng-org/orm";
import { import {
configure, configure,
docs, docs,
@@ -26,6 +31,7 @@ import {
storeRegistry, storeRegistry,
useShape as libUseShape, useShape as libUseShape,
useShapeQuery, useShapeQuery,
initNg,
} from "@ng-eventually/polyfill"; } from "@ng-eventually/polyfill";
// The harness tests the LIBRARY, so it legitimately reaches machinery a consumer // The harness tests the LIBRARY, so it legitimately reaches machinery a consumer
// application must not — but through the internal path, never the published entry. // application must not — but through the internal path, never the published entry.
@@ -60,13 +66,50 @@ import { isNuri } from "../src/model/nuri";
/** The whole local dataset - upstream's own marker, what `""` normalises to. */ /** The whole local dataset - upstream's own marker, what `""` normalises to. */
const EVERYTHING = { graphs: "did:ng:i" }; const EVERYTHING = { graphs: "did:ng:i" };
/**
* The shape the two `useShapeQuery` journeys read - built here rather than inline, since
* both of them need the same one and it is now handed to the SDK.
*
* Two predicates, both `minCardinality: 1`, because that is what
* `engine/verifier/src/orm/graph/query.rs:schema_shape_to_sparql` turns into REQUIRED
* triple patterns: a shape constraining nothing would match every subject in the dataset.
* The title is the second one, and it is what proves the real data landed rather than an
* empty object of the right type.
*/
function wsShape(cls: string) {
return {
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
{
iri: "urn:e2e:ws:title",
readablePredicate: "title",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ valType: "string" }],
},
],
},
},
} satisfies ShapeType<BaseType>;
}
const storeOfType = (t: "public" | "protected" | "private"): Promise<Nuri> => const storeOfType = (t: "public" | "protected" | "private"): Promise<Nuri> =>
t === "public" t === "public"
? storeRegistry.publicStore() ? storeRegistry.publicStore()
: t === "protected" : t === "protected"
? storeRegistry.protectedStore() ? storeRegistry.protectedStore()
: storeRegistry.privateStore(); : storeRegistry.privateStore();
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/polyfill"; import type { BaseType, Nuri, ShapeObservable, ShapeQuery, ShapeType } from "@ng-eventually/polyfill";
const { IdentityStore } = virtualUsers; const { IdentityStore } = virtualUsers;
@@ -120,7 +163,12 @@ function fakeUseShape(_shape: unknown, _scope: unknown): any {
configure({ configure({
ng: realNg, ng: realNg,
useShape: fakeUseShape, useShape: fakeUseShape,
// The SDK's own shape query, passed as it stands - no wrapper, no cast. This is what
// `useShapeQuery` reads through, so the journeys below run against the REAL broker
// rather than against anything this page composed.
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit, init: realInit,
initNg: ormInitNg,
// The harness already holds an open wallet (the suite imports it itself), so it never // The harness already holds an open wallet (the suite imports it itself), so it never
// needs the gate's assisted import. These values exist only so the gate has something // needs the gate's assisted import. These values exist only so the gate has something
// to render when the access-gate steps exercise it — they are never used to import. // to render when the access-gate steps exercise it — they are never used to import.
@@ -794,9 +842,9 @@ const identity = new IdentityStore(
}, },
// ── useShapeQuery (reactive useQuery-shaped read) ──────────────────────── // ── useShapeQuery (reactive useQuery-shaped read) ────────────────────────
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. useShapeQuery // The shape is `wsShape(cls)` above: a hand-written `ShapeType`, two required
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals` // predicates, generic and with no application domain. The read goes through the
// to know which @type to keep — generic, no application domain. // SDK's own shape query now, so these two journeys measure the REAL broker.
_wsProbes: {} as Record< _wsProbes: {} as Record<
string, string,
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void } { obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
@@ -828,23 +876,7 @@ const identity = new IdentityStore(
if (listed.includes(doc)) break; if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
} }
const shape = { const shape = wsShape(cls);
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
],
},
},
};
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable; const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
const unsub = obs.subscribe(() => {}); const unsub = obs.subscribe(() => {});
// First snapshot immediately after subscribe — the barrier is not yet crossed // First snapshot immediately after subscribe — the barrier is not yet crossed
@@ -866,8 +898,10 @@ const identity = new IdentityStore(
isSuccess: snap.isSuccess, isSuccess: snap.isSuccess,
isError: snap.isError, isError: snap.isError,
dataLen: snap.data.length, dataLen: snap.data.length,
// The seeded title, if the datum is present (proves the real data landed). // The seeded title, if the datum is present (proves the real data landed). It is the
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []), // shape's own readable predicate now, not a property bag: the objects come from the
// ORM.
titles: snap.data.map((d) => d.title).filter((v) => typeof v === "string"),
}; };
}, },
useShapeQueryStop(handle: string) { useShapeQueryStop(handle: string) {
@@ -884,23 +918,7 @@ const identity = new IdentityStore(
registryInternals.resetRegistryCache(); registryInternals.resetRegistryCache();
const id = "@ws-empty-" + handle; const id = "@ws-empty-" + handle;
setCurrentUser(id); setCurrentUser(id);
const shape = { const shape = wsShape(cls);
shape: "urn:e2e:ws:Shape",
schema: {
"urn:e2e:ws:Shape": {
iri: "urn:e2e:ws:Shape",
predicates: [
{
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
readablePredicate: "type",
maxCardinality: 1,
minCardinality: 1,
dataTypes: [{ literals: [cls], valType: "iri" }],
},
],
},
},
};
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable; const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
const unsub = obs.subscribe(() => {}); const unsub = obs.subscribe(() => {});
const initial = obs.getSnapshot(); const initial = obs.getSnapshot();
@@ -1105,6 +1123,11 @@ const identity = new IdentityStore(
(event: { session: BrokerSession }) => { (event: { session: BrokerSession }) => {
session = event.session; session = event.session;
state.status = "connected"; state.status = "connected";
// The ORM reaches the SDK through a module-level promise of its own, resolved
// once. Nothing it opens starts before this, so it goes here rather than beside
// the first read - through the PACKAGE's `initNg`, which is the call an
// application makes.
initNg(realNg, session);
sessionResolve(session); sessionResolve(session);
}, },
true, true,
+8 -6
View File
@@ -387,16 +387,18 @@ async function main(): Promise<void> {
}); });
// ── useShapeQuery (reactive useQuery-shaped read) ─────────────────────── // ── useShapeQuery (reactive useQuery-shaped read) ───────────────────────
// The real cycle: first subscription reads isPending (barrier not yet crossed), // The real cycle, through the SDK's own shape query since `1.0.0-dev.6`: the first
// then after the broker sync it reaches isSuccess with the seeded datum present. // subscription reads isPending (the ORM's initial payload has not arrived), then the
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the // broker answers and it reaches isSuccess with the seeded datum present. A separate
// distinction the announced `useShapeQuery` makes native, surfaced here from getSyncState). // identity with nothing of that shape reaches isSuccess with data:[] - which over
// `did:ng:i` is the CAPABILITY FILTER doing the bounding, since the engine adds nothing
// to narrow the whole dataset by what the reader may open.
console.log("\n── useShapeQuery (reactive useQuery-shaped read) ──"); console.log("\n── useShapeQuery (reactive useQuery-shaped read) ──");
await step("useShapeQuery: first subscribe isPending → isSuccess with data present", async () => { await step("useShapeQuery: first subscribe isPending → isSuccess with data present", async () => {
const h = "cyc" + Date.now(); const h = "cyc" + Date.now();
const CLS = "urn:e2e:ws:Event"; const CLS = "urn:e2e:ws:Event";
const seed = await sdk<any>(frame, "useShapeQuerySeedAndSubscribe", h, CLS); const seed = await sdk<any>(frame, "useShapeQuerySeedAndSubscribe", h, CLS);
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`); check("first snapshot after subscribe is isPending (no initial payload yet)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
// Event-driven: wait for the barrier to cross + the datum to land. // Event-driven: wait for the barrier to cross + the datum to land.
await frame.waitForFunction( await frame.waitForFunction(
(hh) => { (hh) => {
@@ -423,7 +425,7 @@ async function main(): Promise<void> {
{ timeout: 30000 }, { timeout: 30000 },
); );
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h); const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h);
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`); check("nothing of that shape: isSuccess, data:[] (answered-and-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
await sdk(frame, "useShapeQueryStop", h); await sdk(frame, "useShapeQueryStop", h);
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ng-eventually/polyfill", "name": "@ng-eventually/polyfill",
"version": "1.0.0-dev.5", "version": "1.0.0-dev.6",
"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",
@@ -43,7 +43,7 @@
* code. Making it async would push the wait onto the app, which is exactly the * code. Making it async would push the wait onto the app, which is exactly the
* obligation this removes. So the work runs in the background and announces itself * obligation this removes. So the work runs in the background and announces itself
* through the registry's change signal (`CapRegistry.onChange`), which is what * through the registry's change signal (`CapRegistry.onChange`), which is what
* `watchShape` already listens to — a view that was empty for want of a cap * `useShapeQuery` already listens to - a view that was empty for want of a cap
* re-reads when the cap lands. {@link connectedUser} is there for a caller that * re-reads when the cap lands. {@link connectedUser} is there for a caller that
* genuinely needs to await it (tests, an app that wants a deterministic start). * genuinely needs to await it (tests, an app that wants a deterministic start).
* *
@@ -36,7 +36,7 @@
* - the REGISTER — an inbox opened mid-session appends an `AddInboxCap` record to the * - the REGISTER — an inbox opened mid-session appends an `AddInboxCap` record to the
* User branch of the private store (`branch-registers.openDocumentInbox`), so a push on * User branch of the private store (`branch-registers.openDocumentInbox`), so a push on
* that document re-enumerates `myInboxes()`; * that document re-enumerates `myInboxes()`;
* - WHAT THIS IDENTITY HOLDS (`CapRegistry.onChange`) — the same signal `watchShape` * - WHAT THIS IDENTITY HOLDS (`CapRegistry.onChange`) - the same signal `useShapeQuery`
* re-reads on. It answers the case the register cannot: an identity that connects * re-reads on. It answers the case the register cannot: an identity that connects
* before it has an account at all. Provisioning is lazy here, so a person's FIRST visit * before it has an account at all. Provisioning is lazy here, so a person's FIRST visit
* connects with no stores, no private store to follow and no inbox to watch; the * connects with no stores, no private store to follow and no inbox to watch; the
@@ -137,8 +137,8 @@ export function resetOpenedRepos(): void {
syncState.clear(); syncState.clear();
boundSessionId = null; boundSessionId = null;
OPEN_TIMEOUT_MS = 8000; OPEN_TIMEOUT_MS = 8000;
// Whatever ELSE was still subscribed to a document — a `watchShape` following a scope, // Whatever ELSE was still subscribed to a document - an inbox observation, an
// an inbox observation — belongs to the session that just went away too. One real // application's own `subscribeDoc` - belongs to the session that just went away too. One real
// subscription now serves all of them (`surface/subscribe.ts`), so dropping the bootstrap // subscription now serves all of them (`surface/subscribe.ts`), so dropping the bootstrap
// ones above no longer closes those channels; they are re-opened against the new session, // ones above no longer closes those channels; they are re-opened against the new session,
// with their barrier forgotten. Without this a bootstrap open would join a surviving // with their barrier forgotten. Without this a bootstrap open would join a surviving
@@ -16,12 +16,15 @@
* ── What this filter cannot do, and where that shows ────────────────────── * ── What this filter cannot do, and where that shows ──────────────────────
* It is SYNCHRONOUS and decides from what the holder holds at that instant. A document * It is SYNCHRONOUS and decides from what the holder holds at that instant. A document
* in a PUBLIC store hands its cap to whoever asks (`public-store.ts`), but asking is a * in a PUBLIC store hands its cap to whoever asks (`public-store.ts`), but asking is a
* round-trip — so this view drops such a document until some read path has asked. * round-trip - so this filter drops such a document until some read path has asked.
* Every path this library owns does ask (`readUnion`, `docs.sparqlQuery`, * The paths that DO ask are the ones this library still reads with (`readUnion`,
* `ensureRepoOpen`, `documentInboxAddress`), which covers `watchShape`; what it does * `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`). The two SHAPE reads
* not cover is `useShape`, whose signature is the real ORM's and has no await to * ask nothing: `useShape` because its signature is the real ORM's and has no await to
* spend. An application reaching a public document through `useShape` alone, having * spend, and `useShapeQuery` since `1.0.0-dev.6`, because it reads through the SDK's
* read it nowhere first, sees nothing. A polyfill-era limit, removed with the module. * own shape query and no longer opens a document of its own. An application reaching a
* public document through either of them alone, having read it nowhere first, sees
* nothing until it does - and a cap that lands later makes both re-publish, since each
* follows `CapRegistry.onChange`. A polyfill-era limit, removed with the module.
* *
* Note there is no `user` parameter anywhere below, and that is the point: reading * Note there is no `user` parameter anywhere below, and that is the point: reading
* is key possession, so the only question askable is "do I hold this document's * is key possession, so the only question askable is "do I hold this document's
+7 -1
View File
@@ -84,6 +84,12 @@
// PrincipalId `ensureIdentity`, `inbox.Deposit`/`PostOptions`, `EventuallyConfig` // PrincipalId `ensureIdentity`, `inbox.Deposit`/`PostOptions`, `EventuallyConfig`
// NgLike `EventuallyConfig.ng` // NgLike `EventuallyConfig.ng`
// UseShapeLike `EventuallyConfig.useShape` // UseShapeLike `EventuallyConfig.useShape`
// OrmSubscriptionLike `EventuallyConfig.ormSubscription`
//
// `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
// subscription, and publishing the handle would offer a path around the capability filter
// and the shared-wallet identity boundary. It stays defined in `model/types.ts`.
// //
// `Scope` left this list in `1.0.0-dev.5`, and it is the only one that left because the // `Scope` left this list in `1.0.0-dev.5`, and it is the only one that left because the
// TYPE was wrong rather than unreachable. It was `"public" | "protected" | "private"` - // TYPE was wrong rather than unreachable. It was `"public" | "protected" | "private"` -
@@ -93,7 +99,7 @@
// 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 } from "./model/types"; export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike, OrmSubscriptionLike } 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";
+54
View File
@@ -2,6 +2,12 @@
* Generic, NextGraph-shaped types. ZERO application domain. * Generic, NextGraph-shaped types. ZERO application domain.
*/ */
// The SDK's own types, imported as TYPES (erased at build, so this module stays free of
// any runtime `@ng-org` import). They describe what `configure` RECEIVES; nothing here
// re-exports them.
import type { Scope } from "@ng-org/orm";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
/** /**
* A NextGraph URI (document / store / inbox) in its **cap-less** form — it NAMES * A NextGraph URI (document / store / inbox) in its **cap-less** form — it NAMES
* and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`. * and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`.
@@ -72,6 +78,54 @@ export type NgLike = Record<string, any>;
// @provenance UseShapeLike kind=invention level=none ref=none — same, for the injected `useShape` // @provenance UseShapeLike kind=invention level=none ref=none — same, for the injected `useShape`
export type UseShapeLike = (...args: any[]) => any; export type UseShapeLike = (...args: any[]) => any;
/**
* The shape query in force, as `surface/use-shape-query.ts` reads it - the four members
* of `RdfOrmSubscription` this package touches, and not one more.
*
* NOT published: an application never holds one. It hands {@link OrmSubscriptionLike}
* to `configure` and reads the snapshot back; what the query itself offers is the SDK's
* business, and re-publishing it here would hand a caller a path around the capability
* filter and the shared-wallet identity boundary.
*
* `signalObject` is typed `Iterable<unknown>` rather than by the shape: upstream it is a
* `DeepSignalSet<T>` whose items are `DeepSignal<T>`, and narrowing it here would make
* the real `getOrCreate` unassignable to the injection for a difference no reader of
* this package can act on. The items are typed where the shape says what they are.
*/
// @provenance OrmSubscriptionHandle kind=aligned level=3 ref=sdk/js/orm/src/connector/GraphOrmSubscription.ts:OrmSubscription - the members this package reads: the readiness promise, the signal object, the change listeners and `close`; the published `@ng-org/orm@0.1.2-alpha.21` renames the class `RdfOrmSubscription`, which the cited revision has not yet done
export interface OrmSubscriptionHandle {
/** Resolves when the initial payload has arrived, and on no other path - which is
* what tells "not ready yet" from "ready and empty". Never rejects upstream today. */
readonly readyPromise: Promise<unknown>;
/** The live objects matching the shape and scope. A set (or an ordered array) that
* the engine's patches keep up to date. */
readonly signalObject: Iterable<unknown>;
/** Notified when root objects are added, updated or removed. */
addChangeListener(
listener: (changes: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void,
): void;
removeChangeListener(
listener: (changes: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void,
): void;
/** Stop the subscription. Pooled upstream, so re-opening it shortly after is cheap. */
close(): void;
}
/**
* Loose shape of `@ng-org/orm`'s `RdfOrmSubscription.getOrCreate` - the SDK's shape
* query, reached WITHOUT a framework adapter.
*
* An application injects the real one (`ormSubscription: RdfOrmSubscription.getOrCreate`);
* it is assignable to this as it stands, with no wrapper and no cast. `useShape` is not a
* substitute: it is React's hook, it returns a bare `DeepSignalSet`, and a set cannot
* carry the request state `ShapeQuery` publishes.
*/
// @provenance OrmSubscriptionLike 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 `getOrCreate`
export type OrmSubscriptionLike = <T extends BaseType>(
shapeType: ShapeType<T>,
scope: Scope,
) => OrmSubscriptionHandle;
/** /**
* 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
@@ -404,8 +404,8 @@ function recordFromRows(
// The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises // The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises
// real NURIs; a shim missing a scope document yields `""` here, exactly as it // real NURIs; a shim missing a scope document yields `""` here, exactly as it
// always has, and the cast records that this is a KNOWN gap rather than a proven // always has, and the cast records that this is a KNOWN gap rather than a proven
// invariant. Callers already test for the empty value (e.g. `watchShape` skips a // invariant. Callers already test for the empty value (`listMyEntityDocs` reads
// falsy container). Rejecting such a record outright would be the right fix and is // nothing out of a falsy container). Rejecting such a record outright would be the right fix and is
// a behaviour change — its own lot, not this one. // a behaviour change — its own lot, not this one.
return { return {
id: id || fallbackId, id: id || fallbackId,
@@ -703,8 +703,8 @@ async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise<void>
* *
* ── Why this is load-bearing (concurrent-provision de-dup) ────────────────── * ── Why this is load-bearing (concurrent-provision de-dup) ──────────────────
* On a fresh page over the persistent wallet, SEVERAL independent callers hit * On a fresh page over the persistent wallet, SEVERAL independent callers hit
* `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container * `ensureAccount(A)` near-simultaneously (a listing per store, an inbox enumeration, the
* subscriptions, the app's owned-events effect). With the barrier-authoritative * app's own effects). With the barrier-authoritative
* resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the * resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the
* account is GENUINELY new, N concurrent callers would still each see 0 and each * account is GENUINELY new, N concurrent callers would still each see 0 and each
* provision a set of scope docs (an in-session fork). De-duping concurrent provisions * provision a set of scope docs (an in-session fork). De-duping concurrent provisions
@@ -1263,20 +1263,13 @@ export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
return out; return out;
} }
/** // `userStoreDoc(id, scope)` was REMOVED in `1.0.0-dev.6`. It handed out the scope-INDEX
* The scope-INDEX document NURI of ONE account (`id`) for `scope` — the // document so that a reactive reader could SUBSCRIBE to it and re-resolve its document set
* store-container document that LISTS the account's per-entity document NURIs // when the index changed - and the reactive reader was the only thing that ever wanted it.
* (what {@link listMyEntityDocs} reads). Exposed so a reactive reader // `useShapeQuery` now reads through the SDK's own shape query, which follows the data
* ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the // rather than a container this package maintains, so the last caller went with the
* entity-doc set when the index changes (a new entity created appends a NURI // pipeline. `storeOf(await ensureAccount(id), scope)` is the two-line body, for whoever
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the // needs the value again.
* user's real per-scope store NURI (the container the store itself provides).
*/
// @provenance userStoreDoc kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — the per-scope index document stands in for the store the session names directly
export async function userStoreDoc(id: string, scope: StoreType): Promise<Nuri> {
const record = await ensureAccount(id);
return storeOf(record, scope);
}
/** /**
* The entity-document NURIs of `scope` belonging to ONE account (`id`) — * The entity-document NURIs of `scope` belonging to ONE account (`id`) —
@@ -15,7 +15,15 @@
* The entry now only re-exports; the internals import this module instead. * The entry now only re-exports; the internals import this module instead.
*/ */
import type { NgLike, UseShapeLike, Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types"; import type {
NgLike,
UseShapeLike,
OrmSubscriptionLike,
Nuri,
NuriLike,
PrincipalId,
ReadCap,
} from "../model/types";
import type { SharedWalletConfig } from "./access-gate"; import type { SharedWalletConfig } from "./access-gate";
import { toNuri } from "../model/nuri"; import { toNuri } from "../model/nuri";
import type { RegistrySession } from "./account-registry"; import type { RegistrySession } from "./account-registry";
@@ -113,6 +121,19 @@ export interface EventuallyConfig {
ng: NgLike; ng: NgLike;
/** The REAL `@ng-org/orm` `useShape`. */ /** The REAL `@ng-org/orm` `useShape`. */
useShape: UseShapeLike; useShape: UseShapeLike;
/**
* The REAL `@ng-org/orm` `RdfOrmSubscription.getOrCreate` - the SDK's shape query,
* which is what {@link ../surface/use-shape-query}.useShapeQuery reads through since
* `1.0.0-dev.6`. Pass it as it stands: `ormSubscription: RdfOrmSubscription.getOrCreate`.
*
* OPTIONAL, and that is a scope decision rather than a soft one: an application that
* never calls `useShapeQuery` has nothing to inject here, and every suite of this
* package that exercises another surface would otherwise carry a stub for a call it
* does not make. What is NOT optional is the consequence - `useShapeQuery` without it
* publishes `isError` naming this field, never an empty set, because "nobody answered"
* reported as "there is nothing" is the one failure that surface exists to prevent.
*/
ormSubscription?: OrmSubscriptionLike;
/** /**
* 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.
@@ -414,7 +435,7 @@ export function hasCap(nuri: NuriLike): boolean {
*/ */
export function resetCaps(): void { export function resetCaps(): void {
// Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's // Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's
// change signal (`watchShape`) stays subscribed to the live instance instead of // change signal (`useShapeQuery`) stays subscribed to the live instance instead of
// silently holding a listener on an orphaned one. // silently holding a listener on an orphaned one.
caps.clear(); caps.clear();
// …and forget which documents were already asked about, or the emulated public-store // …and forget which documents were already asked about, or the emulated public-store
+1 -1
View File
@@ -817,7 +817,7 @@ async function processForOwner(targetInbox: Nuri): Promise<void> {
// still refused by `readForHolder` below. // still refused by `readForHolder` below.
// //
// ONE visible consequence, and it is deliberate: filing a cap fires the registry's change // ONE visible consequence, and it is deliberate: filing a cap fires the registry's change
// signal, which is global rather than per-holder, so a `watchShape` open in the connected // signal, which is global rather than per-holder, so a `useShapeQuery` open in the connected
// session re-reads. It re-reads the SAME data — the connected identity gains nothing — // session re-reads. It re-reads the SAME data — the connected identity gains nothing —
// and the signal cannot be suppressed for the other case this same path serves: an owner // and the signal cannot be suppressed for the other case this same path serves: an owner
// draining an inbox on their own document, where re-reading is exactly the contract // draining an inbox on their own document, where re-reading is exactly the contract
+6 -5
View File
@@ -46,10 +46,11 @@
* nothing logs, the stale unsubscribe still "works". * nothing logs, the stale unsubscribe still "works".
* *
* That made every internal subscriber a hazard to every other one. `ensureRepoOpen` * That made every internal subscriber a hazard to every other one. `ensureRepoOpen`
* holds a bootstrap subscription per document for the whole session, `watchShape` * holds a bootstrap subscription per document for the whole session, the reactive shape
* subscribes to the documents of a scope, `inbox.watch` to an inbox — so opening a * read of the day subscribed to the documents of a scope (a pipeline retired in
* repo killed the watch on it, and the app-visible result was a view that never * `1.0.0-dev.6`), `inbox.watch` to an inbox - so opening a repo killed the watch on it,
* re-read and an inbox that never notified. Both were reported as "the layer does not * and the app-visible result was a view that never re-read and an inbox that never
* notified. Both were reported as "the layer does not
* notify of its own actions", which is NOT what happens: a same-session write DOES * notify of its own actions", which is NOT what happens: a same-session write DOES
* push (verified the same day — `Patch@69ms` on the writer's own `sparqlUpdate`, * push (verified the same day — `Patch@69ms` on the writer's own `sparqlUpdate`,
* `e2e/reactivity-doc-subscribe.ts`). The push arrived; there was no longer anybody * `e2e/reactivity-doc-subscribe.ts`). The push arrived; there was no longer anybody
@@ -453,7 +454,7 @@ export function resetDocSubscriptions(): void {
* synced. Zero rows, "synced", no error: the silent staleness this module exists to remove. * synced. Zero rows, "synced", no error: the silent staleness this module exists to remove.
* *
* So the barrier is forgotten and the channel re-opened, rather than the listeners dropped: * So the barrier is forgotten and the channel re-opened, rather than the listeners dropped:
* a `watchShape` that was following a document keeps following it across the change. * an `inbox.watch` that was following a document keeps following it across the change.
*/ */
// @provenance resubscribeDocs kind=invention level=none ref=none — per-session bookkeeping, like `resetOpenedRepos`; upstream a session's subscriptions die with it and nothing carries them over // @provenance resubscribeDocs kind=invention level=none ref=none — per-session bookkeeping, like `resetOpenedRepos`; upstream a session's subscriptions die with it and nothing carries them over
export function resubscribeDocs(): void { export function resubscribeDocs(): void {
+220 -391
View File
@@ -1,280 +1,155 @@
/** /**
* use-shape-query — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in * use-shape-query - a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
* one SDK `Scope`. This is the surface the consuming app binds with * one SDK `Scope`. This is the surface the consuming app binds with
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a * `useSyncExternalStore` - the polyfill deliberately exposes an OBSERVABLE, never a
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`). * React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
* *
* ── The name, and the one difference from the SDK that is deliberate ─────── * -- The name, and the one difference from the SDK that is deliberate -------
* It was `watchShape` until `1.0.0-dev.5`. That name has ZERO occurrences upstream, and * It was `watchShape` until `1.0.0-dev.5`. That name has ZERO occurrences upstream, and
* it spent `watch`, a verb the ORM has already spent on signal observation * it spent `watch`, a verb the ORM has already spent on signal observation
* (`watch as watchDeepSignal`). The target's own name is `useShapeQuery`, and what it * (`watch as watchDeepSignal`). The target's own name is `useShapeQuery`, and what it
* returns is the snapshot below rather than the bare `DeepSignalSet` today's `useShape` * returns is the snapshot below rather than the bare `DeepSignalSet` today's `useShape`
* hands back — announced by NextGraph's developers and on record in * hands back - announced by NextGraph's developers and on record in
* `.project/rules/nextgraph/surface-mirrors-the-sdk.md`, which is what the annotation on * `.project/rules/nextgraph/surface-mirrors-the-sdk.md`, which is what the annotation on
* the function cites. Deviating TOWARD an announced target is alignment; the rest of the * the function cites. Deviating TOWARD an announced target is alignment; the rest of the
* surface is what will catch up: * surface is what will catch up:
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`. * `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads * `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a * `{ data: [], isPending: false, isSuccess: true }` - the key distinction - while a
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`. * scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
* *
* ── The scope is the SDK's, and translating it is EXPLICITLY TEMPORARY ───── * -- What it reads THROUGH, since `1.0.0-dev.6` ----------------------------
* `scope` is `@ng-org/orm`'s own `Scope` — `{ graphs: string[] | string; subjects?: * The SDK's own shape query - `RdfOrmSubscription.getOrCreate(shapeType, scope)` - and
* string[] }` — not the three strings this module took before. A `graphs` entry names * nothing of this library's own. Four steps went with the change, and they were one
* ONE document's graph, `did:ng:i` (or `""`) discards the rest and means the whole local * mechanism: a logical scope resolved to a document set (`listMyEntityDocs`), those
* dataset, and `[]` or `undefined` returns no objects at all: all three are upstream's * documents opened through `ensureReposOpen` for a sync barrier, `readUnion` over them,
* rules, reproduced by `normalizeScope` below. * and a filter of the union by the shape's `@type` IRI. All four existed because the
* parameter had to be TRANSLATED; once the parameter is upstream's own `Scope`, the
* question goes straight to the thing that answers it. `graphs`, `subjects` and the
* whole-dataset marker `did:ng:i` are now read by the engine, literally, and this module
* does not interpret them at all - so the three readings are upstream's, not ours.
* *
* `graphs` is REQUIRED as of `@ng-org/orm@0.1.2-alpha.21`; it used to be optional. Only * It does NOT delegate to the SDK's `useShape`: that is React's hook, it returns a bare
* the spelling changed, never the meaning: a caller who wants "everything" still has to * `DeepSignalSet`, and a set cannot carry the request state this surface exists to
* say `did:ng:i`, because on this axis an empty list is the EMPTY set and not the absent * publish. What is taken from the SDK is the QUERY; the observable, the snapshot and the
* filter. `subjects` keeps the opposite convention - there `[]` means no filtering - so * state tracking stay here.
* the two axes read `[]` in opposite directions and `normalizeScope` keeps them apart.
* *
* Only the whole-dataset reading still has to be TRANSLATED, and the translation is the * -- What stays ours, and why ----------------------------------------------
* tell that the mechanism under it is about to stop being ours: it is served by * 1. **The three states.** `readyPromise` is the ORM's own answer to "has the initial
* enumerating this identity's own documents across its three stores * payload arrived" - it resolves on that path and on no other - so it is what tells
* (`account-registry.listMyEntityDocs`, no longer published), because this pipeline has * `isPending` from a genuinely empty `isSuccess`. The React adapter one floor up
* no other way to say "everything". When the read queries the store itself, the * drops that distinction (an empty set "if still loading"); this does not.
* enumeration and this branch go together. A named `graphs` list needs no translation at * 2. **The capability filter.** `emulated-verifier/read-filter.ts` keeps only items
* all — it is already the doc set. * whose document (`@graph`) the current holder holds a cap for - the emulation of
* what the broker will bound at delivery, applied per document on what comes back,
* exactly where `surface/use-shape.ts` applies it. Before the first cap is issued
* the registry is not enforcing and everything passes through.
* 3. **The keyring signal.** A cap that arrives asynchronously (an inbox delivery
* absorbed by `inbox.watch`) makes items readable that were not, so
* `CapRegistry.onChange` re-publishes. Without it a view stays stale until an
* unrelated change happens to fire.
* *
* ── What the observable OWNS (the whole read pipeline) ───────────────────── * -- Reactivity WITHOUT polling (no `setInterval`) --------------------------
* 1. Resolve the scope → the doc set: the graphs named, or — for the whole-dataset * Two push sources, both of them signals: the subscription's own
* reading — the CURRENT wallet's own per-entity documents, and nothing else. There * `addChangeListener` (root objects added, updated or removed - the engine's patches
* is no "everything public" to fold in — **you cannot discover, you can only follow * reaching the signal object), and the keyring above. No document subscription of this
* links** (see docs/readcap-and-nuri-model.md §4ter-bis), and a link reaches you * library's is opened here any more: the ORM holds the one that matters.
* through an inbox or through a document you already hold. A document whose cap you
* were given is read by NAMING it, not by turning up in a scope you never put it in.
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
* fallback). `isPending` holds until the barrier is reached for the current
* doc set AND the first read has rendered.
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
* NOT double-filter), then FILTER the union by the requested shape's `@type` and by
* the scope's `subjects` axis (a union spans multiple types; each `useShapeQuery`
* yields only the subjects of its shape). Non-domain: the type IRI is read from the
* SHEX ShapeType, not from any application concept.
* *
* ── Reactivity WITHOUT polling (no `setInterval`) ────────────────────────── * -- `isError` is wired to what CAN fail, and says so ----------------------
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document * One thing on this path throws, and it throws at the call: opening the shape query -
* pushes, and the KEYRING — a cap that arrives asynchronously (an inbox delivery * a shape the ORM cannot canonicalise, or a `configure()` that was never given the
* absorbed by the consumer's `inbox.watch`) makes documents readable that were not, * query to open. That is what `isError` carries.
* so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
* unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
* in the current set re-runs `readUnion` on any push. Under the whole-dataset reading the
* set is DYNAMIC (creating an entity appends a NURI to its store's index doc), so we ALSO
* subscribe to those index documents: a push there re-RESOLVES and re-keys the subscribed
* set. A named `graphs` list has no container to follow — the caller fixed the set.
* Subscriptions are idempotent — an already-followed doc is not re-subscribed. Everything
* reuses `subscribe.ts` / `open-repo.ts`; no parallel channel.
* *
* ── timed-out → isSuccess (best-effort), NOT isError ─────────────────────── * What it does NOT carry, recorded rather than assumed: a start the ENGINE refuses.
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached" * `RdfOrmSubscription` awaits its session and calls `orm_start_graph` inside a
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error. * `try { ... } catch (e) { console.error(e) }`, and its readiness promise is built with
* `isError` fires ONLY on a real thrown exception in the pipeline. * `resolve` alone - nothing rejects it, and `resolveReady` runs on one path only, the
* arrival of initial data. So upstream's *"I could not find out"* IS its *"still
* pending"*, and a refused read reads here as `isPending` for ever, never as
* `isSuccess` with `data: []`. The distinction this surface exists for therefore holds
* - an application is never told "there is nothing" for "nobody answered" - but the
* third state is narrower than it was while the pipeline was ours. `isError` stays
* because this package's job is to ENRICH the SDK in its announced direction, not to be
* bounded by what it exposes today; the day the ORM surfaces a failed start, this is
* where it lands.
* *
* ── A scope that did not answer is NOT an empty scope ────────────────────── * -- An observable cannot un-emit: `data` survives an error -----------------
* The pipeline's first step ASKS a question — which documents are mine — and until
* 2026-08-17 a failure to answer it was logged and treated as "none": the empty set
* flowed on, the barrier over zero docs is trivially reached, and the surface published
* `{ data: [], isSuccess: true }`. That snapshot is BYTE-FOR-BYTE the synced-but-empty
* one this module exists to distinguish, so the one distinction it publishes was the one
* it destroyed — an application rendered "you have nothing" for "the store did not
* answer". `listMyEntityDocs` stopped handing out that reading one layer down
* (`90712e0`); catching it here put it straight back.
*
* A resolution failure now travels the LOAD-STATE channel, which is where "this is
* not an answer" already lives on this surface: `isError` with the thrown `error`,
* and never `isSuccess`. The channel is the honest place because it is the one an
* application already has to read to tell pending from empty — the same three-way
* question, and the third state costs it no new vocabulary.
*
* ── An observable cannot un-emit: `data` survives an error ─────────────────
* A one-shot call rejects and is done. An observable has already handed a list to a * A one-shot call rejects and is done. An observable has already handed a list to a
* subscriber that rendered it, so its failure snapshot has to say something about * subscriber that rendered it, so its failure snapshot has to say something about
* that list — and collapsing it to `[]` would hand out, in `data`, exactly the empty * that list - and collapsing it to `[]` would hand out, in `data`, exactly the empty
* answer this fix removes. So `data` KEEPS the last read that actually answered, and * answer this fix removes. So `data` KEEPS the last read that actually answered, and
* `isError` says the current answer is unknown. `isSuccess` is false throughout: the * `isError` says the current answer is unknown. `isSuccess` is false throughout: the
* array is a memory, not a reply to the question just asked. * array is a memory, not a reply to the question just asked.
*/ */
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { ensureReposOpen, getSyncState } from "../emulated-verifier/open-repo"; import { filterReadable } from "../emulated-verifier/read-filter";
import { readUnion, type UnionSubject } from "./read-model"; import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types";
import { subscribeDoc, type Unsubscribe } from "./subscribe"; // The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org`
import { listMyEntityDocs, userStoreDoc } from "../shared-wallet/account-registry"; // import is added here). The provenance of what is PUBLISHED is recorded in `index.ts`.
import { toNuri } from "../model/nuri";
import type { Nuri, StoreType } from "../model/types";
// The SDK's own scope type, imported as a TYPE (erased at build, so no runtime `@ng-org`
// import is added here). Its provenance is recorded where it is PUBLISHED, in `index.ts`.
import type { Scope } from "@ng-org/orm"; import type { Scope } from "@ng-org/orm";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
/** Upstream's whole-dataset marker. `normalizeScope` maps `""` onto it, and
* `orm_start_graph` lets an entry equal to it DISCARD every other entry. */
const ENTIRE_DATASET = "did:ng:i";
/** The three stores an identity's own documents can sit in. */
const STORE_TYPES: readonly StoreType[] = ["private", "protected", "public"];
/** What `normalizeScope` produces: two lists, `""` already mapped onto `did:ng:i`. */
type NormalizedScope = { graphs: string[]; subjects: string[] };
/**
* `normalizeScope`, reproduced rather than imported: this package never hard-imports the
* SDK at RUNTIME (see the module header of `use-shape.ts` and `configure`'s `NgLike`), and
* a type-only import is erased. Same three rules as upstream - a bare string is one graph,
* `""` becomes `did:ng:i`, an absent list is empty.
*/
// @provenance normalizeScope kind=aligned level=3 ref=sdk/js/orm/src/types.ts:normalizeScope — the same three rules; reproduced because the SDK is injected, never imported at runtime
function normalizeScope(scope: Scope | string | undefined): NormalizedScope {
if (typeof scope === "string") {
return { graphs: [scope === "" ? ENTIRE_DATASET : scope], subjects: [] };
}
const graphs = (!scope?.graphs ? [] : [scope.graphs]).flat().map((g) => (g === "" ? ENTIRE_DATASET : g));
return { graphs, subjects: scope?.subjects ?? [] };
}
/**
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
* constraint on this predicate; we filter the read union by it.
*/
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/** /**
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array * A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `useShapeQuery` yields * (never `undefined`), and its items are the SHAPE's own objects - what the ORM
* the generic per-subject property bags of the read-model (NO application domain); * returns, carrying `@id`, `@graph` and the shape's readable predicates.
* the app maps them to its own entity types in phase B.
*/ */
// @provenance ShapeQuery kind=invention level=none ref=none — no upstream type carries load state; `OrmSubscription.readyPromise` expresses pending-vs-ready and nothing else (built with `resolve` alone, and `orm_start_graph` failing is only logged — a failed read is an eternal pending), so `isError` has no counterpart at any level // @provenance ShapeQuery kind=invention level=none ref=none - no upstream type carries load state; `RdfOrmSubscription.readyPromise` expresses pending-vs-ready and nothing else (built with `resolve` alone, and a refused `orm_start_graph` is only `console.error`'d - a failed read is an eternal pending), so `isError` has no counterpart at any level
export interface ShapeQuery<T = UnionSubject> { export interface ShapeQuery<T extends BaseType = BaseType> {
/** The subjects of the requested shape/scope. Empty array when none (never undefined). /** The objects of the requested shape/scope. Empty array when none (never undefined).
* When `isError`, this is the last read that ANSWERED — kept, not cleared, because an * When `isError`, this is the last read that ANSWERED - kept, not cleared, because an
* observable cannot un-emit and `[]` here would be the false "you have nothing" the * observable cannot un-emit and `[]` here would be the false "you have nothing" the
* error state exists to prevent. Read it as a memory then, not as a reply. */ * error state exists to prevent. Read it as a memory then, not as a reply. */
data: T[]; data: T[];
/** True while the sync barrier for the current doc set is not yet reached OR the /** True until the shape query's initial payload has arrived (`readyPromise`).
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */ * Mutually exclusive with `isSuccess`. */
isPending: boolean; isPending: boolean;
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the /** True once the initial payload has arrived. A ready-but-EMPTY scope is `isSuccess`
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with * with `data: []` - the distinction this surface exists for. */
* `data: []` — the distinction this surface exists for. */
isSuccess: boolean; isSuccess: boolean;
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`): /** True ONLY when opening the shape query threw: the shape could not be subscribed, or
* the scope could not be resolved, or the union could not be read. It means the * `configure()` was never given the query to open it with. It means the current answer
* current answer is UNKNOWN — never that the scope is empty. Mutually exclusive with * is UNKNOWN - never that the scope is empty. Mutually exclusive with both `isPending`
* both `isPending` and `isSuccess`. */ * and `isSuccess`. See the module header for the failure it does NOT cover. */
isError: boolean; isError: boolean;
/** The caught error when `isError`, else `undefined`. */ /** The caught error when `isError`, else `undefined`. */
error: unknown; error: unknown;
} }
/** The observable a caller binds with `useSyncExternalStore` (phase B). */ /** The observable a caller binds with `useSyncExternalStore` (phase B). */
// @provenance ShapeObservable kind=invention level=none ref=none — no upstream observable exists; the ORM exposes a hook returning a DeepSignalSet and nothing else // @provenance ShapeObservable kind=invention level=none ref=none - no upstream observable exists; the ORM exposes a hook returning a DeepSignalSet and nothing else
export interface ShapeObservable<T = UnionSubject> { export interface ShapeObservable<T extends BaseType = BaseType> {
/** The current snapshot. STABLE across calls until it actually changes (so /** The current snapshot. STABLE across calls until it actually changes (so
* `useSyncExternalStore` does not loop): the same reference is returned until a * `useSyncExternalStore` does not loop): the same reference is returned until a
* state transition produces a new one. */ * state transition produces a new one. */
getSnapshot(): ShapeQuery<T>; getSnapshot(): ShapeQuery<T>;
/** Register a change listener; returns an unsubscribe. The last listener's /** Register a change listener; returns an unsubscribe. The last listener's
* unsubscribe tears down the underlying doc subscriptions. */ * unsubscribe closes the underlying shape query. */
subscribe(onChange: () => void): () => void; subscribe(onChange: () => void): () => void;
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent /** Ask again, now: close the shape query in force and open a fresh one. Idempotent
* w.r.t. subscriptions; never polls. */ * w.r.t. subscribers, and it never polls - the ORM pools a subscription it has just
* closed, so asking again over an answering broker costs nothing and answers at
* once. The snapshot is NOT reset: whatever was rendered stays until the new
* subscription answers or fails. */
refetch(): void; refetch(): void;
} }
/**
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
* A generated shape constrains its subject's type via a triple constraint on the
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
* and every subject in the doc set flows through). Purely structural — reads only
* the SHEX schema, no application domain.
*/
function shapeTypeIris(shapeType: unknown): Set<string> | null {
try {
const st = shapeType as {
shape?: string;
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
};
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
const preds = shape?.predicates ?? [];
const iris = new Set<string>();
for (const p of preds) {
if (p?.iri !== RDF_TYPE) continue;
for (const dt of p.dataTypes ?? []) {
for (const lit of dt.literals ?? []) {
if (typeof lit === "string") iris.add(lit);
}
}
}
return iris.size > 0 ? iris : null;
} catch {
return null;
}
}
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
const types = subject.props[RDF_TYPE] ?? [];
return types.some((t) => typeIris.has(t));
}
/**
* Whether a subject passes the scope's `subjects` axis. An empty list is upstream's "no
* filtering" (`Scope.subjects`: *"Set to `[]` or leave it `undefined` for no filtering"*),
* and the filter is literal - a subject IRI is in the list or it is not, exactly as
* `FILTER(?s IN (…))` is emitted upstream.
*/
function matchesSubjects(subject: UnionSubject, wanted: string[]): boolean {
return wanted.length === 0 || wanted.includes(subject.subject);
}
/**
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
* state that still holds the barrier open is `syncing` (subscribed, first `State`
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
* best-effort, not an error). `unknown` means the injected `ng` has no
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
* a completed open it can only mean that path, so it counts as reached (opened,
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
*/
function barrierReached(docs: Nuri[]): boolean {
for (const d of docs) {
if (getSyncState(d) === "syncing") return false;
}
return true;
}
/** /**
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one * Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
* `scope` - the SDK's own `{ graphs, subjects }`. See the module header for the full * `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the
* pipeline and for what the `graphs` axis costs while the read path is still this * module header for what is read underneath and what each state means. The returned
* library's. The returned observable is inert until its first * observable is inert until its first {@link ShapeObservable.subscribe} (or
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is * {@link ShapeObservable.refetch}) - that is what opens the shape query; before then
* what kicks off resolution, opening and the first read; before then `getSnapshot` * `getSnapshot` reports the initial pending snapshot.
* reports the initial pending snapshot.
*/ */
// @provenance useShapeQuery kind=announced-not-written level=3 ref=.project/rules/nextgraph/surface-mirrors-the-sdk.md:useShapeQuery announced-by=nextgraph-developers@2026-09-16 — the SDK ships `useShape` returning a bare `DeepSignalSet`; the announced direction is a `useShapeQuery` returning a TanStack-shaped snapshot // @provenance useShapeQuery kind=announced-not-written level=3 ref=.project/rules/nextgraph/surface-mirrors-the-sdk.md:useShapeQuery announced-by=nextgraph-developers@2026-09-16 - the SDK ships `useShape` returning a bare `DeepSignalSet`; the announced direction is a `useShapeQuery` returning a TanStack-shaped snapshot
export function useShapeQuery<T = UnionSubject>( export function useShapeQuery<T extends BaseType = BaseType>(
shapeType: unknown, shapeType: ShapeType<T>,
scope: Scope, scope: Scope,
): ShapeObservable<T> { ): ShapeObservable<T> {
const typeIris = shapeTypeIris(shapeType);
const asked = normalizeScope(scope);
// `did:ng:i` (or `""`) discards every other entry upstream and means the whole local
// dataset. TEMPORARY: until the read queries the store itself, that is served
// by enumerating this identity's own documents across its three stores - the widest
// set this pipeline can reach. Every other `graphs` list names documents and is used
// as given, which is what the axis will mean once the enumeration is gone.
const wholeDataset = asked.graphs.includes(ENTIRE_DATASET);
// The current, STABLE snapshot (same reference until a transition rebuilds it). // The current, STABLE snapshot (same reference until a transition rebuilds it).
let snapshot: ShapeQuery<UnionSubject> = { let snapshot: ShapeQuery<T> = {
data: [], data: [],
isPending: true, isPending: true,
isSuccess: false, isSuccess: false,
@@ -284,21 +159,21 @@ export function useShapeQuery<T = UnionSubject>(
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
let started = false; let started = false;
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe. /** The shape query in force, or `null` between a teardown and the next open. */
// Idempotent: a doc already here is not re-subscribed. Excludes the container let query: OrmSubscriptionHandle | null = null;
// (scope-index / discovery-index) subscriptions, held separately. /** The change listener registered on {@link query}, so it can be taken off again. */
const docSubs = new Map<Nuri, Unsubscribe>(); let onQueryChange: (() => void) | null = null;
// Container subscriptions (scope-index doc; discovery-index doc for `public`) — /** Unsubscribe from the held-caps change signal (see the subscription in `start`). */
// a push here means the doc SET may have changed → re-resolve.
const containerSubs = new Map<Nuri, Unsubscribe>();
// Unsubscribe from the held-caps change signal (see the subscription in `start`).
let capsUnsub: (() => void) | null = null; let capsUnsub: (() => void) | null = null;
// True while `resolveDocs` runs. Folding a repo link files a cap, which fires the /**
// held-caps signal; the resolution in progress already accounts for it, so the * Which open owns the question. A `refetch` supersedes the previous subscription, and
// signal is ignored during that window instead of restarting the cycle. * a teardown supersedes all of them - so every callback checks it is still the current
let resolving = false; * one before publishing. A stale answer is dropped, never merged.
// Monotonic token so a slow in-flight refresh cannot clobber a newer one. */
let refreshToken = 0; let generation = 0;
/** Has the CURRENT generation's initial payload arrived? Gates every publication:
* a change signal that beats `readyPromise` must not be read as an answer. */
let ready = false;
function emit(): void { function emit(): void {
for (const l of listeners) { for (const l of listeners) {
@@ -310,22 +185,22 @@ export function useShapeQuery<T = UnionSubject>(
} }
} }
function setSnapshot(next: ShapeQuery<UnionSubject>): void { function setSnapshot(next: ShapeQuery<T>): void {
snapshot = next; snapshot = next;
emit(); emit();
} }
/** /**
* Publish "the current answer is UNKNOWN" — the one snapshot every failure in the * Publish "the current answer is UNKNOWN" - the one snapshot every failure on this
* pipeline produces, wherever it was thrown. * path produces.
* *
* `data` carries the LAST READ THAT ANSWERED rather than `[]`. A subscriber has * `data` carries the LAST READ THAT ANSWERED rather than `[]`. A subscriber has
* already rendered that list and an observable cannot un-emit it; clearing it would * already rendered that list and an observable cannot un-emit it; clearing it would
* put the empty answer back — in the field an application actually renders, and for * put the empty answer back - in the field an application actually renders, and for
* the very case that must never read as empty. `isSuccess` stays false, so the array * the very case that must never read as empty. `isSuccess` stays false, so the array
* is never offered as a reply to the question that just failed, and a subscriber that * is never offered as a reply to the question that just failed, and a subscriber that
* gates on `isSuccess` shows nothing new while one that renders `data` keeps what it * gates on `isSuccess` shows nothing new while one that renders `data` keeps what it
* had. Before the first answer there is nothing to keep and `data` is `[]` — the * had. Before the first answer there is nothing to keep and `data` is `[]` - the
* initial value, published under `isError`, never under `isSuccess`. * initial value, published under `isError`, never under `isSuccess`.
*/ */
function setUnknown(error: unknown): void { function setUnknown(error: unknown): void {
@@ -338,169 +213,134 @@ export function useShapeQuery<T = UnionSubject>(
}); });
} }
/** Resolve the logical scope → the current doc set: the CURRENT wallet's own /**
* entity documents for that scope, and nothing else. * The readable items of the live subscription, as a plain array.
* *
* It PROPAGATES, and that is the whole point: the set it returns is what the rest * The ONE cast in this module, and it sits where the injected SDK meets the shape's
* of the pipeline calls "the scope", so a swallowed failure here does not degrade * promise: the subscription is typed by what `configure` received (`unknown` items,
* the answer, it INVENTS one — zero documents, a barrier trivially reached over * because the injection is a loose shape), while `shapeType` is what says they are
* them, and `isSuccess` published over a store nobody read. Nothing downstream can * `T`. Filtering happens first and on the raw items - the filter reads `@graph` and
* tell that set apart from a scope that is genuinely empty, because it IS the same * nothing else, so it needs no type at all.
* set. The caller's error state exists for this. */
* function readable(handle: OrmSubscriptionHandle): T[] {
* There is no "everything public" to fold in. You cannot discover; you can only const caps = getCaps();
* follow links, and a link reaches you through an inbox or through a document const items = [...handle.signalObject];
* you already hold — never through a shared index. A document someone gave you // No cap issued yet -> the emulation is not in force and the set passes through,
* the cap for is read by naming it (`readUnion`), not by appearing in // exactly as `surface/use-shape.ts` does. See `emulated-verifier/read-filter.ts`.
* a scope you did not put it in. */ const kept = caps.isEnforcing() ? filterReadable(items, caps) : items;
async function resolveDocs(): Promise<Nuri[]> { return kept as T[];
if (!wholeDataset) {
// The caller named the documents. `[]` or an absent `graphs` is upstream's "no
// objects are returned", and it reaches that answer here the same way: an empty
// doc set, a barrier trivially past it, and `isSuccess` over nothing.
return [...new Set(asked.graphs.filter(Boolean))].map((g) => toNuri(g, "useShapeQuery"));
}
const user = getCurrentUser();
const set = new Set<Nuri>();
resolving = true;
try {
if (user) {
for (const type of STORE_TYPES) {
for (const d of await listMyEntityDocs(user, type)) set.add(d);
}
}
} finally {
resolving = false;
}
return [...set];
} }
/** Subscribe to the CONTAINER document (the scope index) so a change to the doc /** Publish what the subscription holds right now. Only ever called on a READY
* SET re-resolves. Idempotent per NURI. * generation, so this snapshot is always an answer. */
* function publish(handle: OrmSubscriptionHandle, gen: number): void {
* This one SWALLOWS, deliberately and unlike `resolveDocs` next door. It resolves if (gen !== generation || !ready) return;
* no data and publishes no snapshot — it wires a change signal — so its failure
* cannot dress an unread store up as an empty one. And the answer is not lost with
* it: `resolveDocs` runs immediately after over the SAME account, so a store that
* cannot be reached surfaces there, as an error, one line later. What is lost is
* reactivity to a later change of the SET, which is a different (and much quieter)
* problem than the one this module was fixed for. */
async function ensureContainerSubs(): Promise<void> {
// A named `graphs` list IS the set; nothing a store records can change it, so there
// is no container to follow. Only the whole-dataset reading has one - three, in fact,
// one per store this identity places documents in.
if (!wholeDataset) return;
const containers: Nuri[] = [];
const user = getCurrentUser();
if (user) {
for (const type of STORE_TYPES) {
try {
containers.push(await userStoreDoc(user, type));
} catch (error) {
console.error("[use-shape-query] userStoreDoc failed", error);
}
}
}
for (const c of containers) {
if (!c || containerSubs.has(c)) continue;
// A push on a container doc means the set may have changed → full re-resolve.
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
}
}
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
function syncDocSubs(docs: Nuri[]): void {
const wanted = new Set(docs.filter(Boolean));
for (const [nuri, unsub] of docSubs) {
if (!wanted.has(nuri)) {
try {
unsub();
} catch {
/* ignore */
}
docSubs.delete(nuri);
}
}
for (const nuri of wanted) {
if (docSubs.has(nuri)) continue;
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
}
}
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
let subjects: UnionSubject[];
try {
subjects = await readUnion(docs);
} catch (error) {
if (token !== refreshToken) return;
setUnknown(error);
return;
}
if (token !== refreshToken) return; // superseded by a newer refresh/reread
const data = subjects.filter((s) => matchesShape(s, typeIris) && matchesSubjects(s, asked.subjects));
const past = barrierReached(docs);
setSnapshot({ setSnapshot({
data, data: readable(handle),
isPending: !past, isPending: false,
isSuccess: past, isSuccess: true,
isError: false, isError: false,
error: undefined, error: undefined,
}); });
} }
/** Full cycle: resolve the scope, (re)establish container subs, open the docs /**
* (await the barrier), sync per-doc subs, then read + publish. * The SDK's shape query, or the error that says it was never injected.
* *
* This catch is the surface's ONE answer to "the pipeline could not answer" — * Upstream an application imports the ORM; here it hands it to `configure`, like the
* scope resolution included, since `resolveDocs` propagates. It is reached only * `ng` and the `useShape` beside it. Absent, there is nothing to read through - and
* when the cycle is still the current one; a superseded cycle's failure is * that has to be said out loud rather than answered with an empty set, which is the
* dropped, because a newer cycle owns the question by then. */ * one reading this surface must never produce.
async function refresh(): Promise<void> { */
const token = ++refreshToken; function shapeQuery(): OrmSubscriptionLike {
const injected = getConfig().ormSubscription;
if (!injected) {
throw new Error(
"[ng-eventually] useShapeQuery: no shape query was injected. Pass " +
"`ormSubscription: RdfOrmSubscription.getOrCreate` (from `@ng-org/orm`) to " +
"configure() - this package reads through the SDK's own shape query and has " +
"no read of its own to fall back on.",
);
}
return injected;
}
/** Take the current subscription down: stop listening, close it, forget it. Leaves
* the snapshot and the subscribers alone - this is not an answer, it is plumbing. */
function closeQuery(): void {
if (query === null) return;
const handle = query;
query = null;
ready = false;
if (onQueryChange) {
try { try {
await ensureContainerSubs(); handle.removeChangeListener(onQueryChange);
const docs = await resolveDocs(); } catch {
if (token !== refreshToken) return; /* ignore */
syncDocSubs(docs); }
// Open/await the barrier (first State per doc, or timed-out). No-op once open. onQueryChange = null;
await ensureReposOpen(docs); }
if (token !== refreshToken) return; try {
await readAndPublish(docs, token); handle.close();
} catch (error) { } catch {
if (token !== refreshToken) return; /* ignore */
setUnknown(error);
} }
} }
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve, /**
* the set is unchanged). Reuses the docs we are subscribed to. */ * Open a shape query for `(shapeType, scope)` and wire its two signals.
async function reread(): Promise<void> { *
const token = ++refreshToken; * Supersedes whatever was open: the previous generation's callbacks are dropped on
const docs = [...docSubs.keys()]; * arrival rather than raced, so a slow answer can never clobber a newer question.
await readAndPublish(docs, token); */
function open(): void {
closeQuery();
const gen = ++generation;
let handle: OrmSubscriptionHandle;
try {
handle = shapeQuery()(shapeType, scope);
} catch (error) {
setUnknown(error);
return;
}
query = handle;
const onChange = (): void => publish(handle, gen);
onQueryChange = onChange;
handle.addChangeListener(onChange);
handle.readyPromise.then(
() => {
if (gen !== generation) return;
ready = true;
publish(handle, gen);
},
(error: unknown) => {
// Nothing upstream rejects this promise today (see the module header); the
// rejection path is wired all the same, because a state nobody handles is how
// a failure becomes silence the day the ORM starts reporting one.
if (gen !== generation) return;
setUnknown(error);
},
);
} }
function start(): void { function start(): void {
if (started) return; if (started) return;
started = true; started = true;
// A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the // A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the
// consumer's `inbox.watch`) makes documents readable that were not. Without // consumer's `inbox.watch`) makes items readable that were not. Without
// this the view would stay stale until some unrelated push happened to fire — // this the view would stay stale until some unrelated push happened to fire -
// so re-read whenever they change. This is the delivery channel key // so re-publish whenever the keyring changes. This is the delivery channel key
// ROTATION uses too, which is why keeping an access needs no subscription // ROTATION uses too, which is why keeping an access needs no subscription
// obligation on the consumer's side. // obligation on the consumer's side.
capsUnsub = getCaps().onChange(() => { capsUnsub = getCaps().onChange(() => {
if (!resolving) void refresh(); if (query !== null) publish(query, generation);
}); });
void refresh(); open();
} }
return { return {
getSnapshot(): ShapeQuery<T> { getSnapshot(): ShapeQuery<T> {
return snapshot as unknown as ShapeQuery<T>; return snapshot;
}, },
subscribe(onChange: () => void): () => void { subscribe(onChange: () => void): () => void {
listeners.add(onChange); listeners.add(onChange);
@@ -508,24 +348,10 @@ export function useShapeQuery<T = UnionSubject>(
return () => { return () => {
listeners.delete(onChange); listeners.delete(onChange);
if (listeners.size === 0) { if (listeners.size === 0) {
// Last listener gone → tear down the underlying subscriptions. A later // Last listener gone -> close the shape query. A later subscribe opens a
// subscribe restarts a fresh cycle. // fresh one; the ORM pools what it has just closed, so that is cheap.
for (const u of docSubs.values()) { generation += 1;
try { closeQuery();
u();
} catch {
/* ignore */
}
}
for (const u of containerSubs.values()) {
try {
u();
} catch {
/* ignore */
}
}
docSubs.clear();
containerSubs.clear();
if (capsUnsub) { if (capsUnsub) {
capsUnsub(); capsUnsub();
capsUnsub = null; capsUnsub = null;
@@ -535,8 +361,11 @@ export function useShapeQuery<T = UnionSubject>(
}; };
}, },
refetch(): void { refetch(): void {
if (!started) {
start(); start();
void refresh(); return;
}
open();
}, },
}; };
} }
+225
View File
@@ -0,0 +1,225 @@
/**
* orm-fake - a stand-in for the SDK's shape query, `RdfOrmSubscription.getOrCreate`.
*
* Not a `*.test.ts`, so `bun test` does not pick it up: it is the injection two suites
* share, and it is the seam the package is built on - an application hands `configure`
* the real ORM, and a suite hands it this.
*
* -- Why a small ENGINE and not a canned answer ----------------------------
* `useShapeQuery` no longer interprets a scope: `graphs`, `subjects` and the
* whole-dataset marker go to the engine verbatim. A fake that ignored them and returned
* a fixed list would make every scope test green while proving nothing - the failure
* mode `test-the-case-space` is about. So this answers the way
* `schema_shape_to_sparql` / `query_quads_for_shape` do, and the three readings of the
* axes are real here:
*
* - `graphs: []` (or absent) -> NO objects. `graphs` carrying `did:ng:i` or `""` ->
* every other entry is discarded and the graph filter drops entirely. Otherwise the
* filter is LITERAL - a graph is in the list or it is not, no prefix matching.
* - `subjects: []` -> no filtering; otherwise literal, over ROOT subjects.
* - the shape -> every predicate of `schema[shape]` must be present on the subject,
* and a predicate carrying `literals` must match one of them. The object is
* projected under each predicate's `readablePredicate`, plus `@id` and `@graph`.
*
* -- What it does NOT invent -----------------------------------------------
* Readiness resolves on ONE path - a delivery - exactly as upstream's `resolveReady`
* runs only on the arrival of initial data. Nothing here ever REJECTS `readyPromise`,
* because nothing upstream does: `orm_start_graph` failing is caught and logged, and a
* refused read is an eternal pending. A suite that wants the pending-for-ever state
* asks for it by not delivering, which is the state the real thing produces.
*
* {@link FakeOrm.refuse} is the other real failure: `getOrCreate` THROWS at the call.
*/
import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../src/model/types";
import type { Scope } from "@ng-org/orm";
import type { BaseType, Predicate, ShapeType } from "@ng-org/shex-orm";
/** Upstream's whole-dataset marker; `""` means the same thing. */
const ENTIRE_DATASET = "did:ng:i";
/** One triple in the dataset this fake engine answers from. */
export interface OrmQuad {
graph: string;
subject: string;
predicate: string;
object: string;
}
type Listener = (changes: { adds: unknown[]; updates: unknown[]; removes: unknown[] }) => void;
interface OpenQuery {
shape: ShapeType<BaseType>;
scope: Scope;
listeners: Set<Listener>;
items: BaseType[];
delivered: boolean;
closed: boolean;
resolveReady: () => void;
}
export interface FakeOrm {
/** What goes into `configure({ ormSubscription })`. */
query: OrmSubscriptionLike;
/** Add a triple to the dataset. After a delivery this also pushes, as an engine patch
* reaching the signal object does. */
seed(graph: string, subject: string, predicate: string, object: string): void;
/** Deliver the initial payload to every query still waiting for one - what resolves
* readiness upstream, and the only thing that does. */
deliver(): void;
/** Make every SUBSEQUENT `getOrCreate` throw. The one failure this path really has. */
refuse(message: string): void;
/** Stop refusing. */
answer(): void;
/** The scopes this fake was asked for, in order - so a suite can assert the scope
* reached the SDK exactly as the caller wrote it. */
asked: Array<{ shape: string; scope: Scope }>;
/** How many queries are open right now (`close()` decrements). */
openCount(): number;
}
/** The literal values a predicate constrains its object to, if any. */
function literalsOf(p: Predicate): string[] {
const out: string[] = [];
for (const dt of p.dataTypes ?? []) {
for (const lit of dt.literals ?? []) if (typeof lit === "string") out.push(lit);
}
return out;
}
/** Does `subject` in `graph` satisfy every predicate of the shape? If so, the object. */
function project(
quads: OrmQuad[],
graph: string,
subject: string,
predicates: Predicate[],
): BaseType | null {
const own = quads.filter((q) => q.graph === graph && q.subject === subject);
const out: Record<string, unknown> = { "@id": subject, "@graph": graph };
for (const p of predicates) {
const wanted = literalsOf(p);
const values = own
.filter((q) => q.predicate === p.iri)
.map((q) => q.object)
.filter((o) => wanted.length === 0 || wanted.includes(o));
if (values.length === 0) return null; // a required triple pattern with no match
out[p.readablePredicate] = values.length === 1 ? values[0] : values;
}
return out as BaseType;
}
/** The objects the engine would return for one shape in one scope. */
function answerFor(quads: OrmQuad[], shape: ShapeType<BaseType>, scope: Scope): BaseType[] {
const graphs = (typeof scope.graphs === "string" ? [scope.graphs] : (scope.graphs ?? [])).map(
(g) => (g === "" ? ENTIRE_DATASET : g),
);
if (graphs.length === 0) return []; // upstream: no objects are returned
const everywhere = graphs.includes(ENTIRE_DATASET);
const subjects = scope.subjects ?? [];
const predicates = shape.schema[shape.shape]?.predicates ?? [];
const seen = new Set<string>();
const out: BaseType[] = [];
for (const q of quads) {
if (!everywhere && !graphs.includes(q.graph)) continue;
if (subjects.length > 0 && !subjects.includes(q.subject)) continue;
const key = q.graph + "/" + q.subject;
if (seen.has(key)) continue;
seen.add(key);
const obj = project(quads, q.graph, q.subject, predicates);
if (obj !== null) out.push(obj);
}
return out;
}
/**
* A fake shape query over an in-memory dataset.
*
* `autoDeliver` (the default) hands every query its initial payload on the next tick,
* which is the convenient mode for suites that are not about readiness. Turn it off and
* call {@link FakeOrm.deliver} to hold the pending state open.
*/
export function makeOrm(
options: { autoDeliver?: boolean; source?: () => OrmQuad[] } = {},
): FakeOrm {
const quads: OrmQuad[] = [];
/** Where the engine reads from - its own dataset, or one a suite already holds (the
* fake wallet's, so the objects read back are the ones the surface really wrote). */
const dataset = options.source ?? (() => quads);
const open = new Set<OpenQuery>();
const asked: Array<{ shape: string; scope: Scope }> = [];
const autoDeliver = options.autoDeliver !== false;
let refusal: string | null = null;
function recompute(q: OpenQuery): void {
if (!q.delivered || q.closed) return;
q.items = answerFor(dataset(), q.shape, q.scope);
for (const l of q.listeners) l({ adds: q.items, updates: [], removes: [] });
}
function deliverOne(q: OpenQuery): void {
if (q.delivered || q.closed) return;
q.delivered = true;
q.items = answerFor(dataset(), q.shape, q.scope);
q.resolveReady();
for (const l of q.listeners) l({ adds: q.items, updates: [], removes: [] });
}
const query = (<T extends BaseType>(
shapeType: ShapeType<T>,
scope: Scope,
): OrmSubscriptionHandle => {
if (refusal !== null) throw new Error(refusal);
const shape = shapeType as unknown as ShapeType<BaseType>;
asked.push({ shape: shape.shape, scope });
let resolveReady!: () => void;
const readyPromise = new Promise<void>((r) => (resolveReady = r));
const record: OpenQuery = {
shape,
scope,
listeners: new Set(),
items: [],
delivered: false,
closed: false,
resolveReady,
};
open.add(record);
if (autoDeliver) setTimeout(() => deliverOne(record), 0);
return {
readyPromise,
get signalObject(): Iterable<unknown> {
return record.items;
},
addChangeListener(listener: Listener): void {
record.listeners.add(listener);
},
removeChangeListener(listener: Listener): void {
record.listeners.delete(listener);
},
close(): void {
record.closed = true;
record.listeners.clear();
open.delete(record);
},
};
}) as OrmSubscriptionLike;
return {
query,
seed(graph, subject, predicate, object): void {
quads.push({ graph, subject, predicate, object });
for (const q of open) recompute(q);
},
deliver(): void {
for (const q of [...open]) deliverOne(q);
},
refuse(message): void {
refusal = message;
},
answer(): void {
refusal = null;
},
asked,
openCount: () => open.size,
};
}
@@ -1,113 +1,112 @@
/** /**
* `useShapeQuery` says "there is nothing" or "I could not find out" — never the first for the second. * `useShapeQuery` says "there is nothing" or "I could not find out" - never the first for the second.
* *
* The whole reason this surface exists is one distinction: an application must be able to * The whole reason this surface exists is one distinction: an application must be able to
* tell a scope that is EMPTY from a scope it has not heard back about. `useShape` upstream * tell a scope that is EMPTY from a scope it has not heard back about. `useShape` upstream
* cannot — it returns "an empty set, if still loading" — and `useShapeQuery` publishes * cannot - it returns "an empty set, if still loading" - and `useShapeQuery` publishes
* `isPending` / `isSuccess` / `isError` precisely to answer the question three ways. * `isPending` / `isSuccess` / `isError` precisely to answer the question three ways.
* *
* Until 2026-08-17 it destroyed that distinction on the one input that mattered. Step 1 of * -- What changed under it in `1.0.0-dev.6`, and what did not ---------------
* its pipeline asks `listMyEntityDocs` which documents are mine; the failure * Until then the read was this package's own pipeline, and the failure it could not be
* was caught, logged, and the empty set flowed on. A barrier over zero documents is * allowed to mistake was its first step: `listMyEntityDocs` not answering, caught, logged,
* trivially reached, so the surface published `{ data: [], isPending: false, isSuccess: * and the empty set flowing on. A barrier over zero documents is trivially reached, so the
* true }` — the exact snapshot a genuinely empty scope produces, byte for byte. An * surface published `{ data: [], isPending: false, isSuccess: true }` - the exact snapshot
* application rendered "you have created nothing" for "the store did not answer", which is * a genuinely empty scope produces, byte for byte. An application rendered "you have
* the outage this library already shipped twice (`8c8ade7`, `f6d1734`) and closed one layer * created nothing" for "the store did not answer", which is the outage this library shipped
* down the day before (`90712e0`, which made that very call throw). Catching it here put it * twice (`8c8ade7`, `f6d1734`).
* straight back one floor up.
* *
* ── The two questions, and why the second needs its own test ────────────── * The read now goes through the SDK's shape query, and that step is gone with the pipeline.
* The DISTINCTION is not: it is the thing under test here, and the two ways it can now be
* broken are both below. The shape query REFUSES TO OPEN - it throws at the call - and the
* surface must say `isError`. The shape query opens and the engine never answers - upstream
* catches that failure and logs it, so readiness simply never settles - and the surface
* must stay `isPending` for ever rather than fall through to the empty answer.
*
* -- The two questions, and why the second needs its own test --------------
* A one-shot call rejects and is done. An observable has already handed a list to a * A one-shot call rejects and is done. An observable has already handed a list to a
* subscriber that RENDERED it, so a failure that arrives afterwards has to say something * subscriber that RENDERED it, so a failure that arrives afterwards has to say something
* about that list — and `data: []` would be the empty answer again, in the one field an * about that list - and `data: []` would be the empty answer again, in the one field an
* application actually paints. So there are two cases here, not one: the failure BEFORE any * application actually paints. So there are two cases here, not one: the failure BEFORE any
* answer, and the failure AFTER one. * answer, and the failure AFTER one.
* *
* ── The fault is the broker's, and it is one a real one produces ────────── * -- The fault is the SDK's, and it is one the real one produces -----------
* Nothing here plants a state or reaches in to make a library function throw. The documents * Nothing here plants a state or reaches into the library to make one of its functions
* were CREATED through the surface, over the same wallet, in the session before; then the * throw. The documents were CREATED through the surface, over the same wallet, in the
* connection dies — the broker answers normally and stops answering, and every query after * session before; then the injected shape query stops opening, which is what the real
* that rejects the way the wasm binding rejects a transport error. Same montage as * `getOrCreate` does when it cannot subscribe the shape it was handed, and what this
* `listing-surfaces-failure.test.ts`, one layer up. * package itself does when `configure` was never given one.
*/ */
import { test, expect, describe, mock, beforeEach, afterAll } from "bun:test"; import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { configure, useShapeQuery, storeRegistry } from "../src/index"; import { configure, useShapeQuery, storeRegistry } from "../src/index";
import type { ShapeObservable, ShapeQuery } from "../src/surface/use-shape-query"; import type { ShapeObservable, ShapeQuery } from "../src/surface/use-shape-query";
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap"; import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
import { sparqlUpdate } from "../src/surface/docs"; import { sparqlUpdate } from "../src/surface/docs";
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types"; import type { NgLike, StoreType, UseShapeLike } from "../src/model/types";
import type { Nuri } from "../src/model/types";
import { makeOrm, type FakeOrm, type OrmQuad } from "./orm-fake";
import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake"; import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
const SHIM = "urn:ng-eventually:shim";
/** The Main-branch read of a store — "which documents are in here". Step 1 of the pipeline. */
const LISTING_READ = `<${SHIM}:contains>`;
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const EVENT = "http://festipod.org/Event"; const EVENT = "http://festipod.org/Event";
const TITLE = "http://festipod.org/title"; const TITLE = "http://festipod.org/title";
/** A minimal SHEX ShapeType pinning `rdf:type` to {@link EVENT}. */ /** A minimal SHEX ShapeType pinning `rdf:type` to {@link EVENT} and requiring a title. */
const EventShape = { const EventShape = {
shape: "http://festipod.org/EventShape", shape: "http://festipod.org/EventShape",
schema: { schema: {
"http://festipod.org/EventShape": { "http://festipod.org/EventShape": {
iri: "http://festipod.org/EventShape", iri: "http://festipod.org/EventShape",
predicates: [{ iri: RDF_TYPE, dataTypes: [{ literals: [EVENT], valType: "iri" }] }], predicates: [
{
iri: RDF_TYPE,
readablePredicate: "@type",
dataTypes: [{ literals: [EVENT], valType: "iri" }],
minCardinality: 1,
maxCardinality: 1,
},
{
iri: TITLE,
readablePredicate: "title",
dataTypes: [{ valType: "string" }],
minCardinality: 1,
maxCardinality: 1,
},
],
}, },
}, },
}; } satisfies ShapeType<BaseType>;
/** /**
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query * Wire the library onto `quads`, with the shape query answering over THE SAME wallet.
* `lostAt` accepts — that one included, and every one after it, for the rest of the page.
* *
* A dropped connection is not selective, so neither is this: the switch decides *when* the * That shared dataset is the point: the objects the read hands back are the ones the
* link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up. * surface really wrote, in the session before, rather than a list this file composed.
*
* `doc_subscribe` is carried here rather than left off (`wallet-fake` has none, and the
* library then takes its documented no-op open path): with no subscription there is no
* `State`, so the sync BARRIER would be skipped instead of crossed, and `isPending` would
* fall away for a reason the real platform never gives. It pushes what the platform pushes
* — `TabInfo` then `State`, in that order — and a link that is down pushes NOTHING, which
* is what a link that is down does.
*/ */
function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void { function bootPage(quads: Quad[]): FakeOrm {
const wallet = makeWallet(quads); const wallet = makeWallet(quads);
let lost = false; const asOrmQuads = (): OrmQuad[] =>
const ng = { quads.map((q) => ({ graph: q.g, subject: q.s, predicate: q.p, object: q.o }));
doc_create: wallet.doc_create, const orm = makeOrm({ source: asOrmQuads });
sparql_update: wallet.sparql_update, configure({
sparql_query: mock(async (...a: unknown[]) => { ng: wallet as unknown as NgLike,
if (lost || lostAt(a[1] as string)) { useShape: (() => {}) as unknown as UseShapeLike,
lost = true; ormSubscription: orm.query,
throw new Error("BrokerError: connection lost"); });
}
return wallet.sparql_query(...a);
}),
doc_subscribe: mock(async (_nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
if (lost) throw new Error("BrokerError: connection lost");
setTimeout(() => {
if (lost) return;
cb({ V0: { TabInfo: {} } });
cb({ V0: { State: {} } });
}, 0);
return () => {};
}),
};
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
return orm;
} }
/** /**
* The session before: alice signs in and creates one document carrying one Event, over a * The session before: alice signs in and creates one document carrying one Event, over a
* broker that works. Returns the quads the wallet now holds — the durable half that * broker that works. Returns the quads the wallet now holds - the durable half that
* outlives the page. * outlives the page.
*/ */
async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> { async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> {
const quads: Quad[] = []; const quads: Quad[] = [];
forgetEverything(); forgetEverything();
bootPage(quads, () => false); bootPage(quads);
await signIn("alice"); await signIn("alice");
const note = await storeRegistry.createEntityDoc(await storeOfType(scope)); const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
await sparqlUpdate( await sparqlUpdate(
@@ -118,17 +117,17 @@ async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad
return { quads, note }; return { quads, note };
} }
/** A first session where alice signs in and creates NOTHING — a genuinely empty scope. */ /** A first session where alice signs in and creates NOTHING - a genuinely empty scope. */
async function aFirstSessionThatCreatedNothing(): Promise<Quad[]> { async function aFirstSessionThatCreatedNothing(): Promise<Quad[]> {
const quads: Quad[] = []; const quads: Quad[] = [];
forgetEverything(); forgetEverything();
bootPage(quads, () => false); bootPage(quads);
await signIn("alice"); await signIn("alice");
return quads; return quads;
} }
/** /**
* What the APPLICATION reads off a snapshot — the whole point of the surface, written the * What the APPLICATION reads off a snapshot - the whole point of the surface, written the
* way a view would branch on it. Four readings, and the two this file is about are * way a view would branch on it. Four readings, and the two this file is about are
* `"nothing"` and `"unknown"`: they must never be the same one. * `"nothing"` and `"unknown"`: they must never be the same one.
*/ */
@@ -160,10 +159,15 @@ async function subscribeAndSettle(
return { renders: () => renders, stop }; return { renders: () => renders, stop };
} }
/** The titles a snapshot carries - what a view would render. */
function titles(snap: ShapeQuery): string[] {
return snap.data.map((d) => String(d.title)).sort();
}
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"]; const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
/** The whole local dataset - upstream's own marker, and the reading that still has to /** The whole local dataset - upstream's own marker, and the one reading an application
* enumerate this identity's documents, which is the step these tests make fail. */ * reaches for when it wants everything it holds. */
const EVERYTHING = { graphs: "did:ng:i" }; const EVERYTHING = { graphs: "did:ng:i" };
beforeEach(() => { beforeEach(() => {
@@ -171,7 +175,7 @@ beforeEach(() => {
}); });
afterAll(() => { afterAll(() => {
// The caps registry, the config and the registry caches are PROCESS-WIDE, and `bun test` // The caps registry, the config and the registry caches are PROCESS-WIDE, and `bun test`
// runs every file in ONE process — so this teardown is not housekeeping. Caps left behind // runs every file in ONE process - so this teardown is not housekeeping. Caps left behind
// put the possession gate in force for a suite that declares none, and `subscribe.test.ts` // put the possession gate in force for a suite that declares none, and `subscribe.test.ts`
// is then refused at the door for documents it legitimately owns; a config left behind // is then refused at the door for documents it legitimately owns; a config left behind
// hands `access-log.test.ts` this file's `ng`. Both failed exactly that way before this // hands `access-log.test.ts` this file's `ng`. Both failed exactly that way before this
@@ -182,14 +186,14 @@ afterAll(() => {
describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", () => { describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", () => {
// The NORMAL cases first, and they are not a formality: a surface that reported `isError` // The NORMAL cases first, and they are not a formality: a surface that reported `isError`
// unconditionally would pass every failure case below. These two are what those are a // unconditionally would pass every failure case below. These two are what those are a
// departure from — and the first of them is the reading that must never be reused for a // departure from - and the first of them is the reading that must never be reused for a
// failure, so it has to be pinned before anything can be said to differ from it. // failure, so it has to be pinned before anything can be said to differ from it.
for (const scope of ALL_SCOPES) { for (const scope of ALL_SCOPES) {
test(`[${scope}] a scope this account never wrote to reads as NOTHING`, async () => { test(`[${scope}] a scope this account never wrote to reads as NOTHING`, async () => {
const quads = await aFirstSessionThatCreatedNothing(); const quads = await aFirstSessionThatCreatedNothing();
forgetEverything(); forgetEverything();
bootPage(quads, () => false); bootPage(quads);
await signIn("alice"); await signIn("alice");
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable; const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
@@ -209,7 +213,7 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
const { quads } = await aFirstSessionThatCreated(scope); const { quads } = await aFirstSessionThatCreated(scope);
forgetEverything(); forgetEverything();
bootPage(quads, () => false); bootPage(quads);
await signIn("alice"); await signIn("alice");
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable; const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
@@ -217,24 +221,24 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
const snap = obs.getSnapshot(); const snap = obs.getSnapshot();
expect(whatTheAppSees(snap)).toBe("some"); expect(whatTheAppSees(snap)).toBe("some");
expect(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]); expect(titles(snap)).toEqual(["the note"]);
stop(); stop();
}); });
} }
// The defect. Alice HAS a document; the store cannot be read; the surface used to answer // The defect, in the form the new read path can take it. Alice HAS a document; the shape
// with the empty-scope snapshot above — same `data`, same `isSuccess`, nothing to tell // query cannot be opened; the surface must not answer with the empty-scope snapshot
// them apart. It is the more dangerous of the two failure values, because "you created // above - same `data`, same `isSuccess`, nothing to tell them apart. It is the more
// nothing" is a sentence an application renders without hesitating. // dangerous of the two failure values, because "you created nothing" is a sentence an
// application renders without hesitating.
for (const scope of ALL_SCOPES) { for (const scope of ALL_SCOPES) {
test(`[${scope}] a store that does not answer reads as UNKNOWN, not as nothing`, async () => { test(`[${scope}] a shape query that cannot be opened reads as UNKNOWN, not as nothing`, async () => {
const { quads } = await aFirstSessionThatCreated(scope); const { quads } = await aFirstSessionThatCreated(scope);
let armed = false;
forgetEverything(); forgetEverything();
bootPage(quads, (query) => armed && query.includes(LISTING_READ)); const orm = bootPage(quads);
await signIn("alice"); await signIn("alice");
armed = true; orm.refuse("OrmError: the shape could not be subscribed");
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable; const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
const { stop } = await subscribeAndSettle(obs); const { stop } = await subscribeAndSettle(obs);
@@ -244,7 +248,7 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
expect(snap.isError).toBe(true); expect(snap.isError).toBe(true);
expect(snap.isSuccess).toBe(false); expect(snap.isSuccess).toBe(false);
expect(snap.isPending).toBe(false); expect(snap.isPending).toBe(false);
expect(String((snap.error as Error)?.message)).toMatch(/BrokerError/); expect(String((snap.error as Error)?.message)).toMatch(/OrmError/);
// Said as the difference itself, since one reading standing in for the other IS the // Said as the difference itself, since one reading standing in for the other IS the
// defect: this is not the snapshot the empty scope publishes. // defect: this is not the snapshot the empty scope publishes.
expect(whatTheAppSees(snap)).not.toBe(whatTheAppSees({ expect(whatTheAppSees(snap)).not.toBe(whatTheAppSees({
@@ -254,17 +258,58 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
}); });
} }
// The other way the read can fail now, and the one upstream actually produces: the query
// OPENS and the engine never answers. `orm_start_graph` failing is caught and logged
// there, and the readiness promise is built with `resolve` alone, so nothing settles it.
// What matters here is the reading an application gets: "still asking", for ever if need
// be - and never "there is nothing", which is the sentence it would render.
for (const scope of ALL_SCOPES) {
test(`[${scope}] a shape query that never answers stays STILL-ASKING, not nothing`, async () => {
const { quads } = await aFirstSessionThatCreated(scope);
forgetEverything();
const wallet = makeWallet(quads);
const orm = makeOrm({
autoDeliver: false,
source: () => quads.map((q) => ({ graph: q.g, subject: q.s, predicate: q.p, object: q.o })),
});
configure({
ng: wallet as unknown as NgLike,
useShape: (() => {}) as unknown as UseShapeLike,
ormSubscription: orm.query,
});
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
await signIn("alice");
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
const stop = obs.subscribe(() => {});
await new Promise((r) => setTimeout(r, 50));
const snap = obs.getSnapshot();
expect(whatTheAppSees(snap)).toBe("still-asking");
expect(snap.isPending).toBe(true);
expect(snap.isSuccess).toBe(false);
expect(snap.data).toEqual([]);
// And the moment the engine does answer, it is the document that comes back - so the
// pending state was a wait, not a dead end.
orm.deliver();
await until("the answer", () => obs.getSnapshot().isSuccess);
expect(titles(obs.getSnapshot())).toEqual(["the note"]);
stop();
});
}
// The half a one-shot call never has to answer: the subscriber has ALREADY rendered a // The half a one-shot call never has to answer: the subscriber has ALREADY rendered a
// list when the link dies. The observable cannot un-emit it, so the failure snapshot must // list when the read stops working. The observable cannot un-emit it, so the failure
// not put `[]` in the field the view paints — that would be the empty answer again, // snapshot must not put `[]` in the field the view paints - that would be the empty
// arriving later and to a screen that has something on it. // answer again, arriving later and to a screen that has something on it.
for (const scope of ALL_SCOPES) { for (const scope of ALL_SCOPES) {
test(`[${scope}] a list already rendered is not replaced by an empty one`, async () => { test(`[${scope}] a list already rendered is not replaced by an empty one`, async () => {
const { quads } = await aFirstSessionThatCreated(scope); const { quads } = await aFirstSessionThatCreated(scope);
let armed = false;
forgetEverything(); forgetEverything();
bootPage(quads, (query) => armed && query.includes(LISTING_READ)); const orm = bootPage(quads);
await signIn("alice"); await signIn("alice");
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable; const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
@@ -272,17 +317,17 @@ describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", (
expect(whatTheAppSees(obs.getSnapshot())).toBe("some"); expect(whatTheAppSees(obs.getSnapshot())).toBe("some");
const rendersBefore = renders(); const rendersBefore = renders();
// The link dies, and the application asks again — the imperative refresh the surface // The shape query stops opening, and the application asks again - the imperative
// publishes for exactly this. // refresh the surface publishes for exactly this.
armed = true; orm.refuse("OrmError: the shape could not be subscribed");
obs.refetch(); obs.refetch();
await until("the failure to reach the subscriber", () => obs.getSnapshot().isError); await until("the failure to reach the subscriber", () => obs.getSnapshot().isError);
const snap = obs.getSnapshot(); const snap = obs.getSnapshot();
expect(whatTheAppSees(snap)).toBe("unknown"); expect(whatTheAppSees(snap)).toBe("unknown");
expect(snap.isSuccess).toBe(false); expect(snap.isSuccess).toBe(false);
// The list it had is still there — a memory, never offered as a fresh answer. // The list it had is still there - a memory, never offered as a fresh answer.
expect(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]); expect(titles(snap)).toEqual(["the note"]);
// And the subscriber was TOLD: a view that renders `data` and never re-reads the // And the subscriber was TOLD: a view that renders `data` and never re-reads the
// load state would otherwise keep showing the list as though it were current. // load state would otherwise keep showing the list as though it were current.
expect(renders()).toBeGreaterThan(rendersBefore); expect(renders()).toBeGreaterThan(rendersBefore);
+242 -226
View File
@@ -1,38 +1,36 @@
/** /**
* use-shape-query.test.ts — behavioural tests for `useShapeQuery` * use-shape-query.test.ts - behavioural tests for `useShapeQuery`
* (src/surface/use-shape-query.ts), * (src/surface/use-shape-query.ts), against the SDK's shape query stood in for by
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`. * `orm-fake.ts` and a stateful fake `ng` for everything else the page does.
* *
* The fake emulates just enough of the broker: * -- The montage, and why it takes TWO fakes -------------------------------
* - `doc_create` mints monotonic doc NURIs. * The read goes through the SDK now, so the thing that answers it is the injected
* - `sparql_update` parses the shim account writes + the per-entity index * shape query (`test/orm-fake.ts`, a small engine that honours the scope axes the way
* `contains` append + arbitrary anchored triple writes into an in-memory quad * `schema_shape_to_sparql` does). Everything AROUND the read is still this package's:
* store (same tolerant parser shape as store-registry.test / read-model.test). * signing in, creating a document, filing its capability. That is the fake `ng` - and
* - `sparql_query` answers the shim account SELECT, the scope-index `contains` * keeping it is what makes the documents here real ones, obtained from
* SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to * `createEntityDoc` rather than named by the test.
* the anchor graph.
* - `doc_subscribe` models the platform push order TabInfo→State: on subscribe it
* records the callback and fires a `TabInfo` immediately, but the sync BARRIER
* `State` is fired only when the TEST releases it (`fireState`) — so we can
* assert isPending BEFORE the barrier and isSuccess AFTER. A later write to a
* subscribed doc fires a `Patch` push (reactivity).
* *
* These prove the four distinctions the surface exists for: * These prove the distinctions the surface exists for:
* (a) isPending at first, isSuccess after the first State (barrier); * (a) isPending until the initial payload arrives, isSuccess once it has;
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction); * (b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction);
* (c) a write then push → data updates (reactivity, no polling); * (c) an engine patch updates data (reactivity, no polling);
* (d) timed-out → isSuccess (best-effort), NOT isError; * (d) a query that never delivers stays isPending - it never becomes "nothing";
* plus the three readings of the SDK `Scope` the surface now takes: the whole dataset, * plus the three readings of the SDK `Scope`, which this module no longer interprets
* a named `graphs` list, and the empty list that upstream answers with no objects. * and therefore has to hand over VERBATIM, and the capability filter over what comes
* back.
*/ */
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test"; import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
import { useShapeQuery } from "../src/surface/use-shape-query"; import { useShapeQuery } from "../src/surface/use-shape-query";
import { configure } from "../src/index"; import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap"; import { configureStoreRegistry, setCurrentUser, getCaps } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry"; import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/emulated-verifier/open-repo"; import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { makeOrm, type FakeOrm } from "./orm-fake";
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
import type { NgLike, UseShapeLike } from "../src/model/types";
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/** The whole local dataset - upstream's own marker, what `""` normalises to. */ /** The whole local dataset - upstream's own marker, what `""` normalises to. */
@@ -57,26 +55,13 @@ function unescapeLiteral(s: string): string {
interface SubRec { nuri: string; cb: (r: unknown) => void } interface SubRec { nuri: string; cb: (r: unknown) => void }
/** /**
* The stateful fake with a controllable doc_subscribe. `holdState: true` means a * The stateful fake `ng` - the shim account, the per-scope index and the document
* fresh subscription does NOT auto-fire its `State` — the test fires it via * creation the page does around the read. It is NOT what answers the read any more.
* `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe
* (synced immediately), which is the convenient mode for the reactivity/empty cases.
*/ */
function makeFake(opts?: { holdState?: boolean }) { function makeFake() {
const quads: Quad[] = []; const quads: Quad[] = [];
let docCounter = 0; let docCounter = 0;
const subs: SubRec[] = []; const subs: SubRec[] = [];
const hold = opts?.holdState ?? false;
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
const released = new Set<string>();
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
// (the store repo is bootstrapped at connect), so its barrier `State` is always
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
// before touching the shim — pre-release it here so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
// real invariant the production heal relies on.
released.add(`did:ng:${SESSION.privateStoreId}`);
let releaseEverything = false;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`); const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
@@ -104,17 +89,7 @@ function makeFake(opts?: { holdState?: boolean }) {
const p = m[1] ?? "urn:ng-eventually:shim:Account"; const p = m[1] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o }); quads.push({ g, s, p, o });
// The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like
// the store-root: `doc_create` bootstrapped it into the session, so its barrier
// `State` is immediately available. Pre-release it so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is
// published BEFORE the doc-shim barrier open (resolveShimDoc first-login order).
if (p === "urn:ng-eventually:shim:shimDoc") {
released.add(o);
for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } });
} }
}
// A write to a subscribed doc fires a Patch push (reactivity signal).
for (const sub of subs) { for (const sub of subs) {
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } }); if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
} }
@@ -132,9 +107,7 @@ function makeFake(opts?: { holdState?: boolean }) {
return { results: { bindings } }; return { results: { bindings } };
} }
if (query.includes("<urn:ng-eventually:shim:id>")) { if (query.includes("<urn:ng-eventually:shim:id>")) {
const subjM = query.match( const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
);
const onlySubject = subjM ? subjM[1]! : null; const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>(); const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) { for (const q of quads) {
@@ -157,70 +130,37 @@ function makeFake(opts?: { holdState?: boolean }) {
})); }));
return { results: { bindings } }; return { results: { bindings } };
} }
if (query.includes("<urn:ng-eventually:shim:inboxCap>")) { for (const marker of ["inboxCap", "readCap", "contains"] as const) {
if (query.includes(`<urn:ng-eventually:shim:${marker}>`)) {
const key = marker === "contains" ? "e" : "c";
const bindings = quads const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:inboxCap") .filter((q) => q.g === anchor && q.p === `urn:ng-eventually:shim:${marker}`)
.map((q) => ({ c: { value: q.o } })); .map((q) => ({ [key]: { value: q.o } }));
return { results: { bindings } }; return { results: { bindings } };
} }
if (query.includes("<urn:ng-eventually:shim:readCap>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:readCap")
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
} }
if (query.includes("<urn:ng-eventually:shim:contains>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`).
const bindings = quads const bindings = quads
.filter((q) => q.g === anchor) .filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })); .map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
return { results: { bindings } }; return { results: { bindings } };
}); });
const doc_subscribe = mock( const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subs.push({ nuri, cb }); subs.push({ nuri, cb });
// Platform pushes TabInfo FIRST (never the barrier).
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0); setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
// Fire the barrier State if this fake auto-syncs, or if this nuri was already
// released (so a doc subscribed AFTER a release still crosses the barrier).
if (!hold || releaseEverything || released.has(nuri)) {
setTimeout(() => cb({ V0: { State: {} } }), 0); setTimeout(() => cb({ V0: { State: {} } }), 0);
}
return () => {}; return () => {};
}, });
);
/** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */ return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads };
function fireState(nuri: string): void {
released.add(nuri);
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
}
/** Release the barrier for EVERY doc, present and future. */
function releaseAll(): void {
releaseEverything = true;
for (const sub of subs) sub.cb({ V0: { State: {} } });
} }
return { function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
doc_create, configure({
sparql_update, ng: ng as unknown as NgLike,
sparql_query, useShape: (() => {}) as unknown as UseShapeLike,
doc_subscribe, ormSubscription: orm.query,
_quads: quads, });
fireState,
releaseAll,
subs,
};
}
function inject(ng: ReturnType<typeof makeFake>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => SESSION, getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(), normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
@@ -230,36 +170,55 @@ function inject(ng: ReturnType<typeof makeFake>) {
resetCaps(); resetCaps();
} }
// Insert a triple straight into a doc's graph in the fake store (no push).
function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string): void {
ng._quads.push({ g: doc, s: doc, p, o });
}
// The same, under a subject of its own rather than the document's reference: one document
// may hold several subjects, which is what the scope's `subjects` axis filters on.
function seedSubject(
ng: ReturnType<typeof makeFake>,
doc: string,
subject: string,
p: string,
o: string,
): void {
ng._quads.push({ g: doc, s: subject, p, o });
}
const tick = () => new Promise((r) => setTimeout(r, 5)); const tick = () => new Promise((r) => setTimeout(r, 5));
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`. /**
* A minimal SHEX ShapeType pinning `rdf:type` to `${FP}Event` and carrying a title.
*
* Both predicates are `minCardinality: 1`, because that is what
* `schema_shape_to_sparql` turns into REQUIRED triple patterns: a shape with none of
* them would match every subject in the dataset. `rdf-orm build` generates exactly this
* object from a `.shex` file, and the generator is not what is under test here.
*/
const EventShape = { const EventShape = {
shape: `${FP}EventShape`, shape: `${FP}EventShape`,
schema: { schema: {
[`${FP}EventShape`]: { [`${FP}EventShape`]: {
iri: `${FP}EventShape`, iri: `${FP}EventShape`,
predicates: [{ iri: TYPE, dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }] }], predicates: [
{
iri: TYPE,
readablePredicate: "@type",
dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }],
minCardinality: 1,
maxCardinality: 1,
},
{
iri: `${FP}title`,
readablePredicate: "title",
dataTypes: [{ valType: "string" }],
minCardinality: 1,
maxCardinality: 1,
},
],
}, },
}, },
}; } satisfies ShapeType<BaseType>;
/** The titles a snapshot carries, sorted - what a view would render. */
function titles(data: BaseType[]): string[] {
return data.map((d) => String(d.title)).sort();
}
/** Seed one shape-matching object into a document's graph. */
function seedEvent(orm: FakeOrm, doc: string, subject: string, title: string): void {
orm.seed(doc, subject, TYPE, `${FP}Event`);
orm.seed(doc, subject, `${FP}title`, title);
}
beforeEach(() => {
resetRegistryCache();
});
afterEach(() => { afterEach(() => {
setCurrentUser(null); setCurrentUser(null);
}); });
@@ -274,14 +233,13 @@ afterAll(() => {
}); });
describe("useShapeQuery", () => { describe("useShapeQuery", () => {
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => { it("(a) isPending until the initial payload arrives, then isSuccess with the data", async () => {
const ng = makeFake({ holdState: true }); const ng = makeFake();
inject(ng); const orm = makeOrm({ autoDeliver: false });
inject(ng, orm);
setCurrentUser("alice"); setCurrentUser("alice");
// One protected entity doc for alice, carrying an Event triple.
const doc = await createEntityDoc("alice", "protected"); const doc = await createEntityDoc("alice", "protected");
seed(ng, doc, TYPE, `${FP}Event`); seedEvent(orm, doc, `${doc}:q:one`, "Alpha");
seed(ng, doc, `${FP}title`, "Alpha");
const obs = useShapeQuery(EventShape, EVERYTHING); const obs = useShapeQuery(EventShape, EVERYTHING);
let notes = 0; let notes = 0;
@@ -289,33 +247,30 @@ describe("useShapeQuery", () => {
notes += 1; notes += 1;
}); });
// Before the barrier: pending, no data. // Before the engine answers: pending, no data.
await tick(); await tick();
expect(obs.getSnapshot().isPending).toBe(true); expect(obs.getSnapshot().isPending).toBe(true);
expect(obs.getSnapshot().isSuccess).toBe(false); expect(obs.getSnapshot().isSuccess).toBe(false);
expect(obs.getSnapshot().data).toEqual([]); expect(obs.getSnapshot().data).toEqual([]);
// Release the barrier for every opened doc (present + future) → synced. orm.deliver();
ng.releaseAll();
await tick();
await tick();
await tick(); await tick();
const snap = obs.getSnapshot(); const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false); expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true); expect(snap.isSuccess).toBe(true);
expect(snap.isError).toBe(false); expect(snap.isError).toBe(false);
expect(snap.data.length).toBe(1); expect(titles(snap.data)).toEqual(["Alpha"]);
expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
expect(notes).toBeGreaterThan(0); expect(notes).toBeGreaterThan(0);
unsub(); unsub();
}); });
it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => { it("(b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction)", async () => {
const ng = makeFake(); // auto-fires State → synced immediately const ng = makeFake();
inject(ng); const orm = makeOrm();
inject(ng, orm);
setCurrentUser("bob"); setCurrentUser("bob");
// bob has NO entity docs in this scope — the scope is genuinely empty. // bob has no document of this shape anywhere - the scope is genuinely empty.
const obs = useShapeQuery(EventShape, EVERYTHING); const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {}); const unsub = obs.subscribe(() => {});
@@ -324,139 +279,160 @@ describe("useShapeQuery", () => {
const snap = obs.getSnapshot(); const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false); expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true); // synced, NOT stuck pending expect(snap.isSuccess).toBe(true); // answered, NOT stuck pending
expect(snap.isError).toBe(false); expect(snap.isError).toBe(false);
expect(snap.data).toEqual([]); // empty — distinguishable from "still syncing" expect(snap.data).toEqual([]); // empty - distinguishable from "still asking"
unsub(); unsub();
}); });
it("(c) a write then push updates data (reactivity, no polling)", async () => { it("(c) an engine patch updates data (reactivity, no polling)", async () => {
const ng = makeFake(); // synced immediately const ng = makeFake();
inject(ng); const orm = makeOrm();
inject(ng, orm);
setCurrentUser("carol"); setCurrentUser("carol");
const doc = await createEntityDoc("carol", "protected"); const doc = await createEntityDoc("carol", "protected");
seed(ng, doc, TYPE, `${FP}Event`); seedEvent(orm, doc, `${doc}:q:one`, "One");
seed(ng, doc, `${FP}title`, "One");
const obs = useShapeQuery(EventShape, EVERYTHING); const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {}); const unsub = obs.subscribe(() => {});
await tick(); await tick();
await tick(); await tick();
expect(obs.getSnapshot().data.length).toBe(1); expect(titles(obs.getSnapshot().data)).toEqual(["One"]);
// Write a SECOND event doc + fire the push via a write to the ALREADY-subscribed // A second object lands in a second document - the engine pushes it into the live
// doc. Because a new doc must appear in the set, write into the scope-INDEX // subscription, and the snapshot follows. No document subscription of ours, no timer.
// (createEntityDoc appends to it, and the index is subscribed → re-resolve).
const doc2 = await createEntityDoc("carol", "protected"); const doc2 = await createEntityDoc("carol", "protected");
seed(ng, doc2, TYPE, `${FP}Event`); seedEvent(orm, doc2, `${doc2}:q:one`, "Two");
seed(ng, doc2, `${FP}title`, "Two");
// createEntityDoc's index append fired a Patch on the index doc → re-resolve.
await tick();
await tick(); await tick();
const titles = obs expect(titles(obs.getSnapshot().data)).toEqual(["One", "Two"]);
.getSnapshot()
.data.flatMap((s) => s.props[`${FP}title`] ?? [])
.sort();
expect(titles).toEqual(["One", "Two"]);
// No setInterval anywhere — reactivity was push-driven.
unsub(); unsub();
}); });
it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => { it("(d) a query that never delivers stays isPending - never 'there is nothing'", async () => {
// A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback // The state the real ORM produces when the engine refuses a start: `orm_start_graph`
// fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to // is called inside a `try { ... } catch (e) { console.error(e) }` and the readiness
// a few ms so this is fast, and assert the barrier is genuinely reached via // promise is built with `resolve` alone, so nothing ever settles it. The surface must
// timed-out (getSyncState === "timed-out") and that the snapshot maps that to // keep saying "still asking" - the one thing it must never do is publish the
// isSuccess, never isError. // empty-and-answered snapshot, which is what an application renders as "you have
const ng = makeFake({ holdState: true }); // State is never released // created nothing".
inject(ng); const ng = makeFake();
setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s const orm = makeOrm({ autoDeliver: false });
inject(ng, orm);
setCurrentUser("dave"); setCurrentUser("dave");
const doc = await createEntityDoc("dave", "protected"); const doc = await createEntityDoc("dave", "protected");
seed(ng, doc, TYPE, `${FP}Event`); seedEvent(orm, doc, `${doc}:q:one`, "Never arrives");
seed(ng, doc, `${FP}title`, "Timed");
const obs = useShapeQuery(EventShape, EVERYTHING); const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {}); const unsub = obs.subscribe(() => {});
await tick(); await tick();
// Before the fallback fires: still pending (subscribed, no State).
expect(obs.getSnapshot().isPending).toBe(true);
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
// Long enough for FOUR of them in a row: the whole-dataset reading opens the three
// store index documents as well as the entity document, and with the `State` held
// back each one waits out the full fallback before the next begins.
await new Promise((r) => setTimeout(r, 200));
await tick();
await tick(); await tick();
await new Promise((r) => setTimeout(r, 50));
// The entity doc's barrier resolved via timed-out (never a State).
expect(getSyncState(doc)).toBe("timed-out");
const snap = obs.getSnapshot(); const snap = obs.getSnapshot();
expect(snap.isPending).toBe(true);
expect(snap.isSuccess).toBe(false);
expect(snap.isError).toBe(false); expect(snap.isError).toBe(false);
expect(snap.isSuccess).toBe(true); // timed-out is best-effort success expect(snap.data).toEqual([]);
expect(snap.isPending).toBe(false);
// The data still read (best-effort): the doc's triples resolved.
expect(snap.data.length).toBe(1);
unsub(); unsub();
}); });
// ── the scope axis: the three readings upstream gives it ────────────────── it("(e) opening the shape query threw -> isError, and never an empty answer", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("erin");
orm.refuse("the shape could not be subscribed");
const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {});
await tick();
const snap = obs.getSnapshot();
expect(snap.isError).toBe(true);
expect(snap.isSuccess).toBe(false);
expect(snap.isPending).toBe(false);
expect(String((snap.error as Error).message)).toMatch(/could not be subscribed/);
unsub();
});
it("(f) `configure` without a shape query says so, rather than answering empty", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
// ...and then a bootstrap that forgot the injection. The read has nothing to go
// through, and an empty set would be the one lie this surface exists to prevent.
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {});
await tick();
const snap = obs.getSnapshot();
expect(snap.isError).toBe(true);
expect(snap.isSuccess).toBe(false);
expect(String((snap.error as Error).message)).toMatch(/ormSubscription/);
unsub();
});
// -- the scope axis: the three readings upstream gives it ------------------
// //
// These are not variants of one case. `graphs` names ONE document's graph per entry; // These are not variants of one case. `graphs` names ONE document's graph per entry;
// `did:ng:i` (or `""`) discards the rest and means the whole dataset; `[]` or // `did:ng:i` (or `""`) discards the rest and means the whole dataset; `[]` or
// `undefined` returns no objects at all. The first two answer differently over the SAME // `undefined` returns no objects at all. This module no longer interprets any of it -
// wallet, and the third is the one a caller reaches by accident - so each is pinned. // the scope is handed to the SDK verbatim - so each case pins BOTH that the object
// arrived unchanged and what the engine then answers.
it("(e) a named `graphs` list reads those documents and no others", async () => { it("(g) a named `graphs` list reaches the SDK verbatim and reads those documents only", async () => {
const ng = makeFake(); const ng = makeFake();
inject(ng); const orm = makeOrm();
setCurrentUser("erin"); inject(ng, orm);
const kept = await createEntityDoc("erin", "protected"); setCurrentUser("frank");
seed(ng, kept, TYPE, `${FP}Event`); const kept = await createEntityDoc("frank", "protected");
seed(ng, kept, `${FP}title`, "Named"); seedEvent(orm, kept, `${kept}:q:one`, "Named");
const other = await createEntityDoc("erin", "protected"); const other = await createEntityDoc("frank", "protected");
seed(ng, other, TYPE, `${FP}Event`); seedEvent(orm, other, `${other}:q:one`, "Unnamed");
seed(ng, other, `${FP}title`, "Unnamed");
// The whole dataset sees both - that is the reading the named list departs from. // The whole dataset sees both - that is the reading the named list departs from.
const all = useShapeQuery(EventShape, EVERYTHING); const all = useShapeQuery(EventShape, EVERYTHING);
const unsubAll = all.subscribe(() => {}); const unsubAll = all.subscribe(() => {});
await tick(); await tick();
await tick(); await tick();
expect(all.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([ expect(titles(all.getSnapshot().data)).toEqual(["Named", "Unnamed"]);
"Named",
"Unnamed",
]);
unsubAll(); unsubAll();
const obs = useShapeQuery(EventShape, { graphs: [kept] }); const scope = { graphs: [kept] };
const obs = useShapeQuery(EventShape, scope);
const unsub = obs.subscribe(() => {}); const unsub = obs.subscribe(() => {});
await tick(); await tick();
await tick(); await tick();
const snap = obs.getSnapshot(); expect(obs.getSnapshot().isSuccess).toBe(true);
expect(snap.isSuccess).toBe(true); expect(titles(obs.getSnapshot().data)).toEqual(["Named"]);
expect(snap.data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Named"]); // The object the caller wrote is the object the SDK received: no translation, no
// copy, nothing added. That is what removing the pipeline bought.
expect(orm.asked.at(-1)!.scope).toBe(scope);
unsub(); unsub();
}); });
it("(f) an empty `graphs` returns no objects, and says so as SUCCESS", async () => { it("(h) an empty `graphs` returns no objects, and says so as SUCCESS", async () => {
const ng = makeFake(); const ng = makeFake();
inject(ng); const orm = makeOrm();
setCurrentUser("frank"); inject(ng, orm);
const doc = await createEntityDoc("frank", "protected"); setCurrentUser("gina");
seed(ng, doc, TYPE, `${FP}Event`); const doc = await createEntityDoc("gina", "protected");
seed(ng, doc, `${FP}title`, "Unreachable"); seedEvent(orm, doc, `${doc}:q:one`, "Unreachable");
// Upstream: "Setting value to `[]` or leaving it `undefined`, no objects are returned." // Upstream: "Setting value to `[]` or leaving it `undefined`, no objects are returned."
// It is an ANSWER, not a failure - and not the pending state either, which is what an // It is an ANSWER, not a failure - and not the pending state either, which is what an
// application would otherwise be left staring at. // application would otherwise be left staring at.
// `graphs` became REQUIRED in `@ng-org/orm@0.1.2-alpha.21`, so the "leaving it // `graphs` became REQUIRED in `@ng-org/orm@0.1.2-alpha.21`, so the "leaving it
// `undefined`" half of that sentence is no longer reachable by omitting the key. `[]` // `undefined`" half of that sentence is no longer reachable by omitting the key. `[]`
// is the other half, and it is the one that still spells "empty" - `normalizeScope` // is the other half, and it is the one that still spells "empty".
// maps both onto the same empty list, so this asks the same question it always did.
const obs = useShapeQuery(EventShape, { graphs: [] }); const obs = useShapeQuery(EventShape, { graphs: [] });
const unsub = obs.subscribe(() => {}); const unsub = obs.subscribe(() => {});
await tick(); await tick();
@@ -470,26 +446,22 @@ describe("useShapeQuery", () => {
unsub(); unsub();
}); });
it("(g) `subjects` narrows to the subject IRIs named, literally", async () => { it("(i) `subjects` narrows to the subject IRIs named, literally", async () => {
const ng = makeFake(); const ng = makeFake();
inject(ng); const orm = makeOrm();
setCurrentUser("gina"); inject(ng, orm);
const doc = await createEntityDoc("gina", "protected"); setCurrentUser("hank");
const doc = await createEntityDoc("hank", "protected");
// Two subjects in ONE document: the `subjects` axis is what tells them apart, and // Two subjects in ONE document: the `subjects` axis is what tells them apart, and
// `graphs` cannot - both carry the same `@graph`. // `graphs` cannot - both carry the same `@graph`.
seedSubject(ng, doc, `${FP}one`, TYPE, `${FP}Event`); seedEvent(orm, doc, `${FP}one`, "First");
seedSubject(ng, doc, `${FP}one`, `${FP}title`, "First"); seedEvent(orm, doc, `${FP}two`, "Second");
seedSubject(ng, doc, `${FP}two`, TYPE, `${FP}Event`);
seedSubject(ng, doc, `${FP}two`, `${FP}title`, "Second");
const both = useShapeQuery(EventShape, EVERYTHING); const both = useShapeQuery(EventShape, EVERYTHING);
const unsubBoth = both.subscribe(() => {}); const unsubBoth = both.subscribe(() => {});
await tick(); await tick();
await tick(); await tick();
expect(both.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([ expect(titles(both.getSnapshot().data)).toEqual(["First", "Second"]);
"First",
"Second",
]);
unsubBoth(); unsubBoth();
const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] }); const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] });
@@ -497,8 +469,52 @@ describe("useShapeQuery", () => {
await tick(); await tick();
await tick(); await tick();
expect(obs.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Second"]); expect(titles(obs.getSnapshot().data)).toEqual(["Second"]);
unsub(); unsub();
}); });
// -- the capability filter, which this module still owns -------------------
it("(j) an object whose document the holder has no capability for does not come back", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("iris");
// Hers: created through the surface, so its capability is filed as a by-product.
const mine = await createEntityDoc("iris", "protected");
seedEvent(orm, mine, `${mine}:q:one`, "Mine");
// Somebody else's, sitting in the same local dataset - which is exactly what
// `did:ng:i` returns, since the engine adds nothing to narrow it by what the reader
// may open. The capability filter is the only thing between it and the caller.
seedEvent(orm, "did:ng:o:someone-elses", "did:ng:o:someone-elses:q:one", "Theirs");
expect(getCaps().isEnforcing()).toBe(true);
const obs = useShapeQuery(EventShape, EVERYTHING);
const unsub = obs.subscribe(() => {});
await tick();
await tick();
expect(titles(obs.getSnapshot().data)).toEqual(["Mine"]);
unsub();
});
// -- teardown ------------------------------------------------------------
it("(k) the last unsubscribe closes the shape query", async () => {
const ng = makeFake();
const orm = makeOrm();
inject(ng, orm);
setCurrentUser("jane");
const obs = useShapeQuery(EventShape, EVERYTHING);
const first = obs.subscribe(() => {});
const second = obs.subscribe(() => {});
await tick();
expect(orm.openCount()).toBe(1);
first();
expect(orm.openCount()).toBe(1); // still a subscriber
second();
expect(orm.openCount()).toBe(0); // the query is closed, not leaked
});
}); });
@@ -49,6 +49,12 @@ const TARGET_WORDS = new Set([
"publisher", "topic", "advert", "publisher", "topic", "advert",
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`) // the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
"observable", "deep", "signal", "set", "observable", "deep", "signal", "set",
// `subscription` is the ORM's own noun for the handle its shape query returns -
// `OrmSubscription` and `DiscreteOrmSubscription` in
// `sdk/js/orm/src/connector/`, and `branch_subscriptions` in the engine. Added when
// `OrmSubscriptionLike` was published, which is the type of the one an application
// injects; the verb `subscribe` was already here and the noun is the same thing.
"subscription",
]); ]);
/** /**