Files
ng-helpers/.project/concepts/indexing/indexing-layer/contract_indexing-layer.md
T

141 lines
16 KiB
Markdown

---
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
---
# contract_indexing-layer — `@ng-helpers/indexing`
## Scope
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 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.
### 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.
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. 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.
## Surface
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures:
```ts
// ── wiring: one handle, one identity, and that identity's connection ─────────
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) ────────────────────
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
export type { PrincipalId, UnionSubject, NextGraphPort, IncomingDeposit, ObjectResolution };
// ── the three acts an application performs ───────────────────────────────────
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. */
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])`. */
read(index: NuriLike): Promise<IndexEntry[]>;
}
// ── what an index holds, and what travels from a depositor to an index ───────
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 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
```
## 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.
**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.
**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.
**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.
**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.
**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.
**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.
**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.
**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.
**No refresh.** An already-indexed object is never read again, so one whose value changes later keeps its original indefinitely.
**No private data.** Only objects the index's creator can open are indexed; one it cannot read is not added.
**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.
**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.
**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.
## 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.
- **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…`).
**`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.
**`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.
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`.
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.