feat!: useShapeQuery prend le Scope du SDK, et un store se nomme par sa reference
This commit is contained in:
@@ -9,7 +9,7 @@ summary: The API @ng-eventually/polyfill exposes to an application — signature
|
||||
|
||||
This package is a polyfill of NextGraph's SDK.
|
||||
|
||||
This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
|
||||
This package covers placement (creating an application's documents in one of its three stores), reading (a document's subjects, one-shot or reactive, by shape), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
|
||||
|
||||
### Deployment requirements
|
||||
|
||||
@@ -43,14 +43,17 @@ export async function ensureIdentity(): Promise<PrincipalId>; // returns who y
|
||||
// ── addressing ───────────────────────────────────────────────────────────
|
||||
export type Nuri = `did:ng:${string}`;
|
||||
export type NuriLike = Nuri | string;
|
||||
export type Scope = "public" | "protected" | "private";
|
||||
// The SDK's own scope, re-exported unchanged. It scopes a READ, on two axes and no third.
|
||||
export type Scope = { graphs?: string[] | string; subjects?: string[] };
|
||||
|
||||
// ── placement: where an application's documents live ─────────────────────
|
||||
// A store is named by its REFERENCE — one accessor per store, and `createEntityDoc` takes
|
||||
// one of them. Omit it and the document goes to the private store.
|
||||
export const storeRegistry: { // no identity parameter — a session is one user's
|
||||
createEntityDoc(scope: Scope): Promise<Nuri>;
|
||||
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
|
||||
resolveScopeGraph(scope: Scope): Promise<Nuri>;
|
||||
resolveWriteGraph(scope: Scope): Promise<Nuri>;
|
||||
privateStore(): Promise<Nuri>;
|
||||
protectedStore(): Promise<Nuri>;
|
||||
publicStore(): Promise<Nuri>;
|
||||
createEntityDoc(store?: NuriLike): Promise<Nuri>;
|
||||
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
|
||||
};
|
||||
|
||||
@@ -58,9 +61,9 @@ export const storeRegistry: { // no identity parameter — a session is
|
||||
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
|
||||
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
|
||||
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
|
||||
// TWO positional arguments — the same pair `useShape` takes. `ShapeQuery` is what you
|
||||
// READ BACK (the snapshot), never what you pass in.
|
||||
export function watchShape<T = UnionSubject>(shapeType: unknown, scope: Scope): ShapeObservable<T>;
|
||||
// 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<T = UnionSubject>(shapeType: unknown, scope: Scope): ShapeObservable<T>;
|
||||
export interface ShapeObservable<T = UnionSubject> {
|
||||
getSnapshot(): ShapeQuery<T>; // stable reference until the state changes
|
||||
subscribe(onChange: () => void): () => void; // returns its own unsubscribe
|
||||
@@ -112,7 +115,7 @@ export function initNg(...args: any[]): any;
|
||||
|
||||
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
|
||||
|
||||
A returned reference carries no key — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
|
||||
A returned reference carries no key — not `createEntityDoc`, not the three store accessors, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
|
||||
|
||||
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
|
||||
|
||||
@@ -154,11 +157,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.** `watchShape` 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 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.
|
||||
|
||||
The same rule reaches what a call hands BACK, not only what it looked up first: **`listMyEntityDocs` returns a listing whose documents you can open, or it throws.** It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same `Nuri[]`, and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.
|
||||
**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.
|
||||
|
||||
**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 `watchShape` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
|
||||
**A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
|
||||
|
||||
**The watching is in place by the time `ensureIdentity()` resolves**, whatever else that call made of its own work: a connection that could not restore something still rejects, and the identity it settled is watched all the same. It lasts exactly as long as that identity stays connected — changing identity or clearing it stops it, and whoever connects next is watched in their own right, so nothing of the previous one keeps applying.
|
||||
|
||||
@@ -180,7 +183,7 @@ The same rule reaches what a call hands BACK, not only what it looked up first:
|
||||
|
||||
**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 `watchShape` for a reactive read and `readUnion` for a one-shot one — neither goes through that path.
|
||||
**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.
|
||||
|
||||
**No revocation.** `inbox.share` cannot be undone.
|
||||
|
||||
@@ -206,14 +209,25 @@ The same rule reaches what a call hands BACK, not only what it looked up first:
|
||||
|
||||
What each level means here, in this package's own terms:
|
||||
|
||||
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession` and `inbox.materialize` all left this way), **or** an existing call narrows: it now throws where it returned (`listMyEntityDocs` refuses a listing whose documents you could not open; `ensureIdentity` rejects rather than resolve on an incomplete restore), or it reports a state you did not have to handle before (`watchShape` 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), 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.
|
||||
- **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.4`), 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.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.
|
||||
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.4` 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.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.
|
||||
|
||||
`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.4`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.4`, the string you pinned, never the version it is heading for. `1.0.0-dev.4` narrows a **guarantee** and adds a rejection: a deposit this package applies is now consumed, so a failure that used to be retried at the next arrival or the next connection is now final and reported once; and `inbox.processInbox` rejects on a broker failure where it used to resolve. An application that leaned on the retry — however implicitly — has to keep its own record instead, and one that awaited `processInbox` without a `catch` now has a rejection to handle. Either is a **major** by the rules above. `1.0.0-dev.3` before it narrowed an existing call the same way: two payload shapes an application could carry freely became this package's own, and one it cannot apply is reported. It lands inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
|
||||
`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-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:
|
||||
|
||||
- **`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.
|
||||
- **`storeRegistry.createEntityDoc(scope)` → `createEntityDoc(store?)`.** `store` is a reference, not a word: `await storeRegistry.protectedStore()`, `publicStore()` or `privateStore()`. Omitting it places the document in the private store, exactly as `doc_create` does when no store is given. `createEntityDoc("protected")` becomes `createEntityDoc(await storeRegistry.protectedStore())`.
|
||||
- **`storeRegistry.listMyEntityDocs(scope)` is REMOVED, with no replacement call.** There is no `doc_list` upstream, at any level. What replaces it is the read itself: ask `useShapeQuery` for a shape over `{ graphs: "did:ng:i" }` and what you hold of that shape comes back. An application that listed its documents in order to read them was doing in two steps what the read does in one — but it must now write something the shape can match, typically an `rdf:type` triple, since nothing indexes a document for you.
|
||||
- **`storeRegistry.resolveScopeGraph(scope)` is REMOVED.** It handed out a value whose stated use was a read scope, and a store's graph holds its containment triples and nothing else — so pointing a shape query at it matches nothing and says so with an empty set, silently. If you were using it as a write anchor, use the store's reference from the accessor of the same name.
|
||||
- **`storeRegistry.resolveWriteGraph(scope)` is REMOVED.** It returned the very reference `privateStore()` / `protectedStore()` / `publicStore()` return. One value, one published name.
|
||||
|
||||
`1.0.0-dev.4` before it narrowed a **guarantee** and added a rejection: a deposit this package applies is now consumed, so a failure that used to be retried at the next arrival or the next connection is now final and reported once; and `inbox.processInbox` rejects on a broker failure where it used to resolve. An application that leaned on the retry — however implicitly — has to keep its own record instead, and one that awaited `processInbox` without a `catch` now has a rejection to handle. `1.0.0-dev.3` before that narrowed an existing call the same way: two payload shapes an application could carry freely became this package's own, and one it cannot apply is reported. All of them land inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
|
||||
|
||||
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|
||||
|
||||
+24
@@ -35,4 +35,28 @@ blocked: false
|
||||
|
||||
## Progress
|
||||
|
||||
**2026-09-16 — the open question, settled in the open.** The planning note left two things unsettled: what the placement axis should be called once the SDK's `Scope` takes the name, and whether `listMyEntityDocs` survives. The Resolved block answered the second (deleted) and refused a rename of the first (`Scope` disappears, a renamed invented name is still invented). That forces a third answer the note did not ask for, because `resolveScopeGraph` and `resolveWriteGraph` took the same three literals and could not keep them.
|
||||
|
||||
**What the three literals actually are.** They are not this package's invention as VALUES: `local_broker::doc_create(session_id, crdt, class_name, destination, store_type, store_repo)` takes a `store_type` of exactly `"public" | "protected" | "private"` and turns it into a `StoreRepo` with `from_type_and_repo`. What was invented is publishing them under the name `Scope`, for a READ. So the type survives as `StoreType`, DEFINED in `model/types.ts` and published nowhere - the standing `ReadCap` and `InboxScope` already have.
|
||||
|
||||
**How an application names a store now.** By its REFERENCE, which is what `doc_create` takes and what upstream's `Session` hands out (`private_store_id`, `protected_store_id`, `public_store_id`). Three accessors, one per store, and `createEntityDoc(store?)` takes one of them - private when omitted, as upstream. A reference that is not one of the connected identity's three is refused rather than silently placed.
|
||||
|
||||
Verified obstacle, which is why the reference is the per-store INDEX document and not the session store NURI: in this emulation public and protected are co-located on one physical store id (`store-registry.test.ts` pins `resolveScopeGraph("public") === resolveScopeGraph("protected")`), so a session store NURI cannot tell the two apart.
|
||||
|
||||
**`resolveScopeGraph` unpublished, not re-parameterised.** Its stated use was a READ scope, and `knowledge_what-a-read-scope-is-upstream` establishes that a store's graph holds its containment triples and nothing else - so upstream that query matches nothing and says so with an empty set. `resolveWriteGraph` returned the very reference the three accessors return. Both stay internal; the e2e isolation probe reaches `resolveScopeGraph` by its internal path, which is what a harness may do.
|
||||
|
||||
**The translation, and why it is only half a translation.** `graphs` naming documents needs none - it IS the doc set. Only `did:ng:i` / `""` does, and it is served by enumerating the identity's documents across the three stores. `subjects` is applied literally to the union's subject IRIs, as `FILTER(?s IN (…))` is upstream. `[]` / `undefined` returns no objects at all, as `Scope`'s own documentation states.
|
||||
|
||||
**The example application is where the removal bites.** `myNotes(scope)` was `listMyEntityDocs` + `readUnion`, and there is no per-store read to replace it with. It now holds ONE `useShapeQuery` over `{ graphs: "did:ng:i" }` and writes an `rdf:type` triple so the shape can match. The shelf selector governs where the next note is WRITTEN and nothing else. Every list assertion in the applicative e2e is an `includes` positive or a cross-identity negative, so all of them survive a global list.
|
||||
|
||||
## Result
|
||||
|
||||
**Done.** `watchShape` is `useShapeQuery` and takes the SDK's `Scope` (`{ graphs, subjects }`, re-exported from `@ng-org/orm`); `ShapeQuery` and the capability filter are unchanged. The annotation is `announced-not-written`, copied verbatim from the rule. Our three-literal `Scope` is gone from the published surface: it is `StoreType`, defined in `model/types.ts`, published nowhere. A store is now named by its REFERENCE - `storeRegistry.privateStore() / protectedStore() / publicStore()` - and `createEntityDoc(store?)` takes one, private when omitted, as `doc_create` does. `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` are unpublished (still internal). Contract leaf rewritten with a symbol-by-symbol migration note; `1.0.0-dev.5`.
|
||||
|
||||
**Files.** `packages/polyfill/src/{index,model/types,surface/placement,surface/use-shape-query,shared-wallet/account-registry,emulated-verifier/caps,emulated-verifier/branch-registers}.ts`, 13 test files, `e2e/{polyfill-entry,run,notebook}.ts`, `examples/notebook/app.ts`, `docs/api-contract.md`, both READMEs, `contract_polyfill-surface.md`, `package.json`.
|
||||
|
||||
**Gate.** `bun test` 401 pass / 0 fail; both `tsc` exit 0; concept lint 0 ERROR; grep on `index.ts` no match.
|
||||
|
||||
**Surprises.** VERIFIED: the three literals are upstream's own `store_type`, so only the NAME was invented. VERIFIED: public and protected share one store id here, so the store reference must be the index document. VERIFIED: `listMyEntityDocs` is still the read path's - T01.d removes the last caller.
|
||||
|
||||
**Follow-ups.** The whole-dataset read now opens three store documents instead of one. `surface-mirrors-the-sdk.md` still calls the rename "not yet applied". `docs/internal-contract.md`, `docs/simulation.md` and the root `README.md` still name the removed symbols.
|
||||
@@ -45,3 +45,4 @@ The polyfill's published surface has drifted from the SDK's: invented names, inv
|
||||
|
||||
- T01.a — the taxonomy gains `announced-not-written`: its `ref` points at the `.md` in THIS repo where the statement is on record, never a source file, plus `announced-by=<who>@<date>`. Upstream citations now resolve-check only when the sibling checkout is present; announced records always. Two latent defects fell out: the test's closed set was missing `reserved-name`, and the rule said "five kinds" over six rows. All 92 existing refs already resolved.
|
||||
- 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`.
|
||||
|
||||
+40
-20
@@ -218,12 +218,12 @@ Divergence to note: the wrapper types everything `unknown`, losing the generic `
|
||||
|
||||
---
|
||||
|
||||
## 5. Reactive typed reads with load state — `watchShape`
|
||||
## 5. Reactive typed reads with load state — `useShapeQuery`
|
||||
|
||||
### Today — `@ng-eventually/polyfill`
|
||||
|
||||
```ts
|
||||
// watch-shape.ts:73
|
||||
// use-shape-query.ts — ShapeQuery
|
||||
export interface ShapeQuery<T = UnionSubject> {
|
||||
data: T[];
|
||||
isPending: boolean;
|
||||
@@ -231,19 +231,22 @@ export interface ShapeQuery<T = UnionSubject> {
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
}
|
||||
// watch-shape.ts:90
|
||||
// use-shape-query.ts — ShapeObservable
|
||||
export interface ShapeObservable<T = UnionSubject> {
|
||||
getSnapshot(): ShapeQuery<T>;
|
||||
subscribe(onChange: () => void): () => void;
|
||||
refetch(): void;
|
||||
}
|
||||
// watch-shape.ts:166
|
||||
export function watchShape<T = UnionSubject>(
|
||||
// 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<T = UnionSubject>(
|
||||
shapeType: unknown,
|
||||
scope: Scope,
|
||||
): ShapeObservable<T>;
|
||||
```
|
||||
|
||||
**The name and the scope, since `1.0.0-dev.5`.** It was `watchShape(shapeType, "public" | "protected" | "private")`. `watchShape` has ZERO occurrences in `nextgraph-rs` and spends `watch`, a verb the ORM already spends on signal observation; the target's own name is `useShapeQuery`, announced by NextGraph's developers and on record in `.project/rules/nextgraph/surface-mirrors-the-sdk.md`, which is what the symbol's `announced-not-written` annotation cites. The three literals were this package's own and named the scope of a READ, which the SDK answers on two axes instead: `graphs` (one document's graph per entry, `did:ng:i` or `""` for the whole dataset, `[]` or `undefined` for no objects at all) and `subjects`. Only the whole-dataset reading still needs translating — it is served by enumerating the identity's own documents across its three stores — and that translation goes when the read queries the store itself.
|
||||
|
||||
**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.
|
||||
|
||||
**`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`.
|
||||
@@ -259,7 +262,7 @@ get readyPromise(): Promise<void>; // resolves when the subscription is synced
|
||||
public close(): void;
|
||||
```
|
||||
|
||||
So the constraint on the bet: the target can already answer "synced?" (`readyPromise`), and `useShape` today returns "an empty set, if still loading" (its own doc comment, `useShape.ts:29-31`) — indistinguishable from synced-empty. `watchShape` surfaces the distinction with a TanStack-`useQuery`-minimal vocabulary (`isPending`/`isSuccess`/`isError`), which is a **shape of this library's choosing**. If the future hook exposes load state under different names, the consumer's binding code changes; the underlying distinction it teaches (pending ≠ empty) is target-expressible and safe to learn.
|
||||
So the constraint on the bet: the target can already answer "synced?" (`readyPromise`), and `useShape` today returns "an empty set, if still loading" (its own doc comment, `useShape.ts:29-31`) — indistinguishable from synced-empty. `useShapeQuery` surfaces the distinction with a TanStack-`useQuery`-minimal vocabulary (`isPending`/`isSuccess`/`isError`). That is **not** a shape of this library's choosing any more: NextGraph's developers have stated it is the direction, and the statement is on record in `.project/rules/nextgraph/surface-mirrors-the-sdk.md` with its date — which is what the symbol's `announced-not-written` annotation cites. Deviating toward an announced target is alignment; the distinction it teaches (pending is not empty) is target-expressible either way.
|
||||
|
||||
**But `readyPromise` answers two of those three questions, not three — VERIFIED 2026-08-17, and it bears on `isError` specifically.** It is constructed with `resolve` alone (`new Promise<void>((resolve) => { this.resolveReady = resolve; })`, `GraphOrmSubscription.ts` constructor), nothing anywhere rejects it, and `resolveReady()` is called on one path only — the arrival of initial data. The subscription that fails does not settle it: `orm_start_graph` is awaited inside a `try` whose `catch` is `console.error(e)`, so the promise stays pending forever and every `await this.readyPromise_` behind it hangs. Upstream's "I could not find out" IS its "still pending". So `isPending` and `isSuccess` map onto something real at level 3, while `isError` has **no counterpart at any level** — it is this library's third state, and the reason it exists is that swallowing a failed read into a plausible-looking result is the shape that took this project a week to close everywhere else. A consumer should keep reading it; if the future hook ships without one, the failure is the pending that never ends, which is a worse thing to have to render, not a reason to have learned less.
|
||||
|
||||
@@ -554,13 +557,16 @@ The escaping helpers were published until the surface was narrowed. Their remova
|
||||
|
||||
## 12. Scope resolution, per-entity documents, and the store registry
|
||||
|
||||
### Today — `@ng-eventually/polyfill` (namespace `storeRegistry`) — plus `Scope` from `types.ts`
|
||||
### Today — `@ng-eventually/polyfill` (namespace `storeRegistry`)
|
||||
|
||||
> **Narrowed twice.** 2026-08-03 the entry stopped re-exporting the whole `store-registry` module and kept an app-facing slice (`src/surface/placement.ts`). 2026-08-05 that slice lost its two inbox-ADDRESS functions as well: an application deposits with `inbox.postToDocument(doc, …)` and shares with `inbox.share(doc, toUser)` — always naming a document or a person, never an address, because upstream an address is resolved from a profile and never handled by a caller. **Five functions remain published**, listed first below; everything after them is kept for the record and is covered by `docs/internal-contract.md`.
|
||||
|
||||
```ts
|
||||
// types.ts:38 — NB: NOT the ORM's Scope (a graphs/subjects filter); this is the store scope
|
||||
export type Scope = "public" | "protected" | "private";
|
||||
// `Scope` (this package's, `"public" | "protected" | "private"`) was published here until
|
||||
// `1.0.0-dev.5`. It is now `StoreType`, DEFINED in `types.ts` and published nowhere: an
|
||||
// application names a store by the reference one of the three accessors below hands it.
|
||||
// The three strings are upstream's own (`doc_create`'s `store_type`), but the name `Scope`
|
||||
// was covering two questions at once — see § 5 for the other one.
|
||||
|
||||
// store-registry.ts:90,234
|
||||
// VirtualUserRecord is INTERNAL (shape kept here for the ruling below).
|
||||
@@ -584,12 +590,26 @@ interface RegistrySession {
|
||||
// identity to every placement call was a gesture with no successor — and it forced an
|
||||
// application to KNOW its identity, which it could only do by reading the access gate's
|
||||
// private storage key. `ensureIdentity()` returns it now; these take it from the session.
|
||||
export async function createEntityDoc(scope: Scope): Promise<Nuri>;
|
||||
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
|
||||
export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
|
||||
export async function resolveWriteGraph(scope: Scope): Promise<Nuri>;
|
||||
// A store is named by its REFERENCE, since `1.0.0-dev.5`. Upstream the session hands the
|
||||
// application its three store ids (`Session.private_store_id | protected_store_id |
|
||||
// public_store_id`) and `doc_create` takes one of them, the private store when none is
|
||||
// given — so these three accessors answer what the session answers, and `createEntityDoc`
|
||||
// takes one of them exactly where `doc_create` takes its store.
|
||||
export async function privateStore(): Promise<Nuri>;
|
||||
export async function protectedStore(): Promise<Nuri>;
|
||||
export async function publicStore(): Promise<Nuri>;
|
||||
export async function createEntityDoc(store?: NuriLike): Promise<Nuri>;
|
||||
export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
|
||||
|
||||
// UNPUBLISHED in `1.0.0-dev.5`, each for its own reason:
|
||||
// listMyEntityDocs — no counterpart at any level (no `doc_list`, nothing in the
|
||||
// binding). Listing what one created is an ordinary query over the
|
||||
// store's `ldp:contains`. Still internal: the read path calls it.
|
||||
// resolveScopeGraph — it handed out a value whose stated use was a READ scope, and a
|
||||
// store's graph holds containment triples and nothing else, so
|
||||
// upstream that query matches nothing and says so with an empty set.
|
||||
// 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,
|
||||
// addLink, readLinks, resolveAccount, ensureAccount, reservedAccount,
|
||||
@@ -675,10 +695,10 @@ The entry used to say `export * from "./model/types"`, a blanket re-export publi
|
||||
> **A type is published only if a PUBLISHED SIGNATURE uses it.**
|
||||
|
||||
```ts
|
||||
export type { Nuri, NuriLike, Scope, PrincipalId, NgLike, UseShapeLike } from "./model/types";
|
||||
export type { Nuri, NuriLike, PrincipalId, NgLike, UseShapeLike } from "./model/types";
|
||||
```
|
||||
|
||||
Each one's warrant: `Nuri` is what every reference-returning call returns and `NuriLike` what every entry accepts (§ 10, § 11); `Scope` types `storeRegistry.*` and `watchShape` (§ 12, § 5); `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` and `UseShapeLike` type the two injected objects in `EventuallyConfig` (§ 1).
|
||||
|
||||
Two types the blanket export published are now internal, each because **nothing published names it**:
|
||||
|
||||
@@ -705,12 +725,12 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
|
||||
|
||||
### Places the current surface teaches something to unlearn
|
||||
|
||||
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
|
||||
- ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `useShapeQuery`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery".
|
||||
- ~~**`share` is importable from both entries.**~~ **FIXED 2026-08-07** with the entry merge: there is one entry and one `share`, under `inbox`.
|
||||
- **One entry means the import line no longer says what disappears.** Until 2026-08-07 a second import path (`/polyfill`) WAS the deletion list. It is now the `POLYFILL-ERA` block in `src/index.ts`, this appendix's note above, and the per-subject rulings in this document. That is a documentation-carried signal where it used to be a mechanical one — the appendix is pinned by a test, the grouping is not.
|
||||
- **`inbox.read` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely.
|
||||
- **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API.
|
||||
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes).
|
||||
- ~~**`watchShape`'s "planned `useShape` upgrade"**~~ **ANSWERED in `1.0.0-dev.5`.** The claim was in the module header with no provenance anywhere. It has one now, and it is the strongest kind available to something the target has not written: NextGraph's developers stated the direction, the statement is written down in `.project/rules/nextgraph/surface-mirrors-the-sdk.md` with who said it and when, and the symbol carries `kind=announced-not-written` citing that record. The name went with it — `useShapeQuery`, not `watchShape` (§ 5).
|
||||
- **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `useShapeQuery`'s design already assumes).
|
||||
- **The sync-returning `subscribeDoc` unsubscribe** vs the target's promise-resolved one (§ 8) — a deliberate, documented ergonomic delta; an adapter is one line at migration, but it is a delta.
|
||||
|
||||
---
|
||||
@@ -722,10 +742,10 @@ 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, watchShape
|
||||
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
|
||||
docs: docCreate, sparqlQuery, sparqlUpdate
|
||||
inbox: Deposit, PostOptions, post, postToDocument, processInbox, read, readForDocument, readSynced, readSyncedForDocument, share, watch
|
||||
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
|
||||
storeRegistry: createEntityDoc, openDocumentInbox, privateStore, protectedStore, publicStore
|
||||
```
|
||||
|
||||
**Of these, exactly ONE is polyfill-era with no target counterpart** — `configure` (plus
|
||||
|
||||
+112
-28
@@ -19,7 +19,7 @@
|
||||
* ── The domain is deliberately thin ───────────────────────────────────────
|
||||
* Notes. Each user writes their own, may publish one, may hand a reader the key to a
|
||||
* private one, and may leave a message on someone else's note. That is enough to
|
||||
* exercise placement by scope, capability possession, directed sharing, per-document
|
||||
* exercise placement by store, capability possession, directed sharing, per-document
|
||||
* inboxes and reactive reads — without inventing a product.
|
||||
*
|
||||
* ── Plain DOM, on purpose ─────────────────────────────────────────────────
|
||||
@@ -37,8 +37,9 @@ import {
|
||||
readUnion,
|
||||
storeRegistry,
|
||||
subscribeDoc,
|
||||
useShapeQuery,
|
||||
type Nuri,
|
||||
type Scope,
|
||||
type ShapeObservable,
|
||||
// Polyfill-era — ONE call, and it is the whole of what goes away.
|
||||
configure,
|
||||
} from "@ng-eventually/polyfill";
|
||||
@@ -48,6 +49,8 @@ import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||
|
||||
const TITLE = "urn:notebook:title";
|
||||
const BODY = "urn:notebook:body";
|
||||
const NOTE = "urn:notebook:Note";
|
||||
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
|
||||
interface Note {
|
||||
doc: Nuri;
|
||||
@@ -55,6 +58,44 @@ interface Note {
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which store a note goes in, as the interface offers it - and the only thing that word
|
||||
* still decides.
|
||||
*
|
||||
* A store is chosen when a note is CREATED, and it is named by its reference: the library
|
||||
* hands out one per store and `createEntityDoc` takes it, exactly where `doc_create` takes
|
||||
* its store. There is no matching question on the way back - **a read cannot be scoped by
|
||||
* store**, upstream or here, so the list below is every note this identity holds, whatever
|
||||
* shelf it was written to. That is not a shortcoming of this example; it is what the
|
||||
* target can express.
|
||||
*/
|
||||
function shelf(name: string): Promise<Nuri> {
|
||||
return name === "public"
|
||||
? storeRegistry.publicStore()
|
||||
: name === "protected"
|
||||
? storeRegistry.protectedStore()
|
||||
: storeRegistry.privateStore();
|
||||
}
|
||||
|
||||
/** The shape of a note: a subject carrying `rdf:type urn:notebook:Note`. */
|
||||
const NoteShape = {
|
||||
shape: "urn:notebook:NoteShape",
|
||||
schema: {
|
||||
"urn:notebook:NoteShape": {
|
||||
iri: "urn:notebook:NoteShape",
|
||||
predicates: [
|
||||
{
|
||||
iri: RDF_TYPE,
|
||||
readablePredicate: "type",
|
||||
minCardinality: 1,
|
||||
maxCardinality: 1,
|
||||
dataTypes: [{ literals: [NOTE], valType: "iri" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// --- bootstrap: ONE polyfill-era call --------------------------------------
|
||||
//
|
||||
// Everything else an application calls is SDK surface, preserved at migration. This one
|
||||
@@ -63,7 +104,7 @@ interface Note {
|
||||
|
||||
configure({
|
||||
ng: realNg,
|
||||
useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM
|
||||
useShape: (() => {}) as never, // `useShapeQuery` and `readUnion` do not go through the ORM hook
|
||||
init: realInit,
|
||||
sharedWallet: {
|
||||
fileUrl: "/shared-wallet.ngw",
|
||||
@@ -90,27 +131,64 @@ const sessionReady = new Promise<{ session_id: string }>((resolve) => {
|
||||
|
||||
// --- the acts ---------------------------------------------------------------
|
||||
|
||||
/** Write a new note in `scope`. The document is created, then filled. */
|
||||
async function writeNote(scope: Scope, title: string, body: string): Promise<Nuri> {
|
||||
const doc = await storeRegistry.createEntityDoc(scope);
|
||||
/** Write a new note on `shelfName`'s shelf. The document is created, then filled. */
|
||||
async function writeNote(shelfName: string, title: string, body: string): Promise<Nuri> {
|
||||
const doc = await storeRegistry.createEntityDoc(await shelf(shelfName));
|
||||
const s = await sessionReady;
|
||||
// The type triple is what makes the note findable afterwards: the reactive read asks
|
||||
// for a SHAPE, and a shape is pinned on `rdf:type`. Nothing indexes it for us.
|
||||
await docs.sparqlUpdate(
|
||||
s.session_id,
|
||||
`INSERT DATA { <${doc}> <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
|
||||
`INSERT DATA { <${doc}> <${RDF_TYPE}> <${NOTE}> ; <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
|
||||
doc,
|
||||
);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** My notes in `scope`, read the way the library intends: list, then read. */
|
||||
async function myNotes(scope: Scope): Promise<Note[]> {
|
||||
const docsOfScope = await storeRegistry.listMyEntityDocs(scope);
|
||||
const subjects = await readUnion(docsOfScope);
|
||||
return subjects.map((s) => ({
|
||||
doc: s.graph,
|
||||
title: s.props[TITLE]?.[0] ?? "(sans titre)",
|
||||
body: s.props[BODY]?.[0] ?? "",
|
||||
}));
|
||||
/**
|
||||
* My notes, read the way the library offers them: ONE reactive query over everything this
|
||||
* identity holds, shaped as a note.
|
||||
*
|
||||
* `did:ng:i` is the scope that means the whole local dataset - the caller's own documents
|
||||
* are what it can reach. The application does not list its documents and then read them:
|
||||
* there is no call to list them with, and there is no question "what is in this store" to
|
||||
* ask. It names a SHAPE, and what it holds of that shape comes back.
|
||||
*/
|
||||
let notes: ShapeObservable | null = null;
|
||||
|
||||
function noteQuery(): ShapeObservable {
|
||||
if (!notes) {
|
||||
notes = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
|
||||
// One subscription for the life of the page: a note that arrives - written here, or
|
||||
// shared by somebody else - re-renders the list with no call from this code.
|
||||
notes.subscribe(() => renderNotes());
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
/** Wait until the query has ANSWERED - pending is not an answer, and neither is a failure
|
||||
* read as an empty list. */
|
||||
function answered(q: ShapeObservable): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let off: (() => void) | null = null;
|
||||
const look = (): void => {
|
||||
if (q.getSnapshot().isPending) return;
|
||||
off?.();
|
||||
resolve();
|
||||
};
|
||||
off = q.subscribe(look);
|
||||
look();
|
||||
});
|
||||
}
|
||||
|
||||
function currentNotes(): Note[] {
|
||||
return noteQuery()
|
||||
.getSnapshot()
|
||||
.data.map((s) => ({
|
||||
doc: s.graph,
|
||||
title: s.props[TITLE]?.[0] ?? "(sans titre)",
|
||||
body: s.props[BODY]?.[0] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,9 +279,20 @@ const el = (id: string): HTMLElement => document.getElementById(id)!;
|
||||
const val = (id: string): string => (el(id) as HTMLInputElement).value.trim();
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const scope = (el("scope") as HTMLSelectElement).value as Scope;
|
||||
const notes = await myNotes(scope);
|
||||
el("notes").innerHTML = notes
|
||||
const q = noteQuery();
|
||||
q.refetch();
|
||||
await answered(q);
|
||||
renderNotes();
|
||||
// What `ensureIdentity()` returns is an OPAQUE identifier, not a display name: the SDK
|
||||
// publishes none. So it is shown verbatim and marked as an identifier — never parsed,
|
||||
// never split, never dressed up as a person's name.
|
||||
const idTag = document.createElement("code");
|
||||
idTag.textContent = identity;
|
||||
el("who").replaceChildren("id ", idTag);
|
||||
}
|
||||
|
||||
function renderNotes(): void {
|
||||
el("notes").innerHTML = currentNotes()
|
||||
.map(
|
||||
(n) => `<li data-doc="${n.doc}">
|
||||
<b class="t">${n.title}</b> — <span class="b">${n.body}</span>
|
||||
@@ -214,20 +303,15 @@ async function refresh(): Promise<void> {
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
// What `ensureIdentity()` returns is an OPAQUE identifier, not a display name: the SDK
|
||||
// publishes none. So it is shown verbatim and marked as an identifier — never parsed,
|
||||
// never split, never dressed up as a person's name.
|
||||
const idTag = document.createElement("code");
|
||||
idTag.textContent = identity;
|
||||
el("who").replaceChildren("id ", idTag);
|
||||
}
|
||||
|
||||
function wire(): void {
|
||||
// Changing the scope changes which notes are listed — without this the list keeps
|
||||
// showing the previous scope's notes, which reads as "my note disappeared".
|
||||
// The shelf chooses where the NEXT note is written, and nothing else: a read cannot be
|
||||
// scoped by store, so the list below always shows every note this identity holds.
|
||||
// Re-rendering on a change is still worth it — a note shared in the meantime shows up.
|
||||
el("scope").addEventListener("change", () => void refresh());
|
||||
el("write").addEventListener("click", async () => {
|
||||
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
|
||||
await writeNote((el("scope") as HTMLSelectElement).value, val("title"), val("body"));
|
||||
await refresh();
|
||||
});
|
||||
el("openRef").addEventListener("click", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# @ng-eventually/polyfill
|
||||
|
||||
One entry point. Most of what it publishes has the same signature as the future SDK —
|
||||
`ng`, `useShape`, `watchShape`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
|
||||
`ng`, `useShape`, `useShapeQuery`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
|
||||
and is a drop-in for `@ng-org/web` / `@ng-org/orm`: as NextGraph matures it resolves to
|
||||
the real SDK (build alias removed) with no code change.
|
||||
|
||||
@@ -34,9 +34,14 @@ import {
|
||||
|
||||
configure({ ng: realNg, useShape: realUseShape, getSession, sharedWallet });
|
||||
await ensureIdentity(); // who I am (returned), connection work awaited
|
||||
const doc = await storeRegistry.createEntityDoc("protected");
|
||||
// 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.
|
||||
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
|
||||
const subjects = await readUnion(await storeRegistry.listMyEntityDocs("protected"));
|
||||
// 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.
|
||||
const query = useShapeQuery(NoteShape, { graphs: "did:ng:i" });
|
||||
```
|
||||
|
||||
## Principle — the polyfill compensates, it never extends
|
||||
|
||||
@@ -314,7 +314,7 @@ from the reactive contract:
|
||||
`sparqlUpdate` on a doc it is itself subscribed to (`subscribeDoc` /
|
||||
`ng.doc_subscribe`), the subscription callback appears NOT to fire for its OWN
|
||||
local commit in the same session — so the reactive re-read chain
|
||||
([`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) `watchShape` → `reread` →
|
||||
([`../src/surface/use-shape-query.ts`](../src/surface/use-shape-query.ts) `useShapeQuery` → `reread` →
|
||||
[`../src/surface/read-model.ts`](../src/surface/read-model.ts) `readUnion`) never runs, and
|
||||
consumers keep the STALE value until the next connection delivers a fresh
|
||||
initial `State`. **Remote** commits DO push correctly (verified: cross-browser
|
||||
|
||||
@@ -561,8 +561,12 @@ async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise<
|
||||
// ── the acts, expressed as the application expresses them ───────────────────
|
||||
|
||||
/**
|
||||
* Show the notes of `scope` — the list is per-scope, so acting on a note means looking at
|
||||
* the right shelf first.
|
||||
* Choose a shelf, and wait for the list to settle.
|
||||
*
|
||||
* The shelf decides where the NEXT note is written and nothing else: since `1.0.0-dev.5`
|
||||
* a read cannot be scoped by store — the SDK has no way to say it and neither has this
|
||||
* package — so the application lists every note its identity holds, whatever shelf it went
|
||||
* to. Selecting one still re-renders, which is what the wait below is for.
|
||||
*
|
||||
* The wait is not decoration: the application's `change` handler runs `void refresh()`,
|
||||
* un-awaited, so reading `textContent` straight after `selectOption` reads the PREVIOUS
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
inbox,
|
||||
storeRegistry,
|
||||
useShape as libUseShape,
|
||||
watchShape,
|
||||
useShapeQuery,
|
||||
} 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.
|
||||
@@ -48,6 +48,24 @@ import { ensureIdentity } from "@ng-eventually/polyfill";
|
||||
// The harness narrows for its OWN assertions; a consumer never has to (the entries take
|
||||
// plain strings and validate inside). Internal path, like the rest of its machinery.
|
||||
import { isNuri } from "../src/model/nuri";
|
||||
|
||||
/**
|
||||
* The published reference of one of the connected identity's three stores.
|
||||
*
|
||||
* Placement stopped being spelled as a word on the published surface in `1.0.0-dev.5`:
|
||||
* an application names a store by what `privateStore()`, `protectedStore()` or
|
||||
* `publicStore()` hands it. The journeys below still walk the three, so this is the step
|
||||
* from one to the other - through the published calls.
|
||||
*/
|
||||
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
|
||||
const EVERYTHING = { graphs: "did:ng:i" };
|
||||
|
||||
const storeOfType = (t: "public" | "protected" | "private"): Promise<Nuri> =>
|
||||
t === "public"
|
||||
? storeRegistry.publicStore()
|
||||
: t === "protected"
|
||||
? storeRegistry.protectedStore()
|
||||
: storeRegistry.privateStore();
|
||||
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/polyfill";
|
||||
|
||||
const { IdentityStore } = virtualUsers;
|
||||
@@ -575,16 +593,16 @@ const identity = new IdentityStore(
|
||||
// and the caps of what you create are filed under the identity you were acting
|
||||
// as. Creating B's document while connected as A is not a thing the model has.
|
||||
setCurrentUser(idA);
|
||||
const dA1 = await storeRegistry.createEntityDoc("public");
|
||||
const dA2 = await storeRegistry.createEntityDoc("public");
|
||||
const dA1 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
const dA2 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
setCurrentUser(idB);
|
||||
const dB1 = await storeRegistry.createEntityDoc("public");
|
||||
const dB1 = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
|
||||
setCurrentUser(idA);
|
||||
let listA: string[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
registryInternals.resetRegistryCache();
|
||||
listA = await storeRegistry.listMyEntityDocs("public");
|
||||
listA = await registryInternals.listMyEntityDocs(idA, "public");
|
||||
if (listA.includes(dA1) && listA.includes(dA2)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
@@ -616,7 +634,7 @@ const identity = new IdentityStore(
|
||||
// document is filed under nobody and the very session that created it is
|
||||
// refused the write below.
|
||||
setCurrentUser(id);
|
||||
const entityNuri = await storeRegistry.createEntityDoc(scope);
|
||||
const entityNuri = await storeRegistry.createEntityDoc(await storeOfType(scope));
|
||||
const marker = "recon-" + Date.now();
|
||||
await docs.sparqlUpdate(
|
||||
s.session_id,
|
||||
@@ -629,7 +647,7 @@ const identity = new IdentityStore(
|
||||
let listed: string[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
registryInternals.resetRegistryCache();
|
||||
listed = await storeRegistry.listMyEntityDocs(scope);
|
||||
listed = await registryInternals.listMyEntityDocs(id, scope);
|
||||
if (listed.includes(entityNuri)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
@@ -655,7 +673,7 @@ const identity = new IdentityStore(
|
||||
await connectedUser();
|
||||
|
||||
registryInternals.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs(scope);
|
||||
const listed = await registryInternals.listMyEntityDocs(id, scope);
|
||||
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
|
||||
// many rows the bare anchored query resolves for a not-yet-opened repo (the
|
||||
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
|
||||
@@ -703,10 +721,14 @@ const identity = new IdentityStore(
|
||||
const rec = await registryInternals.ensureAccount(id);
|
||||
return { docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||
},
|
||||
// `resolveScopeGraph` stopped being published in `1.0.0-dev.5` — a store's graph holds
|
||||
// its containment triples and nothing else, so the value it handed out matched nothing
|
||||
// as a read scope upstream. The probe keeps measuring the mapping, at its internal path:
|
||||
// a harness may do that, an application may not.
|
||||
async scopeResolvers() {
|
||||
const priv = await storeRegistry.resolveScopeGraph("private");
|
||||
const prot = await storeRegistry.resolveScopeGraph("protected");
|
||||
const pub = await storeRegistry.resolveScopeGraph("public");
|
||||
const priv = await registryInternals.resolveScopeGraph("private");
|
||||
const prot = await registryInternals.resolveScopeGraph("protected");
|
||||
const pub = await registryInternals.resolveScopeGraph("public");
|
||||
return { priv, prot, pub };
|
||||
},
|
||||
|
||||
@@ -771,8 +793,8 @@ const identity = new IdentityStore(
|
||||
}
|
||||
},
|
||||
|
||||
// ── watchShape (reactive useQuery-shaped read) ───────────────────────────
|
||||
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape
|
||||
// ── 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.
|
||||
_wsProbes: {} as Record<
|
||||
@@ -781,17 +803,17 @@ const identity = new IdentityStore(
|
||||
>,
|
||||
/**
|
||||
* REAL-BROKER cycle proof. Under a fresh identity, create ONE protected entity
|
||||
* document carrying an rdf:type=<e2e class> triple, then open a `watchShape` over
|
||||
* (that class shape, "protected"). Capture the FIRST snapshot right after subscribe
|
||||
* document carrying an rdf:type=<e2e class> triple, then open a `useShapeQuery` over
|
||||
* (that class shape, the whole dataset). Capture the FIRST snapshot right after subscribe
|
||||
* (must be isPending) so the caller can then wait event-driven for isSuccess with
|
||||
* the seeded datum present. Returns the handle + the initial snapshot + the seed
|
||||
* doc/type so the runner can assert the data landed.
|
||||
*/
|
||||
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
|
||||
async useShapeQuerySeedAndSubscribe(handle: string, cls: string) {
|
||||
registryInternals.resetRegistryCache();
|
||||
const id = "@ws-" + handle;
|
||||
setCurrentUser(id);
|
||||
const doc = await storeRegistry.createEntityDoc("protected");
|
||||
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
const s = await sessionReady;
|
||||
// Seed the entity doc with the shape's type + a title (anchored default graph).
|
||||
await docs.sparqlUpdate(
|
||||
@@ -802,7 +824,7 @@ const identity = new IdentityStore(
|
||||
// Wait until this session sees the index append (data persisted on the broker).
|
||||
for (let i = 0; i < 15; i++) {
|
||||
registryInternals.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs("protected");
|
||||
const listed = await registryInternals.listMyEntityDocs(id, "protected");
|
||||
if (listed.includes(doc)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
@@ -823,19 +845,19 @@ const identity = new IdentityStore(
|
||||
},
|
||||
},
|
||||
};
|
||||
const obs = watchShape(shape, "protected") as ShapeObservable;
|
||||
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
|
||||
const unsub = obs.subscribe(() => {});
|
||||
// First snapshot immediately after subscribe — the barrier is not yet crossed
|
||||
// (docs opening via doc_subscribe), so this MUST be pending.
|
||||
const initial = obs.getSnapshot();
|
||||
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||
// NB: leave the current user SET for the probe's lifetime — watchShape resolves
|
||||
// NB: leave the current user SET for the probe's lifetime — useShapeQuery resolves
|
||||
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
|
||||
// keeps a stable identity. watchShapeStop clears it.
|
||||
// keeps a stable identity. useShapeQueryStop clears it.
|
||||
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
|
||||
},
|
||||
/** Current snapshot of a watchShape probe (event-driven poll target). */
|
||||
watchShapeSnapshot(handle: string) {
|
||||
/** Current snapshot of a useShapeQuery probe (event-driven poll target). */
|
||||
useShapeQuerySnapshot(handle: string) {
|
||||
const rec = (window as any).__sdk._wsProbes[handle];
|
||||
if (!rec) return null;
|
||||
const snap: ShapeQuery = rec.obs.getSnapshot();
|
||||
@@ -848,17 +870,17 @@ const identity = new IdentityStore(
|
||||
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
|
||||
};
|
||||
},
|
||||
watchShapeStop(handle: string) {
|
||||
useShapeQueryStop(handle: string) {
|
||||
const rec = (window as any).__sdk._wsProbes[handle];
|
||||
if (rec) rec.unsub();
|
||||
setCurrentUser(null);
|
||||
},
|
||||
/**
|
||||
* EMPTY-scope proof: a brand-new identity with NO entity docs of `scope`. watchShape
|
||||
* EMPTY-scope proof: a brand-new identity with NO entity docs at all. `useShapeQuery`
|
||||
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
|
||||
* the handle; poll watchShapeSnapshot for the transition.
|
||||
* the handle; poll useShapeQuerySnapshot for the transition.
|
||||
*/
|
||||
watchShapeEmptyStart(handle: string, cls: string) {
|
||||
useShapeQueryEmptyStart(handle: string, cls: string) {
|
||||
registryInternals.resetRegistryCache();
|
||||
const id = "@ws-empty-" + handle;
|
||||
setCurrentUser(id);
|
||||
@@ -879,11 +901,11 @@ const identity = new IdentityStore(
|
||||
},
|
||||
},
|
||||
};
|
||||
const obs = watchShape(shape, "protected") as ShapeObservable;
|
||||
const obs = useShapeQuery(shape, EVERYTHING) as ShapeObservable;
|
||||
const unsub = obs.subscribe(() => {});
|
||||
const initial = obs.getSnapshot();
|
||||
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||
// Keep the identity set for the probe's lifetime (watchShapeStop clears it): a
|
||||
// Keep the identity set for the probe's lifetime (useShapeQueryStop clears it): a
|
||||
// faithful "empty scope for a real identity", not "no identity at all".
|
||||
return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } };
|
||||
},
|
||||
|
||||
@@ -386,45 +386,45 @@ async function main(): Promise<void> {
|
||||
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
|
||||
});
|
||||
|
||||
// ── watchShape (reactive useQuery-shaped read) ──────────────────────────
|
||||
// ── 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 useShape's upgrade will make native, surfaced here from getSyncState).
|
||||
console.log("\n── watchShape (reactive useQuery-shaped read) ──");
|
||||
await step("watchShape: first subscribe isPending → isSuccess with data present", async () => {
|
||||
// distinction the announced `useShapeQuery` makes native, surfaced here from getSyncState).
|
||||
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<any>(frame, "watchShapeSeedAndSubscribe", h, CLS);
|
||||
const seed = await sdk<any>(frame, "useShapeQuerySeedAndSubscribe", h, CLS);
|
||||
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
|
||||
// Event-driven: wait for the barrier to cross + the datum to land.
|
||||
await frame.waitForFunction(
|
||||
(hh) => {
|
||||
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
||||
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
|
||||
return s && s.isSuccess && s.dataLen >= 1;
|
||||
},
|
||||
h,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
||||
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h);
|
||||
check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`);
|
||||
await sdk(frame, "watchShapeStop", h);
|
||||
await sdk(frame, "useShapeQueryStop", h);
|
||||
});
|
||||
await step("watchShape: empty scope reaches isSuccess with data:[]", async () => {
|
||||
await step("useShapeQuery: empty scope reaches isSuccess with data:[]", async () => {
|
||||
const h = "empty" + Date.now();
|
||||
const CLS = "urn:e2e:ws:Event";
|
||||
await sdk<any>(frame, "watchShapeEmptyStart", h, CLS);
|
||||
await sdk<any>(frame, "useShapeQueryEmptyStart", h, CLS);
|
||||
await frame.waitForFunction(
|
||||
(hh) => {
|
||||
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
||||
const s = (window as any).__sdk.useShapeQuerySnapshot(hh as string);
|
||||
return s && s.isSuccess;
|
||||
},
|
||||
h,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
||||
const snap = await sdkGet<any>(frame, "useShapeQuerySnapshot", h);
|
||||
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
|
||||
await sdk(frame, "watchShapeStop", h);
|
||||
await sdk(frame, "useShapeQueryStop", h);
|
||||
});
|
||||
|
||||
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ng-eventually/polyfill",
|
||||
"version": "1.0.0-dev.4",
|
||||
"version": "1.0.0-dev.5",
|
||||
"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",
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
recordInbox,
|
||||
type VirtualUserRecord,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, Scope } from "../model/types";
|
||||
import type { InboxScope, Nuri, NuriLike, PrincipalId, ReadCap, StoreType } from "../model/types";
|
||||
|
||||
/**
|
||||
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
|
||||
@@ -102,7 +102,7 @@ export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
|
||||
* the consumer spells the identity.
|
||||
*/
|
||||
// @provenance holdOwnCap kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the cap of a document you created, filed on the store's Store branch. WIRED upstream — `send_add_repo_to_store` constructs it
|
||||
export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void {
|
||||
export function holdOwnCap(id: string, scope: StoreType, doc: Nuri, cap: ReadCap): void {
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null || accountKey(holder) !== accountKey(id)) return;
|
||||
const caps = getCaps();
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
*/
|
||||
|
||||
import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri";
|
||||
import type { Nuri, PrincipalId, ReadCap, Scope } from "../model/types";
|
||||
import type { Nuri, PrincipalId, ReadCap, StoreType } from "../model/types";
|
||||
|
||||
/**
|
||||
* The stand-in cap value, and the minting point — moved here from `model/nuri.ts`
|
||||
@@ -330,7 +330,7 @@ export class CapRegistry {
|
||||
* Deliberately does NOT touch write caps: those are decorative until cap-enforcement, and
|
||||
* arming their guard here would be enforcement this batch does not do.
|
||||
*/
|
||||
open(nuri: Nuri, scope: Scope): ReadCap {
|
||||
open(nuri: Nuri, scope: StoreType): ReadCap {
|
||||
// `file`, NOT `mint` — and the difference is a hole that was open for one commit.
|
||||
//
|
||||
// Every caller of this method files a STRUCTURAL document: one of the holder's three
|
||||
|
||||
@@ -81,14 +81,22 @@
|
||||
// Each one, and the signature that earns it its place:
|
||||
// Nuri every reference the surface RETURNS
|
||||
// NuriLike every reference the surface ACCEPTS
|
||||
// Scope `storeRegistry.*`, `watchShape`
|
||||
// PrincipalId `ensureIdentity`, `inbox.Deposit`/`PostOptions`, `EventuallyConfig`
|
||||
// NgLike `EventuallyConfig.ng`
|
||||
// UseShapeLike `EventuallyConfig.useShape`
|
||||
export type { Nuri, NuriLike, Scope, PrincipalId, NgLike, UseShapeLike } from "./model/types";
|
||||
//
|
||||
// `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"` -
|
||||
// our own three strings under a name the SDK also uses, for a different thing: upstream
|
||||
// a `Scope` is `{ graphs, subjects }` and scopes a READ, while those three strings are
|
||||
// `doc_create`'s `store_type` and place a WRITE. One name over two questions. The read
|
||||
// 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 { useShape } from "./surface/use-shape";
|
||||
export { watchShape } from "./surface/watch-shape";
|
||||
export type { ShapeQuery, ShapeObservable } from "./surface/watch-shape";
|
||||
export { useShapeQuery } from "./surface/use-shape-query";
|
||||
export type { ShapeQuery, ShapeObservable } from "./surface/use-shape-query";
|
||||
export { init, initNg } from "./surface/lifecycle";
|
||||
export * as inbox from "./surface/inbox";
|
||||
export * as docs from "./surface/docs";
|
||||
@@ -110,6 +118,11 @@ export * as storeRegistry from "./surface/placement";
|
||||
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
|
||||
// @provenance DeepSignalSet kind=passthrough level=3 ref=sdk/js/alien-deepsignals/src/types.ts:DeepSignalSet
|
||||
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
|
||||
// The scope `useShapeQuery` takes, re-exported from the SDK rather than restated here:
|
||||
// restating it is how a type drifts, and the two axes it filters on (`graphs`,
|
||||
// `subjects`) are upstream's to change.
|
||||
// @provenance Scope kind=passthrough level=3 ref=sdk/js/orm/src/types.ts:Scope
|
||||
export type { Scope } from "@ng-org/orm";
|
||||
// @provenance NG kind=passthrough level=2 ref=sdk/js/web/src/index.ts:NG — 88 members, all forwarded; the proxy adds none and removes none
|
||||
export type { NG } from "@ng-org/web";
|
||||
|
||||
|
||||
@@ -35,10 +35,23 @@ export type Nuri = `did:ng:${string}`;
|
||||
// @provenance ReadCap kind=aligned level=1 ref=engine/repo/src/types.rs:readcap_nuri — the `r:` segment and its one-opaque-segment encoding are upstream's; the key material is a stand-in constant
|
||||
export type ReadCap = `did:ng:${string}:r:${string}`;
|
||||
|
||||
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
|
||||
* consumer's concern; this layer only knows the three scopes exist. */
|
||||
// @provenance Scope kind=aligned level=1 ref=engine/repo/src/types.rs:from_type_and_repo — upstream's store types; `group` is omitted here and `dialog` is `unimplemented!()` upstream
|
||||
export type Scope = "public" | "protected" | "private";
|
||||
/**
|
||||
* Which of a session's three stores a document is placed in. **Defined here, not
|
||||
* published** - the standing `ReadCap` and {@link InboxScope} already have, and for
|
||||
* the same reason: no published signature takes it. It was published as `Scope` until
|
||||
* `1.0.0-dev.5`, where one name covered the scope of a READ and the placement of a
|
||||
* write. The SDK's own `Scope` is an object filtering on `graphs` and `subjects`, and
|
||||
* the two were never the same question.
|
||||
*
|
||||
* The three strings are upstream's own: `doc_create` takes a `store_type` of exactly
|
||||
* `"public" | "protected" | "private"` (plus `"group"`, omitted here, and `"dialog"`,
|
||||
* which is `unimplemented!()` upstream) and pairs it with a store id to build a
|
||||
* `StoreRepo`. An application names a store by its REFERENCE
|
||||
* (`storeRegistry.privateStore()` and its two neighbours); this type is how the
|
||||
* library's own modules tell the three apart once one has been resolved.
|
||||
*/
|
||||
// @provenance StoreType kind=aligned level=1 ref=engine/repo/src/types.rs:from_type_and_repo — upstream's store types, the strings `doc_create`'s `store_type` accepts; `group` is omitted here and `dialog` is `unimplemented!()` upstream
|
||||
export type StoreType = "public" | "protected" | "private";
|
||||
|
||||
/** The current identity id. Target: the wallet user (`session.user`). Polyfill:
|
||||
* a chosen id, because everyone shares one wallet. */
|
||||
@@ -60,7 +73,7 @@ export type NgLike = Record<string, any>;
|
||||
export type UseShapeLike = (...args: any[]) => any;
|
||||
|
||||
/**
|
||||
* The scopes that can carry an inbox. NOT `Scope`: upstream only the public and
|
||||
* The stores that can carry an inbox. NOT `StoreType`: upstream only the public and
|
||||
* protected store repos get one — `new_store_default` attaches an inbox solely
|
||||
* `if !private` (`engine/verifier/src/verifier.rs:2994`), and the engine's only two
|
||||
* `AddInboxCap` commits are for those two (`engine/verifier/src/site.rs:127-152`).
|
||||
@@ -86,4 +99,4 @@ export type UseShapeLike = (...args: any[]) => any;
|
||||
export type NuriLike = Nuri | string;
|
||||
|
||||
// @provenance InboxScope kind=aligned level=1 ref=engine/verifier/src/verifier.rs:new_store_default — an inbox is attached only `if !private`, so the private scope cannot carry one
|
||||
export type InboxScope = Extract<Scope, "public" | "protected">;
|
||||
export type InboxScope = Extract<StoreType, "public" | "protected">;
|
||||
|
||||
@@ -85,7 +85,7 @@ import { hasReadCap, isNuri } from "../model/nuri";
|
||||
import { mintCap } from "../emulated-verifier/caps";
|
||||
import { mustNotAttempt } from "../emulated-verifier/reach";
|
||||
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
|
||||
import type { InboxScope, Nuri, Scope } from "../model/types";
|
||||
import type { InboxScope, Nuri, StoreType } from "../model/types";
|
||||
|
||||
// --- sharedWalletShim model ----------------------------------------------
|
||||
|
||||
@@ -918,7 +918,7 @@ export async function isKnownInbox(nuri: Nuri): Promise<boolean> {
|
||||
}
|
||||
|
||||
/** The index document NURI of an account for a scope (the store-container). */
|
||||
export function storeOf(record: VirtualUserRecord, scope: Scope): Nuri {
|
||||
export function storeOf(record: VirtualUserRecord, scope: StoreType): Nuri {
|
||||
return scope === "public"
|
||||
? record.docPublic
|
||||
: scope === "protected"
|
||||
@@ -931,7 +931,7 @@ export function storeOf(record: VirtualUserRecord, scope: Scope): Nuri {
|
||||
* per-scope index document, for entities that need no per-entity document / no
|
||||
* inbox). For per-entity scopes use {@link createEntityDoc} instead.
|
||||
*/
|
||||
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
|
||||
export async function resolveWriteGraph(id: string, scope: StoreType): Promise<Nuri> {
|
||||
const record = await ensureAccount(id);
|
||||
return storeOf(record, scope);
|
||||
}
|
||||
@@ -952,7 +952,7 @@ export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>
|
||||
|
||||
/** The native store NURI backing `scope`, resolved from the injected session.
|
||||
* Requires `protectedStoreId` on the session for the non-private scopes. */
|
||||
async function scopeStoreNuri(scope: Scope): Promise<Nuri> {
|
||||
async function scopeStoreNuri(scope: StoreType): Promise<Nuri> {
|
||||
const s = await session();
|
||||
if (scope === "private") return `did:ng:${s.privateStoreId}`;
|
||||
// public + protected → the protected native store (see note above). Falls
|
||||
@@ -969,7 +969,7 @@ async function scopeStoreNuri(scope: Scope): Promise<Nuri> {
|
||||
* (`useShape(shape, nuri)`) and the `@graph` write target.
|
||||
*/
|
||||
// @provenance storeRegistry.resolveScopeGraph kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — `did:ng:` + `private_store_id | protected_store_id | public_store_id`, straight off the session
|
||||
export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
|
||||
export async function resolveScopeGraph(scope: StoreType): Promise<Nuri> {
|
||||
return scopeStoreNuri(scope);
|
||||
}
|
||||
|
||||
@@ -1037,7 +1037,7 @@ const inboxCache = new Map<string, Nuri>();
|
||||
* says anything about inboxes — `@ng-org/web` has no method containing "inbox" and the
|
||||
* session exposes none — so the engine's model is what decides, and it says two.
|
||||
*
|
||||
* **The private store has none**, hence {@link InboxScope} rather than `Scope`: asking
|
||||
* **The private store has none**, hence {@link InboxScope} rather than `StoreType`: asking
|
||||
* for a private inbox is not a lookup that returns nothing, it is a question the model
|
||||
* has no meaning for.
|
||||
*
|
||||
@@ -1140,7 +1140,7 @@ export async function userInbox(id: string, scope: InboxScope): Promise<Nuri> {
|
||||
* it does not carry its key. The key goes to what the creator holds (see
|
||||
* {@link holdOwnCap}), which is where you look it up.
|
||||
*/
|
||||
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
export async function createEntityDoc(id: string, scope: StoreType): Promise<Nuri> {
|
||||
const record = await ensureAccount(id);
|
||||
const indexDoc = storeOf(record, scope);
|
||||
const entityNuri = await createDoc();
|
||||
@@ -1273,7 +1273,7 @@ export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
|
||||
* 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: Scope): Promise<Nuri> {
|
||||
export async function userStoreDoc(id: string, scope: StoreType): Promise<Nuri> {
|
||||
const record = await ensureAccount(id);
|
||||
return storeOf(record, scope);
|
||||
}
|
||||
@@ -1300,7 +1300,7 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
* Nothing to list is NOT a failure and stays silent: a store that answers with zero
|
||||
* documents means this account created none, which is a fact.
|
||||
*/
|
||||
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
|
||||
export async function listMyEntityDocs(id: string, scope: StoreType): Promise<Nuri[]> {
|
||||
const record = await ensureAccount(id);
|
||||
const store = storeOf(record, scope);
|
||||
const docs = await readUserStore(store);
|
||||
|
||||
@@ -41,11 +41,12 @@
|
||||
|
||||
import {
|
||||
createEntityDoc as registryCreateEntityDoc,
|
||||
listMyEntityDocs as registryListMyEntityDocs,
|
||||
resolveWriteGraph as registryResolveWriteGraph,
|
||||
ensureAccount,
|
||||
storeOf,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import { getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import type { Nuri, Scope } from "../model/types";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import type { Nuri, NuriLike, StoreType } from "../model/types";
|
||||
|
||||
/** WHO is acting. Absent means the application has not signed in yet — a caller error,
|
||||
* and one worth naming rather than turning into an empty result. */
|
||||
@@ -60,26 +61,92 @@ function connectedIdentity(op: string): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
|
||||
// @provenance storeRegistry.createEntityDoc kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_create — upstream `doc_create` performs the `ldp:contains` listing and the `AddRepo { read_cap }` natively; here they are two explicit writes
|
||||
export async function createEntityDoc(scope: Scope): Promise<Nuri> {
|
||||
return registryCreateEntityDoc(connectedIdentity("createEntityDoc"), scope);
|
||||
/*
|
||||
* How an application names a STORE, and why by reference rather than by a word.
|
||||
*
|
||||
* Until `1.0.0-dev.5` every call here took `Scope` - the three strings `"public" |
|
||||
* "protected" | "private"` - and that type also named the scope of a READ. The two are
|
||||
* not one question: the SDK's `Scope` is `{ graphs, subjects }` and filters documents
|
||||
* and subjects, while a store is chosen when a document is CREATED. Upstream the two
|
||||
* gestures are separate too, and the creation one takes a store the caller already
|
||||
* holds: `doc_create(session_id, crdt, class_name, destination, store_repo)`, with the
|
||||
* private store when `store_repo` is left undefined, and the three store ids handed to
|
||||
* the application on the session itself (`Session.private_store_id`,
|
||||
* `protected_store_id`, `public_store_id`).
|
||||
*
|
||||
* So the three accessors below answer what the session answers upstream - one reference
|
||||
* per store - and `createEntityDoc` takes one of them, exactly where `doc_create` takes
|
||||
* its store. Nothing published spells a store as a word any more, and nothing has to be
|
||||
* unlearned: at migration these three read the session's own fields and the reference
|
||||
* becomes the real store's.
|
||||
*/
|
||||
|
||||
/** Resolve one of the connected identity's three stores to its reference. */
|
||||
async function storeReference(op: string, type: StoreType): Promise<Nuri> {
|
||||
return storeOf(await ensureAccount(connectedIdentity(op)), type);
|
||||
}
|
||||
|
||||
/** The entity documents this user owns in `scope` — with their caps recovered. */
|
||||
// @provenance storeRegistry.listMyEntityDocs kind=aligned level=1 ref=engine/verifier/src/request_processor.rs:doc_create — reads back the `ldp:contains` listing that `doc_create` writes on the store's Main branch
|
||||
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]> {
|
||||
return registryListMyEntityDocs(connectedIdentity("listMyEntityDocs"), scope);
|
||||
/** The connected identity's PRIVATE store — where a document goes when none is named. */
|
||||
// @provenance storeRegistry.privateStore kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — upstream the session carries `private_store_id`; here the per-store index document stands in for the store the session names directly
|
||||
export async function privateStore(): Promise<Nuri> {
|
||||
return storeReference("privateStore", "private");
|
||||
}
|
||||
|
||||
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
|
||||
// @provenance storeRegistry.resolveWriteGraph kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — a scope resolves to the session's own store id; upstream the store IS the container and the per-scope index document disappears
|
||||
export async function resolveWriteGraph(scope: Scope): Promise<Nuri> {
|
||||
return registryResolveWriteGraph(connectedIdentity("resolveWriteGraph"), scope);
|
||||
/** The connected identity's PROTECTED store. */
|
||||
// @provenance storeRegistry.protectedStore kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — upstream the session carries `protected_store_id`; here the per-store index document stands in for the store the session names directly
|
||||
export async function protectedStore(): Promise<Nuri> {
|
||||
return storeReference("protectedStore", "protected");
|
||||
}
|
||||
|
||||
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
|
||||
export { resolveScopeGraph } from "../shared-wallet/account-registry";
|
||||
/** The connected identity's PUBLIC store — a document placed here serves its read key
|
||||
* to whoever asks for it. */
|
||||
// @provenance storeRegistry.publicStore kind=aligned level=2 ref=sdk/js/web/src/index.ts:Session — upstream the session carries `public_store_id`; here the per-store index document stands in for the store the session names directly
|
||||
export async function publicStore(): Promise<Nuri> {
|
||||
return storeReference("publicStore", "public");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a document for ONE entity in `store`, and record it there.
|
||||
*
|
||||
* `store` is one of {@link privateStore}, {@link protectedStore}, {@link publicStore};
|
||||
* omitting it places the document in the private store, which is what `doc_create` does
|
||||
* when its `store_repo` is left undefined.
|
||||
*/
|
||||
// @provenance storeRegistry.createEntityDoc kind=aligned level=2 ref=sdk/js/lib-wasm/src/lib.rs:doc_create — the same trailing store slot, private store when omitted; upstream `doc_create` performs the `ldp:contains` listing and the `AddRepo { read_cap }` natively, here they are two explicit writes
|
||||
export async function createEntityDoc(store?: NuriLike): Promise<Nuri> {
|
||||
const id = connectedIdentity("createEntityDoc");
|
||||
const record = await ensureAccount(id);
|
||||
if (store === undefined) return registryCreateEntityDoc(id, "private");
|
||||
const named = toNuri(store, "storeRegistry.createEntityDoc");
|
||||
for (const type of ["private", "protected", "public"] as const) {
|
||||
if (storeOf(record, type) === named) return registryCreateEntityDoc(id, type);
|
||||
}
|
||||
// A reference that is not one of this identity's three stores. Upstream a store you
|
||||
// are not a member of refuses the creation; here it would silently land in whichever
|
||||
// store the fallback picked, which is the "recommendation enforced by the code"
|
||||
// failure the doctrine names. Say it instead.
|
||||
throw new Error(
|
||||
`[ng-eventually] storeRegistry.createEntityDoc: ${named} is not one of this ` +
|
||||
"identity's stores. Pass `privateStore()`, `protectedStore()` or `publicStore()`, " +
|
||||
"or nothing at all for the private store.",
|
||||
);
|
||||
}
|
||||
|
||||
// `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` STOPPED being published
|
||||
// in `1.0.0-dev.5`, and each for its own reason.
|
||||
//
|
||||
// - `listMyEntityDocs` has no counterpart at any level: there is no `doc_list`, no
|
||||
// `list_docs`, nothing in the binding. Listing what one created is an ordinary query
|
||||
// over the store's `ldp:contains` — the same shape `readUnion` is, sugar over
|
||||
// something already expressible. It stays reachable at `../shared-wallet/account-registry`
|
||||
// for this library's own read path, which still calls it until that path queries the
|
||||
// store itself.
|
||||
// - `resolveScopeGraph` handed out a value whose stated use was a READ scope, and a
|
||||
// store's graph holds its containment triples and nothing else: pointed at a shape
|
||||
// query upstream it matches nothing and says so with an empty set, silently. See
|
||||
// `.project/concepts/app-contract/knowledge_what-a-read-scope-is-upstream.md`.
|
||||
// - `resolveWriteGraph` returned the very reference the three accessors above return.
|
||||
// One value, one published name.
|
||||
|
||||
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
|
||||
|
||||
|
||||
+125
-46
@@ -1,36 +1,54 @@
|
||||
/**
|
||||
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
|
||||
* one logical scope. This is the surface the consuming app will bind (phase B) with
|
||||
* 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
|
||||
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
|
||||
*
|
||||
* ── Why an observable, and why this exact shape ────────────────────────────
|
||||
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
|
||||
* natively distinguish "sync in progress" from "synced but empty". That distinction
|
||||
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
|
||||
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
|
||||
* ── 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
|
||||
* `.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<T> = { 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
|
||||
* 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.
|
||||
*
|
||||
* 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 the observable OWNS (the whole read pipeline) ─────────────────────
|
||||
* 1. Resolve the logical scope → the doc set: the CURRENT wallet's own per-entity
|
||||
* documents for that scope (`storeRegistry.listMyEntityDocs`), 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
|
||||
* (`readUnion`), not by turning up in a scope you never put it in.
|
||||
* 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 `readUnion` has rendered.
|
||||
* 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`
|
||||
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
|
||||
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
|
||||
* ShapeType, not from any application concept.
|
||||
* 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`) ──────────────────────────
|
||||
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
|
||||
@@ -38,12 +56,12 @@
|
||||
* 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. The set is DYNAMIC (creating an
|
||||
* entity appends a NURI to the scope-index doc), so we ALSO subscribe to the
|
||||
* scope-index document: a push there re-RESOLVES the scope and re-keys the
|
||||
* subscribed set. Subscriptions are idempotent — an already-followed
|
||||
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
|
||||
* parallel channel.
|
||||
* in the current set re-runs `readUnion` on any push. Under the whole-dataset reading the
|
||||
* set is DYNAMIC (creating an entity appends a NURI to its store's index doc), so we ALSO
|
||||
* subscribe to those index documents: a push there re-RESOLVES and re-keys the subscribed
|
||||
* set. A named `graphs` list has no container to follow — the caller fixed the set.
|
||||
* Subscriptions are idempotent — an already-followed doc is not re-subscribed. Everything
|
||||
* reuses `subscribe.ts` / `open-repo.ts`; no parallel channel.
|
||||
*
|
||||
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
|
||||
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
|
||||
@@ -51,14 +69,14 @@
|
||||
* `isError` fires ONLY on a real thrown exception in the pipeline.
|
||||
*
|
||||
* ── A scope that did not answer is NOT an empty scope ──────────────────────
|
||||
* The pipeline's first step ASKS a question — which documents are mine in this scope
|
||||
* — 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.
|
||||
* 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`,
|
||||
@@ -80,7 +98,36 @@ 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 type { Nuri, Scope } from "../model/types";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import type { Nuri, StoreType } from "../model/types";
|
||||
// The SDK's own scope type, imported as a TYPE (erased at build, so no runtime `@ng-org`
|
||||
// import is added here). Its provenance is recorded where it is PUBLISHED, in `index.ts`.
|
||||
import type { Scope } from "@ng-org/orm";
|
||||
|
||||
/** 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
|
||||
@@ -90,7 +137,7 @@ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
|
||||
/**
|
||||
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
|
||||
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
|
||||
* (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.
|
||||
*/
|
||||
@@ -170,6 +217,16 @@ function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): bool
|
||||
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
|
||||
@@ -189,18 +246,26 @@ function barrierReached(docs: Nuri[]): boolean {
|
||||
|
||||
/**
|
||||
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
||||
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
|
||||
* the full pipeline. The returned observable is inert until its first
|
||||
* `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.
|
||||
*/
|
||||
// @provenance watchShape kind=invention level=none ref=none — nothing upstream distinguishes syncing from synced-empty at the hook, and nothing anywhere expresses the third state it now publishes: a failed `orm_start_graph` leaves `readyPromise` unsettled forever, so upstream's "could not find out" IS its "still pending"; the header's 'planned useShape upgrade' has NO upstream provenance
|
||||
export function watchShape<T = UnionSubject>(
|
||||
// @provenance useShapeQuery kind=announced-not-written level=3 ref=.project/rules/nextgraph/surface-mirrors-the-sdk.md:useShapeQuery announced-by=nextgraph-developers@2026-09-16 — the SDK ships `useShape` returning a bare `DeepSignalSet`; the announced direction is a `useShapeQuery` returning a TanStack-shaped snapshot
|
||||
export function useShapeQuery<T = UnionSubject>(
|
||||
shapeType: unknown,
|
||||
scope: Scope,
|
||||
): ShapeObservable<T> {
|
||||
const typeIris = shapeTypeIris(shapeType);
|
||||
const asked = normalizeScope(scope);
|
||||
// `did:ng:i` (or `""`) discards every other entry upstream and means the whole local
|
||||
// dataset. TEMPORARY: until the read queries the store itself, that is served
|
||||
// by enumerating this identity's own documents across its three stores - the widest
|
||||
// set this pipeline can reach. Every other `graphs` list names documents and is used
|
||||
// as given, which is what the axis will mean once the enumeration is gone.
|
||||
const wholeDataset = asked.graphs.includes(ENTIRE_DATASET);
|
||||
|
||||
// The current, STABLE snapshot (same reference until a transition rebuilds it).
|
||||
let snapshot: ShapeQuery<UnionSubject> = {
|
||||
@@ -234,7 +299,7 @@ export function watchShape<T = UnionSubject>(
|
||||
try {
|
||||
l();
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] listener threw", error);
|
||||
console.error("[use-shape-query] listener threw", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,12 +348,20 @@ export function watchShape<T = UnionSubject>(
|
||||
* 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<Nuri[]> {
|
||||
if (!wholeDataset) {
|
||||
// The caller named the documents. `[]` or an absent `graphs` is upstream's "no
|
||||
// objects are returned", and it reaches that answer here the same way: an empty
|
||||
// doc set, a barrier trivially past it, and `isSuccess` over nothing.
|
||||
return [...new Set(asked.graphs.filter(Boolean))].map((g) => toNuri(g, "useShapeQuery"));
|
||||
}
|
||||
const user = getCurrentUser();
|
||||
const set = new Set<Nuri>();
|
||||
resolving = true;
|
||||
try {
|
||||
if (user) {
|
||||
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
|
||||
for (const type of STORE_TYPES) {
|
||||
for (const d of await listMyEntityDocs(user, type)) set.add(d);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resolving = false;
|
||||
@@ -307,13 +380,19 @@ export function watchShape<T = UnionSubject>(
|
||||
* reactivity to a later change of the SET, which is a different (and much quieter)
|
||||
* problem than the one this module was fixed for. */
|
||||
async function ensureContainerSubs(): Promise<void> {
|
||||
// A named `graphs` list IS the set; nothing a store records can change it, so there
|
||||
// is no container to follow. Only the whole-dataset reading has one - three, in fact,
|
||||
// one per store this identity places documents in.
|
||||
if (!wholeDataset) return;
|
||||
const containers: Nuri[] = [];
|
||||
const user = getCurrentUser();
|
||||
if (user) {
|
||||
try {
|
||||
containers.push(await userStoreDoc(user, scope));
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] userStoreDoc failed", error);
|
||||
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) {
|
||||
@@ -355,7 +434,7 @@ export function watchShape<T = UnionSubject>(
|
||||
return;
|
||||
}
|
||||
if (token !== refreshToken) return; // superseded by a newer refresh/reread
|
||||
const data = subjects.filter((s) => matchesShape(s, typeIris));
|
||||
const data = subjects.filter((s) => matchesShape(s, typeIris) && matchesSubjects(s, asked.subjects));
|
||||
const past = barrierReached(docs);
|
||||
setSnapshot({
|
||||
data,
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
resetStoreRegistry,
|
||||
setCurrentUser,
|
||||
} from "../src/shared-wallet/bootstrap";
|
||||
import { createEntityDoc as registryCreateEntityDoc, resetRegistryCache } from "../src/shared-wallet/account-registry";
|
||||
import {
|
||||
createEntityDoc as registryCreateEntityDoc,
|
||||
listMyEntityDocs as registryListMyEntityDocs,
|
||||
resetRegistryCache,
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
import { share } from "../src/surface/inbox";
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
@@ -126,18 +130,30 @@ test("ensureIdentity returns the identity it settled", async () => {
|
||||
test("a placement call before signing in names the mistake", async () => {
|
||||
inject();
|
||||
setCurrentUser(null);
|
||||
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/ensureIdentity/i);
|
||||
await expect(storeRegistry.listMyEntityDocs("protected")).rejects.toThrow(/no identity/i);
|
||||
await expect(storeRegistry.createEntityDoc()).rejects.toThrow(/ensureIdentity/i);
|
||||
await expect(storeRegistry.protectedStore()).rejects.toThrow(/no identity/i);
|
||||
});
|
||||
|
||||
test("placement acts as the connected user, with nothing passed", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await storeRegistry.createEntityDoc("protected");
|
||||
expect(await storeRegistry.listMyEntityDocs("protected")).toContain(doc);
|
||||
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
expect(await registryListMyEntityDocs("alice", "protected")).toContain(doc);
|
||||
|
||||
setCurrentUser("bob");
|
||||
expect(await storeRegistry.listMyEntityDocs("protected")).not.toContain(doc);
|
||||
expect(await registryListMyEntityDocs("bob", "protected")).not.toContain(doc);
|
||||
});
|
||||
|
||||
// A store reference that is not one of the connected identity's three is a caller error,
|
||||
// and silence here would be the worst answer available: the document would be created,
|
||||
// successfully, somewhere its author did not ask for — and only a later listing that comes
|
||||
// back short would say so.
|
||||
test("createEntityDoc refuses a store that is not one of this identity's", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const hers = await storeRegistry.protectedStore();
|
||||
setCurrentUser("bob");
|
||||
await expect(storeRegistry.createEntityDoc(hers)).rejects.toThrow(/not one of this identity/i);
|
||||
});
|
||||
|
||||
// ── sharing names someone who exists ───────────────────────────────────────
|
||||
@@ -145,7 +161,7 @@ test("placement acts as the connected user, with nothing passed", async () => {
|
||||
test("share refuses a recipient nobody has signed in as, instead of creating them", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await storeRegistry.createEntityDoc("protected");
|
||||
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
// "bpb" is a typo for "bob". It used to mint that name's three stores and an inbox,
|
||||
// and the cap landed where nobody will ever look — with no error at all.
|
||||
await expect(share(doc, "bpb")).rejects.toThrow(/no such recipient/i);
|
||||
@@ -157,7 +173,7 @@ test("share refuses a recipient nobody has signed in as, instead of creating the
|
||||
test("a failed lookup surfaces as a failure, not as 'no such recipient'", async () => {
|
||||
const { quads } = inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await storeRegistry.createEntityDoc("protected");
|
||||
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
setCurrentUser("bob");
|
||||
await registryCreateEntityDoc("bob", "private"); // bob genuinely exists
|
||||
setCurrentUser("alice");
|
||||
@@ -178,7 +194,7 @@ test("a failed lookup surfaces as a failure, not as 'no such recipient'", async
|
||||
test("createEntityDoc reports a half-written document instead of returning its reference", async () => {
|
||||
inject(/shim:contains/); // the LISTING write fails
|
||||
setCurrentUser("alice");
|
||||
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/not listed in its store/i);
|
||||
await expect(storeRegistry.createEntityDoc(await storeRegistry.protectedStore())).rejects.toThrow(/not listed in its store/i);
|
||||
});
|
||||
|
||||
// Both writes are attempted before the failure is raised — the cap must land even when
|
||||
@@ -187,14 +203,14 @@ test("createEntityDoc reports a half-written document instead of returning its r
|
||||
test("a failed listing does not cost the document its key", async () => {
|
||||
const { quads } = inject(/shim:contains/);
|
||||
setCurrentUser("alice");
|
||||
await storeRegistry.createEntityDoc("protected").catch(() => {});
|
||||
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore()).catch(() => {});
|
||||
expect(quads.some((q) => q.p === `${SHIM}:readCap`)).toBe(true);
|
||||
});
|
||||
|
||||
test("a failed key write is reported too, and names that half", async () => {
|
||||
inject(/shim:readCap/);
|
||||
setCurrentUser("alice");
|
||||
await expect(storeRegistry.createEntityDoc("protected")).rejects.toThrow(/key is not recorded/i);
|
||||
await expect(storeRegistry.createEntityDoc(await storeRegistry.protectedStore())).rejects.toThrow(/key is not recorded/i);
|
||||
});
|
||||
|
||||
// The third write a PUBLIC creation makes, and the one that was still silent: exposing the
|
||||
@@ -206,6 +222,6 @@ test("a failed key write is reported too, and names that half", async () => {
|
||||
test("a public document whose cap could not be exposed is reported, not returned", async () => {
|
||||
inject(/shim:exposedReadCap/);
|
||||
setCurrentUser("alice");
|
||||
await expect(storeRegistry.createEntityDoc("public")).rejects.toThrow(/broker refused/i);
|
||||
await expect(storeRegistry.createEntityDoc(await storeRegistry.publicStore())).rejects.toThrow(/broker refused/i);
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import { test, expect, describe, afterAll, beforeEach } from "bun:test";
|
||||
import { docs, storeRegistry } from "../src/index";
|
||||
import { adoptCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
import { getSyncState } from "../src/emulated-verifier/open-repo";
|
||||
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
|
||||
import type { Nuri, PrincipalId } from "../src/model/types";
|
||||
|
||||
@@ -59,7 +60,7 @@ function settledButNotConnected(id: PrincipalId): void {
|
||||
async function aNoteOpenedForMessages(quads: Quad[]): Promise<Nuri> {
|
||||
bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc("public");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await docs.sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
|
||||
@@ -92,9 +93,14 @@ function herPrivateStore(quads: Quad[]): Nuri {
|
||||
return record.o as Nuri;
|
||||
}
|
||||
|
||||
/** Alice's note, found the way her application finds it: by listing her own store. */
|
||||
/** Alice's note, found by listing her own store.
|
||||
*
|
||||
* That listing is the LIBRARY's own path now, not an application's: `listMyEntityDocs`
|
||||
* stopped being published in `1.0.0-dev.5` (no counterpart at any level upstream), so
|
||||
* this reaches it where it lives. What is under test is unchanged - the cold store still
|
||||
* has to answer, and it still has to refile the caps while it does. */
|
||||
async function myNote(): Promise<Nuri> {
|
||||
const mine = await storeRegistry.listMyEntityDocs("public");
|
||||
const mine = await listMyEntityDocs("alice", "public");
|
||||
const note = mine[0];
|
||||
if (!note) throw new Error("the note Alice wrote is not in her store");
|
||||
return note;
|
||||
@@ -154,7 +160,7 @@ describe("opening my note for messages again, on a page that has just loaded", (
|
||||
const quads: Quad[] = [];
|
||||
bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
const bare = await storeRegistry.createEntityDoc("public");
|
||||
const bare = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await docs.sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <${bare}> <${TITLE}> "Vierge" }`,
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
|
||||
import { docs, inbox as inboxSurface, storeRegistry } from "../src/index";
|
||||
import { getSyncState } from "../src/emulated-verifier/open-repo";
|
||||
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
@@ -47,7 +48,7 @@ const COLD = { unsyncedUntilSubscribed: true } as const;
|
||||
async function aNoteWithAMessageOnIt(quads: Quad[]): Promise<Nuri> {
|
||||
bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc("public");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await docs.sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
|
||||
@@ -75,9 +76,14 @@ function inboxOnTheNote(quads: Quad[], note: Nuri): Nuri {
|
||||
return record.o.split(" ")[1] as Nuri;
|
||||
}
|
||||
|
||||
/** Alice's note, found the way her application finds it: by listing her own store. */
|
||||
/** Alice's note, found by listing her own store.
|
||||
*
|
||||
* That listing is the LIBRARY's own path now, not an application's: `listMyEntityDocs`
|
||||
* stopped being published in `1.0.0-dev.5` (no counterpart at any level upstream), so
|
||||
* this reaches it where it lives. What is under test is unchanged - the cold store still
|
||||
* has to answer, and it still has to refile the caps while it does. */
|
||||
async function myNote(): Promise<Nuri> {
|
||||
const mine = await storeRegistry.listMyEntityDocs("public");
|
||||
const mine = await listMyEntityDocs("alice", "public");
|
||||
const note = mine[0];
|
||||
if (!note) throw new Error("the note Alice wrote is not in her store");
|
||||
return note;
|
||||
@@ -141,7 +147,7 @@ describe("reading the messages left on my note, on a page that has just loaded",
|
||||
const quads: Quad[] = [];
|
||||
bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
const bare = await storeRegistry.createEntityDoc("public");
|
||||
const bare = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
|
||||
reloadPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
|
||||
@@ -151,9 +151,9 @@ async function whileWatchingTheLog(body: () => Promise<void>): Promise<string[]>
|
||||
*/
|
||||
async function bobSharesWithAlice(): Promise<{ doc: Nuri; inTransit: Quad[] }> {
|
||||
await signIn("alice");
|
||||
await storeRegistry.createEntityDoc("protected"); // her first visit — now she exists
|
||||
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore()); // her first visit — now she exists
|
||||
await signIn("bob");
|
||||
const doc = await storeRegistry.createEntityDoc("protected");
|
||||
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
await inboxSurface.share(doc, "alice");
|
||||
// Test-side inspection only — the address is read off the wallet to say WHICH document
|
||||
// is in transit, and is never handed to an actor.
|
||||
@@ -217,7 +217,7 @@ describe("an inbox opened in the middle of a session", () => {
|
||||
await signIn("alice");
|
||||
// The note and its inbox come into existence AFTER connecting, so nothing the
|
||||
// connection enumerated could have included them.
|
||||
const note = await storeRegistry.createEntityDoc("public");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await storeRegistry.openDocumentInbox(note);
|
||||
await converge();
|
||||
|
||||
@@ -237,7 +237,7 @@ describe("an inbox opened in the middle of a session", () => {
|
||||
|
||||
test("the messages left on it are readable, on the document its owner named", async () => {
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc("public");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await storeRegistry.openDocumentInbox(note);
|
||||
await converge();
|
||||
|
||||
@@ -411,7 +411,7 @@ describe("a deposit that cannot be applied", () => {
|
||||
// subscribes again as if it had never been attempted, and the initial push of that new
|
||||
// subscription finds the deposit still waiting.
|
||||
refusing = false;
|
||||
await storeRegistry.createEntityDoc("protected");
|
||||
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
await converge();
|
||||
expect(getCaps().capForHolder("alice", doc)).toBeDefined();
|
||||
expect(await documentsGivenTo("alice")).toContain(doc);
|
||||
@@ -513,7 +513,7 @@ describe("listing the inboxes when one of the two registers cannot be read", ()
|
||||
await signIn("alice");
|
||||
// A document inbox: a record on the User branch of the private store, which is the
|
||||
// register the broker is about to stop answering for.
|
||||
const note = await storeRegistry.createEntityDoc("public");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await storeRegistry.openDocumentInbox(note);
|
||||
await converge();
|
||||
const store = (await resolveAccount("alice"))?.docPrivate;
|
||||
@@ -574,7 +574,7 @@ describe("listing the inboxes when one of the two registers cannot be read", ()
|
||||
|
||||
test("still REJECTS for the caller that cannot use a partial list", async () => {
|
||||
await signIn("alice");
|
||||
await storeRegistry.openDocumentInbox(await storeRegistry.createEntityDoc("public"));
|
||||
await storeRegistry.openDocumentInbox(await storeRegistry.createEntityDoc(await storeRegistry.publicStore()));
|
||||
await converge();
|
||||
const store = (await resolveAccount("alice"))?.docPrivate;
|
||||
if (store === undefined) throw new Error("the fixture did not give Alice a private store");
|
||||
@@ -597,12 +597,12 @@ describe("listing the inboxes when one of the two registers cannot be read", ()
|
||||
*/
|
||||
async function bobSharesTwiceWithAlice(): Promise<Quad[][]> {
|
||||
await signIn("alice");
|
||||
await storeRegistry.createEntityDoc("protected"); // her first visit — now she exists
|
||||
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore()); // her first visit — now she exists
|
||||
await signIn("bob");
|
||||
const aliceInbox = await userInbox("alice", "protected");
|
||||
const batches: Quad[][] = [];
|
||||
for (let round = 0; round < 2; round += 1) {
|
||||
const doc = await storeRegistry.createEntityDoc("protected");
|
||||
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
await inboxSurface.share(doc, "alice");
|
||||
batches.push(heldByTheBroker(aliceInbox));
|
||||
}
|
||||
@@ -680,7 +680,7 @@ describe("a register that stays unreadable", () => {
|
||||
// the observation look again (`CapRegistry.onChange`).
|
||||
fake._failReadsOn.delete(shim);
|
||||
fake._failReadsOn.add(store);
|
||||
await storeRegistry.createEntityDoc("protected");
|
||||
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
await converge();
|
||||
});
|
||||
fake._failReadsOn.delete(store);
|
||||
|
||||
@@ -525,7 +525,7 @@ test("nothing about the deferred service reaches the published surface", () => {
|
||||
"subscribeDoc",
|
||||
"subscribeDocs",
|
||||
"useShape",
|
||||
"watchShape",
|
||||
"useShapeQuery",
|
||||
]);
|
||||
expect(Object.keys(polyfill.inbox).sort()).toEqual([
|
||||
"post",
|
||||
@@ -547,11 +547,14 @@ test("nothing about the deferred service reaches the published surface", () => {
|
||||
"sparqlQuery",
|
||||
"sparqlUpdate",
|
||||
]);
|
||||
// `1.0.0-dev.5`: `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` left,
|
||||
// and the three store accessors arrived in their place — an application names a store by
|
||||
// the reference one of them hands it, never by a word this package made up.
|
||||
expect(Object.keys(polyfill.storeRegistry).sort()).toEqual([
|
||||
"createEntityDoc",
|
||||
"listMyEntityDocs",
|
||||
"openDocumentInbox",
|
||||
"resolveScopeGraph",
|
||||
"resolveWriteGraph",
|
||||
"privateStore",
|
||||
"protectedStore",
|
||||
"publicStore",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -87,13 +87,13 @@ async function reportedDuring(body: () => Promise<void>): Promise<string[]> {
|
||||
*/
|
||||
async function aliceHasBeenHere(): Promise<void> {
|
||||
await signIn("alice");
|
||||
await storeRegistry.createEntityDoc("protected");
|
||||
await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
}
|
||||
|
||||
/** Bob shares a document of his with Alice, by name. Answers the document. */
|
||||
async function bobSharesWithAlice(): Promise<Nuri> {
|
||||
await signIn("bob");
|
||||
const doc = await storeRegistry.createEntityDoc("protected");
|
||||
const doc = await storeRegistry.createEntityDoc(await storeRegistry.protectedStore());
|
||||
await inboxSurface.share(doc, "alice");
|
||||
return doc;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ describe("a deposit no arm can apply", () => {
|
||||
await signIn("alice");
|
||||
// PUBLIC, so Bob can read it — the address rides on the document, so a depositor who
|
||||
// cannot read it cannot reach its owner. That is the model, not a fixture detail.
|
||||
const note = await storeRegistry.createEntityDoc("public");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await openDocumentInbox(note);
|
||||
await signIn("bob");
|
||||
await inboxSurface.postToDocument(note, { payload: { kind: UNARMED_KIND } });
|
||||
@@ -364,9 +364,9 @@ test("an identity that moves DURING the pop does not get the deposit applied for
|
||||
// Alice opens an index and Bob deposits a bare reference into it. That arm is the one
|
||||
// that reads AMBIENT state, so it is the one an identity switch can mis-file.
|
||||
await signIn("alice");
|
||||
const index = await storeRegistry.createEntityDoc("public");
|
||||
const index = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await openDocumentInbox(index);
|
||||
const object = await storeRegistry.createEntityDoc("public");
|
||||
const object = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await signIn("bob");
|
||||
await inboxSurface.postToDocument(index, { payload: object });
|
||||
|
||||
|
||||
@@ -32,8 +32,9 @@ import { test, expect, describe, mock, beforeEach } from "bun:test";
|
||||
import { configure, storeRegistry, readUnion } from "../src/index";
|
||||
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
|
||||
import { SESSION, forgetEverything, makeWallet, signIn, type Quad } from "./wallet-fake";
|
||||
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
|
||||
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||
import { SESSION, forgetEverything, makeWallet, signIn, storeOfType, type Quad } from "./wallet-fake";
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
/** The Main-branch read — "which documents are in this store". */
|
||||
@@ -74,12 +75,12 @@ function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
|
||||
* Returns the reference she keeps, and the quads the wallet now holds — the durable half
|
||||
* that outlives the page.
|
||||
*/
|
||||
async function aFirstSessionThatCreated(scope: Scope): Promise<{ quads: Quad[]; note: Nuri }> {
|
||||
async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> {
|
||||
const quads: Quad[] = [];
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(scope);
|
||||
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
|
||||
await sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <urn:test:note> <${TITLE}> "the note" }`,
|
||||
@@ -101,7 +102,7 @@ async function aSecondSessionLosingTheBrokerOn(quads: Quad[], read: string): Pro
|
||||
armed = true;
|
||||
}
|
||||
|
||||
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
|
||||
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
|
||||
|
||||
beforeEach(() => {
|
||||
forgetEverything();
|
||||
@@ -119,7 +120,7 @@ describe("listMyEntityDocs answers a verified listing or surfaces", () => {
|
||||
bootPage(quads, () => false);
|
||||
await signIn("alice");
|
||||
|
||||
expect(await storeRegistry.listMyEntityDocs(scope)).toContain(note);
|
||||
expect(await listMyEntityDocs("alice", scope)).toContain(note);
|
||||
const subjects = await readUnion([note]);
|
||||
expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
|
||||
});
|
||||
@@ -132,10 +133,10 @@ describe("listMyEntityDocs answers a verified listing or surfaces", () => {
|
||||
const { quads, note } = await aFirstSessionThatCreated(scope);
|
||||
await aSecondSessionLosingTheBrokerOn(quads, KEY_READ);
|
||||
|
||||
await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
|
||||
await expect(listMyEntityDocs("alice", scope)).rejects.toThrow(/BrokerError/);
|
||||
// And the point of the refusal, stated as the caller sees it: nothing came back that
|
||||
// could be mistaken for "here are your documents".
|
||||
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
|
||||
const answered = await listMyEntityDocs("alice", scope).catch(() => null);
|
||||
expect(answered).toBeNull();
|
||||
expect(answered).not.toEqual([note]);
|
||||
});
|
||||
@@ -149,8 +150,8 @@ describe("listMyEntityDocs answers a verified listing or surfaces", () => {
|
||||
const { quads } = await aFirstSessionThatCreated(scope);
|
||||
await aSecondSessionLosingTheBrokerOn(quads, LISTING_READ);
|
||||
|
||||
await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
|
||||
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
|
||||
await expect(listMyEntityDocs("alice", scope)).rejects.toThrow(/BrokerError/);
|
||||
const answered = await listMyEntityDocs("alice", scope).catch(() => null);
|
||||
expect(answered).toBeNull();
|
||||
expect(answered).not.toEqual([]);
|
||||
});
|
||||
|
||||
@@ -331,10 +331,10 @@ test("every announced-not-written annotation cites a record that is really here"
|
||||
});
|
||||
|
||||
test("announced-not-written accepts a statement on record and refuses every weaker form", () => {
|
||||
// The kind exists for a target ANNOUNCED and not yet written, so its first real
|
||||
// annotation cannot be in the tree to exercise it. This is that annotation written out
|
||||
// — the form `useShapeQuery` will carry — checked against the document that holds the
|
||||
// statement today.
|
||||
// The kind exists for a target ANNOUNCED and not yet written. The form below is the one
|
||||
// `useShapeQuery` carries in `surface/use-shape-query.ts` since `1.0.0-dev.5`, restated
|
||||
// here so the REFUSALS beneath it have something well-formed to depart from — and
|
||||
// checked against the document that holds the statement.
|
||||
const onRecord: Provenance = {
|
||||
name: "useShapeQuery",
|
||||
kind: ANNOUNCED,
|
||||
@@ -357,7 +357,7 @@ test("announced-not-written accepts a statement on record and refuses every weak
|
||||
// `ref=none` is what `invention` carries, and this kind is the opposite of invention.
|
||||
refused({ ref: "none" }, "must cite the record of the statement");
|
||||
// Our own source standing as the evidence for its own shape.
|
||||
refused({ ref: "packages/polyfill/src/surface/watch-shape.ts:useShapeQuery" }, "never a source file");
|
||||
refused({ ref: "packages/polyfill/src/surface/use-shape-query.ts:useShapeQuery" }, "never a source file");
|
||||
// An announcement names the layer that will carry the thing.
|
||||
refused({ level: "none" }, "level must be 1, 2 or 3");
|
||||
// And the evidence cannot be lifted onto a weaker claim to dress it up.
|
||||
|
||||
@@ -33,6 +33,7 @@ import { test, expect, describe, afterAll, beforeEach } from "bun:test";
|
||||
import { docs, inbox as inboxSurface, storeRegistry } from "../src/index";
|
||||
import { getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import { runScheduledInboxProcessingNow } from "../src/emulated-verifier/inbox-processor";
|
||||
import { listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
@@ -49,7 +50,7 @@ const MESSAGE = "j'apporte le café";
|
||||
async function aNoteOpenForMessages(quads: Quad[]): Promise<Nuri> {
|
||||
bootPage(quads);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc("public");
|
||||
const note = await storeRegistry.createEntityDoc(await storeRegistry.publicStore());
|
||||
await docs.sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
|
||||
@@ -74,9 +75,14 @@ function inboxOnTheNote(quads: Quad[], note: Nuri): Nuri {
|
||||
return record.o.split(" ")[1] as Nuri;
|
||||
}
|
||||
|
||||
/** Alice's note, found the way her application finds it: by listing her own store. */
|
||||
/** Alice's note, found by listing her own store.
|
||||
*
|
||||
* That listing is the LIBRARY's own path now, not an application's: `listMyEntityDocs`
|
||||
* stopped being published in `1.0.0-dev.5` (no counterpart at any level upstream), so
|
||||
* this reaches it where it lives. What is under test is unchanged - the cold store still
|
||||
* has to answer, and it still has to refile the caps while it does. */
|
||||
async function myNote(): Promise<Nuri> {
|
||||
const mine = await storeRegistry.listMyEntityDocs("public");
|
||||
const mine = await listMyEntityDocs("alice", "public");
|
||||
const note = mine[0];
|
||||
if (!note) throw new Error("the note Alice wrote is not in her store");
|
||||
return note;
|
||||
|
||||
@@ -40,12 +40,13 @@ import {
|
||||
resetStoreRegistry,
|
||||
getCaps,
|
||||
} from "../src/shared-wallet/bootstrap";
|
||||
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
|
||||
import { listMyEntityDocs, resetRegistryCache } from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
|
||||
import { connectedUser } from "../src/emulated-verifier/connect";
|
||||
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
|
||||
import { storeOfType } from "./wallet-fake";
|
||||
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-reload", privateStoreId: "PRIV-RELOAD" };
|
||||
|
||||
@@ -55,7 +56,7 @@ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
/** The application's own predicate — the note's title, in the document it created. */
|
||||
const TITLE = "urn:test:title";
|
||||
|
||||
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
|
||||
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
|
||||
|
||||
// --- the fake broker -------------------------------------------------------
|
||||
|
||||
@@ -303,10 +304,10 @@ async function signIn(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
/** The first visit: sign in, create the note, write its title into it. */
|
||||
async function firstVisit(quads: Quad[], scope: Scope): Promise<Nuri> {
|
||||
async function firstVisit(quads: Quad[], scope: StoreType): Promise<Nuri> {
|
||||
boot(quads);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(scope);
|
||||
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
|
||||
await docs.sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <${note}> <${TITLE}> "the note" }`,
|
||||
@@ -349,7 +350,7 @@ describe("after a reload, the creator comes back to its own document", () => {
|
||||
reload(quads);
|
||||
await signIn("alice");
|
||||
|
||||
expect(await storeRegistry.listMyEntityDocs(scope)).toContain(note);
|
||||
expect(await listMyEntityDocs("alice", scope)).toContain(note);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+20
-16
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* `watchShape` 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 `watchShape` 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 in this scope; the failure
|
||||
* 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
|
||||
@@ -32,12 +32,12 @@
|
||||
*/
|
||||
|
||||
import { test, expect, describe, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { configure, watchShape, storeRegistry } from "../src/index";
|
||||
import type { ShapeObservable, ShapeQuery } from "../src/surface/watch-shape";
|
||||
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, Scope, UseShapeLike } from "../src/model/types";
|
||||
import { SESSION, forgetEverything, makeWallet, signIn, type Quad } from "./wallet-fake";
|
||||
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
|
||||
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. */
|
||||
@@ -104,12 +104,12 @@ function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
|
||||
* broker that works. Returns the quads the wallet now holds — the durable half that
|
||||
* outlives the page.
|
||||
*/
|
||||
async function aFirstSessionThatCreated(scope: Scope): Promise<{ quads: Quad[]; note: Nuri }> {
|
||||
async function aFirstSessionThatCreated(scope: StoreType): Promise<{ quads: Quad[]; note: Nuri }> {
|
||||
const quads: Quad[] = [];
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(scope);
|
||||
const note = await storeRegistry.createEntityDoc(await storeOfType(scope));
|
||||
await sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <urn:test:evt> <${RDF_TYPE}> <${EVENT}> ; <${TITLE}> "the note" }`,
|
||||
@@ -160,7 +160,11 @@ async function subscribeAndSettle(
|
||||
return { renders: () => renders, stop };
|
||||
}
|
||||
|
||||
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
|
||||
const ALL_SCOPES: StoreType[] = ["public", "protected", "private"];
|
||||
|
||||
/** The whole local dataset - upstream's own marker, and the reading that still has to
|
||||
* enumerate this identity's documents, which is the step these tests make fail. */
|
||||
const EVERYTHING = { graphs: "did:ng:i" };
|
||||
|
||||
beforeEach(() => {
|
||||
forgetEverything();
|
||||
@@ -171,11 +175,11 @@ afterAll(() => {
|
||||
// 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
|
||||
// ran, and neither names watch-shape anywhere. `forgetEverything` is the whole teardown.
|
||||
// ran, and neither names use-shape-query anywhere. `forgetEverything` is the whole teardown.
|
||||
forgetEverything();
|
||||
});
|
||||
|
||||
describe("watchShape tells 'there is nothing' from 'I could not find out'", () => {
|
||||
describe("useShapeQuery tells 'there is nothing' from 'I could not find out'", () => {
|
||||
// The NORMAL cases first, and they are not a formality: a surface that reported `isError`
|
||||
// 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
|
||||
@@ -188,7 +192,7 @@ describe("watchShape tells 'there is nothing' from 'I could not find out'", () =
|
||||
bootPage(quads, () => false);
|
||||
await signIn("alice");
|
||||
|
||||
const obs = watchShape(EventShape, scope) as ShapeObservable;
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
const { stop } = await subscribeAndSettle(obs);
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
@@ -208,7 +212,7 @@ describe("watchShape tells 'there is nothing' from 'I could not find out'", () =
|
||||
bootPage(quads, () => false);
|
||||
await signIn("alice");
|
||||
|
||||
const obs = watchShape(EventShape, scope) as ShapeObservable;
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
const { stop } = await subscribeAndSettle(obs);
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
@@ -232,7 +236,7 @@ describe("watchShape tells 'there is nothing' from 'I could not find out'", () =
|
||||
await signIn("alice");
|
||||
armed = true;
|
||||
|
||||
const obs = watchShape(EventShape, scope) as ShapeObservable;
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
const { stop } = await subscribeAndSettle(obs);
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
@@ -263,7 +267,7 @@ describe("watchShape tells 'there is nothing' from 'I could not find out'", () =
|
||||
bootPage(quads, (query) => armed && query.includes(LISTING_READ));
|
||||
await signIn("alice");
|
||||
|
||||
const obs = watchShape(EventShape, scope) as ShapeObservable;
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING) as ShapeObservable;
|
||||
const { renders, stop } = await subscribeAndSettle(obs);
|
||||
expect(whatTheAppSees(obs.getSnapshot())).toBe("some");
|
||||
const rendersBefore = renders();
|
||||
+124
-9
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* watch-shape.test.ts — behavioural tests for `watchShape` (src/watch-shape.ts),
|
||||
* 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`.
|
||||
*
|
||||
* The fake emulates just enough of the broker:
|
||||
@@ -20,11 +21,13 @@
|
||||
* (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.
|
||||
* (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.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
||||
import { watchShape } from "../src/surface/watch-shape";
|
||||
import { useShapeQuery } from "../src/surface/use-shape-query";
|
||||
import { configure } from "../src/index";
|
||||
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
||||
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
@@ -32,6 +35,8 @@ import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/accoun
|
||||
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/emulated-verifier/open-repo";
|
||||
|
||||
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||
/** The whole local dataset - upstream's own marker, what `""` normalises to. */
|
||||
const EVERYTHING = { graphs: "did:ng:i" };
|
||||
const FP = "http://festipod.org/";
|
||||
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
|
||||
|
||||
@@ -230,6 +235,18 @@ function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string
|
||||
ng._quads.push({ g: doc, s: doc, p, o });
|
||||
}
|
||||
|
||||
// The same, under a subject of its own rather than the document's reference: one document
|
||||
// may hold several subjects, which is what the scope's `subjects` axis filters on.
|
||||
function seedSubject(
|
||||
ng: ReturnType<typeof makeFake>,
|
||||
doc: string,
|
||||
subject: string,
|
||||
p: string,
|
||||
o: string,
|
||||
): void {
|
||||
ng._quads.push({ g: doc, s: subject, p, o });
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||
|
||||
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
|
||||
@@ -256,7 +273,7 @@ afterAll(() => {
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
describe("watchShape", () => {
|
||||
describe("useShapeQuery", () => {
|
||||
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
|
||||
const ng = makeFake({ holdState: true });
|
||||
inject(ng);
|
||||
@@ -266,7 +283,7 @@ describe("watchShape", () => {
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Alpha");
|
||||
|
||||
const obs = watchShape(EventShape, "protected");
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
let notes = 0;
|
||||
const unsub = obs.subscribe(() => {
|
||||
notes += 1;
|
||||
@@ -300,7 +317,7 @@ describe("watchShape", () => {
|
||||
setCurrentUser("bob");
|
||||
// bob has NO entity docs in this scope — the scope is genuinely empty.
|
||||
|
||||
const obs = watchShape(EventShape, "protected");
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
@@ -321,7 +338,7 @@ describe("watchShape", () => {
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "One");
|
||||
|
||||
const obs = watchShape(EventShape, "protected");
|
||||
const obs = useShapeQuery(EventShape, EVERYTHING);
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
@@ -360,14 +377,17 @@ describe("watchShape", () => {
|
||||
seed(ng, doc, TYPE, `${FP}Event`);
|
||||
seed(ng, doc, `${FP}title`, "Timed");
|
||||
|
||||
const obs = watchShape(EventShape, "protected");
|
||||
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.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
// 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();
|
||||
|
||||
@@ -382,4 +402,99 @@ describe("watchShape", () => {
|
||||
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.
|
||||
|
||||
it("(e) a named `graphs` list reads those documents and no others", 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");
|
||||
|
||||
// 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",
|
||||
]);
|
||||
unsubAll();
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: [kept] });
|
||||
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"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(f) 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");
|
||||
|
||||
// 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.
|
||||
const obs = useShapeQuery(EventShape, {});
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
const snap = obs.getSnapshot();
|
||||
expect(snap.isPending).toBe(false);
|
||||
expect(snap.isSuccess).toBe(true);
|
||||
expect(snap.isError).toBe(false);
|
||||
expect(snap.data).toEqual([]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("(g) `subjects` narrows to the subject IRIs named, literally", async () => {
|
||||
const ng = makeFake();
|
||||
inject(ng);
|
||||
setCurrentUser("gina");
|
||||
const doc = await createEntityDoc("gina", "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");
|
||||
|
||||
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",
|
||||
]);
|
||||
unsubBoth();
|
||||
|
||||
const obs = useShapeQuery(EventShape, { graphs: "did:ng:i", subjects: [`${FP}two`] });
|
||||
const unsub = obs.subscribe(() => {});
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
expect(obs.getSnapshot().data.flatMap((s) => s.props[`${FP}title`] ?? [])).toEqual(["Second"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { mock } from "bun:test";
|
||||
import { configure } from "../src/index";
|
||||
import { configure, storeRegistry } from "../src/index";
|
||||
import {
|
||||
configureStoreRegistry,
|
||||
setCurrentUser,
|
||||
@@ -32,7 +32,7 @@ import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
|
||||
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
|
||||
import { connectedUser } from "../src/emulated-verifier/connect";
|
||||
import type { NgLike, UseShapeLike } from "../src/model/types";
|
||||
import type { NgLike, Nuri, StoreType, UseShapeLike } from "../src/model/types";
|
||||
|
||||
export const SESSION: RegistrySession = { sessionId: "sid-wallet", privateStoreId: "PRIV-WALLET" };
|
||||
|
||||
@@ -438,3 +438,20 @@ export async function signIn(id: string): Promise<void> {
|
||||
setCurrentUser(id);
|
||||
await connectedUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* The published reference of one of the connected identity's three stores.
|
||||
*
|
||||
* Placement stopped being spelled as a word on the published surface in `1.0.0-dev.5`:
|
||||
* an application names a store by the reference `privateStore()`, `protectedStore()` or
|
||||
* `publicStore()` hands it, and passes that to `createEntityDoc`. A suite that walks the
|
||||
* three still needs to go from one to the other, and this is that step - through the
|
||||
* published calls, never around them.
|
||||
*/
|
||||
export function storeOfType(type: StoreType): Promise<Nuri> {
|
||||
return type === "public"
|
||||
? storeRegistry.publicStore()
|
||||
: type === "protected"
|
||||
? storeRegistry.protectedStore()
|
||||
: storeRegistry.privateStore();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user