From 77e78ec81de81959e90e40da8d302e274b025c51 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 21 Sep 2026 11:41:31 +0200 Subject: [PATCH] feat!: useShapeQuery lit par la requete de forme du SDK --- .../contract_polyfill-surface.md | 48 +- ...d-use-shape-query-reads-through-the-sdk.md | 18 + .../todo/T01-align-the-published-surface.md | 1 + bun.lock | 28 +- docs/api-contract.md | 34 +- docs/internal-contract.md | 10 +- examples/notebook/app.ts | 42 +- examples/notebook/package.json | 3 +- packages/polyfill/README.md | 5 +- packages/polyfill/e2e/orm-live-entry.ts | 67 +- packages/polyfill/e2e/orm-live-read.ts | 77 +-- packages/polyfill/e2e/polyfill-entry.ts | 103 +-- packages/polyfill/e2e/run.ts | 14 +- packages/polyfill/package.json | 2 +- .../polyfill/src/emulated-verifier/connect.ts | 2 +- .../src/emulated-verifier/inbox-observer.ts | 2 +- .../src/emulated-verifier/open-repo.ts | 4 +- .../src/emulated-verifier/read-filter.ts | 15 +- packages/polyfill/src/index.ts | 8 +- packages/polyfill/src/model/types.ts | 54 ++ .../src/shared-wallet/account-registry.ts | 29 +- .../polyfill/src/shared-wallet/bootstrap.ts | 25 +- packages/polyfill/src/surface/inbox.ts | 2 +- packages/polyfill/src/surface/subscribe.ts | 11 +- .../polyfill/src/surface/use-shape-query.ts | 613 +++++++----------- packages/polyfill/test/orm-fake.ts | 225 +++++++ .../use-shape-query-surfaces-failure.test.ts | 231 ++++--- .../polyfill/test/use-shape-query.test.ts | 482 +++++++------- packages/polyfill/test/vocabulary.test.ts | 6 + 29 files changed, 1190 insertions(+), 971 deletions(-) rename .tasks/{doing => done}/T01.d-use-shape-query-reads-through-the-sdk.md (62%) create mode 100644 packages/polyfill/test/orm-fake.ts diff --git a/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md b/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md index d738e02..12c7171 100644 --- a/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md +++ b/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md @@ -16,6 +16,7 @@ This package covers placement (creating an application's documents in one of its 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 }`; +- 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; - **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 { ng: NgLike; // the `ng` object from @ng-org/web 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? } debugAccessLog?: boolean; init?: (...args: any[]) => any; @@ -61,15 +65,19 @@ export const storeRegistry: { // no identity parameter — a session is export async function readUnion(docs: NuriLike[]): Promise; export interface UnionSubject { subject: string; graph: Nuri; props: Record } export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view -// TWO positional arguments — the same pair `useShape` takes, and the same `Scope`. -// `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in. -export function useShapeQuery(shapeType: unknown, scope: Scope): ShapeObservable; -export interface ShapeObservable { +// TWO positional arguments — the same pair `useShape` takes, both of them the SDK's own +// types. `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in, and +// its items are the SHAPE's objects: `@id`, `@graph` and the shape's readable predicates. +export function useShapeQuery( + shapeType: ShapeType, + scope: Scope, +): ShapeObservable; +export interface ShapeObservable { getSnapshot(): ShapeQuery; // stable reference until the state changes 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 { +export interface ShapeQuery { data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown; } 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. -**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. @@ -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. -**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. @@ -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. +**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. ## 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: -- **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). - **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`'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` (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. - **`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. diff --git a/.tasks/doing/T01.d-use-shape-query-reads-through-the-sdk.md b/.tasks/done/T01.d-use-shape-query-reads-through-the-sdk.md similarity index 62% rename from .tasks/doing/T01.d-use-shape-query-reads-through-the-sdk.md rename to .tasks/done/T01.d-use-shape-query-reads-through-the-sdk.md index 533ad79..3a6545c 100644 --- a/.tasks/doing/T01.d-use-shape-query-reads-through-the-sdk.md +++ b/.tasks/done/T01.d-use-shape-query-reads-through-the-sdk.md @@ -33,4 +33,22 @@ So this change takes the one reactive read that works and puts it on the path th ## 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 + +**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`. **`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. diff --git a/.tasks/todo/T01-align-the-published-surface.md b/.tasks/todo/T01-align-the-published-surface.md index f367e12..669d083 100644 --- a/.tasks/todo/T01-align-the-published-surface.md +++ b/.tasks/todo/T01-align-the-published-surface.md @@ -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.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:*`. +- 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. diff --git a/bun.lock b/bun.lock index eb20f18..2003657 100644 --- a/bun.lock +++ b/bun.lock @@ -14,14 +14,18 @@ "name": "@ng-eventually/example-notebook", "dependencies": { "@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": { "name": "ng-e2e-helpers", - "version": "0.0.0", + "version": "1.0.0-dev.2", + "bin": { + "ng-mint-wallet": "./bin/mint-wallet.ts", + }, "devDependencies": { - "@ng-org/web": "0.1.2-alpha.13", + "@ng-org/web": "0.1.2-alpha.14", "playwright": "^1.61.1", }, "peerDependencies": { @@ -31,11 +35,13 @@ }, "packages/polyfill": { "name": "@ng-eventually/polyfill", - "version": "0.0.0", + "version": "1.0.0-dev.5", "devDependencies": { - "@ng-org/alien-deepsignals": "0.1.2-alpha.11", - "@ng-org/shex-orm": "0.1.2-alpha.8", - "@ng-org/web": "0.1.2-alpha.13", + "@ng-org/alien-deepsignals": "0.1.2-alpha.13", + "@ng-org/orm": "0.1.2-alpha.21", + "@ng-org/shex-orm": "0.1.2-alpha.9", + "@ng-org/web": "0.1.2-alpha.14", + "ng-e2e-helpers": "workspace:*", }, "peerDependencies": { "@ng-org/alien-deepsignals": "*", @@ -60,11 +66,13 @@ "@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=="], diff --git a/docs/api-contract.md b/docs/api-contract.md index 0d2871c..4784928 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -48,6 +48,7 @@ The prose labels above map onto the annotation's closed `kind` set, and where th export interface EventuallyConfig { ng: NgLike; // the REAL @ng-org/web ng useShape: UseShapeLike; // the REAL @ng-org/orm useShape + ormSubscription?: OrmSubscriptionLike; // the REAL @ng-org/orm RdfOrmSubscription.getOrCreate pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number }; sharedWallet?: SharedWalletConfig; // the gate's, § 2bis 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. -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 @@ -224,7 +227,7 @@ Divergence to note: the wrapper types everything `unknown`, losing the generic ` ```ts // use-shape-query.ts — ShapeQuery -export interface ShapeQuery { +export interface ShapeQuery { data: T[]; isPending: boolean; isSuccess: boolean; @@ -232,22 +235,27 @@ export interface ShapeQuery { error: unknown; } // use-shape-query.ts — ShapeObservable -export interface ShapeObservable { +export interface ShapeObservable { getSnapshot(): ShapeQuery; subscribe(onChange: () => void): () => void; refetch(): void; } -// use-shape-query.ts — useShapeQuery. `Scope` here is the SDK's own -// (`sdk/js/orm/src/types.ts:Scope`), re-exported by the entry — NOT a store scope. -export function useShapeQuery( - shapeType: unknown, +// use-shape-query.ts — useShapeQuery. Both parameters are the SDK's own: +// `ShapeType` (`sdk/js/shex-orm/src/types.ts`) and `Scope` (`sdk/js/orm/src/types.ts`), +// both re-exported by the entry. `Scope` is NOT a store scope. +export function useShapeQuery( + shapeType: ShapeType, scope: Scope, ): ShapeObservable; ``` -**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` 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`. @@ -611,7 +619,7 @@ export async function openDocumentInbox(doc: NuriLike): Promise; // resolveWriteGraph — it returned the very reference the three accessors return. // 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, // resetRegistryCache, and the VirtualUserRecord type. // `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; - **`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. -- **`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. - **`openDocumentInbox(doc)` / `documentInboxAddress(doc)` → level 1, VERIFIED support, no exerciser.** Every `Repo` carries `inbox: Option` (`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). @@ -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"; ``` -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**: @@ -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) ```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 inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch storeRegistry: createEntityDoc, openDocumentInbox, privateStore, protectedStore, publicStore diff --git a/docs/internal-contract.md b/docs/internal-contract.md index 07cb45f..c7cea88 100644 --- a/docs/internal-contract.md +++ b/docs/internal-contract.md @@ -169,11 +169,11 @@ export async function ensureAccount(id: string): Promise; ### 9b. Scope-index resolution ```ts -// store-registry.ts:1079 -export async function userStoreDoc(id: string, scope: Scope): Promise; +// REMOVED in `1.0.0-dev.6` +// export async function userStoreDoc(id: string, scope: Scope): Promise; ``` -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. @@ -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. -**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`. @@ -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`). -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. diff --git a/examples/notebook/app.ts b/examples/notebook/app.ts index 3268525..2a5fd30 100644 --- a/examples/notebook/app.ts +++ b/examples/notebook/app.ts @@ -37,6 +37,7 @@ import { readUnion, storeRegistry, subscribeDoc, + initNg as initNgSignals, useShapeQuery, type Nuri, type ShapeObservable, @@ -44,6 +45,10 @@ import { configure, } from "@ng-eventually/polyfill"; 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 --------------------------------------------- @@ -91,6 +96,23 @@ const NoteShape = { maxCardinality: 1, 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({ 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, + initNg: ormInitNg, sharedWallet: { fileUrl: "/shared-wallet.ngw", password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "", @@ -125,7 +149,13 @@ configure({ // is only what this app itself reads. const sessionReady = new Promise<{ session_id: string }>((resolve) => { 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, []); }); @@ -185,9 +215,11 @@ function currentNotes(): Note[] { return noteQuery() .getSnapshot() .data.map((s) => ({ - doc: s.graph, - title: s.props[TITLE]?.[0] ?? "(sans titre)", - body: s.props[BODY]?.[0] ?? "", + // The read hands back the SHAPE's own objects now, not property bags: `@graph` is + // the document a note lives in, and each field is the predicate's readable name. + doc: s["@graph"] as Nuri, + title: typeof s.title === "string" ? s.title : "(sans titre)", + body: typeof s.body === "string" ? s.body : "", })); } diff --git a/examples/notebook/package.json b/examples/notebook/package.json index d09b6f4..cac1815 100644 --- a/examples/notebook/package.json +++ b/examples/notebook/package.json @@ -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.", "dependencies": { "@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" } } diff --git a/packages/polyfill/README.md b/packages/polyfill/README.md index 93a2e10..3516e40 100644 --- a/packages/polyfill/README.md +++ b/packages/polyfill/README.md @@ -32,7 +32,7 @@ import { configure, } 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 // 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. @@ -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; // 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. +// 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" }); ``` diff --git a/packages/polyfill/e2e/orm-live-entry.ts b/packages/polyfill/e2e/orm-live-entry.ts index 02975a3..a7da597 100644 --- a/packages/polyfill/e2e/orm-live-entry.ts +++ b/packages/polyfill/e2e/orm-live-entry.ts @@ -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 - * read filter is pure and does not need the real ORM to be exercised. What it therefore - * never touches is the only hop that reaches the broker: `ng.orm_start_graph`, the streamed - * method `OrmSubscription` opens and the one `bug_useshape-live-read-needs-a-newer-broker` - * is about. So this page injects the REAL `@ng-org/orm` — nothing else about the bootstrap - * differs — and the published `useShape` runs over a live subscription. - * - * ── 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. + * The 42-check harness injects the REAL `RdfOrmSubscription.getOrCreate` since + * `1.0.0-dev.6` (that is what `useShapeQuery` reads through), so a live ORM read is no + * longer unreachable from there. What stays here is the PUBLISHED `useShape` half: the + * bare `DeepSignalSet` an application gets from the ORM hook, and a `.delete()` on it + * reaching the document. That is a different surface from the snapshot `useShapeQuery` + * publishes, and it has no other measurement. * * ── 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`): @@ -247,51 +239,6 @@ async function seededTripleCount(doc: Nuri, subject: string): Promise { } }, - /** - * 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. */ async liveSeededTriples() { if (!probe) return -1; diff --git a/packages/polyfill/e2e/orm-live-read.ts b/packages/polyfill/e2e/orm-live-read.ts index 51156cd..a321ecf 100644 --- a/packages/polyfill/e2e/orm-live-read.ts +++ b/packages/polyfill/e2e/orm-live-read.ts @@ -2,43 +2,33 @@ * Does a live `useShape` read reach the DEPLOYED broker, and does deleting from the set it * returns reach the document? * - * Standalone, and deliberately outside `e2e/run.ts`: this is the one measurement that needs - * `@ng-org/orm`, an OPTIONAL PEER this package does not install (`bun.lock`, - * `optionalPeers`). Folding it into the 42-check suite would make that suite unbuildable on - * a checkout without it. Here, a missing ORM fails the build of this runner alone, with the - * reason said out loud — which is the honest outcome, because a measurement that did not - * run must not be reported as a verdict either way. + * Standalone, and deliberately outside `e2e/run.ts`: what it measures is the PUBLISHED + * `useShape` - the bare `DeepSignalSet` the ORM hook hands an application, and a + * `.delete()` on it reaching the document. `e2e/run.ts` exercises the other shape read, + * `useShapeQuery`, which since `1.0.0-dev.6` reads through the SDK's shape query too. Two + * surfaces, two runs. * * 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 * only — the arrival of an initial payload from the engine * (`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 * 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 ───────────────── - * The read does NOT start, and the reason is not the one - * `bug_useshape-live-read-needs-a-newer-broker` names. That leaf's mechanism — a broker - * built before `orm_start` was renamed `orm_start_graph` — is REFUTED here: the method - * resolves, and the call fails INSIDE it, on an argument. The engine answers - * `Deserialization error of config Error: invalid type: JsValue(Function), expected any - * valid JSON value`. + * -- What it found, and the row that went with the answer ---------------- + * On 2026-09-16 the read did not start: the engine answered `Deserialization error of + * config`, and a sixth row of this run established WHERE that `config` sits - position 4, + * before the callback, where the five-argument client of the day landed its callback. The + * deployment was AHEAD of the pinned client packages, not behind them. * - * The last row establishes where that `config` sits, because the position is the whole - * diagnosis: calling the SDK member directly with `{}` in position 4 is ACCEPTED, and the - * engine then streams back. So the deployed `orm_start_graph` is - * `(graph_scope, subject_scope, shapeType, session_id, config, callback)`, and the - * five-argument call the published `@ng-org/orm` makes lands its callback in `config`. - * That arity is in no commit of `nextgraph-rs` at the read revision — the one window that - * 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. + * Updating them answered it: `@ng-org/web@0.1.2-alpha.14` declares six arguments with + * `config` at index 4 and `@ng-org/orm@0.1.2-alpha.21` sends six to match, and the run + * measured `6 passed, 0 failed` with the read ready in 16ms and the delete reaching the + * document. The characterisation row was retired with the question it answered: there is + * nothing left for it to find out, and a row that always prints the same sentence teaches + * whoever reads this report to skip it. */ 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`", "`.delete()` on that set is accepted", "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 }; @@ -116,17 +105,6 @@ function check(name: string, ok: boolean, detail?: string): void { results.push({ name, ok, 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 serialized callback could not carry across. */ type Bridge = Record unknown>; @@ -261,27 +239,6 @@ async function main(): Promise { } 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 { if (page) await closeQuietly("the ORM live-read page", () => page!.close()); if (ctx) await closeContext("orm-live", ctx); diff --git a/packages/polyfill/e2e/polyfill-entry.ts b/packages/polyfill/e2e/polyfill-entry.ts index be1141d..f8320cd 100644 --- a/packages/polyfill/e2e/polyfill-entry.ts +++ b/packages/polyfill/e2e/polyfill-entry.ts @@ -15,6 +15,11 @@ */ 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 { configure, docs, @@ -26,6 +31,7 @@ import { storeRegistry, useShape as libUseShape, useShapeQuery, + initNg, } from "@ng-eventually/polyfill"; // The harness tests the LIBRARY, so it legitimately reaches machinery a consumer // 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. */ 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; +} + const storeOfType = (t: "public" | "protected" | "private"): Promise => t === "public" ? storeRegistry.publicStore() : t === "protected" ? storeRegistry.protectedStore() : 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; @@ -120,7 +163,12 @@ function fakeUseShape(_shape: unknown, _scope: unknown): any { configure({ ng: realNg, 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, + initNg: ormInitNg, // 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 // 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) ──────────────────────── - // A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. useShapeQuery - // reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals` - // to know which @type to keep — generic, no application domain. + // The shape is `wsShape(cls)` above: a hand-written `ShapeType`, two required + // predicates, generic and with no application domain. The read goes through the + // SDK's own shape query now, so these two journeys measure the REAL broker. _wsProbes: {} as Record< string, { obs: ShapeObservable; initial: ShapeQuery; unsub: () => void } @@ -828,23 +876,7 @@ const identity = new IdentityStore( if (listed.includes(doc)) break; await new Promise((r) => setTimeout(r, 1000)); } - const shape = { - 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 shape = wsShape(cls); const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable; const unsub = obs.subscribe(() => {}); // First snapshot immediately after subscribe — the barrier is not yet crossed @@ -866,8 +898,10 @@ const identity = new IdentityStore( isSuccess: snap.isSuccess, isError: snap.isError, dataLen: snap.data.length, - // The seeded title, if the datum is present (proves the real data landed). - titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []), + // The seeded title, if the datum is present (proves the real data landed). It is the + // 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) { @@ -884,23 +918,7 @@ const identity = new IdentityStore( registryInternals.resetRegistryCache(); const id = "@ws-empty-" + handle; setCurrentUser(id); - const shape = { - 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 shape = wsShape(cls); const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable; const unsub = obs.subscribe(() => {}); const initial = obs.getSnapshot(); @@ -1105,6 +1123,11 @@ const identity = new IdentityStore( (event: { session: BrokerSession }) => { session = event.session; 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); }, true, diff --git a/packages/polyfill/e2e/run.ts b/packages/polyfill/e2e/run.ts index 8cf5b89..9aa99c1 100644 --- a/packages/polyfill/e2e/run.ts +++ b/packages/polyfill/e2e/run.ts @@ -387,16 +387,18 @@ async function main(): Promise { }); // ── useShapeQuery (reactive useQuery-shaped read) ─────────────────────── - // The real cycle: first subscription reads isPending (barrier not yet crossed), - // then after the broker sync it reaches isSuccess with the seeded datum present. - // A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the - // distinction the announced `useShapeQuery` makes native, surfaced here from getSyncState). + // The real cycle, through the SDK's own shape query since `1.0.0-dev.6`: the first + // subscription reads isPending (the ORM's initial payload has not arrived), then the + // broker answers and it reaches isSuccess with the seeded datum present. A separate + // 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) ──"); await step("useShapeQuery: first subscribe isPending → isSuccess with data present", async () => { const h = "cyc" + Date.now(); const CLS = "urn:e2e:ws:Event"; const seed = await sdk(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. await frame.waitForFunction( (hh) => { @@ -423,7 +425,7 @@ async function main(): Promise { { timeout: 30000 }, ); const snap = await sdkGet(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); }); diff --git a/packages/polyfill/package.json b/packages/polyfill/package.json index 6377680..5c10b07 100644 --- a/packages/polyfill/package.json +++ b/packages/polyfill/package.json @@ -1,6 +1,6 @@ { "name": "@ng-eventually/polyfill", - "version": "1.0.0-dev.5", + "version": "1.0.0-dev.6", "type": "module", "description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.", "main": "./src/index.ts", diff --git a/packages/polyfill/src/emulated-verifier/connect.ts b/packages/polyfill/src/emulated-verifier/connect.ts index 2275243..b63dbe5 100644 --- a/packages/polyfill/src/emulated-verifier/connect.ts +++ b/packages/polyfill/src/emulated-verifier/connect.ts @@ -43,7 +43,7 @@ * 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 * 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 * genuinely needs to await it (tests, an app that wants a deterministic start). * diff --git a/packages/polyfill/src/emulated-verifier/inbox-observer.ts b/packages/polyfill/src/emulated-verifier/inbox-observer.ts index 1a926fa..4f41c32 100644 --- a/packages/polyfill/src/emulated-verifier/inbox-observer.ts +++ b/packages/polyfill/src/emulated-verifier/inbox-observer.ts @@ -36,7 +36,7 @@ * - 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 * 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 * 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 diff --git a/packages/polyfill/src/emulated-verifier/open-repo.ts b/packages/polyfill/src/emulated-verifier/open-repo.ts index 476c8d1..5cec112 100644 --- a/packages/polyfill/src/emulated-verifier/open-repo.ts +++ b/packages/polyfill/src/emulated-verifier/open-repo.ts @@ -137,8 +137,8 @@ export function resetOpenedRepos(): void { syncState.clear(); boundSessionId = null; OPEN_TIMEOUT_MS = 8000; - // Whatever ELSE was still subscribed to a document — a `watchShape` following a scope, - // an inbox observation — belongs to the session that just went away too. One real + // Whatever ELSE was still subscribed to a document - an inbox observation, an + // 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 // 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 diff --git a/packages/polyfill/src/emulated-verifier/read-filter.ts b/packages/polyfill/src/emulated-verifier/read-filter.ts index 0d112c3..8ac1091 100644 --- a/packages/polyfill/src/emulated-verifier/read-filter.ts +++ b/packages/polyfill/src/emulated-verifier/read-filter.ts @@ -16,12 +16,15 @@ * ── What this filter cannot do, and where that shows ────────────────────── * 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 - * round-trip — so this view drops such a document until some read path has asked. - * Every path this library owns does ask (`readUnion`, `docs.sparqlQuery`, - * `ensureRepoOpen`, `documentInboxAddress`), which covers `watchShape`; what it does - * not cover is `useShape`, whose signature is the real ORM's and has no await to - * spend. An application reaching a public document through `useShape` alone, having - * read it nowhere first, sees nothing. A polyfill-era limit, removed with the module. + * round-trip - so this filter drops such a document until some read path has asked. + * The paths that DO ask are the ones this library still reads with (`readUnion`, + * `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`). The two SHAPE reads + * ask nothing: `useShape` because its signature is the real ORM's and has no await to + * spend, and `useShapeQuery` since `1.0.0-dev.6`, because it reads through the SDK's + * 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 * is key possession, so the only question askable is "do I hold this document's diff --git a/packages/polyfill/src/index.ts b/packages/polyfill/src/index.ts index 5d80718..0f517a6 100644 --- a/packages/polyfill/src/index.ts +++ b/packages/polyfill/src/index.ts @@ -84,6 +84,12 @@ // PrincipalId `ensureIdentity`, `inbox.Deposit`/`PostOptions`, `EventuallyConfig` // NgLike `EventuallyConfig.ng` // 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 // 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 // named by its reference. The three strings stay DEFINED in `model/types.ts` as // `StoreType`, where the library's own modules still tell the three apart. -export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike } from "./model/types"; +export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike, OrmSubscriptionLike } from "./model/types"; export { useShape } from "./surface/use-shape"; export { useShapeQuery } from "./surface/use-shape-query"; export type { ShapeQuery, ShapeObservable } from "./surface/use-shape-query"; diff --git a/packages/polyfill/src/model/types.ts b/packages/polyfill/src/model/types.ts index 2daaa78..00a033f 100644 --- a/packages/polyfill/src/model/types.ts +++ b/packages/polyfill/src/model/types.ts @@ -2,6 +2,12 @@ * 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 * and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`. @@ -72,6 +78,54 @@ export type NgLike = Record; // @provenance UseShapeLike kind=invention level=none ref=none — same, for the injected `useShape` 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` rather than by the shape: upstream it is a + * `DeepSignalSet` whose items are `DeepSignal`, 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; + /** 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; + /** 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 = ( + shapeType: ShapeType, + scope: Scope, +) => OrmSubscriptionHandle; + /** * 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 diff --git a/packages/polyfill/src/shared-wallet/account-registry.ts b/packages/polyfill/src/shared-wallet/account-registry.ts index c4022d2..c7e8f19 100644 --- a/packages/polyfill/src/shared-wallet/account-registry.ts +++ b/packages/polyfill/src/shared-wallet/account-registry.ts @@ -404,8 +404,8 @@ function recordFromRows( // The ONE place the `""`-for-corrupt case is absorbed. `VirtualUserRecord` promises // 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 - // invariant. Callers already test for the empty value (e.g. `watchShape` skips a - // falsy container). Rejecting such a record outright would be the right fix and is + // invariant. Callers already test for the empty value (`listMyEntityDocs` reads + // 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. return { id: id || fallbackId, @@ -703,8 +703,8 @@ async function writeRecord(doc: Nuri, record: VirtualUserRecord): Promise * * ── Why this is load-bearing (concurrent-provision de-dup) ────────────────── * On a fresh page over the persistent wallet, SEVERAL independent callers hit - * `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container - * subscriptions, the app's owned-events effect). With the barrier-authoritative + * `ensureAccount(A)` near-simultaneously (a listing per store, an inbox enumeration, the + * app's own effects). With the barrier-authoritative * 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 * 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 { return out; } -/** - * The scope-INDEX document NURI of ONE account (`id`) for `scope` — the - * store-container document that LISTS the account's per-entity document NURIs - * (what {@link listMyEntityDocs} reads). Exposed so a reactive reader - * ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the - * entity-doc set when the index changes (a new entity created appends a NURI - * here). Idempotent via `ensureAccount`'s cache. At migration this becomes the - * 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 { - const record = await ensureAccount(id); - return storeOf(record, scope); -} +// `userStoreDoc(id, scope)` was REMOVED in `1.0.0-dev.6`. It handed out the scope-INDEX +// document so that a reactive reader could SUBSCRIBE to it and re-resolve its document set +// when the index changed - and the reactive reader was the only thing that ever wanted it. +// `useShapeQuery` now reads through the SDK's own shape query, which follows the data +// rather than a container this package maintains, so the last caller went with the +// pipeline. `storeOf(await ensureAccount(id), scope)` is the two-line body, for whoever +// needs the value again. /** * The entity-document NURIs of `scope` belonging to ONE account (`id`) — diff --git a/packages/polyfill/src/shared-wallet/bootstrap.ts b/packages/polyfill/src/shared-wallet/bootstrap.ts index 5b4ce81..505da74 100644 --- a/packages/polyfill/src/shared-wallet/bootstrap.ts +++ b/packages/polyfill/src/shared-wallet/bootstrap.ts @@ -15,7 +15,15 @@ * 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 { toNuri } from "../model/nuri"; import type { RegistrySession } from "./account-registry"; @@ -113,6 +121,19 @@ export interface EventuallyConfig { ng: NgLike; /** The REAL `@ng-org/orm` `useShape`. */ 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 * → a single read, which keeps the synchronous unit fakes fast. @@ -414,7 +435,7 @@ export function hasCap(nuri: NuriLike): boolean { */ export function resetCaps(): void { // 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. caps.clear(); // …and forget which documents were already asked about, or the emulated public-store diff --git a/packages/polyfill/src/surface/inbox.ts b/packages/polyfill/src/surface/inbox.ts index 85cb61f..fcf84c5 100644 --- a/packages/polyfill/src/surface/inbox.ts +++ b/packages/polyfill/src/surface/inbox.ts @@ -817,7 +817,7 @@ async function processForOwner(targetInbox: Nuri): Promise { // still refused by `readForHolder` below. // // 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 — // 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 diff --git a/packages/polyfill/src/surface/subscribe.ts b/packages/polyfill/src/surface/subscribe.ts index cbe1fa0..bdc7e18 100644 --- a/packages/polyfill/src/surface/subscribe.ts +++ b/packages/polyfill/src/surface/subscribe.ts @@ -46,10 +46,11 @@ * nothing logs, the stale unsubscribe still "works". * * That made every internal subscriber a hazard to every other one. `ensureRepoOpen` - * holds a bootstrap subscription per document for the whole session, `watchShape` - * subscribes to the documents of a scope, `inbox.watch` to an inbox — so opening a - * repo killed the watch on it, 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 + * holds a bootstrap subscription per document for the whole session, the reactive shape + * read of the day subscribed to the documents of a scope (a pipeline retired in + * `1.0.0-dev.6`), `inbox.watch` to an inbox - so opening a repo killed the watch on it, + * 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 * 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 @@ -453,7 +454,7 @@ export function resetDocSubscriptions(): void { * 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: - * 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 export function resubscribeDocs(): void { diff --git a/packages/polyfill/src/surface/use-shape-query.ts b/packages/polyfill/src/surface/use-shape-query.ts index 654626e..4deb869 100644 --- a/packages/polyfill/src/surface/use-shape-query.ts +++ b/packages/polyfill/src/surface/use-shape-query.ts @@ -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 - * `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`). * - * ── 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 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 * 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 * the function cites. Deviating TOWARD an announced target is alignment; the rest of the * surface is what will catch up: * `ShapeQuery = { data: T[]; isPending; isSuccess; isError; error }`. * `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 }`. * - * ── The scope is the SDK's, and translating it is EXPLICITLY TEMPORARY ───── - * `scope` is `@ng-org/orm`'s own `Scope` — `{ graphs: string[] | string; subjects?: - * string[] }` — not the three strings this module took before. A `graphs` entry names - * ONE document's graph, `did:ng:i` (or `""`) discards the rest and means the whole local - * dataset, and `[]` or `undefined` returns no objects at all: all three are upstream's - * rules, reproduced by `normalizeScope` below. + * -- What it reads THROUGH, since `1.0.0-dev.6` ---------------------------- + * The SDK's own shape query - `RdfOrmSubscription.getOrCreate(shapeType, scope)` - and + * nothing of this library's own. Four steps went with the change, and they were one + * mechanism: a logical scope resolved to a document set (`listMyEntityDocs`), those + * documents opened through `ensureReposOpen` for a sync barrier, `readUnion` over them, + * 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 - * the spelling changed, never the meaning: a caller who wants "everything" still has to - * say `did:ng:i`, because on this axis an empty list is the EMPTY set and not the absent - * filter. `subjects` keeps the opposite convention - there `[]` means no filtering - so - * the two axes read `[]` in opposite directions and `normalizeScope` keeps them apart. + * It does NOT delegate to the SDK's `useShape`: that is React's hook, it returns a bare + * `DeepSignalSet`, and a set cannot carry the request state this surface exists to + * publish. What is taken from the SDK is the QUERY; the observable, the snapshot and the + * state tracking stay here. * - * Only the whole-dataset reading still has to be TRANSLATED, and the translation is the - * tell that the mechanism under it is about to stop being ours: it is served by - * enumerating this identity's own documents across its three stores - * (`account-registry.listMyEntityDocs`, no longer published), because this pipeline has - * no other way to say "everything". When the read queries the store itself, the - * enumeration and this branch go together. A named `graphs` list needs no translation at - * all — it is already the doc set. + * -- What stays ours, and why ---------------------------------------------- + * 1. **The three states.** `readyPromise` is the ORM's own answer to "has the initial + * payload arrived" - it resolves on that path and on no other - so it is what tells + * `isPending` from a genuinely empty `isSuccess`. The React adapter one floor up + * drops that distinction (an empty set "if still loading"); this does not. + * 2. **The capability filter.** `emulated-verifier/read-filter.ts` keeps only items + * 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) ───────────────────── - * 1. Resolve the scope → the doc set: the graphs named, or — for the whole-dataset - * reading — the CURRENT wallet's own per-entity documents, and nothing else. There - * is no "everything public" to fold in — **you cannot discover, you can only follow - * links** (see docs/readcap-and-nuri-model.md §4ter-bis), and a link reaches you - * 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`) -------------------------- + * Two push sources, both of them signals: the subscription's own + * `addChangeListener` (root objects added, updated or removed - the engine's patches + * reaching the signal object), and the keyring above. No document subscription of this + * library's is opened here any more: the ORM holds the one that matters. * - * ── Reactivity WITHOUT polling (no `setInterval`) ────────────────────────── - * Reactivity is push-only (rule no-broker-polling). It has TWO sources: document - * pushes, and the KEYRING — a cap that arrives asynchronously (an inbox delivery - * absorbed by the consumer's `inbox.watch`) makes documents readable that were not, - * 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. + * -- `isError` is wired to what CAN fail, and says so ---------------------- + * One thing on this path throws, and it throws at the call: opening the shape query - + * a shape the ORM cannot canonicalise, or a `configure()` that was never given the + * query to open. That is what `isError` carries. * - * ── timed-out → isSuccess (best-effort), NOT isError ─────────────────────── - * A doc whose barrier fell back to `timed-out` still counts as "barrier reached" - * (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error. - * `isError` fires ONLY on a real thrown exception in the pipeline. + * What it does NOT carry, recorded rather than assumed: a start the ENGINE refuses. + * `RdfOrmSubscription` awaits its session and calls `orm_start_graph` inside a + * `try { ... } catch (e) { console.error(e) }`, and its readiness promise is built with + * `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 ────────────────────── - * 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 ───────────────── + * -- 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 * 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 * `isError` says the current answer is unknown. `isSuccess` is false throughout: the * array is a memory, not a reply to the question just asked. */ -import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; -import { ensureReposOpen, getSyncState } from "../emulated-verifier/open-repo"; -import { readUnion, type UnionSubject } from "./read-model"; -import { subscribeDoc, type Unsubscribe } from "./subscribe"; -import { listMyEntityDocs, userStoreDoc } from "../shared-wallet/account-registry"; -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 { getCaps, getConfig } from "../shared-wallet/bootstrap"; +import { filterReadable } from "../emulated-verifier/read-filter"; +import type { OrmSubscriptionHandle, OrmSubscriptionLike } from "../model/types"; +// The SDK's own types, imported as TYPES (erased at build, so no runtime `@ng-org` +// import is added here). The provenance of what is PUBLISHED is recorded in `index.ts`. import type { Scope } from "@ng-org/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"; +import type { BaseType, ShapeType } from "@ng-org/shex-orm"; /** * A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array - * (never `undefined`). Defaults `T` to {@link UnionSubject} — `useShapeQuery` yields - * the generic per-subject property bags of the read-model (NO application domain); - * the app maps them to its own entity types in phase B. + * (never `undefined`), and its items are the SHAPE's own objects - what the ORM + * returns, carrying `@id`, `@graph` and the shape's readable predicates. */ -// @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 -export interface ShapeQuery { - /** The subjects 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 +// @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 { + /** 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 * 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. */ data: T[]; - /** True while the sync barrier for the current doc set is not yet reached OR the - * first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */ + /** True until the shape query's initial payload has arrived (`readyPromise`). + * Mutually exclusive with `isSuccess`. */ isPending: boolean; - /** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the - * first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with - * `data: []` — the distinction this surface exists for. */ + /** True once the initial payload has arrived. A ready-but-EMPTY scope is `isSuccess` + * with `data: []` - the distinction this surface exists for. */ isSuccess: boolean; - /** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`): - * the scope could not be resolved, or the union could not be read. It means the - * current answer is UNKNOWN — never that the scope is empty. Mutually exclusive with - * both `isPending` and `isSuccess`. */ + /** True ONLY when opening the shape query threw: the shape could not be subscribed, or + * `configure()` was never given the query to open it with. It means the current answer + * is UNKNOWN - never that the scope is empty. Mutually exclusive with both `isPending` + * and `isSuccess`. See the module header for the failure it does NOT cover. */ isError: boolean; /** The caught error when `isError`, else `undefined`. */ error: unknown; } /** 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 -export interface ShapeObservable { +// @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 { /** The current snapshot. STABLE across calls until it actually changes (so * `useSyncExternalStore` does not loop): the same reference is returned until a * state transition produces a new one. */ getSnapshot(): ShapeQuery; /** 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; - /** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent - * w.r.t. subscriptions; never polls. */ + /** Ask again, now: close the shape query in force and open a fresh one. Idempotent + * 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; } -/** - * 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 | null { - try { - const st = shapeType as { - shape?: string; - schema?: Record }> }>; - }; - const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined; - const preds = shape?.predicates ?? []; - const iris = new Set(); - 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 | 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 - * `scope` - the SDK's own `{ graphs, subjects }`. See the module header for the full - * pipeline and for what the `graphs` axis costs while the read path is still this - * library's. The returned observable is inert until its first - * {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is - * what kicks off resolution, opening and the first read; before then `getSnapshot` - * reports the initial pending snapshot. + * `scope` - the SDK's own `{ graphs, subjects }`, passed through untouched. See the + * module header for what is read underneath and what each state means. The returned + * observable is inert until its first {@link ShapeObservable.subscribe} (or + * {@link ShapeObservable.refetch}) - that is what opens the shape query; before then + * `getSnapshot` 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 -export function useShapeQuery( - shapeType: unknown, +// @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( + shapeType: ShapeType, scope: Scope, ): ShapeObservable { - 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). - let snapshot: ShapeQuery = { + let snapshot: ShapeQuery = { data: [], isPending: true, isSuccess: false, @@ -284,21 +159,21 @@ export function useShapeQuery( const listeners = new Set<() => void>(); let started = false; - // The docs currently subscribed for change signals, keyed by NURI → unsubscribe. - // Idempotent: a doc already here is not re-subscribed. Excludes the container - // (scope-index / discovery-index) subscriptions, held separately. - const docSubs = new Map(); - // Container subscriptions (scope-index doc; discovery-index doc for `public`) — - // a push here means the doc SET may have changed → re-resolve. - const containerSubs = new Map(); - // Unsubscribe from the held-caps change signal (see the subscription in `start`). + /** The shape query in force, or `null` between a teardown and the next open. */ + let query: OrmSubscriptionHandle | null = null; + /** The change listener registered on {@link query}, so it can be taken off again. */ + let onQueryChange: (() => void) | null = null; + /** Unsubscribe from the held-caps change signal (see the subscription in `start`). */ 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 - // signal is ignored during that window instead of restarting the cycle. - let resolving = false; - // Monotonic token so a slow in-flight refresh cannot clobber a newer one. - let refreshToken = 0; + /** + * Which open owns the question. A `refetch` supersedes the previous subscription, and + * a teardown supersedes all of them - so every callback checks it is still the current + * one before publishing. A stale answer is dropped, never merged. + */ + 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 { for (const l of listeners) { @@ -310,22 +185,22 @@ export function useShapeQuery( } } - function setSnapshot(next: ShapeQuery): void { + function setSnapshot(next: ShapeQuery): void { snapshot = next; emit(); } /** - * Publish "the current answer is UNKNOWN" — the one snapshot every failure in the - * pipeline produces, wherever it was thrown. + * Publish "the current answer is UNKNOWN" - the one snapshot every failure on this + * path produces. * * `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 - * 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 * 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 - * 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`. */ function setUnknown(error: unknown): void { @@ -338,169 +213,134 @@ export function useShapeQuery( }); } - /** 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 - * of the pipeline calls "the scope", so a swallowed failure here does not degrade - * the answer, it INVENTS one — zero documents, a barrier trivially reached over - * them, and `isSuccess` published over a store nobody read. Nothing downstream can - * tell that set apart from a scope that is genuinely empty, because it IS the same - * set. The caller's error state exists for this. - * - * There is no "everything public" to fold in. You cannot discover; you can only - * follow links, and a link reaches you through an inbox or through a document - * you already hold — never through a shared index. A document someone gave you - * the cap for is read by naming it (`readUnion`), not by appearing in - * a scope you did not put it in. */ - async function resolveDocs(): Promise { - 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(); - 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]; + * The ONE cast in this module, and it sits where the injected SDK meets the shape's + * promise: the subscription is typed by what `configure` received (`unknown` items, + * because the injection is a loose shape), while `shapeType` is what says they are + * `T`. Filtering happens first and on the raw items - the filter reads `@graph` and + * nothing else, so it needs no type at all. + */ + function readable(handle: OrmSubscriptionHandle): T[] { + const caps = getCaps(); + const items = [...handle.signalObject]; + // No cap issued yet -> the emulation is not in force and the set passes through, + // exactly as `surface/use-shape.ts` does. See `emulated-verifier/read-filter.ts`. + const kept = caps.isEnforcing() ? filterReadable(items, caps) : items; + return kept as T[]; } - /** Subscribe to the CONTAINER document (the scope index) so a change to the doc - * SET re-resolves. Idempotent per NURI. - * - * This one SWALLOWS, deliberately and unlike `resolveDocs` next door. It resolves - * 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 { - // 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 { - 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); + /** Publish what the subscription holds right now. Only ever called on a READY + * generation, so this snapshot is always an answer. */ + function publish(handle: OrmSubscriptionHandle, gen: number): void { + if (gen !== generation || !ready) return; setSnapshot({ - data, - isPending: !past, - isSuccess: past, + data: readable(handle), + isPending: false, + isSuccess: true, isError: false, 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" — - * scope resolution included, since `resolveDocs` propagates. It is reached only - * when the cycle is still the current one; a superseded cycle's failure is - * dropped, because a newer cycle owns the question by then. */ - async function refresh(): Promise { - const token = ++refreshToken; + * Upstream an application imports the ORM; here it hands it to `configure`, like the + * `ng` and the `useShape` beside it. Absent, there is nothing to read through - and + * that has to be said out loud rather than answered with an empty set, which is the + * one reading this surface must never produce. + */ + 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 { + handle.removeChangeListener(onQueryChange); + } catch { + /* ignore */ + } + onQueryChange = null; + } try { - await ensureContainerSubs(); - const docs = await resolveDocs(); - if (token !== refreshToken) return; - syncDocSubs(docs); - // Open/await the barrier (first State per doc, or timed-out). No-op once open. - await ensureReposOpen(docs); - if (token !== refreshToken) return; - await readAndPublish(docs, token); - } catch (error) { - if (token !== refreshToken) return; - setUnknown(error); + handle.close(); + } catch { + /* ignore */ } } - /** 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. */ - async function reread(): Promise { - const token = ++refreshToken; - const docs = [...docSubs.keys()]; - await readAndPublish(docs, token); + /** + * Open a shape query for `(shapeType, scope)` and wire its two signals. + * + * Supersedes whatever was open: the previous generation's callbacks are dropped on + * arrival rather than raced, so a slow answer can never clobber a newer question. + */ + 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 { if (started) return; started = true; // A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the - // consumer's `inbox.watch`) makes documents readable that were not. Without - // 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 + // consumer's `inbox.watch`) makes items readable that were not. Without + // this the view would stay stale until some unrelated push happened to fire - + // 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 // obligation on the consumer's side. capsUnsub = getCaps().onChange(() => { - if (!resolving) void refresh(); + if (query !== null) publish(query, generation); }); - void refresh(); + open(); } return { getSnapshot(): ShapeQuery { - return snapshot as unknown as ShapeQuery; + return snapshot; }, subscribe(onChange: () => void): () => void { listeners.add(onChange); @@ -508,24 +348,10 @@ export function useShapeQuery( return () => { listeners.delete(onChange); if (listeners.size === 0) { - // Last listener gone → tear down the underlying subscriptions. A later - // subscribe restarts a fresh cycle. - for (const u of docSubs.values()) { - try { - u(); - } catch { - /* ignore */ - } - } - for (const u of containerSubs.values()) { - try { - u(); - } catch { - /* ignore */ - } - } - docSubs.clear(); - containerSubs.clear(); + // Last listener gone -> close the shape query. A later subscribe opens a + // fresh one; the ORM pools what it has just closed, so that is cheap. + generation += 1; + closeQuery(); if (capsUnsub) { capsUnsub(); capsUnsub = null; @@ -535,8 +361,11 @@ export function useShapeQuery( }; }, refetch(): void { - start(); - void refresh(); + if (!started) { + start(); + return; + } + open(); }, }; } diff --git a/packages/polyfill/test/orm-fake.ts b/packages/polyfill/test/orm-fake.ts new file mode 100644 index 0000000..6c90621 --- /dev/null +++ b/packages/polyfill/test/orm-fake.ts @@ -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; + scope: Scope; + listeners: Set; + 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 = { "@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, 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(); + 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(); + 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 = (( + shapeType: ShapeType, + scope: Scope, + ): OrmSubscriptionHandle => { + if (refusal !== null) throw new Error(refusal); + const shape = shapeType as unknown as ShapeType; + asked.push({ shape: shape.shape, scope }); + let resolveReady!: () => void; + const readyPromise = new Promise((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 { + 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, + }; +} diff --git a/packages/polyfill/test/use-shape-query-surfaces-failure.test.ts b/packages/polyfill/test/use-shape-query-surfaces-failure.test.ts index 0086d25..de707de 100644 --- a/packages/polyfill/test/use-shape-query-surfaces-failure.test.ts +++ b/packages/polyfill/test/use-shape-query-surfaces-failure.test.ts @@ -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 * 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. * - * Until 2026-08-17 it destroyed that distinction on the one input that mattered. Step 1 of - * its pipeline asks `listMyEntityDocs` which documents are mine; the failure - * was caught, logged, and the empty set flowed on. A barrier over zero documents is - * trivially reached, so the surface published `{ data: [], isPending: false, isSuccess: - * true }` — the exact snapshot a genuinely empty scope produces, byte for byte. An - * application rendered "you have created nothing" for "the store did not answer", which is - * the outage this library already shipped twice (`8c8ade7`, `f6d1734`) and closed one layer - * down the day before (`90712e0`, which made that very call throw). Catching it here put it - * straight back one floor up. + * -- What changed under it in `1.0.0-dev.6`, and what did not --------------- + * Until then the read was this package's own pipeline, and the failure it could not be + * allowed to mistake was its first step: `listMyEntityDocs` not answering, caught, logged, + * and the empty set flowing on. A barrier over zero documents is trivially reached, so the + * surface published `{ data: [], isPending: false, isSuccess: true }` - the exact snapshot + * a genuinely empty scope produces, byte for byte. An application rendered "you have + * created nothing" for "the store did not answer", which is the outage this library shipped + * twice (`8c8ade7`, `f6d1734`). * - * ── 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 * 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 * answer, and the failure AFTER one. * - * ── The fault is the broker's, and it is one a real one produces ────────── - * Nothing here plants a state or reaches in to make a library function throw. The documents - * were CREATED through the surface, over the same wallet, in the session before; then the - * connection dies — the broker answers normally and stops answering, and every query after - * that rejects the way the wasm binding rejects a transport error. Same montage as - * `listing-surfaces-failure.test.ts`, one layer up. + * -- The fault is the SDK's, and it is one the real one produces ----------- + * Nothing here plants a state or reaches into the library to make one of its functions + * throw. The documents were CREATED through the surface, over the same wallet, in the + * session before; then the injected shape query stops opening, which is what the real + * `getOrCreate` does when it cannot subscribe the shape it was handed, and what this + * 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 type { ShapeObservable, ShapeQuery } from "../src/surface/use-shape-query"; import { configureStoreRegistry } from "../src/shared-wallet/bootstrap"; 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"; - -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>`; +import type { BaseType, ShapeType } from "@ng-org/shex-orm"; const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const EVENT = "http://festipod.org/Event"; 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 = { shape: "http://festipod.org/EventShape", schema: { "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; /** - * Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query - * `lostAt` accepts — that one included, and every one after it, for the rest of the page. + * Wire the library onto `quads`, with the shape query answering over THE SAME wallet. * - * A dropped connection is not selective, so neither is this: the switch decides *when* the - * link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up. - * - * `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. + * That shared dataset is the point: the objects the read hands back are the ones the + * surface really wrote, in the session before, rather than a list this file composed. */ -function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void { +function bootPage(quads: Quad[]): FakeOrm { const wallet = makeWallet(quads); - let lost = false; - const ng = { - doc_create: wallet.doc_create, - sparql_update: wallet.sparql_update, - sparql_query: mock(async (...a: unknown[]) => { - if (lost || lostAt(a[1] as string)) { - lost = true; - 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 }); + const asOrmQuads = (): OrmQuad[] => + quads.map((q) => ({ graph: q.g, subject: q.s, predicate: q.p, object: q.o })); + const orm = makeOrm({ source: asOrmQuads }); + configure({ + ng: wallet as unknown as NgLike, + useShape: (() => {}) as unknown as UseShapeLike, + ormSubscription: orm.query, + }); 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 - * 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. */ async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> { const quads: Quad[] = []; forgetEverything(); - bootPage(quads, () => false); + bootPage(quads); await signIn("alice"); const note = await storeRegistry.createEntityDoc(await storeOfType(scope)); await sparqlUpdate( @@ -118,17 +117,17 @@ async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad 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 { const quads: Quad[] = []; forgetEverything(); - bootPage(quads, () => false); + bootPage(quads); await signIn("alice"); 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 * `"nothing"` and `"unknown"`: they must never be the same one. */ @@ -160,10 +159,15 @@ async function subscribeAndSettle( 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"]; -/** The whole local dataset - upstream's own marker, and the reading that still has to - * enumerate this identity's documents, which is the step these tests make fail. */ +/** The whole local dataset - upstream's own marker, and the one reading an application + * reaches for when it wants everything it holds. */ const EVERYTHING = { graphs: "did:ng:i" }; beforeEach(() => { @@ -171,7 +175,7 @@ beforeEach(() => { }); afterAll(() => { // 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` // 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 @@ -182,14 +186,14 @@ afterAll(() => { 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` // 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. for (const scope of ALL_SCOPES) { test(`[${scope}] a scope this account never wrote to reads as NOTHING`, async () => { const quads = await aFirstSessionThatCreatedNothing(); forgetEverything(); - bootPage(quads, () => false); + bootPage(quads); await signIn("alice"); 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); forgetEverything(); - bootPage(quads, () => false); + bootPage(quads); await signIn("alice"); 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(); expect(whatTheAppSees(snap)).toBe("some"); - expect(snap.data.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]); + expect(titles(snap)).toEqual(["the note"]); stop(); }); } - // The defect. Alice HAS a document; the store cannot be read; the surface used to answer - // with the empty-scope snapshot above — same `data`, same `isSuccess`, nothing to tell - // them apart. It is the more dangerous of the two failure values, because "you created - // nothing" is a sentence an application renders without hesitating. + // The defect, in the form the new read path can take it. Alice HAS a document; the shape + // query cannot be opened; the surface must not answer with the empty-scope snapshot + // above - same `data`, same `isSuccess`, nothing to tell them apart. It is the more + // dangerous of the two failure values, because "you created nothing" is a sentence an + // application renders without hesitating. 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); - let armed = false; forgetEverything(); - bootPage(quads, (query) => armed && query.includes(LISTING_READ)); + const orm = bootPage(quads); await signIn("alice"); - armed = true; + orm.refuse("OrmError: the shape could not be subscribed"); const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable; 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.isSuccess).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 // defect: this is not the snapshot the empty scope publishes. 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 - // list when the link dies. The observable cannot un-emit it, so the failure snapshot must - // not put `[]` in the field the view paints — that would be the empty answer again, - // arriving later and to a screen that has something on it. + // list when the read stops working. The observable cannot un-emit it, so the failure + // snapshot must not put `[]` in the field the view paints - that would be the empty + // answer again, arriving later and to a screen that has something on it. for (const scope of ALL_SCOPES) { test(`[${scope}] a list already rendered is not replaced by an empty one`, async () => { const { quads } = await aFirstSessionThatCreated(scope); - let armed = false; forgetEverything(); - bootPage(quads, (query) => armed && query.includes(LISTING_READ)); + const orm = bootPage(quads); await signIn("alice"); 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"); const rendersBefore = renders(); - // The link dies, and the application asks again — the imperative refresh the surface - // publishes for exactly this. - armed = true; + // The shape query stops opening, and the application asks again - the imperative + // refresh the surface publishes for exactly this. + orm.refuse("OrmError: the shape could not be subscribed"); obs.refetch(); await until("the failure to reach the subscriber", () => obs.getSnapshot().isError); const snap = obs.getSnapshot(); expect(whatTheAppSees(snap)).toBe("unknown"); expect(snap.isSuccess).toBe(false); - // 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"]); + // The list it had is still there - a memory, never offered as a fresh answer. + expect(titles(snap)).toEqual(["the note"]); // 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. expect(renders()).toBeGreaterThan(rendersBefore); diff --git a/packages/polyfill/test/use-shape-query.test.ts b/packages/polyfill/test/use-shape-query.test.ts index f53d2ce..2e30f75 100644 --- a/packages/polyfill/test/use-shape-query.test.ts +++ b/packages/polyfill/test/use-shape-query.test.ts @@ -1,38 +1,36 @@ /** - * use-shape-query.test.ts — behavioural tests for `useShapeQuery` - * (src/surface/use-shape-query.ts), - * against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`. + * use-shape-query.test.ts - behavioural tests for `useShapeQuery` + * (src/surface/use-shape-query.ts), against the SDK's shape query stood in for by + * `orm-fake.ts` and a stateful fake `ng` for everything else the page does. * - * The fake emulates just enough of the broker: - * - `doc_create` mints monotonic doc NURIs. - * - `sparql_update` parses the shim account writes + the per-entity index - * `contains` append + arbitrary anchored triple writes into an in-memory quad - * store (same tolerant parser shape as store-registry.test / read-model.test). - * - `sparql_query` answers the shim account SELECT, the scope-index `contains` - * SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to - * 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). + * -- The montage, and why it takes TWO fakes ------------------------------- + * The read goes through the SDK now, so the thing that answers it is the injected + * shape query (`test/orm-fake.ts`, a small engine that honours the scope axes the way + * `schema_shape_to_sparql` does). Everything AROUND the read is still this package's: + * signing in, creating a document, filing its capability. That is the fake `ng` - and + * keeping it is what makes the documents here real ones, obtained from + * `createEntityDoc` rather than named by the test. * - * These prove the four distinctions the surface exists for: - * (a) isPending at first, isSuccess after the first State (barrier); - * (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction); - * (c) a write then push → data updates (reactivity, no polling); - * (d) timed-out → isSuccess (best-effort), NOT isError; - * plus the three readings of the SDK `Scope` the surface now takes: the whole dataset, - * a named `graphs` list, and the empty list that upstream answers with no objects. + * These prove the distinctions the surface exists for: + * (a) isPending until the initial payload arrives, isSuccess once it has; + * (b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction); + * (c) an engine patch updates data (reactivity, no polling); + * (d) a query that never delivers stays isPending - it never becomes "nothing"; + * plus the three readings of the SDK `Scope`, which this module no longer interprets + * 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 { useShapeQuery } from "../src/surface/use-shape-query"; 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 { 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"; /** 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 } /** - * The stateful fake with a controllable doc_subscribe. `holdState: true` means a - * fresh subscription does NOT auto-fire its `State` — the test fires it via - * `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe - * (synced immediately), which is the convenient mode for the reactivity/empty cases. + * The stateful fake `ng` - the shim account, the per-scope index and the document + * creation the page does around the read. It is NOT what answers the read any more. */ -function makeFake(opts?: { holdState?: boolean }) { +function makeFake() { const quads: Quad[] = []; let docCounter = 0; const subs: SubRec[] = []; - const hold = opts?.holdState ?? false; - // Nuris whose barrier `State` has been released (auto-fire on future subscribe). - const released = new Set(); - // 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}`); @@ -104,17 +89,7 @@ function makeFake(opts?: { holdState?: boolean }) { const p = m[1] ?? "urn:ng-eventually:shim:Account"; const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? ""); 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) { if (sub.nuri === g) sub.cb({ V0: { Patch: {} } }); } @@ -132,9 +107,7 @@ function makeFake(opts?: { holdState?: boolean }) { return { results: { bindings } }; } if (query.includes("")) { - const subjM = query.match( - /<([^>]+)>\s+a\s+/, - ); + const subjM = query.match(/<([^>]+)>\s+a\s+/); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); for (const q of quads) { @@ -157,70 +130,37 @@ function makeFake(opts?: { holdState?: boolean }) { })); return { results: { bindings } }; } - if (query.includes("")) { - const bindings = quads - .filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:inboxCap") - .map((q) => ({ c: { value: q.o } })); - return { results: { bindings } }; + for (const marker of ["inboxCap", "readCap", "contains"] as const) { + if (query.includes(``)) { + const key = marker === "contains" ? "e" : "c"; + const bindings = quads + .filter((q) => q.g === anchor && q.p === `urn:ng-eventually:shim:${marker}`) + .map((q) => ({ [key]: { value: q.o } })); + return { results: { bindings } }; + } } - if (query.includes("")) { - 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("")) { - 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 .filter((q) => q.g === anchor) .map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })); return { results: { bindings } }; }); - const doc_subscribe = mock( - async (nuri: string, _sid: string, cb: (r: unknown) => void) => { - subs.push({ nuri, cb }); - // Platform pushes TabInfo FIRST (never the barrier). - 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); - } - return () => {}; - }, - ); + const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => { + subs.push({ nuri, cb }); + setTimeout(() => cb({ V0: { TabInfo: {} } }), 0); + setTimeout(() => cb({ V0: { State: {} } }), 0); + return () => {}; + }); - /** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */ - 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 { - doc_create, - sparql_update, - sparql_query, - doc_subscribe, - _quads: quads, - fireState, - releaseAll, - subs, - }; + return { doc_create, sparql_update, sparql_query, doc_subscribe, _quads: quads }; } -function inject(ng: ReturnType) { - configure({ ng: ng as any, useShape: (() => {}) as any }); +function inject(ng: ReturnType, orm: FakeOrm) { + configure({ + ng: ng as unknown as NgLike, + useShape: (() => {}) as unknown as UseShapeLike, + ormSubscription: orm.query, + }); configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(), @@ -230,36 +170,55 @@ function inject(ng: ReturnType) { resetCaps(); } -// Insert a triple straight into a doc's graph in the fake store (no push). -function seed(ng: ReturnType, 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, - 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)); -// 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 = { shape: `${FP}EventShape`, schema: { [`${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; +/** 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(() => { setCurrentUser(null); }); @@ -274,14 +233,13 @@ afterAll(() => { }); describe("useShapeQuery", () => { - it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => { - const ng = makeFake({ holdState: true }); - inject(ng); + it("(a) isPending until the initial payload arrives, then isSuccess with the data", async () => { + const ng = makeFake(); + const orm = makeOrm({ autoDeliver: false }); + inject(ng, orm); setCurrentUser("alice"); - // One protected entity doc for alice, carrying an Event triple. const doc = await createEntityDoc("alice", "protected"); - seed(ng, doc, TYPE, `${FP}Event`); - seed(ng, doc, `${FP}title`, "Alpha"); + seedEvent(orm, doc, `${doc}:q:one`, "Alpha"); const obs = useShapeQuery(EventShape, EVERYTHING); let notes = 0; @@ -289,33 +247,30 @@ describe("useShapeQuery", () => { notes += 1; }); - // Before the barrier: pending, no data. + // Before the engine answers: pending, no data. await tick(); expect(obs.getSnapshot().isPending).toBe(true); expect(obs.getSnapshot().isSuccess).toBe(false); expect(obs.getSnapshot().data).toEqual([]); - // Release the barrier for every opened doc (present + future) → synced. - ng.releaseAll(); - await tick(); - await tick(); + orm.deliver(); await tick(); const snap = obs.getSnapshot(); expect(snap.isPending).toBe(false); expect(snap.isSuccess).toBe(true); expect(snap.isError).toBe(false); - expect(snap.data.length).toBe(1); - expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]); + expect(titles(snap.data)).toEqual(["Alpha"]); expect(notes).toBeGreaterThan(0); unsub(); }); - it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => { - const ng = makeFake(); // auto-fires State → synced immediately - inject(ng); + it("(b) isSuccess + data:[] on a ready-but-EMPTY scope (the key distinction)", async () => { + const ng = makeFake(); + const orm = makeOrm(); + inject(ng, orm); 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 unsub = obs.subscribe(() => {}); @@ -324,139 +279,160 @@ describe("useShapeQuery", () => { const snap = obs.getSnapshot(); 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.data).toEqual([]); // empty — distinguishable from "still syncing" + expect(snap.data).toEqual([]); // empty - distinguishable from "still asking" unsub(); }); - it("(c) a write then push updates data (reactivity, no polling)", async () => { - const ng = makeFake(); // synced immediately - inject(ng); + it("(c) an engine patch updates data (reactivity, no polling)", async () => { + const ng = makeFake(); + const orm = makeOrm(); + inject(ng, orm); setCurrentUser("carol"); const doc = await createEntityDoc("carol", "protected"); - seed(ng, doc, TYPE, `${FP}Event`); - seed(ng, doc, `${FP}title`, "One"); + seedEvent(orm, doc, `${doc}:q:one`, "One"); const obs = useShapeQuery(EventShape, EVERYTHING); const unsub = obs.subscribe(() => {}); 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 - // doc. Because a new doc must appear in the set, write into the scope-INDEX - // (createEntityDoc appends to it, and the index is subscribed → re-resolve). + // A second object lands in a second document - the engine pushes it into the live + // subscription, and the snapshot follows. No document subscription of ours, no timer. const doc2 = await createEntityDoc("carol", "protected"); - seed(ng, doc2, TYPE, `${FP}Event`); - seed(ng, doc2, `${FP}title`, "Two"); - // createEntityDoc's index append fired a Patch on the index doc → re-resolve. - await tick(); + seedEvent(orm, doc2, `${doc2}:q:one`, "Two"); await tick(); - const titles = obs - .getSnapshot() - .data.flatMap((s) => s.props[`${FP}title`] ?? []) - .sort(); - expect(titles).toEqual(["One", "Two"]); - // No setInterval anywhere — reactivity was push-driven. + expect(titles(obs.getSnapshot().data)).toEqual(["One", "Two"]); unsub(); }); - it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => { - // A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback - // fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to - // a few ms so this is fast, and assert the barrier is genuinely reached via - // timed-out (getSyncState === "timed-out") and that the snapshot maps that to - // isSuccess, never isError. - const ng = makeFake({ holdState: true }); // State is never released - inject(ng); - setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s + it("(d) a query that never delivers stays isPending - never 'there is nothing'", async () => { + // The state the real ORM produces when the engine refuses a start: `orm_start_graph` + // is called inside a `try { ... } catch (e) { console.error(e) }` and the readiness + // promise is built with `resolve` alone, so nothing ever settles it. The surface must + // keep saying "still asking" - the one thing it must never do is publish the + // empty-and-answered snapshot, which is what an application renders as "you have + // created nothing". + const ng = makeFake(); + const orm = makeOrm({ autoDeliver: false }); + inject(ng, orm); setCurrentUser("dave"); const doc = await createEntityDoc("dave", "protected"); - seed(ng, doc, TYPE, `${FP}Event`); - seed(ng, doc, `${FP}title`, "Timed"); + seedEvent(orm, doc, `${doc}:q:one`, "Never arrives"); const obs = useShapeQuery(EventShape, EVERYTHING); const unsub = obs.subscribe(() => {}); 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 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(); + expect(snap.isPending).toBe(true); + expect(snap.isSuccess).toBe(false); expect(snap.isError).toBe(false); - expect(snap.isSuccess).toBe(true); // timed-out is best-effort success - expect(snap.isPending).toBe(false); - // The data still read (best-effort): the doc's triples resolved. - expect(snap.data.length).toBe(1); + expect(snap.data).toEqual([]); 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; // `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 - // wallet, and the third is the one a caller reaches by accident - so each is pinned. + // `undefined` returns no objects at all. This module no longer interprets any of it - + // 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(); - inject(ng); - setCurrentUser("erin"); - const kept = await createEntityDoc("erin", "protected"); - seed(ng, kept, TYPE, `${FP}Event`); - seed(ng, kept, `${FP}title`, "Named"); - const other = await createEntityDoc("erin", "protected"); - seed(ng, other, TYPE, `${FP}Event`); - seed(ng, other, `${FP}title`, "Unnamed"); + const orm = makeOrm(); + inject(ng, orm); + setCurrentUser("frank"); + const kept = await createEntityDoc("frank", "protected"); + seedEvent(orm, kept, `${kept}:q:one`, "Named"); + const other = await createEntityDoc("frank", "protected"); + seedEvent(orm, other, `${other}:q:one`, "Unnamed"); // The whole dataset sees both - that is the reading the named list departs from. const all = useShapeQuery(EventShape, EVERYTHING); const unsubAll = all.subscribe(() => {}); await tick(); await tick(); - expect(all.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([ - "Named", - "Unnamed", - ]); + expect(titles(all.getSnapshot().data)).toEqual(["Named", "Unnamed"]); unsubAll(); - const obs = useShapeQuery(EventShape, { graphs: [kept] }); + const scope = { graphs: [kept] }; + const obs = useShapeQuery(EventShape, scope); const unsub = obs.subscribe(() => {}); await tick(); await tick(); - const snap = obs.getSnapshot(); - expect(snap.isSuccess).toBe(true); - expect(snap.data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Named"]); + expect(obs.getSnapshot().isSuccess).toBe(true); + expect(titles(obs.getSnapshot().data)).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(); }); - 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(); - inject(ng); - setCurrentUser("frank"); - const doc = await createEntityDoc("frank", "protected"); - seed(ng, doc, TYPE, `${FP}Event`); - seed(ng, doc, `${FP}title`, "Unreachable"); + const orm = makeOrm(); + inject(ng, orm); + setCurrentUser("gina"); + const doc = await createEntityDoc("gina", "protected"); + seedEvent(orm, doc, `${doc}:q:one`, "Unreachable"); // 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 // application would otherwise be left staring at. // `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. `[]` - // is the other half, and it is the one that still spells "empty" - `normalizeScope` - // maps both onto the same empty list, so this asks the same question it always did. + // is the other half, and it is the one that still spells "empty". const obs = useShapeQuery(EventShape, { graphs: [] }); const unsub = obs.subscribe(() => {}); await tick(); @@ -470,26 +446,22 @@ describe("useShapeQuery", () => { 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(); - inject(ng); - setCurrentUser("gina"); - const doc = await createEntityDoc("gina", "protected"); + const orm = makeOrm(); + inject(ng, orm); + setCurrentUser("hank"); + const doc = await createEntityDoc("hank", "protected"); // Two subjects in ONE document: the `subjects` axis is what tells them apart, and // `graphs` cannot - both carry the same `@graph`. - seedSubject(ng, doc, `${FP}one`, TYPE, `${FP}Event`); - seedSubject(ng, doc, `${FP}one`, `${FP}title`, "First"); - seedSubject(ng, doc, `${FP}two`, TYPE, `${FP}Event`); - seedSubject(ng, doc, `${FP}two`, `${FP}title`, "Second"); + seedEvent(orm, doc, `${FP}one`, "First"); + seedEvent(orm, doc, `${FP}two`, "Second"); const both = useShapeQuery(EventShape, EVERYTHING); const unsubBoth = both.subscribe(() => {}); await tick(); await tick(); - expect(both.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? []).sort()).toEqual([ - "First", - "Second", - ]); + expect(titles(both.getSnapshot().data)).toEqual(["First", "Second"]); unsubBoth(); const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] }); @@ -497,8 +469,52 @@ describe("useShapeQuery", () => { await tick(); await tick(); - expect(obs.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Second"]); + expect(titles(obs.getSnapshot().data)).toEqual(["Second"]); 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 + }); }); diff --git a/packages/polyfill/test/vocabulary.test.ts b/packages/polyfill/test/vocabulary.test.ts index d8203f4..6cbdf45 100644 --- a/packages/polyfill/test/vocabulary.test.ts +++ b/packages/polyfill/test/vocabulary.test.ts @@ -49,6 +49,12 @@ const TARGET_WORDS = new Set([ "publisher", "topic", "advert", // the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`) "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", ]); /**