feat!: useShapeQuery prend le Scope du SDK, et un store se nomme par sa reference

This commit is contained in:
Sylvain Duchesne
2026-09-16 16:47:09 +02:00
parent ee9319f89b
commit d0ca5c5d41
31 changed files with 812 additions and 291 deletions
@@ -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.