feat!: la lecture quitte le contrat, un index se lit comme un document

This commit is contained in:
Sylvain Duchesne
2026-08-21 15:49:59 +02:00
parent ebaae15baf
commit c2f9ff4674
5 changed files with 270 additions and 70 deletions
@@ -1,6 +1,6 @@
---
type: contract
summary: The API @ng-helpers/indexing exposes to an application — creating an index, depositing references into it, reading it back; what a reference becomes is not a call: it becomes an entry once the index's creator is connected
summary: What @ng-helpers/indexing engages to do — create an index, take a reference anyone deposits, and make it an entry once the index's creator connects; the index itself is an ordinary document anyone queries
---
# contract_indexing-layer — `@ng-helpers/indexing`
@@ -9,101 +9,96 @@ summary: The API @ng-helpers/indexing exposes to an application — creating an
This package builds an **index** on top of NextGraph: an ordinary public document that holds one entry per indexed object, keyed by that object's NURI and carrying its value for a single declared field.
It covers creating an index, depositing a reference to an object into one (open to anyone), and reading the entries back in order. What becomes of them is covered too, but never as a call — see `## Guarantees`.
It covers creating an index and depositing a reference to an object into one (open to anyone). What becomes of that reference is covered too, but never as a call — see `## Guarantees`. Reading is not covered: the document is ordinary, and `## Surface` has the shape to query it.
It does not cover NextGraph itself — documents, identity, sharing, transport all of which reach it through a port you supply. It does not cover search, filtering, pagination, or querying by anything but the index's field. It **never removes anything**, anywhere — an engagement, not a missing feature.
It does not cover NextGraph itself — documents, identity, sharing, transport, all of which reach it through a port you supply — nor search, filtering, pagination, or querying by anything but the index's field.
### Deployment requirements
An application using this package must:
- have a NextGraph session open under the identity it wants to act as, and build the port from it — `polyfillPort({ sessionId })`, where `sessionId` is what `@ng-eventually/polyfill`'s own `init(…)` hands its callback;
- **await `indexing(port)` once at startup and keep what it produces.** That handle is one identity's and is also that identity's connection: awaiting it is what makes entries appear, dropping it is what stops them;
- reach a broker: every operation here reaches NextGraph, and nothing is answered locally;
- **supply `@ng-eventually/polyfill` itself.** This package declares it a *peer*: the application names it among its own dependencies, and that copy must be the one its own code calls — that package requires exactly one instance of itself, for reasons its own contract states;
- **connect as an index's creator if that index is ever to fill.** Unconditional, whoever holds its reference: entries are made while the creator is connected and by nothing else, so an index whose creator never returns stays as it was, however many references it is handed.
- **await `indexing(port)` once at startup and keep what it produces.** That handle is one identity's — no call takes an identifier, so two users mean two handles — and it is also that identity's connection: awaiting it is what makes entries appear, dropping it is what stops them;
- reach a broker — nothing here is answered locally;
- **supply `@ng-eventually/polyfill` itself**, as a *peer*: the application names it among its own dependencies, and that copy must be the one its own code calls — that package requires exactly one instance of itself;
- **connect as an index's creator if that index is ever to fill.** Unconditional, whoever holds its reference: an index whose creator never returns stays as it was, however many references it is handed.
One handle is one identity: the port carries a session, no call takes an identifier, and two users mean two handles.
**Holding an index's reference.** An index is reached by its NURI, held however the application holds any other reference — per user, per context, or read out of a document it opens anyway. That reference is the only way anyone reaches it, and losing it loses the index. **Hardcoding it is what a single GLOBAL index needs, and only that case**; anything narrower is discovered.
**Holding an index's reference.** An index is reached by its NURI, held however the application holds any other reference — per user, per context, or read out of a document it opens anyway. Nothing marks a document as an index, so that reference is the only way anyone reaches it, and losing it loses the index. **Hardcoding it in the source is what a single GLOBAL index needs, and only that case**: one index serving the whole application has nothing else to be discovered by. Anything narrower is discovered, not hardcoded.
**Obtaining it.** Not published to npm or any other host, and not built output: the entry point is TypeScript source, so whatever builds the application compiles it, and `@ng-eventually/polyfill` arrives the same way. What this contract fixes is the version you pin and what you must provide alongside it.
**Obtaining it.** Not published to npm and not built output: the entry point is TypeScript source, so whatever builds the application compiles it — and `@ng-eventually/polyfill` arrives the same way.
## Surface
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures:
Full typed shape: the package's `types` entry. The load-bearing signatures:
```ts
// ── wiring: one handle, one identity, and that identity's connection ─────────
// wiring
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort;
export interface PolyfillPortOptions { readonly sessionId: string | number }
/** This identity's handle, and its connection: see `## Guarantees`. */
export function indexing(port: NextGraphPort): Promise<Indexing>;
// ── addressing (re-exported so you import them from here) ────────────────────
// addressing (import these from here)
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type { PrincipalId, UnionSubject, NextGraphPort, IncomingDeposit, ObjectResolution };
export type { UnionSubject, NextGraphPort, IncomingDeposit, ObjectResolution };
// ── the three acts an application performs ───────────────────────────────────
// the three acts
export interface Indexing {
/** A new index in THIS identity's public store, and its NURI. Any user may. `field`
* is the predicate an indexed object must carry; an empty or blank one throws. */
createIndex(field: string): Promise<Nuri>;
/** Deposits a bare reference to an object into the index. Open to ANYONE. Produces
* nothing. Throws when the document cannot take one, rather than losing it. */
/** Deposits a bare reference to an object into the index. Open to ANYONE. Throws
* when the document cannot take one, rather than losing it. */
refer(index: NuriLike, object: NuriLike): Promise<void>;
/** The entries, ordered by value. Refuses a document that declares no index field
* rather than producing an empty list. Sugar over `readUnion([index])`. */
/** Convenience: the entries, ordered by value, without the index's own subject.
* Refuses a document declaring no field. NOT an engagement — see below. */
read(index: NuriLike): Promise<IndexEntry[]>;
}
// ── what an index holds, and what travels from a depositor to an index ───────
// what an index holds, and what a depositor sends
export interface IndexEntry { readonly object: Nuri; readonly value: string }
export interface IndexDescriptor { readonly field: string }
export type IndexDeposit = Nuri; // the reference IS the whole payload
export type IndexDeposit = Nuri; // the reference IS the whole payload
export function decodeReference(payload: unknown): Nuri | null; // untrusted input
// ── the IRIs, for a reader going straight to `readUnion` ─────────────────────
export const INDEX_FIELD: string; // on the index's own subject: the field it indexes by
export const ENTRY_VALUE: string; // on an entry: that object's value for the field
// the IRIs it is written with
export const INDEX_FIELD: string; // "urn:ng-helpers:index:field"
export const ENTRY_VALUE: string; // "urn:ng-helpers:index:value"
```
**What an index document holds** — two shapes, and this layer writes nothing else:
- on the **index's own subject**, `INDEX_FIELD` carries the predicate an indexed object must carry, as a **literal**, not a URI;
- on **each entry**, whose subject is the indexed object's own `did:ng:` NURI, `ENTRY_VALUE` carries that object's value for the field, as a literal. One subject may carry more than one, which two sessions racing each other produce.
That is everything reading takes: an anchored `SELECT ?object ?value WHERE { ?object <urn:ng-helpers:index:value> ?value }` returns the entries, for a stranger owning neither the index nor the objects exactly as for its creator; `readUnion([index])` returns the same subjects plus the index's own.
## Guarantees
**An index is an ordinary public document, and nothing marks it as one.** It lives in its creator's public store, so any reader opens it from the reference alone; its creator owns it, and any user may create one.
**A reference deposited into an index becomes an entry once the index's creator is connected.** Awaiting `indexing(port)` is that connection: across every index that identity owns, what was deposited while it was away becomes an entry then, and what arrives from that moment on becomes one as it lands. There is nothing to call, schedule or configure, and no way to aim it at one index — the creator's session is the only thing that ever adds an entry, and it serves all of them at once.
**A reference deposited into an index becomes an entry once the index's creator is connected.** Awaiting `indexing(port)` is that connection: across every index that identity owns, what was deposited while it was away becomes an entry then, and what arrives from that moment on becomes one as it lands. Nothing to call, schedule or configure, and no way to aim it at one index — that session serves all of them.
**The field is declared once, inside the document, and cannot be changed.** `createIndex` refuses an empty or blank one at the door: nothing here deletes, so an index created on a useless field is useless for good. Declaring it in the document, not in an application's source, stops two applications indexing one on two fields.
**The field is declared once, inside the document, and cannot be changed.** `createIndex` refuses an empty or blank one at the door; an index created on a useless field is useless for good. A document that ends up declaring several — which nothing on this surface can do, only a direct write to it — stops gaining entries, loudly and permanently.
**A new index is ready the moment `createIndex` produces it.** It can be deposited into straight away, and the session that created it is already the one making its entries — nothing to open, register or reconnect.
**A new index is ready the moment `createIndex` produces it** deposit into it straight away; nothing to open or register.
**Depositing is open to anyone; writing is the creator's alone.** `refer` is a deposit, not a write, so a stranger contributes to an index they could not write. What is deposited is a **bare reference**: no operation, no claim, no copy of the value. What the object itself says, when its entry is made, is what goes in.
**Depositing is open to anyone; writing is the creator's alone.** `refer` is a deposit, so a stranger contributes to an index they could not write. What is deposited is a **bare reference** no operation, no claim, no copy of the value: what the object itself says, when its entry is made, is what goes in.
**An index ONLY EVER GROWS.** No call removes an entry, for anyone including the creator, and none is planned: this package cannot express a removal at all, and it was deliberately never built rather than left for later. Do not design around a future delete — the answer to "this entry must go" is a fresh index.
**An index ONLY EVER GROWS.** No call removes an entry, for anyone including the creator: this package cannot express a removal at all. The answer to "this entry must go" is a fresh index.
**The same references produce the same index, whatever order they arrived in.** One reference deposited a hundred times leaves one entry; an object already indexed is passed over rather than read again; a burst settles exactly where those references would one at a time. The cost: no deposit is ever retired, so the work behind an index is linear in its whole history.
**The same references produce the same index, whatever order they arrived in.** One reference deposited a hundred times leaves one entry, and an object already indexed is passed over rather than read again. The cost: no deposit is ever retired, so the work behind an index is linear in its history.
**None of this can deny you anything.** A session that could not find its indexes, catch one up, or stay posted about one still hands you a working handle: reading an index and depositing into one never depended on that work. Every such failure is on this package's log stream, as is every reference that could not be resolved — harmless is not the same as invisible. Nothing is lost either way: a reference already deposited is still waiting, and the next connection makes its entry.
**Reading is per-entry tolerant.** `read` returns entries ordered by value, ties broken on the object NURI, so two readers always see the same order. Values are compared **as strings**, so an index whose field holds ISO-8601 dates comes out in chronological order. A subject that is not a NURI is passed over rather than thrown on, and only own properties are read: one stray triple cannot make every real entry unreadable.
**An entry carrying several values keeps the smallest, deterministically** — which two sessions racing each other can produce, and which keeps the entry visible with every reader agreeing on it.
**The document's own declaration is read strictly for writing and leniently for reading.** `read` refuses a document that declares no field at all rather than answering "an empty index": an unreadable document and an empty one arrive as the same empty result, so an empty answer would be a failure wearing the shape of a fact — retry before concluding it is malformed. An index declaring SEVERAL fields stops gaining entries, loudly and permanently, and stays readable: picking one would order a single list by two properties, since entries already made are never revisited. That cannot be undone — start a fresh index.
**Reading needs nothing from this package.** An application that knows the NURI can call the polyfill's `readUnion([index])` and get one subject per indexed object, plus the index's own subject declaring its field, which `read` drops. `INDEX_FIELD` and `ENTRY_VALUE` are published for that reader.
**None of this can deny you anything.** A session that could not find its indexes, catch one up, or stay posted about one still hands you a working handle, and nothing deposited is lost: the next connection makes its entry. Such failures, and every reference that could not be resolved, are on this package's log stream.
**Everything deposited is untrusted.** Anyone may deposit anything; `decodeReference` returns `null` for whatever is not a reference, and such a payload is passed over rather than stopping the rest.
**A BET, named as one — this layer's, not yours.** Nothing here is an application's to do or handle; it is what this layer stands on. That a document can receive deposits at all is aligned with NextGraph — a repository takes an inbox capability, at most one, on a real commit upstream. **What a deposit carries, and what receiving one does, are ours**: upstream's own set of deposit kinds is closed, carries no payload this could travel in, and offers no hook to extend it, so an index deposit has a shape NextGraph has not defined. When upstream defines it, this layer moves with it, and a `major` is how you hear about that.
## Non-guarantees
**Nothing tells you what became of a reference.** No report, no outcome list, no callback: an application that cannot ask for it has nowhere to receive the result. A reference that could not be resolved is warned about on the log stream; an object carrying nothing for the field, one carrying several, a self-reference and a payload that is no reference are not reported at all. Reading the index is how you find out.
**Reading is not an engagement.** `read` is sugar over the shape above; its ordering, its tie-breaks and what it makes of a subject carrying several values are free to change. The order an index comes out in is the caller's decision — if it matters, query the document and order the result.
**No timing.** Nothing says how long a reference takes to become an entry or lets you wait, and while its creator stays away it waits for as long as that lasts. There is no depth to inspect and no ordering between a deposit and a read.
**Nothing tells you what became of a reference, or when.** No report, no callback, nothing to wait on, no ordering between a deposit and a read; while an index's creator stays away, a deposit waits as long as that lasts. A reference that could not be resolved is warned about on the log stream; an object carrying nothing for the field, one carrying several, a self-reference and a payload that is no reference are not reported at all. Reading the index is how you find out.
**No refresh.** An already-indexed object is never read again, so one whose value changes later keeps its original indefinitely.
@@ -111,30 +106,30 @@ export const ENTRY_VALUE: string; // on an entry: that object's value for the
**A handle is one identity for its whole life, and nothing releases what it holds.** An application that changes identity within one page must build a new handle and drop the old one, which goes on listening under a session that holds nothing.
**Connecting reads this identity's whole public store** — a store read plus one read per document, every time a handle is built, because nothing marks one as an index. An index whose read did not answer then is not found, silently, and its entries are made at the next connection.
**Connecting reads this identity's whole public store** — a store read plus one read per document, every time a handle is built. An index whose read did not answer then is not found, silently; its entries are made at the next connection.
**The narrow behaviours are open questions, not promises.** An object carrying nothing for the field is not added; one carrying several values is not added; a raced entry keeps the smallest value. Each is implemented in its narrowest form rather than generalised, and each may change.
**No stable error text.** What a throw or a log line reads is for a human. Do not parse or branch on it.
**The narrow behaviours are open questions.** An object carrying nothing for the field is not added; one carrying several values is not added. Each may change.
**No cross-broker reach.** A NURI resolves for users of one broker.
**No depositor authentication and no rate limit.** Anyone may deposit any number of payloads into any index.
**A BET, named as one — this layer's, not yours.** That a document can receive deposits at all is aligned with NextGraph; **what a deposit carries, and what receiving one does, are not** — upstream has defined no such shape and offers no hook to extend the one it has. When it does, this layer moves with it, under a `major`.
## Change policy
**Semver, and majors are the normal case.** This layer sits on a polyfill itself converging on a NextGraph that does not ship yet, several of its behaviours are open questions above, and one part of it is a bet. Settling any of those narrows this surface, so the major number moves often — that frequency is the honest signal about this package, not an apology.
**Semver, and majors are the normal case.** This layer sits on a polyfill still converging on a NextGraph that does not ship yet; several behaviours above are open questions and one part is a bet, and settling any narrows this surface.
- **major** — an exported symbol is removed or renamed, **or** an existing call narrows: it throws where it returned, or reports a state you did not have to handle before. Settling an open question counts, and so does anything the bet forces. A signature change you must react to counts; one that only accepts more does not.
- **minor** — a symbol is added and nothing existing moves: a new read helper, a new optional option.
- **patch** — a fix that changes neither the exported surface nor anything under `## Guarantees`, throw text included.
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`2.0.1`); work on a branch carries a **pre-release** of the version it heads for (`2.1.0-dev.3`), which sorts below it by construction, and between two pre-releases of one version nothing is promised. Nothing you pinned is ever withdrawn: a pre-release keeps resolving once the full version appears alongside it. The tag is bare — `v2.0.1` — because this repository publishes exactly one engagement; should a second ever ship here, tags take the package name (`indexing/v…`).
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`3.0.0`); work on a branch carries a **pre-release** of the version it heads for (`3.1.0-dev.3`), which sorts below it, and between two pre-releases nothing is promised. Nothing you pinned is ever withdrawn. The tag is bare — `v3.0.0`.
**`2.0.1` is prose, and one requirement corrected.** This surface was described throughout as an *inbox* you deposit into and a thing that gets *curated*; neither is yours to know — you deposit a reference and you read entries, and how that travels is this layer's business and free to change under you — so the same facts are stated as effects now. And "hardcode the index's NURI in your source" was stated unconditionally, fused with the creator-must-connect requirement; it is neither. A reference to an index is held however you hold any other, and hardcoding is what a single GLOBAL index needs. Creator-must-connect is unchanged and stays unconditional. **Nothing to migrate** — no symbol moved, no call behaves differently, and the requirement that changed asks less than before. A **patch** under the rule above, worth spelling out because the wording moved so much: no exported symbol changed, nothing under `## Guarantees` promises anything it did not promise in `2.0.0` — only how it is said — and the requirement that did change is a deployment one, asking less.
**`3.0.0` stops engaging on reading, and drops a re-export that never existed.** The guarantees describing what `read` does — ordering, the tie-break on NURI, a subject with several values, a leniently-read declaration — are gone, replaced by the storage shape under `## Surface`. `PrincipalId` leaves it too: listed as re-exported, never exported by `src/index.ts`. **Nothing to migrate and no call behaves differently**`read` still orders exactly as before. A **major** under the rule above: the surface loses a symbol, and `## Guarantees` loses statements you may no longer rely on.
**`2.0.0` removed `Indexing.curate(index)` — and `CurationReport`, `CurationOutcome`, `SkipReason` with it — and made `indexing(port)` a promise.** Removals, hence the major: that call asked an application to decide who owns an index and when its entries are made, neither of which is an application's decision. **Migrating from `1.x`**: delete every call to `curate`, `await` the `indexing(port)` you already make, and where you read a `CurationReport` read the index instead — the log carries what did not resolve. `1.0.1` and `1.0.0` keep resolving and neither is forced to upgrade.
**`2.0.0` removed `Indexing.curate(index)` — and `CurationReport`, `CurationOutcome`, `SkipReason` with it — and made `indexing(port)` a promise.** **Migrating from `1.x`**: delete every call to `curate`, `await` the `indexing(port)` you already make, and where you read a `CurationReport` read the index instead. `2.0.1` was prose and one relaxed deployment requirement; nothing to migrate. `1.0.1` and `1.0.0` keep resolving.
Cut on `main`, so `2.0.1` is what you pin, and your `usage_` leaf anchors `against:` on that exact string — `against: @ng-helpers/indexing@2.0.1`.
Cut on `main`: pin `3.0.0`, and anchor your `usage_` leaf on `against: @ng-helpers/indexing@3.0.0`.
There is no changelog file and no deprecation window: **the sections above are the release note.** Diff this leaf between two pulls, `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
**No changelog file and no deprecation window: the sections above are the release note.** Diff `## Guarantees`, `## Non-guarantees` and `## Surface` between two pulls.