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

14 KiB

type, summary
type summary
contract 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:

// ── 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.