fix: ng-helpers rejoint la surface dev.6, et son faux suit le contrat

This commit is contained in:
Sylvain Duchesne
2026-09-21 12:16:42 +02:00
parent e566c5b0b3
commit 71667ca14e
11 changed files with 482 additions and 90 deletions
@@ -2,7 +2,7 @@
type: contract
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
pulled_from: git@gitea.reconnexion.apps.gueraud.net:Reconnexion/ng-eventually.git/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md
pulled_version: d44063961682f84d4b804e5b349cd0a791dd9550
pulled_version: 77e78ec81de81959e90e40da8d302e274b025c51
pulled_at: 2026-09-21
---
@@ -19,6 +19,7 @@ This package covers placement (creating an application's documents in one of its
An application using this package must:
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
- pass `configure` the SDK's shape query (`ormSubscription: RdfOrmSubscription.getOrCreate`, from `@ng-org/orm`) and its `initNg`, and call this package's `initNg(ng, session)` once a session has arrived, if it uses `useShapeQuery`. That read goes through the SDK now, and the SDK is injected here rather than imported; without the injection the read publishes `isError` naming the field;
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first;
- **declare this package exactly once**, so that everything in the application resolves to one copy of it — see the single-copy rule under `## Non-guarantees`, which is the one packaging mistake this surface cannot protect you from.
@@ -34,6 +35,9 @@ export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web
useShape: UseShapeLike; // `useShape` from @ng-org/orm
// `RdfOrmSubscription.getOrCreate` from @ng-org/orm - the SDK's shape query, which
// `useShapeQuery` reads through. Pass it as it stands; no wrapper, no cast.
ormSubscription?: OrmSubscriptionLike;
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
@@ -64,15 +68,19 @@ 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, 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> {
// TWO positional arguments — the same pair `useShape` takes, both of them the SDK's own
// types. `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in, and
// its items are the SHAPE's objects: `@id`, `@graph` and the shape's readable predicates.
export function useShapeQuery<T extends BaseType = BaseType>(
shapeType: ShapeType<T>,
scope: Scope,
): ShapeObservable<T>;
export interface ShapeObservable<T extends BaseType = BaseType> {
getSnapshot(): ShapeQuery<T>; // stable reference until the state changes
subscribe(onChange: () => void): () => void; // returns its own unsubscribe
refetch(): void; // re-resolve and re-read now; never polls
refetch(): void; // ask again now; never polls
}
export interface ShapeQuery<T = UnionSubject> {
export interface ShapeQuery<T extends BaseType = BaseType> {
data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown;
}
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
@@ -160,9 +168,11 @@ Where a call must first find out whether something already exists — a document
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
**A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
**A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds nothing of that shape — the distinction the surface exists for, and the one thing it will never do is publish that snapshot for a read that did not happen. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
**A read is scoped by document and by subject, never by store.** `Scope` has two axes and there is no third: `graphs` names one document's graph per entry — `did:ng:i` or `""` for the whole local dataset, `[]` or `undefined` for no objects at all — and `subjects` filters subject IRIs literally, with no prefix or hierarchy matching. There is no call that lists the documents in a store, and naming a store in `graphs` is not the question you meant: a store's graph holds its containment triples and nothing of the shape you asked for, so it answers empty rather than raising. That is the target's own shape, not a narrowing this package chose.
**Which failure lands in `isError` changed in `1.0.0-dev.6`, and one of them now stays PENDING.** The read goes through the SDK's own shape query, so what `isError` carries is the query failing to open: a shape the ORM cannot subscribe, or a `configure` that was never given `ormSubscription`. A start the underlying engine REFUSES does not reach it: NextGraph catches that failure and logs it, and its readiness signal is only ever resolved, never rejected, so such a read stays `isPending` indefinitely. The guarantee above is unaffected — you are still never told "there is nothing" for "nobody answered" — but an interface that handles only `isError` will show its loading state where it used to show an error. Handle a `isPending` that does not end.
**A read is scoped by document and by subject, never by store.** `Scope` has two axes and there is no third: `graphs` names one document's graph per entry — `did:ng:i` or `""` for the whole local dataset, `[]` or `undefined` for no objects at all — and `subjects` filters subject IRIs literally, with no prefix or hierarchy matching. There is no call that lists the documents in a store, and naming a store in `graphs` is not the question you meant: a store's graph holds its containment triples and nothing of the shape you asked for, so it answers empty rather than raising. That is the target's own shape, not a narrowing this package chose — and since `1.0.0-dev.6` nothing is translated on the way: the `Scope` object you write is the object the SDK receives.
**A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
@@ -186,7 +196,7 @@ Where a call must first find out whether something already exists — a document
**Naming an identity proves nothing about who named it.** Any visitor may type any identifier at the barrier, or arrive on a URL that already carries one, and act as that identity — the wallet and its password are handed out on the barrier itself. The identifier is a choice of space, never a proof: anyone who knows one can act as it, so do not treat `ensureIdentity()`'s answer as an authenticated subject.
**No live read through `useShape` against a deployed broker.** The reactive subscription it opens is dispatched by method name at two hops below this package, and a broker deployed before that name last changed does not recognise it: the read never starts, its set stays empty, its readiness never settles, and one console error is the only trace — indistinguishable from "this scope is empty". The cause is upstream in NextGraph and the remedy is a broker redeployment; nothing in this package can work around it. Use `useShapeQuery` for a reactive read and `readUnion` for a one-shot one — neither goes through that path.
**A shape query the engine refuses never finishes loading.** Both shape reads — `useShape` and `useShapeQuery` — open a subscription through NextGraph's ORM, and a start the engine refuses is caught there, written to the console and nothing more: the set stays empty, its readiness never settles, and the only trace is that one console line. `useShapeQuery` reports it as a `isPending` that does not end (see the guarantee above); `useShape` returns a set you cannot tell from an empty one. The cause is upstream and nothing in this package can work around it. This was a total outage until the client packages caught up with the deployed broker on 2026-09-21; it is the residual failure mode, not the ordinary case.
**No revocation.** `inbox.share` cannot be undone.
@@ -204,6 +214,8 @@ Where a call must first find out whether something already exists — a document
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
**Neither shape read fetches a public store's key for you.** A document in a public store gives up its key to whoever asks, but asking is a round-trip and neither `useShape` nor `useShapeQuery` spends one — since `1.0.0-dev.6` the second reads through the SDK and no longer opens a document of its own. So a public document you have read nowhere else does not appear in either. Read it once by naming it (`readUnion`, `docs.sparqlQuery`) and both pick it up: the key lands in what you hold, and that is a change both of them re-read on.
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
## Change policy
@@ -212,17 +224,25 @@ Where a call must first find out whether something already exists — a document
What each level means here, in this package's own terms:
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession`, `inbox.materialize`, `storeRegistry.listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` all left this way), **or** a published symbol is renamed (`watchShape` became `useShapeQuery`), **or** an existing call narrows: it now throws where it returned (`ensureIdentity` rejects rather than resolve on an incomplete restore), or it reports a state you did not have to handle before (`useShapeQuery` publishing `isError` where it used to publish a synced-empty snapshot). A signature change a caller must react to counts; one that only accepts more than before does not.
- **major** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession`, `inbox.materialize`, `storeRegistry.listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` all left this way), **or** a published symbol is renamed (`watchShape` became `useShapeQuery`), **or** an existing call narrows: it now throws where it returned (`ensureIdentity` rejects rather than resolve on an incomplete restore), it reports a state you did not have to handle before (`useShapeQuery` publishing `isError` where it used to publish a synced-empty snapshot), or what it HANDS BACK changes shape (`useShapeQuery`'s `data` carrying the shape's own objects where it carried property bags). A signature change a caller must react to counts; one that only accepts more than before does not.
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.5`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.6`), which sorts *below* that version by construction — so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.5` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.6` is this package, `ng-e2e-helpers/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.5`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.5`, the string you pinned, never the version it is heading for.
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything — but the next release very likely is a major. What exists today is `1.0.0-dev.6`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: @ng-eventually/polyfill@1.0.0-dev.6`, the string you pinned, never the version it is heading for.
`1.0.0-dev.5` is the largest **major** of the line so far: it renames one call, changes the parameter of two, and removes three. Every change is one step toward the surface NextGraph will ship, so none of them is reversible by asking. Symbol by symbol, with what replaces it:
`1.0.0-dev.6` is a **major**, and all of it is one change: `useShapeQuery` reads through NextGraph's OWN shape query instead of a read this package built. Nothing was renamed and nothing was removed from the entry, but three things move under a caller:
- **`data` items are the shape's objects, not property bags.** `ShapeQuery<T>`'s `T` is now the shape's type: each item carries `@id`, `@graph` and one field per predicate the shape DECLARES, under that predicate's readable name. `note.props["urn:notebook:title"]?.[0]` becomes `note.title`, and `note.graph` becomes `note["@graph"]`. **A predicate your shape does not name is not projected**, so a shape written only to match — an `rdf:type` constraint and nothing else — now comes back with no fields at all: declare the ones you read. `readUnion` is unchanged and still returns `UnionSubject`.
- **`shapeType` is typed.** It was `unknown`; it is `ShapeType<T>` (the SDK's own, re-exported here), so the snapshot is typed without a cast — and a hand-written shape object must now satisfy that type, which means `readablePredicate`, `minCardinality` and `maxCardinality` on every predicate.
- **`configure` takes one more thing.** `ormSubscription: RdfOrmSubscription.getOrCreate`, plus `initNg` and a call to this package's `initNg(ng, session)` once the session has arrived. Optional in the type, required in practice for this read: without it `useShapeQuery` publishes `isError` naming the field.
And one guarantee narrows, which is the part to read twice: a shape query the engine REFUSES no longer reaches `isError` — it stays `isPending` for ever, because NextGraph swallows that failure. See `## Guarantees`.
`1.0.0-dev.5` before it was the largest **major** of the line: it renames one call, changes the parameter of two, and removes three. Every change is one step toward the surface NextGraph will ship, so none of them is reversible by asking. Symbol by symbol, with what replaces it:
- **`watchShape(shapeType, scope)` → `useShapeQuery(shapeType, scope)`.** The name is the target's own; `watchShape` appears nowhere in NextGraph and spends a verb the ORM already uses for signal observation. Everything you read back is unchanged — the same `ShapeObservable`, the same `ShapeQuery` snapshot, the same three states.
- **`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.
@@ -1,7 +1,7 @@
---
type: usage
summary: The polyfill entries the indexing layer and its end-to-end application stand on - placement, one anchored write, the deposit that becomes an entry, and the one-shot read
against: "@ng-eventually/polyfill@1.0.0-dev.5 (pulled d44063961682f84d4b804e5b349cd0a791dd9550)"
summary: The polyfill entries the indexing layer and its end-to-end application stand on - placement by store reference, one anchored write, the deposit that becomes an entry, the one-shot read, and one shape query
against: "@ng-eventually/polyfill@1.0.0-dev.6 (pulled 77e78ec81de81959e90e40da8d302e274b025c51)"
---
# usage_ng-helpers - `@ng-helpers/indexing` on `polyfill-surface`
@@ -14,29 +14,36 @@ Two callers here, and they consume different things. The **library** (`src/`) re
**Library - placement, write, inbox** (`src/polyfill-adapter.ts`)
- `storeRegistry.createEntityDoc(...)` - the index document, in its creator's public store. **The call site still passes the string `"public"`**, the argument form this engagement withdrew; moving it to the store reference (`await storeRegistry.publicStore()`) is ours to do, and until it is done this one call does not hold against the version pinned above.
- `storeRegistry.publicStore()` then `storeRegistry.createEntityDoc(store)` - the index document, in its creator's public store. A store is named by the REFERENCE the accessor hands back, and the argument is never omitted: omitting it places the document in the private store, and an index nobody but its owner can open is not an index. This is the call that was still passing the withdrawn string `"public"` against the previous version.
- `storeRegistry.openDocumentInbox(doc)` - called once, at creation, under the owner. The address it returns is deliberately dropped.
- `docs.sparqlUpdate(sessionId, update, anchor)` - the only write this package makes, always an anchored `INSERT DATA` of one triple.
- `inbox.postToDocument(doc, { payload })` - the deposit, where the payload IS a bare `did:ng:` reference.
- Types `Nuri` and `NuriLike`, type-only, so they are literally the published ones rather than a copy that can drift.
**End-to-end application - bootstrap and reading** (`e2e/indexing-app.ts`, `e2e/bridge.ts`)
**End-to-end application - bootstrap, reading, and one observation** (`e2e/indexing-app.ts`, `e2e/bridge.ts`)
- `configure({ ng, useShape, init, sharedWallet })`, this package's own `init(callback, true, [])` for the `session_id` its callback delivers, and `ensureIdentity()`.
- `configure({ ng, useShape, ormSubscription, init, initNg, sharedWallet })`, this package's own `init(callback, true, [])` for the `session_id` its callback delivers, this package's `initNg(ng, session)` once that session has arrived, and `ensureIdentity()`. `ormSubscription` is `RdfOrmSubscription.getOrCreate` and the injected `initNg` is the SDK's, both from `@ng-org/orm@0.1.2-alpha.21`, passed as they stand; the `ng` and `init` handed to `configure` are `@ng-org/web@0.1.2-alpha.14`'s.
- The package's own published `ng`, as the argument `initNg` is given.
- `readUnion([doc])` and the type `UnionSubject` - how an index is read, and how a referenced document is read. See `## Needs`.
- `docs.sparqlQuery(sessionId, query, undefined, anchor)` - an anchored SELECT, as the second of the two ways anyone reads an index.
- `storeRegistry.listMyEntityDocs("public")` - **withdrawn by this engagement**, and still called in three places here. What it was asked is in `## Needs`.
- `useShapeQuery(shape, { graphs: "did:ng:i" })`, `ShapeObservable`'s three members (`getSnapshot`, `subscribe`, `refetch`), the three states of `ShapeQuery` and its `data`, and the types `Scope` and `ShapeType`. ONE use, and it is an observation rather than a business read: after a failure is injected into the inbox step of `create`, this is how the suite finds the document that was left behind. The shape is hand-written and names one predicate - the descriptor `create` writes - which is what makes an index document matchable at all.
**Not consumed**, and safely evolvable as far as this repository is concerned: `useShapeQuery`, `useShape` (a stub is handed to `configure` and never called), `subscribeDoc` / `subscribeDocs`, `docs.docCreate`, the three store accessors, `inbox.share` / `post` / `read` / `readForDocument` / `readSynced` / `readSyncedForDocument` / `processInbox` / `watch`, `ng`, `initNg`, and the types `Scope`, `ShapeObservable`, `ShapeQuery`, `Deposit` and `PrincipalId`.
**Not consumed**, and safely evolvable as far as this repository is concerned: `useShape` (a function that REFUSES is handed to `configure`, and nothing calls it - see `## Frictions`), `subscribeDoc` / `subscribeDocs`, `docs.docCreate`, `storeRegistry.privateStore` / `protectedStore`, `inbox.share` / `post` / `read` / `readForDocument` / `readSynced` / `readSyncedForDocument` / `processInbox` / `watch`, and the types `Deposit` and `PrincipalId`.
## Constraints
**A bare reference deposited into a document's inbox becomes an entry on that document.** This is the single guarantee the whole layer rests on: `add` deposits a `did:ng:` reference and nothing else, precisely because that shape is this engagement's own and it acts on it. Nothing of indexing's meaning travels in the payload, and there is no second shape to fall back on.
**An index is created in its creator's PUBLIC store, named by that store's reference.** The public store is what serves an index's read key to whoever asks, and that is the whole reason a stranger can reach one from its reference alone. The unit suite pins both halves - where the document landed, and that the store travelled as the reference an accessor produced - so the private placement that omitting the argument would give is red rather than silent.
**One handle is one identity.** The session is one user's and no call takes an identifier, so an `Indexing` handle is one person's. Two users mean two handles - which is also what keeps the multi-actor end-to-end journeys honest: a depositor obtains the index reference the way an application does.
**`sessionId` is relayed, never converted.** It is carried at the engagement's own `string | number` and handed back untouched.
**The ORM is the APPLICATION's to wire, and the library never touches it.** `src/` performs no shape read of any kind, so a consumer of `@ng-helpers/indexing` that wants none wires nothing and declares no `@ng-org/*` dependency of its own. Only the end-to-end application here wires it, and only because one observation has no other door.
**A reactive read's load state is read before its data, and a pending read is bounded.** An empty `data` means "this scope holds nothing" only under `isSuccess`; under anything else it means nobody answered, and the two never reach a check as one value - the state travels out to the report beside the result. The bound exists because a shape query the engine refuses never finishes loading, so without one that failure would be a hang rather than an observation.
**The write is add-only, and structurally so.** There is no delete builder anywhere in this package and the only statement it can compose is an anchored `INSERT DATA`; the unit suite executes that SPARQL against an engine that refuses anything else, so a removal is unrunnable rather than merely absent. What this asks of the engagement is exactly one write primitive and no more.
**The document is named once, as the anchor.** The statement carries no `GRAPH <...>` wrapper, because the anchor already scopes the write to that repo's default graph. Naming it twice would be two chances to disagree.
@@ -55,7 +62,15 @@ Two callers here, and they consume different things. The **library** (`src/`) re
## Frictions
**The acquisition channel carries no version, so the Change policy has nothing to bite on.** The engagement asks for a pinned version and says the channel by which the source reaches an application is agreed with that application; nothing has been agreed here. This repository reaches the package as a filesystem link and declares the peer range `*`, so what it compiles against is whatever branch happens to be checked out at the other end - and the `ref:` pinned for this contract is a branch, for the same reason. The `against:` line above is the only pin that actually exists on this side. A tag-addressable, fetchable source would turn the whole semver discipline from a description into a mechanism.
**The acquisition channel carries no version, so the Change policy has nothing to bite on.** The engagement asks for a pinned version and says the channel by which the source reaches an application is agreed with that application; nothing has been agreed here. This repository reaches the package as a filesystem link and declares the peer range `*`, so what it compiles against is whatever is checked out at the other end - uncommitted edits included. That is not theoretical: while this declaration was being rewritten the linked tree moved from `1.0.0-dev.6` to work heading for `1.0.0-dev.7`, and the end-to-end suite's result moved with it, mid-session, with no pin able to prevent it. The `against:` line above pins the last COMMIT, which is the only pin that exists on this side. A tag-addressable, fetchable source would turn the whole semver discipline from a description into a mechanism.
**At the pinned version the one shape read here never starts, and the engagement says it cannot be told apart from a shape written wrong.** `useShapeQuery` is wired exactly as the Deployment requirements describe - `ormSubscription: RdfOrmSubscription.getOrCreate`, the SDK's `initNg` injected, this package's `initNg(ng, session)` called once the session has arrived - and against the broker the ORM's call into `ng` fails with `orm_start_graph is not a function` on the console. Per `## Non-guarantees` NextGraph swallows that and the read stays `isPending` for ever, so the observation it feeds reports `observed=pending` and the two end-to-end checks that rest on it fail. Reproduced on three consecutive runs, 2026-09-21. Measured through the published surface: the SDK's own `ng` answers that member, and the `ng` an application hands `initNg` makes no difference because the call substitutes one of its own. Nothing on this side can work around it - the withdrawn `listMyEntityDocs` had no other replacement - so this is carried as a failing check rather than hidden behind a looser one.
**`configure` requires `useShape`, and a frameworkless application has nothing to give it.** `@ng-org/orm` publishes `useShape` only through its react / svelte / vue adapters; a page with no framework can produce none of them. The field is not optional in `EventuallyConfig`, so this application hands over a function that THROWS if anything ever calls it - better than the `() => undefined` it carried before, which a caller could not tell from an empty read-filtered view, but still a value invented to satisfy a signature. Making the field optional, or saying in the engagement what a frameworkless application should pass, removes it.
**A shape has to be hand-written, and the engagement carries no example.** `ShapeType` demands `readablePredicate`, `minCardinality` and `maxCardinality` on every predicate, plus a `schema` keyed by the shape's own IRI and a type carrying `@id` and `@graph`. The Change policy names three of those requirements in one clause and shows none of the shape. What was written here was assembled from the SDK's own type declarations - a read this contract exists to make unnecessary. Three lines of example in `## Surface` would cover every application that has no generated shape to hand.
**`initNg`'s first argument has no effect.** The Deployment requirements say to call `initNg(ng, session)`, and `## Surface` says of the published `ng` that it is the one to call instead of the one `configure` was given - so an application has a choice to make. Measured through the published surface: the call substitutes an `ng` of its own and the argument is discarded, so both readings behave identically and neither can be told apart from the outside. Saying so - or dropping the parameter - saves the next application the experiment.
**No published NURI type guard.** `## Guarantees` says so plainly, so this layer carries its own. It is a guess at what the engagement considers a valid `Nuri`, and a wrong guess either rejects a legitimate reference or lets one through into a query.
@@ -65,12 +80,12 @@ Two callers here, and they consume different things. The **library** (`src/`) re
**`readUnion` has no stated behaviour for a document it cannot read.** `## Guarantees` says what it returns for a document it can, and it says elsewhere that a rejection means "unknown, never absent" - but not whether an unreadable document inside the list comes back as a rejection or is swallowed into the result. The worst is assumed (swallowed, therefore indistinguishable from empty) and every read path here is written defensively around that guess. One sentence in `## Guarantees` would replace it.
**Everything now routed through `useShapeQuery` is out of reach of an application that does not wire the ORM.** This repository's application hands `configure` a `useShape` stub, because it reads through `readUnion`. That was a free choice under the previous surface; it is now the thing that puts the replacement for the withdrawn store enumeration out of reach, and it will put the replacement for any future one out of reach too.
## Needs
**A one-shot read of a named document's subjects that needs no ORM - `readUnion` as it stands, or something that answers the same question.** `@ng-helpers/indexing` publishes to its OWN consumers that an index is an ordinary document, read with `readUnion([index])` or an anchored SELECT and recognised by two IRIs; that sentence is in this package's published entry point, and the application above is written to it. This engagement itself names `readUnion` the one-shot read, and names it the remedy for the reactive path that a deployed broker does not answer. Withdrawing it removes the read this layer tells every consumer to use, and leaves the anchored SELECT as the only fallback - which puts every reader of an index back on raw SPARQL, which is where the three frictions above come from. If it has to go, what is needed in its place is a one-shot, non-reactive read of the subjects of a document whose reference is already held, with no ORM wired and no shape declared.
**A one-shot read of a named document's subjects - `readUnion` as it stands, or something that answers the same question for a document whose key the reader does not yet hold.** The ORM is wired here now, so `useShapeQuery` is reachable and the earlier objection - that this application wires none - has fallen. It still does not replace this read, and the reason is the engagement's own: **neither shape read fetches a public store's key**, so a public document read nowhere else appears in neither, and the way to make it appear is to read it once by naming it, with `readUnion` or an anchored SELECT. An index IS a public document and its central case is a STRANGER reaching it from its reference alone - precisely the case no shape read serves. Three smaller costs sit on top: a shape read is reactive and three-stated where a page-load read is one-shot; it projects only the predicates a shape declares, so an index's entries and the index's own declaration take two shapes where `readUnion([index])` takes one call; and it pushes the ORM, a hand-written shape and the `useShape` friction above onto every consumer of `@ng-helpers/indexing`, which today needs none of them. This package publishes to its own consumers that an index is an ordinary document, read with `readUnion([index])` and recognised by two IRIs; withdrawing it leaves the anchored SELECT as the only fallback, which puts every reader of an index back on raw SPARQL - where three of the frictions above come from.
**A way to observe which documents an identity's public store contains.** The withdrawn `listMyEntityDocs` had exactly one use here and it is an observation, not a business read: after injecting a failure into the inbox step of `create`, the suite asks whether a document nobody named was left behind in the store. No reference for that document is held - discovering it IS the question - so the stated replacement does not reach it: a shape query is not scoped to a store, it requires the document to carry something a shape matches (an index document carries one `urn:ng-helpers:` predicate and no type triple), and it needs an ORM this application does not wire. The Change policy's reason for the removal is understood and the shape of an answer is the provider's to choose; what is stated here is the question left unanswerable.
**A way to tell which store holds a document whose reference is already held.** The withdrawn `listMyEntityDocs` had one use here, and the shape read now covers most of it: after a failure is injected into the inbox step of `create`, the suite finds the document left behind by asking for every index document this identity HOLDS and diffing. What that cannot say is WHERE it sits, so the check it feeds now establishes that a document survived, and no longer that it survived in the public store. This is not the question the Change policy declined: it enumerates nothing, it names a document the caller already holds, and a store's graph is said to hold exactly the containment triples that would answer it. As it stands an application cannot verify its own placement at all - a document written to the wrong store is undetectable until some reader fails to open it.
**A way to tell a shape the ENGINE refused from a shape the application wrote wrong.** `## Non-guarantees` says a refused shape query is caught upstream, written to the console and nothing more: the set stays empty and its readiness never settles. From an application both faults look identical - an `isPending` that does not end - and the console line is not something a page can act on. That is the whole diagnostic surface for the one thing an application must hand-write itself (see `## Frictions`), so the first shape anybody writes has no way of telling "the engine would not take this" from "the ORM was never started". Anything that separates them - a refusal that reaches `isError`, or a state that says "never opened" - turns a silent hang into something an interface can report.
**A way to tell "examined and discarded" from "not examined yet".** A deposit this engagement applies is consumed whether or not it produced an entry, and a refusal goes to the provider's own log stream - nothing an application can observe, and by the engagement's own words there is no way to ask what was lost. The cost here is concrete: the journey that checks that an object carrying nothing for an index's field is NOT indexed cannot be waited for, because no reading distinguishes "the deposit was examined and skipped" from "the deposit has not been examined yet" - and this repository's suite says so in its own header rather than hiding it, declaring that check weaker than its name. Anything observable from the application - a per-deposit outcome, or merely a count that moves when a queue has been drained - would make it a real one. This layer holds nothing of its own on purpose: an index only ever grows, and the reference is the whole of what is deposited.
+17 -3
View File
@@ -36,8 +36,24 @@ export interface BrokenInboxOutcome {
readonly rejected: string | null;
/** What `create` returned, on the impossible branch where it did not reject. */
readonly returned: string | null;
/** The documents that appeared in this identity's public store despite the failure. */
/**
* The index documents this identity did not hold before, and holds after the failure.
*
* It does NOT enumerate a store, because there is no longer such a call: the documents in
* a store are not askable for, so what is read instead is every index document this
* identity HOLDS, through the shape query. The difference matters when reading the
* check it feeds: what it establishes is that a document survived, not where it sits.
*/
readonly appeared: readonly string[];
/**
* Which of the read's three states that observation came out of -- `success`, or the
* state it gave up in.
*
* An empty `appeared` means "nothing turned up" only under `success`. Under `pending`
* or `error` it means nobody answered, and a report that folded the two together would
* let a read that never started pass for a leak that never happened.
*/
readonly observed: string;
}
/**
@@ -120,8 +136,6 @@ export interface IndexingBridge {
* on the bridge because the claim had never been measured against a broker.
*/
select(anchor: string, query: string): Promise<SelectOutcome>;
/** This identity's public documents. How an owner discovers a document it did not keep. */
listPublicDocs(): Promise<string[]>;
/**
* `create` with its inbox step made to fail — everything else real, against the
+177 -24
View File
@@ -14,11 +14,17 @@
* surface: `indexing(sessionId)`, the two acts on what it hands back, and the two IRIs.
* Nothing here makes an entry of a deposit, and nothing here can ask for one: that is
* the business of the layer below, and this page is where that shows or does not.
* Everything else here is the polyfill (`configure`, `ensureIdentity`, `init`,
* `readUnion`, `docs`, `storeRegistry`) — including reading the index, which is an
* Everything else here is the polyfill (`configure`, `ensureIdentity`, `init`, `initNg`,
* `ng`, `readUnion`, `docs`, `useShapeQuery`) -- including reading the index, which is an
* ordinary `readUnion` of an ordinary document. If something here is awkward, it is
* awkward for every consumer, which is the second reason to write it this way.
*
* This page WIRES THE ORM, because one thing here has no other door: the store enumeration it
* used to discover a leaked document left the engagement, and what replaces it is a shape
* query, which reads through NextGraph's own and therefore needs `ormSubscription`,
* `initNg` and a hand-written shape. An application that hands `configure` a stub gets
* none of it.
*
* The two things here no application does, and why they reach past the surface
* `createIndexWithBrokenInbox` injects a failure into the inbox step of `create`: the
* question it answers — does a failed `openInbox` leave a document behind? — cannot be
@@ -35,15 +41,21 @@ import {
docs,
ensureIdentity,
init,
initNg,
ng,
readUnion,
storeRegistry,
type Nuri,
useShapeQuery,
type Scope,
type ShapeObservable,
type ShapeQuery,
type ShapeType,
type UnionSubject,
} from "@ng-eventually/polyfill";
import { ng as realNg, init as realInit } from "@ng-org/web";
import { RdfOrmSubscription, initNg as ormInitNg } from "@ng-org/orm";
// The whole published surface of the package under test.
import { ENTRY_VALUE, indexing, type Indexing } from "../src/index";
import { ENTRY_VALUE, INDEX_FIELD, indexing, type Indexing } from "../src/index";
// NOT published, and reached only by the two probes above — see the header.
import { indexingOn } from "../src/indexing";
import type { NextGraphPort } from "../src/port";
@@ -55,10 +67,27 @@ import type { BrokenInboxOutcome, IndexRow, IndexingBridge, SelectOutcome } from
// `sharedWallet` is declared because the access gate wants somewhere to point when it
// has to render, and never used: this suite always enters through the broker's redirect,
// where the wallet is already open in the run's profile. Nothing is served at that path.
//
// THE ORM IS WIRED HERE, and it is not optional dressing: `useShapeQuery` reads through
// NextGraph's own shape query, which arrives through `ormSubscription` and is started by
// `initNg`. Without them that read publishes `isError` naming the field it never got.
// `RdfOrmSubscription.getOrCreate` is passed as it stands -- no wrapper, no cast.
configure({
ng: realNg,
useShape: () => undefined, // this application reads through `readUnion`, not the ORM
// Required by `configure`, and `@ng-org/orm` publishes `useShape` only through its
// framework adapters (react / svelte / vue). This page has no framework, so there is
// nothing honest to hand over: it REFUSES instead of answering `undefined`, which a
// caller could not tell from an empty read-filtered view. Nothing here calls it, and
// the gap is declared in `usage_ng-helpers`'s `## Frictions`.
useShape: () => {
throw new Error(
"[e2e] useShape was never wired: this page has no framework adapter to give it, " +
"and it reads through readUnion and useShapeQuery instead",
);
},
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit,
initNg: ormInitNg,
sharedWallet: { fileUrl: "/wallet-never-served.ngw", password: "" },
});
@@ -96,6 +125,13 @@ async function boot(): Promise<void> {
// and the identity comes back. The application keeps it only to show it.
state.who = await ensureIdentity();
const session = await sessionReady;
// The ORM is started once the session exists and not before: `useShapeQuery` reads
// through it, and this package's own `initNg` is the one to CALL -- not the SDK's,
// which was handed to `configure` and is reached from there. The `ng` it is given is
// this package's too, the one the surface says to call instead of the one `configure`
// was passed. Measured: the argument makes no difference either way, because the call
// substitutes its own -- see `usage_ng-helpers`'s `## Frictions`.
initNg(ng, session);
// The session id, and nothing else — this application never names a port, and never
// awaits anything here: a handle reaches nothing. A reference this application hands
// an index becomes an entry because the layer below processes that index's inbox,
@@ -127,26 +163,125 @@ async function probePort(): Promise<NextGraphPort> {
}
/**
* Wait for a document to appear in this identity's public store.
* THE SHAPE AN INDEX DOCUMENT MATCHES, hand-written here because there is no other way
* to ask this question any more.
*
* A store listing is a read like any other, and a document written a moment ago is not
* owed to be in it instantly. Polling is therefore what an owner would actually do, and
* it is bounded: an empty answer at the end is evidence, not a hang.
* `storeRegistry.listMyEntityDocs` left the engagement, and what replaces it is the read
* itself: a shape over `{ graphs: "did:ng:i" }` brings back what this identity HOLDS of
* that shape. So the descriptor `create` writes -- one triple, on the index document's
* own subject -- is what makes an index findable, and it is declared here as the one
* predicate this shape names. A predicate a shape does not name is not projected, so
* `field` is both the match and the whole of what comes back.
*
* NOT A STORE LISTING, and the difference is recorded rather than glossed: `Scope` has
* two axes, neither of them a store. This finds an index document wherever it sits, and
* says nothing about which of the three stores holds it.
*/
async function publicDocsAfter(
before: ReadonlySet<string>,
type IndexDescriptor = {
"@id": string;
"@graph": string;
field: string;
};
const INDEX_SHAPE = "urn:ng-helpers-e2e:shape:index";
const IndexShape: ShapeType<IndexDescriptor> = {
shape: INDEX_SHAPE,
schema: {
[INDEX_SHAPE]: {
iri: INDEX_SHAPE,
predicates: [
{
iri: INDEX_FIELD,
readablePredicate: "field",
dataTypes: [{ valType: "string" }],
minCardinality: 1,
maxCardinality: 1,
},
],
},
},
};
/** Everything this identity holds, which is as close to "my documents" as `Scope` goes. */
const EVERYTHING_HELD: Scope = { graphs: "did:ng:i" };
/**
* Which of the three states a reactive read is in.
*
* Read BEFORE the data, always: an empty `data` under `isSuccess` means this scope holds
* nothing of that shape, and an empty `data` under anything else means nobody answered.
* The two must never be reported as one, which is the whole reason this is carried out
* to the report instead of being collapsed into an empty list.
*/
type LoadState = "pending" | "success" | "error";
function loadStateOf(snapshot: ShapeQuery<IndexDescriptor>): LoadState {
if (snapshot.isError) return "error";
if (snapshot.isSuccess) return "success";
return "pending";
}
/** The documents the snapshot's objects came out of -- one entry per index document. */
function documentsIn(snapshot: ShapeQuery<IndexDescriptor>): string[] {
return snapshot.data.map((descriptor) => descriptor["@graph"]);
}
/**
* Wait for the read to ANSWER -- to reach `isSuccess` or `isError`.
*
* A shape query the engine refuses stays `isPending` for ever: NextGraph catches that
* failure, logs it, and its readiness signal is only ever resolved. So a bound here is
* not impatience, it is the only way that failure becomes a named observation instead of
* a hang, and what it returns is the state it gave up in.
*/
async function answered(
held: ShapeObservable<IndexDescriptor>,
budgetMs: number,
): Promise<readonly string[]> {
): Promise<LoadState> {
const deadline = Date.now() + budgetMs;
let appeared: readonly string[] = [];
for (;;) {
const now = await storeRegistry.listMyEntityDocs("public");
appeared = now.filter((d) => !before.has(d));
if (appeared.length > 0 || Date.now() >= deadline) return appeared;
const state = loadStateOf(held.getSnapshot());
if (state !== "pending" || Date.now() >= deadline) return state;
await new Promise((r) => setTimeout(r, 500));
}
}
/**
* Wait for an index document this identity did not hold before to turn up.
*
* A document written a moment ago is not owed to be readable instantly, and this read is
* push-driven rather than polled -- so the loop asks the OBSERVABLE again (`refetch`)
* rather than issuing a fresh read, and it is bounded: an empty answer under `success` at
* the end is evidence, not a hang. An empty answer under any other state is neither, and
* that is why the state travels back with it.
*/
async function indexDocsAfter(
held: ShapeObservable<IndexDescriptor>,
before: ReadonlySet<string>,
budgetMs: number,
): Promise<{ appeared: readonly string[]; observed: LoadState }> {
const deadline = Date.now() + budgetMs;
for (;;) {
const snapshot = held.getSnapshot();
const observed = loadStateOf(snapshot);
const appeared =
observed === "success" ? documentsIn(snapshot).filter((d) => !before.has(d)) : [];
if (appeared.length > 0 || Date.now() >= deadline) return { appeared, observed };
await new Promise((r) => setTimeout(r, 500));
held.refetch();
}
}
/**
* How long the reactive read has, both to answer at all and to notice what turned up.
*
* It replaces the bound the withdrawn store enumeration was polled under, and it carries one
* more job than that one did: a shape query the engine refuses never finishes loading, so
* this is also what turns that failure into a reported state.
*/
const OBSERVE_MS = 15_000;
/**
* How much of an answer a report carries. Bounded so a report stays one, generous enough
* that the answer is readable rather than merely counted.
@@ -281,14 +416,23 @@ const bridge: IndexingBridge = {
}
},
async listPublicDocs(): Promise<string[]> {
const docs: Nuri[] = await storeRegistry.listMyEntityDocs("public");
return [...docs];
},
async createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome> {
const p = await probePort();
const before = new Set<string>(await storeRegistry.listMyEntityDocs("public"));
// The read that will notice the leak, opened BEFORE anything is created so that
// "what was already held" is a reading and not an assumption. It is subscribed
// because an observable with nobody listening has nowhere to push what arrives,
// and it is released at the end: the probe is its only caller.
const held = useShapeQuery(IndexShape, EVERYTHING_HELD);
const release = held.subscribe(() => {});
try {
// The opening read has to ANSWER first. A `before` taken from a read that never
// happened is empty, and an empty `before` would make the first document this
// identity has ever held look like something the failure left behind.
const opening = await answered(held, OBSERVE_MS);
const before = new Set<string>(
opening === "success" ? documentsIn(held.getSnapshot()) : [],
);
// Everything real except the inbox step. The failure is injected at the exact moment
// the question is about: after the document exists and carries its descriptor, before
@@ -308,7 +452,16 @@ const bridge: IndexingBridge = {
rejected = String((e as Error)?.message ?? e);
}
return { rejected, returned, appeared: await publicDocsAfter(before, 15_000) };
const after = await indexDocsAfter(held, before, OBSERVE_MS);
return {
rejected,
returned,
appeared: after.appeared,
observed: opening === "success" ? after.observed : `${opening} before the probe ran`,
};
} finally {
release();
}
},
};
+11 -6
View File
@@ -207,7 +207,7 @@ const { check, journey, finish } = declareSuite({
name: "An index whose inbox cannot be opened leaves a document behind",
checks: [
"createIndex refuses when the inbox cannot be opened",
"a document was nevertheless created in the owner's public store",
"a document was nevertheless created, and its owner still holds it",
"the leaked document carries a descriptor but accepts no deposit",
],
},
@@ -654,15 +654,20 @@ async function main(): Promise<void> {
outcome.rejected !== null && outcome.returned === null,
`rejected=${outcome.rejected} returned=${outcome.returned}`,
);
// WHAT IS OBSERVED HERE, and what is not. There is no call that lists a store any
// more, so the leak is found by reading every index document Alice HOLDS and
// diffing: that establishes the document survived, not which store it sits in.
// `observed` travels with it because an empty answer only means "nothing turned
// up" under `success` -- under any other state it means nobody answered.
check(
"a document was nevertheless created in the owner's public store",
outcome.appeared.length === 1,
`appeared=${JSON.stringify(outcome.appeared)}`,
"a document was nevertheless created, and its owner still holds it",
outcome.appeared.length === 1 && outcome.observed === "success",
`appeared=${JSON.stringify(outcome.appeared)} observed=${outcome.observed}`,
);
// What the leaked document IS: an index in every respect but the one that makes
// it usable. Alice found it in her own store — the only way anyone can, since
// `createIndex` threw its reference away.
// it usable. Alice found it among the documents she holds -- the only way anyone
// can, since `createIndex` threw its reference away.
const leaked = outcome.appeared[0];
if (leaked === undefined) {
check(
+2 -1
View File
@@ -14,7 +14,8 @@
},
"devDependencies": {
"@ng-eventually/polyfill": "link:../ng-eventually-js/packages/polyfill",
"@ng-org/web": "0.1.2-alpha.13",
"@ng-org/orm": "0.1.2-alpha.21",
"@ng-org/web": "0.1.2-alpha.14",
"@types/bun": "latest",
"ng-e2e-helpers": "link:../ng-eventually-js/packages/ng-e2e-helpers",
"playwright": "1.61.1",
+49 -5
View File
@@ -11,9 +11,12 @@ importers:
'@ng-eventually/polyfill':
specifier: link:../ng-eventually-js/packages/polyfill
version: link:../ng-eventually-js/packages/polyfill
'@ng-org/orm':
specifier: 0.1.2-alpha.21
version: 0.1.2-alpha.21
'@ng-org/web':
specifier: 0.1.2-alpha.13
version: 0.1.2-alpha.13
specifier: 0.1.2-alpha.14
version: 0.1.2-alpha.14
'@types/bun':
specifier: latest
version: 1.3.14
@@ -29,8 +32,36 @@ importers:
packages:
'@ng-org/web@0.1.2-alpha.13':
resolution: {integrity: sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ==}
'@ng-org/alien-deepsignals@0.1.2-alpha.13':
resolution: {integrity: sha512-yOEh/J+9IrCo8WDXTdZLxRcO9IHAczrUA4pjSD4BaZzmWhnCFE/amy/0PsAdh/yePurBrtQj3tG/y+hReM15Ew==}
peerDependencies:
react: ^19.0.0 || ^18.0.0
svelte: ^5.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
svelte:
optional: true
vue:
optional: true
'@ng-org/orm@0.1.2-alpha.21':
resolution: {integrity: sha512-+Dtt9jEa+277wonExxqC1BHqVwvWhXeXM51zcGg2w+Mc6C1F74qaXMGYZT3CcWCQISGEMOWV3n2movP3yQ8xJA==}
peerDependencies:
react: ^19.0.0 || ^18.0.0
svelte: ^5.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
svelte:
optional: true
vue:
optional: true
'@ng-org/web@0.1.2-alpha.14':
resolution: {integrity: sha512-FPQ7kyngMhAXK/WJtZ6pkoEJwGcau6GaBH2nhjjYorMq7OG//I+zwghat90FsIeMrfYsps0+5Y0UrRqfxNdUXA==}
'@types/bun@1.3.14':
resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
@@ -38,6 +69,9 @@ packages:
'@types/node@26.2.0':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
alien-signals@2.0.8:
resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==}
async-proxy@0.4.1:
resolution: {integrity: sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w==}
@@ -72,7 +106,15 @@ packages:
snapshots:
'@ng-org/web@0.1.2-alpha.13':
'@ng-org/alien-deepsignals@0.1.2-alpha.13':
dependencies:
alien-signals: 2.0.8
'@ng-org/orm@0.1.2-alpha.21':
dependencies:
'@ng-org/alien-deepsignals': 0.1.2-alpha.13
'@ng-org/web@0.1.2-alpha.14':
dependencies:
async-proxy: 0.4.1
@@ -84,6 +126,8 @@ snapshots:
dependencies:
undici-types: 8.3.0
alien-signals@2.0.8: {}
async-proxy@0.4.1:
dependencies:
object-path-operator: 3.0.0
+6 -1
View File
@@ -38,7 +38,12 @@ export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
async createPublicDocument() {
// An index lives in its creator's public store, so that any reader can open
// it from the reference alone.
return storeRegistry.createEntityDoc("public");
//
// A store is named by its REFERENCE and not by a word: `publicStore()` hands
// back the one this identity has, and `createEntityDoc` takes it. Omitting the
// argument would place the document in the PRIVATE store, where no reader could
// open it -- so this call names the store every time, and never by omission.
return storeRegistry.createEntityDoc(await storeRegistry.publicStore());
},
async addLiteralProperty(
+2 -1
View File
@@ -26,7 +26,8 @@ export type { Nuri, NuriLike };
export interface NextGraphPort {
/**
* A new document in this identity's PUBLIC store, owned by this identity.
* Backs onto `storeRegistry.createEntityDoc("public")`.
* Backs onto `storeRegistry.createEntityDoc(await storeRegistry.publicStore())`
* -- a store is named by its reference, and omitting it would mean the private one.
*/
createPublicDocument(): Promise<Nuri>;
+15
View File
@@ -146,6 +146,21 @@ test("creating an index writes its field declaration, and nothing else", async (
expect(world.contentsOf(ordinary)).toEqual([]);
});
test("an index is recorded in its creator's PUBLIC store, named by that store's reference", async () => {
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
// WHERE it landed, read off the fake rather than inferred from the call: an index a
// stranger cannot open is not an index, and the private store is one keystroke away
// (omitting the argument places it there).
expect(world.storeOf(index)).toBe(world.storesOf("alice").public);
// And HOW the store was named: the reference the accessor handed back, not a word.
// A store is told apart from every other reference only by being one of the three,
// so the accessor is the only thing that can produce it.
const placed = world.calls.filter((call) => call.entry === "storeRegistry.createEntityDoc").at(-1);
expect(placed?.args).toEqual([world.storesOf("alice").public]);
});
test("the write is the polyfill's canonical anchored form: the document named once, as the anchor", async () => {
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
const writes = world.calls.filter((call) => call.entry === "docs.sparqlUpdate");
+125 -6
View File
@@ -27,9 +27,17 @@ import type { Nuri } from "../src/port";
* (`"DEL" + "ETE …"`) is one string by the time it arrives, so the split buys
* nothing.
*
* ## Every rule below is one the polyfill actually enforces
* ## Every rule below is one `contract_polyfill-surface` states
*
* - only a document's owner writes to it (`docs.sparqlUpdate`'s `assertMayWrite`);
* - a store is named by its REFERENCE and by nothing else: `privateStore()`,
* `protectedStore()` and `publicStore()` hand one out, `createEntityDoc(store?)`
* takes one, and every entry "accepts `NuriLike` and validates at the door" -- so
* a word where a reference belongs is refused rather than understood;
* - omitting the store places the document in the PRIVATE store;
* - "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" -- so a
* stranger reaches a document in its owner's public store, and no other;
* - only a document's owner writes to it, and holding a read key never grants a write;
* - anyone may DEPOSIT into a document's inbox (`inbox.postToDocument`);
* - depositing into a document whose owner never opened an inbox THROWS, rather
* than going nowhere;
@@ -37,6 +45,14 @@ import type { Nuri } from "../src/port";
* (`openDocumentInbox`: doing so publishes the document's address);
* - a document with no inbox has no address to read — a state, not an error.
*
* ## The three withdrawn placement entries are modelled by their ABSENCE
*
* `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` left the engagement.
* Nothing here answers for them, so reaching for one throws by name (see
* {@link namespace}) -- which is the only thing a fake can honestly say about a call
* that no longer exists. The same holds for `useShapeQuery`: this package reads
* nothing, and the end-to-end application is where that read is exercised.
*
* ## What is NOT modelled, and what happens then
*
* Anything the adapter reaches for on `docs`, `inbox` or `storeRegistry` that is
@@ -241,6 +257,8 @@ export interface RecordedCall {
interface StoredDocument {
readonly nuri: Nuri;
readonly owner: string;
/** The store it was recorded in -- a REFERENCE, which is the only name a store has. */
readonly store: Nuri;
readonly subjects: Map<string, Map<string, string[]>>;
/** `undefined` until the owner opens one — the state `postToDocument` refuses. */
deposits: Deposit[] | undefined;
@@ -255,6 +273,15 @@ export interface FakePolyfill {
sessionId(): string;
/** Every triple in a document, read from outside the adapter. */
contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[];
/**
* The store a document was recorded in, as the reference the accessors hand out.
* An INSPECTION: placement is not something this package can read back, and the
* point of holding it is that a document placed in the wrong store is visible here
* rather than only in a broker's refusal.
*/
storeOf(doc: string): Nuri;
/** The three stores of an identity, as `privateStore()` and its two siblings answer. */
storesOf(user: string): { private: Nuri; protected: Nuri; public: Nuri };
/**
* What is waiting in a document's inbox, oldest first — or `null` when its owner
* never opened one. An INSPECTION, not an entry of the polyfill: this package
@@ -270,6 +297,7 @@ export interface FakePolyfill {
*/
export function installFakePolyfill(): FakePolyfill {
const documents = new Map<string, StoredDocument>();
const stores = new Map<string, { private: Nuri; protected: Nuri; public: Nuri }>();
const calls: RecordedCall[] = [];
let currentUser = "nobody";
let documentCount = 0;
@@ -339,6 +367,16 @@ export function installFakePolyfill(): FakePolyfill {
const inboxImpl = {
async postToDocument(doc: unknown, options: unknown): Promise<void> {
const stored = require(String(doc));
if (!mayRead(currentUser, stored)) {
// Reaching a document's owner goes THROUGH the document, so a depositor that
// cannot open it has no way in. This is what makes the public store a
// load-bearing choice of `create` rather than a decoration: place an index
// anywhere else and no stranger can hand it anything.
throw new Error(
`[ng-eventually] inbox.postToDocument: you cannot read the document ` +
`${JSON.stringify(doc)}, so you cannot reach its inbox`,
);
}
if (stored.deposits === undefined) {
throw new Error(
`[ng-eventually] inbox.postToDocument: this document has no inbox — either its ` +
@@ -354,14 +392,89 @@ export function installFakePolyfill(): FakePolyfill {
};
/**
* The three stores an identity has, minted on first ask and stable afterwards.
*
* There is no identity parameter on any of the accessors -- a session is one user's
* -- so these are keyed by whoever is signed in, exactly as the engagement has it.
* A store is a REFERENCE: it carries no key, and it is told from every other
* reference only by being one of the three.
*/
function storesOf(user: string): { private: Nuri; protected: Nuri; public: Nuri } {
const known = stores.get(user);
if (known !== undefined) return known;
const minted = {
private: `did:ng:o:store-private-${user}` as Nuri,
protected: `did:ng:o:store-protected-${user}` as Nuri,
public: `did:ng:o:store-public-${user}` as Nuri,
};
stores.set(user, minted);
return minted;
}
/**
* Whether this identity holds a key that opens the document.
*
* Its creator does; so does anyone at all when it sits in a PUBLIC store, which
* serves its read key to whoever asks. Nothing else here shares a key, because
* nothing here calls `inbox.share`.
*/
function mayRead(user: string, stored: StoredDocument): boolean {
return stored.owner === user || stored.store === storesOf(stored.owner).public;
}
const storeRegistryImpl = {
async createEntityDoc(scope: unknown): Promise<Nuri> {
if (scope !== "public" && scope !== "mine") {
throw new Error(`[fake-polyfill] unknown scope ${JSON.stringify(scope)}`);
async privateStore(): Promise<Nuri> {
return storesOf(currentUser).private;
},
async protectedStore(): Promise<Nuri> {
return storesOf(currentUser).protected;
},
async publicStore(): Promise<Nuri> {
return storesOf(currentUser).public;
},
async createEntityDoc(store?: unknown): Promise<Nuri> {
const mine = storesOf(currentUser);
// Omitted means the PRIVATE store, as `doc_create` does when no store is given.
// It is a real placement and not a default to shrug at: a document that lands
// there is one no stranger can open.
let placement: Nuri;
if (store === undefined) {
placement = mine.private;
} else {
// Validated AT THE DOOR, which is what makes a word where a reference belongs
// a refusal rather than a silent placement. This is the exact shape that was
// green here for a whole version while the broker rejected it.
if (typeof store !== "string" || !store.startsWith("did:ng:")) {
throw new Error(
"[fake-polyfill] storeRegistry.createEntityDoc: not a NextGraph reference " +
"-- a store is named by the reference privateStore(), protectedStore() " +
`or publicStore() hands back, got ${JSON.stringify(store)}`,
);
}
const named = [mine.private, mine.protected, mine.public].find((s) => s === store);
if (named === undefined) {
// "A rejection means unknown, never absent": a store this identity does not
// have is a document that could not be recorded, so the call throws.
throw new Error(
`[fake-polyfill] storeRegistry.createEntityDoc: ${store} is not one of this ` +
`identity's stores, so the document could not be recorded in it`,
);
}
placement = named;
}
documentCount += 1;
const nuri = `did:ng:o:doc-${documentCount}` as Nuri;
documents.set(nuri, { nuri, owner: currentUser, subjects: new Map(), deposits: undefined });
documents.set(nuri, {
nuri,
owner: currentUser,
store: placement,
subjects: new Map(),
deposits: undefined,
});
return nuri;
},
@@ -440,6 +553,12 @@ export function installFakePolyfill(): FakePolyfill {
if (stored.deposits === undefined) return null;
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
},
storeOf(doc: string) {
return require(doc).store;
},
storesOf(user: string) {
return storesOf(user);
},
contentsOf(doc: string) {
const stored = require(doc);
const out: { subject: string; predicate: string; values: string[] }[] = [];