docs: declarer ce que ng-helpers consomme, et publier les deux declarations

This commit is contained in:
Sylvain Duchesne
2026-09-21 11:34:08 +02:00
parent ff78a70f14
commit e566c5b0b3
5 changed files with 491 additions and 56 deletions
@@ -1,59 +1,76 @@
---
type: usage
summary: The nine polyfill entries the indexing layer stands on, the constraints it holds itself to, and what it had to build for want of a published helper
against: "@ng-eventually/polyfill@1.0.0-dev.1"
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)"
---
# usage_ng-helpers — `@ng-helpers/indexing` on `polyfill-surface`
# usage_ng-helpers - `@ng-helpers/indexing` on `polyfill-surface`
The consumer is the indexing layer: an index is an ordinary public document, contributions reach it through its inbox, and its owner's session curates it by going through that inbox. NextGraph has no indexing concept, so nothing of what a deposit *means* belongs upstream — the polyfill's inbox stays generic and carries opaque payloads, and this layer decides what they say.
The consumer is the indexing layer: an index is an ordinary public document that declares a field, anyone may hand it a reference by depositing into it, and its entries appear because the layer below applies that deposit. NextGraph has no indexing concept, so nothing of what a deposit MEANS belongs upstream - the polyfill's inbox stays generic and carries an opaque payload.
The whole runtime dependency passes through **one file**, the adapter that builds our `NextGraphPort`. Everything else in the package is written against that port, so a change to the engagement breaks exactly one file and nothing else. The types are imported type-only, so they are literally the published ones rather than a copy that can drift.
Two callers here, and they consume different things. The **library** (`src/`) reaches the polyfill from exactly ONE file, `src/polyfill-adapter.ts`: everything else is written against an internal port, so a change to the engagement breaks that one file and nothing else. The **end-to-end application** (`e2e/indexing-app.ts`, `e2e/bridge.ts`) is written the way a consumer of `@ng-helpers/indexing` writes one, and consumes the polyfill directly for its bootstrap and for reading - which is the point: if something is awkward there, it is awkward for every application.
## Consumed surface
**Placement** — `storeRegistry.createEntityDoc("public")`, for the index document itself; `storeRegistry.listMyEntityDocs("public")`, to find again, at each connection, the indexes this identity owns; `storeRegistry.openDocumentInbox(doc)`, at creation to open one and afterwards to resolve its address.
**Library - placement, write, inbox** (`src/polyfill-adapter.ts`)
**Reading** — `readUnion([doc])` and the type `UnionSubject`. Used both for the index document and for resolving a deposited reference.
- `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.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.
**Writing** — `docs.sparqlUpdate(sessionId, update, anchor)`, with the document named ONCE as the anchor so the statement carries no `GRAPH <…>` wrapper. It is the only write this package makes, and it is always an `INSERT DATA` of literal triples.
**End-to-end application - bootstrap and reading** (`e2e/indexing-app.ts`, `e2e/bridge.ts`)
**Inbox** — `inbox.postToDocument(doc, { payload })` for depositing, `inbox.readForDocument(doc)` for the owner going through it, `inbox.watch(address, onDeposits)` so that a deposit made while the owner is connected is applied as it lands, and the shape of `Deposit` (`from` / `payload` / `ts`), which our `IncomingDeposit` mirrors.
- `configure({ ng, useShape, init, sharedWallet })`, this package's own `init(callback, true, [])` for the `session_id` its callback delivers, and `ensureIdentity()`.
- `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`.
**Types** — `Nuri`, `NuriLike`, `PrincipalId`, `UnionSubject`.
**Bootstrap, in the end-to-end application only** — `configure`, this package's `init` (for the `sessionId` its callback delivers), and `ensureIdentity`.
Everything else on the engagement is offered and NOT consumed: `watchShape`, `useShape`, `subscribeDoc`/`subscribeDocs`, `docs.docCreate`, `docs.sparqlQuery`, `storeRegistry.resolveScopeGraph`/`resolveWriteGraph`, `inbox.share`/`post`/`read`/`readSynced`/`readSyncedForDocument`/`processInbox`, `ng`, `initNg`. It is safely evolvable as far as this layer is concerned.
**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`.
## Constraints
**One port is one identity.** The polyfill's 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 our multi-actor tests honest: a depositor obtains the index NURI the way an application does, never through a shared variable.
**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.
**`sessionId` is relayed, never converted.** We carry it at the engagement's own `string | number` and hand it back untouched.
**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.
**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`. Our own tests execute that SPARQL against an engine that refuses anything else, so a removal is unrunnable rather than merely undetected. This constrains what we ask of the engagement: we need exactly one write primitive and no more.
**`sessionId` is relayed, never converted.** It is carried at the engagement's own `string | number` and handed back untouched.
**The inbox is opened at creation, from one place — and resolved, never opened, afterwards.** The engagement disclaims coalescing `openDocumentInbox` across pages, so the only call that can CREATE one is in `createIndex`, under its owner, at the moment the document is created. Every later call is on a document whose inbox already exists, where the same entry resolves the address instead; the engagement guarantees that idempotence within a page, which is the level our watching lives at.
**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.
**One session watches its own indexes, and nothing stops it.** `inbox.watch`'s unsubscribe is deliberately dropped: our watching lasts exactly as long as the identity is connected, which is what the engagement does with the inboxes it watches on its own account. It also means the inbox address never leaves the one adapter function that resolves it — everything above names a document.
**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.
**Our watching relies on subscriptions coexisting on one document.** `inbox.watch` opens a `subscribeDoc` on the inbox document, and the engagement already watches every inbox this identity may read. Before subscriptions coexisted, one of those two would have silenced the other with nothing raised anywhere. Verified in the resolved copy: the fan-out holds a set of listeners.
**A document's inbox is opened from ONE place** - `create`, under the owner, at the moment the document is created - and no address is held anywhere above that call. The non-guarantee about `openDocumentInbox` not coalescing across pages is therefore respected by construction rather than by care.
**Every payload out of an inbox is untrusted input.** Anyone may deposit anything, so nothing read from a deposit reaches a query before being checked; a non-reference is reported, never thrown on.
**This package declares the polyfill a PEER dependency**, so the application above remains the one place it is named. The single-copy rule is the one packaging mistake the surface cannot protect anyone from, and a library that declared it a direct dependency would be manufacturing exactly that.
**An empty read is never treated as "empty".** Nothing in this layer reads `[]` from `readUnion` as "a valid index that happens to hold nothing" — the descriptor check refuses a document declaring no field, and that refusal aborts curation before a single write.
**Every NURI is validated before it reaches a query or a deposit**, because no type guard is published (see `## Frictions`).
**This layer stays out of `urn:ng-eventually:` entirely.** Its predicates are `urn:ng-helpers:` and the subjects it writes are always `did:ng:` references, so nothing it writes falls under the reserved prefix and nothing it writes is dropped on read.
**An empty read is never concluded from.** Where the end-to-end suite reads an index it polls to a bound and reports what the index held instead, rather than reading once and calling an empty answer an answer.
**The identity `ensureIdentity()` returns is opaque here** - checked for non-emptiness, carried, printed in a run report, never parsed or rendered as a name.
## Frictions
**The engagement does not say what `readUnion` does with a document it cannot read.** It says what it returns for a document it can, and it says 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. We assume the worst (swallowed, therefore indistinguishable from empty) and code defensively around it. A sentence in `## Guarantees` settling this would replace a guess we are carrying in every read path.
**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.
**No published NURI type guard.** `## Guarantees` states plainly that no type guard is published, so this layer carries its own — and it needs one, because a NURI arrives here from an untrusted inbox deposit and must be checked before it can be written between angle brackets. The check we wrote is a guess at what the engagement considers a valid `Nuri`, and a wrong guess is either a rejected legitimate reference or an injected one.
**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.
**No published escaping helpers.** The engagement lists no `escapeIri`/`escapeLiteral`, and we compose SPARQL against `docs.sparqlUpdate` — so this package carries its own escaping rather than reach into the provider's internals. That is a security-relevant duplication of something the provider certainly already has: two implementations of the same rule, one of which is not the one the provider tests.
**No published escaping helpers.** The engagement lists no `escapeIri` / `escapeLiteral`, and this package composes SPARQL against `docs.sparqlUpdate` - so it carries its own escaping rather than reach into the provider. That is a security-relevant duplication: two implementations of one rule, and the one running here is not the one the provider tests.
**Watching a document's inbox needs an address, and the only call that hands one out is the one that creates one.** The engagement is explicit that an application never resolves an inbox address, and it publishes `inbox.watch(address, …)` with no `watchForDocument(doc, …)` beside `readForDocument`. So the one thing an owner cannot avoid — being told about deposits on its own document — is also the one place this layer must hold an address, obtained from `openDocumentInbox`, whose other job is to create. A `watchForDocument(doc, onDeposits)` would close that gap and keep the "never resolve an address" rule whole.
**No published way to write triples above the raw SPARQL primitive.** `docs.sparqlUpdate` is the level this layer had to align on, which is where the two frictions above come from. An "add these triples to this document" entry would delete the query composition, the escaping and the NURI validation from this package in a single move.
**The version declared and the version the guarantees describe disagree.** The resolved copy's `package.json` says `1.0.0-dev.1`, while the engagement dates the continuous inbox observation and the coexisting subscriptions to `1.0.0-dev.2`. The code has them, so nothing is broken; but a pin cannot express what we actually depend on, and `against:` above names the string we resolve rather than the one the guarantees belong to.
**`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.
**No published way to write triples above the raw SPARQL primitive.** `docs.sparqlUpdate` is the level we had to align on, which is why the two frictions above exist at all. A published "add these triples to this document" would remove the query composition, the escaping and the NURI validation from this layer in one move.
**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 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 "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.