149 lines
14 KiB
Markdown
149 lines
14 KiB
Markdown
---
|
|
type: contract
|
|
summary: The API @ng-helpers/indexing exposes to an application — creating an index, depositing references into it, curating it, and reading it back
|
|
---
|
|
|
|
# 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, handing an index a reference to an object (open to anyone), the owner resolving those references and adding what it can, and reading the entries back in order.
|
|
|
|
It does not cover NextGraph itself — documents, identity, sharing, inboxes, 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 own field. It **never removes anything**, from anywhere, and that is a property of the engagement rather than a missing feature.
|
|
|
|
### Deployment requirements
|
|
|
|
An application using this package must:
|
|
|
|
- have a NextGraph session already 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;
|
|
- reach a broker, since every operation here is a document read, a document write, or an inbox deposit;
|
|
- **hardcode the index's NURI in its own source.** Nothing marks a document as an index; the reference is what makes it one, and it is the only way anyone reaches it.
|
|
|
|
One handle is one identity: the port carries a session and no call takes an identifier. Two users mean two handles.
|
|
|
|
## Surface
|
|
|
|
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures:
|
|
|
|
```ts
|
|
// ── wiring: one handle, one identity ─────────────────────────────────────────
|
|
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort;
|
|
export interface PolyfillPortOptions { readonly sessionId: string | number }
|
|
export function indexing(port: NextGraphPort): 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 };
|
|
|
|
// ── everything this package does ─────────────────────────────────────────────
|
|
export interface Indexing {
|
|
/** Creates an index in THIS identity's public store and opens its inbox. Any user may.
|
|
* `field` is the predicate an indexed object must carry, declared once and for good;
|
|
* an empty or blank one throws. Returns the NURI to hardcode. */
|
|
createIndex(field: string): Promise<Nuri>;
|
|
/** Deposits a bare reference into the index's inbox. Open to ANYONE. Nothing lands in
|
|
* the index until its owner curates. Throws if the index has no inbox. */
|
|
refer(index: NuriLike, object: NuriLike): Promise<void>;
|
|
/** OWNER only — resolves the references received and adds what it can. */
|
|
curate(index: NuriLike): Promise<CurationReport>;
|
|
/** The entries, ordered by value. Sugar over `readUnion([index])`. */
|
|
read(index: NuriLike): Promise<IndexEntry[]>;
|
|
}
|
|
|
|
// ── what an index holds ──────────────────────────────────────────────────────
|
|
export interface IndexEntry { readonly object: Nuri; readonly value: string }
|
|
export interface IndexDescriptor { readonly field: string }
|
|
|
|
// ── what curating reports ────────────────────────────────────────────────────
|
|
export type CurationOutcome =
|
|
| { readonly result: "indexed"; readonly object: Nuri; readonly value: string }
|
|
| { readonly result: "unchanged"; readonly object: Nuri }
|
|
| { readonly result: "skipped"; readonly object: Nuri; readonly reason: SkipReason }
|
|
| { readonly result: "unresolved"; readonly object: Nuri; readonly reason: string }
|
|
| { readonly result: "foreign"; readonly reason: string };
|
|
export type SkipReason = "no-field" | "several-values" | "self-reference";
|
|
export interface CurationReport {
|
|
readonly index: Nuri;
|
|
readonly outcomes: readonly CurationOutcome[]; // one per deposit, in deposit order
|
|
}
|
|
|
|
// ── what travels from a depositor to a curator ───────────────────────────────
|
|
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.
|
|
|
|
**The field is declared once, inside the document, and cannot be changed.** `createIndex` refuses an empty or blank field at the door, because nothing here deletes and an index created on a useless field is useless for good. Declaring it in the document rather than in an application's source is what stops two applications curating the same index on two different fields.
|
|
|
|
**`createIndex` opens the index's inbox itself.** Only the owner can, and creation is the one moment the owner is present, so it is not left to a later call to remember.
|
|
|
|
**Depositing is open to anyone; writing is the owner's alone.** `refer` is a deposit into the index document's inbox — not a write — so a stranger can contribute to an index they do not own. `curate` reads that inbox and writes the document, and both are refused to anyone but the owner. The deposit is a **bare reference**: it carries no operation, no index reference (the inbox address already identifies the index), and no copy of the indexed value. What the object itself says is what goes in.
|
|
|
|
**An index ONLY EVER GROWS.** There is no call that removes an entry, for anyone including the owner, and none is planned. This package cannot express a removal at all. The only answer to "this entry must go" is a fresh index.
|
|
|
|
**Curation is convergent and order-independent.** Deposits are never consumed, so every run sees every deposit again; re-applying one re-resolves the reference and lands on the same result. An already-indexed object is skipped outright as `unchanged`. Nothing depends on the order references arrived in.
|
|
|
|
**A reference that does not resolve costs nothing and is reported.** It comes back as `unresolved`, nothing is written for it, and nothing already in the index is touched — a later deposit adds it. Every unresolved reference appears in `CurationReport.outcomes`: harmless is not the same as invisible.
|
|
|
|
**Reading is per-entry tolerant.** `read` returns entries ordered by value, ties broken on the object NURI, so two readers of the same index always see the same order. Values are compared **as strings** — an index whose field holds ISO-8601 dates therefore comes out in chronological order. A subject that is not a NURI is skipped, never 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 curation runs racing each other can produce. The entry stays visible and every reader agrees on it.
|
|
|
|
**`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 the document is malformed.
|
|
|
|
**An index declaring SEVERAL fields refuses to CURATE, loudly and permanently — and stays readable.** Picking one would leave a single list ordered by two different properties, because entries already written are never re-read. Existing entries stay visible and correct; nothing new is added. The refusal cannot be undone, and it says so instead of suggesting a retry.
|
|
|
|
**Reading needs nothing from this package.** An application that knows the NURI can call the polyfill's `readUnion([index])` and get the entries as subjects — one per indexed object, keyed by its NURI — plus the index's own subject declaring its field, which `read` drops. `INDEX_FIELD` and `ENTRY_VALUE` are published for exactly that reader.
|
|
|
|
**Every inbox payload is untrusted.** Anyone may deposit anything; `decodeReference` returns `null` for everything that is not a reference, and such a payload is reported as `foreign` rather than crashing curation.
|
|
|
|
## Non-guarantees
|
|
|
|
**No removal, at any level, ever.** Not an oversight and not "not yet": it was deliberately never built. Do not design around a future delete.
|
|
|
|
**No refresh.** An already-indexed object is never re-read, so an object whose field value changes later keeps its original value in the index, indefinitely.
|
|
|
|
**No private data.** Indexing is limited to objects the curator can open itself. An object the index's owner cannot read is simply `unresolved`.
|
|
|
|
**`unresolved` does not tell you why.** Gone, unreadable, and "the read failed" arrive identically and are deliberately not distinguished. Never read it as "the object does not exist".
|
|
|
|
**The narrow behaviours are open questions, not promises.** An object carrying nothing for the field is `skipped: "no-field"`; one carrying several values is `skipped: "several-values"`; a raced entry keeps the smallest value. Each is implemented in its narrowest form and reported rather than generalised, and each may change.
|
|
|
|
**No stable error text.** What a throw or an `unresolved` reason reads is for a human reading a report. Do not parse it or branch on it.
|
|
|
|
**No timing and no delivery promise.** A deposit is not in the index until the owner curates, and nothing here schedules curation. There is no notification, no queue depth, and no ordering between a deposit and a read.
|
|
|
|
**The report grows with the inbox.** Since deposits are never retired, `CurationReport.outcomes` has one entry per deposit ever made, not per change.
|
|
|
|
**No cross-broker reach.** A NURI resolves for users of the same broker.
|
|
|
|
**No depositor authentication or rate limit.** Anyone may deposit any number of payloads into any index's inbox.
|
|
|
|
## Change policy
|
|
|
|
**Semver, and majors are the normal case.** This layer sits on a polyfill that is itself converging on a NextGraph that does not ship yet, and several of its own behaviours are declared above as open questions. Settling one of them narrows this surface — the major number will move often, and that frequency is the honest signal about this package, not an apology. Refusing to version would not slow the churn down; it would only take away the one tool you have for managing it. Pin a version, upgrade deliberately, and re-pull this contract each time.
|
|
|
|
What each level means here, in this package's own terms:
|
|
|
|
- **major** — an exported symbol is removed or renamed, **or** an existing call narrows: it now throws where it returned, or reports a state you did not have to handle before. Settling an open question counts, and so does adding a `CurationOutcome` variant or a `SkipReason` — an exhaustive `switch` in your code stops being exhaustive. 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: a new read helper, a new optional option.
|
|
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees`, including the text of a throw, which is explicitly disclaimed above.
|
|
|
|
**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.3`), 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.
|
|
|
|
**The tag is bare — `v1.0.0` — because this repository publishes exactly one engagement**, so there is nothing for a prefix to disambiguate. Should a second one ever ship here, tags take the package name from that point on (`indexing/v…`), because a bare tag stops saying which surface it froze the day two versions move independently. Bare tags already laid stay valid as history.
|
|
|
|
`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. This engagement is cut on `main`, so `1.0.0` is what you pin, and your `usage_` leaf anchors `against:` on that exact string — `against: @ng-helpers/indexing@1.0.0`. Had you pinned a pre-release, `against:` would carry that string, pre-release suffix included.
|
|
|
|
There is no changelog file and no deprecation window: **the sections above are the release note.** A removal or a narrowing lands in `## Surface` and `## Guarantees` in the same version that ships it. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|