7 Commits

Author SHA1 Message Date
Sylvain Duchesne 8bcea7c5be fix: lire un document par la requête ancrée, readUnion ayant quitté le polyfill
Le contrat polyfill-surface passe de 1.0.0-dev.6 à 1.0.0-dev.15, épinglé
désormais sur le tag polyfill/v1.0.0-dev.15. readUnion et UnionSubject ont
été retirés en dev.8 : l'application e2e lit un document par un SELECT ancré
regroupé par sujet, en ignorant les registres urn:ng-eventually: que la
requête renvoie désormais.

La déclaration usage_ng-helpers retire ce que l'engagement a absorbé
(exemple de forme, initNg, orm_start_graph corrigé en dev.7, comportement
d'un document illisible, lecture d'un document public par sa seule
référence, pin par tag) et garde les demandes encore ouvertes.

contract_indexing-layer 4.0.1 (patch) : la lecture d'un index ne cite plus
readUnion.
2026-10-01 16:22:02 +02:00
Sylvain Duchesne 71667ca14e fix: ng-helpers rejoint la surface dev.6, et son faux suit le contrat 2026-09-21 12:16:42 +02:00
Sylvain Duchesne e566c5b0b3 docs: declarer ce que ng-helpers consomme, et publier les deux declarations 2026-09-21 11:34:08 +02:00
Sylvain Duchesne ff78a70f14 feat!: le paquet crée un index et lui ajoute une référence, rien de plus 2026-08-21 19:56:11 +02:00
Sylvain Duchesne c2f9ff4674 feat!: la lecture quitte le contrat, un index se lit comme un document 2026-08-21 15:49:59 +02:00
Sylvain Duchesne ebaae15baf docs: dire l'effet et non le canal, et n'exiger le NURI en dur que d'un index global 2026-08-20 14:47:55 +02:00
Sylvain Duchesne e2ed970cbd feat!: curer n'est plus un appel, c'est ce que fait le traitement de l'inbox 2026-08-20 11:04:31 +02:00
30 changed files with 2289 additions and 1974 deletions
-2
View File
@@ -2,8 +2,6 @@ node_modules/
dist/ dist/
*.tsbuildinfo *.tsbuildinfo
.DS_Store .DS_Store
bun.lockb
bun.lock
e2e/.dist/ e2e/.dist/
*.ngw *.ngw
@@ -1,6 +1,6 @@
--- ---
type: contract 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 summary: What @ng-helpers/indexing engages to do — create an index, and hand one a reference anyone may deposit; the index itself is an ordinary document anyone queries, and what it receives becomes entries through the layer below
--- ---
# contract_indexing-layer — `@ng-helpers/indexing` # contract_indexing-layer — `@ng-helpers/indexing`
@@ -9,147 +9,113 @@ 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. 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 covers two acts: creating an index, and handing one a reference to an object (open to anyone). It is a thin helper, and its whole job is to spare you the address an index receives on — you name documents, never an inbox.
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. It does not cover what becomes of a reference you hand over: making entries of what an index receives belongs to the layer below, and there is no call here that does it, aims it or asks about it. Nor reading — the document is ordinary, and `## Surface` has the shape to query it. Nor NextGraph itself: documents, identity, sharing, transport, all reached through the session you hand it. Nor search, filtering, pagination, or querying by anything but the index's field.
### Deployment requirements ### Deployment requirements
An application using this package must: 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; - have a NextGraph session open under the identity it wants to act as, and hand this package its **session id** — what `@ng-eventually/polyfill`'s own `init(…)` hands its callback, relayed and never converted;
- reach a broker, since every operation here is a document read, a document write, or an inbox deposit; - **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. It is also what turns what an index receives into entries, so there is nothing extra to install or configure for that;
- **supply `@ng-eventually/polyfill` itself.** This package declares it a *peer*, not a dependency: the application names it among its own dependencies and decides which copy it gets. That copy must be the very one the application's own code calls, because everything this package does passes through it — and that package requires exactly one instance of itself in an application, for reasons its own contract states. - reach a broker — nothing here is answered locally.
- **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. A handle is one identity's and holds nothing: build one at startup and keep it, or build one where you need it.
**Obtaining it.** This package is not published to npm, nor to any other package host, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. `@ng-eventually/polyfill` is distributed the same way. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and what you must provide alongside it. **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. It is the only way anyone reaches the index, and losing it loses the index. **Hardcoding it is what a single GLOBAL index needs, and only that case**; anything narrower is discovered.
**Obtaining it.** Not published to npm and not built output: the entry point is TypeScript source, so whatever builds the application compiles it — as does `@ng-eventually/polyfill`.
## Surface ## Surface
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures: Full typed shape: the package's `types` entry. All of it, and it is deliberately this small:
```ts ```ts
// ── wiring: one handle, one identity ───────────────────────────────────────── /** This identity's handle. It reaches nothing on its own — only the two acts do.
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort; * `sessionId` is what
export interface PolyfillPortOptions { readonly sessionId: string | number } * `@ng-eventually/polyfill`'s own `init(…)` hands its callback, relayed, never converted. */
export function indexing(port: NextGraphPort): Indexing; export function indexing(sessionId: string | number): Indexing;
// ── addressing (re-exported so you import them from here) ──────────────────── 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. */
create(field: string): Promise<Nuri>;
/** Hands the index a bare reference to an object. Open to ANYONE. Throws when the
* document cannot take one, rather than losing it. Added now, visible later. */
add(index: NuriLike, object: NuriLike): Promise<void>;
}
// addressing, as the two acts above speak it
export type Nuri = `did:ng:${string}`; export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string; export type NuriLike = Nuri | string;
export type { PrincipalId, UnionSubject, NextGraphPort, IncomingDeposit, ObjectResolution };
// ── everything this package does ───────────────────────────────────────────── // the IRIs an index is written with — how you recognise what you read
export interface Indexing { export const INDEX_FIELD: string; // "urn:ng-helpers:index:field"
/** Creates an index in THIS identity's public store and opens its inbox. Any user may. export const ENTRY_VALUE: string; // "urn:ng-helpers:index:value"
* `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
``` ```
**There is no call here that reads an index, and that absence is the engagement.** An index is an ordinary document: its contents come back the way any other document's do, and the two IRIs above are the whole of what you need to make sense of them. A helper of ours would only teach a shape you would have to unlearn.
**What an index document holds** — two shapes:
- on the **index's own subject**, `INDEX_FIELD` carries the predicate an indexed object must carry, as a **literal**, not a URI. `create` writes it, and it is the only thing this package ever writes;
- 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.
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; an anchored `SELECT ?s ?p ?o WHERE { ?s ?p ?o }` returns the same subjects plus the index's own. That wider query also returns subjects under `urn:ng-eventually:` — the registers `@ng-eventually/polyfill` keeps in every document it manages; they are not this package's, and you skip them.
## Guarantees ## 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. **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. **A reference you hand an index becomes one of its entries.** Not by anything on this surface: the layer below processes what an index receives, and that is what an entry is made by. Nothing to call, schedule or configure — and nothing to wait on, which is why `add` returns as soon as the reference is lodged rather than when it can be read back.
**`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. **The field is declared once, at creation, and this package never changes it.** `create` refuses an empty or blank one at the door; an index created on a useless field is useless for good.
**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. **A new index is ready the moment `create` produces it** — hand it a reference straight away; nothing to open or register.
**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. **Handing a reference over is open to anyone; writing an index is its creator's alone.** `add` is not a write, so a stranger contributes to an index they could not write a triple into. What travels is a **bare reference** — no operation, no claim, no copy of the value, and no index either, since the one it is handed to identifies it. What the object itself says, when its entry is made, is what goes in. And it is a refusal rather than a silent loss: `add` throws when the document cannot take a reference, and refuses anything that is not a NURI before it goes anywhere.
**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. **Handing the same reference over twice is safe, and so is doing it a hundred times.** A reference is an invitation to look, never an instruction. Do it when the object is created, and again whenever anyone notices the index may not have it yet — including a third party who only read the index.
**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. **Nothing on this surface removes anything.** No call takes an entry out of an index, for anyone including its creator: this package cannot express a removal at all. The answer to "this entry must go" is a fresh index.
**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 ## 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. **Nothing tells you what became of a reference, or when.** No report, no callback, nothing to wait on, no ordering between handing one over and reading the index. Whether an entry is made at all, what value it ends up holding, and how long that takes are not engaged here — reading the index is how you find out. `add` checks that a reference is a NURI and nothing more: not that the object exists, that it is readable, or that it carries the index's field.
**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 order, and nobody to ask for one.** Nothing here hands you an index's contents, so the order they come out in is whatever your own query says — and a subject carrying several values is yours to make something of.
**No private data.** Indexing is limited to objects the curator can open itself. An object the index's owner cannot read is simply `unresolved`. **A handle holds nothing, and there is nothing to release.** It is one identity's for its whole life — an application changing identity within one page builds another, which is free.
**`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". **No cross-broker reach.** A NURI resolves for users of one broker.
**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 depositor authentication and no rate limit.** Anyone may hand any index any number of references, and what an index receives is untrusted: telling a reference from the rest is the business of whoever processes it.
**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. **A BET, named as one — this layer's, not yours.** That a document can receive at all is aligned with NextGraph; **what is handed over, and what receiving it does, are not** — upstream defines no such shape and offers no hook to extend the one it has. When it does, this layer moves with it, under a `major`.
**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 ## 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. **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.
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 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.
- **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. - **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. - **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** (`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. **A tag says where it comes from.** A release cut on `main` carries a **full version** (`4.0.0`); work on a branch carries a **pre-release** of the version it heads for (`4.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 — `v4.0.0`.
**The tag is bare — `v1.0.1` — 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. **`4.0.0` cuts this package to two acts.** Everything that made entries of what an index received has left it: that work is done by the layer below, on the inbox an index already has, so an index goes on filling without any of the calls listed here.
`1.0.0` was a baseline, not a claim of maturity: it was the number that made your pin mean something. Nothing was released before it. **It could not be installed, however**, and `1.0.1` supersedes it. `1.0.0` declared `@ng-eventually/polyfill` as a dependency resolved through a path that existed only in one working copy, so every attempt to install it from anywhere else failed outright — not on some operations but at the install itself, which is why no application ever ran it. `1.0.1` declares that package a peer, which the application supplies. Nothing exported moved, which is what makes this a patch and not a major: the only thing that changed for a caller is a requirement it could never have satisfied before, so there is no working arrangement for it to break. - **`indexing(port)` → `indexing(sessionId)`, and it no longer returns a promise.** Pass the session id you were passing to `polyfillPort`, and delete the `polyfillPort({ sessionId })` call: this package does that wiring itself now, so `polyfillPort` and `PolyfillPortOptions` go with it. `await indexing(…)` still compiles — there is simply nothing left for it to wait for.
- **`createIndex(field)` → `create(field)`**, and **`refer(index, object)` → `add(index, object)`.** Same behaviour, same throws; `add` is the verb every index carries, and it already says added-now-visible-later.
- **`read(index)` is gone — read the document.** Run the anchored `SELECT` under `## Surface`, or read every triple of the document and skip the index's own subject, taking `ENTRY_VALUE` off the rest. Ordering is yours now; the call you are replacing ordered by value, broke ties on the object's NURI, and refused a document declaring no field.
- **`decodeReference` and `IndexDeposit` are gone**, with the types that served only the calls above: `NextGraphPort`, `IndexEntry`, `IndexDescriptor`, `ObjectResolution`, `IncomingDeposit`, `UnionSubject`. `Nuri`, `NuriLike` and `Indexing` stay.
- **Nothing replaces the guarantees about processing, and nothing needs to.** `3.0.0` engaged that awaiting a handle applied what an identity's indexes had received, kept applying it, caught up a backlog, resolved each reference, passed over an object carrying nothing or too much, and refused an index declaring two fields. None of that is engaged here any more. If your application relied on a handle being what made an index fill, it no longer is — and no longer has to be: the index fills whether that handle exists or not.
**`1.0.0` is superseded, not withdrawn.** The tag stays where it is and keeps resolving, because no pinned reference is ever taken away from under you — this contract's policy holds even for a version that never worked. Nothing forces an upgrade; it is simply that an installation pinned there cannot have succeeded, so there is nothing to migrate. **`3.0.0` stopped engaging on what `read` returned** — its ordering, its tie-breaks, what it made of a subject carrying several values — replacing those guarantees with the storage shape under `## Surface`, and dropped `PrincipalId`, listed as re-exported and never exported. **`2.0.0` removed `Indexing.curate(index)`**, with `CurationReport`, `CurationOutcome` and `SkipReason`. `2.0.1`, `1.0.1` and `1.0.0` keep resolving.
This engagement is cut on `main`, so `1.0.1` is what you pin, and your `usage_` leaf anchors `against:` on that exact string — `against: @ng-helpers/indexing@1.0.1`. Had you pinned a pre-release, `against:` would carry that string, pre-release suffix included. **`4.0.1` is a patch**: nothing exported and nothing under `## Guarantees` moved; what moved is how this leaf tells you to read an index. It used to name `@ng-eventually/polyfill`'s `readUnion([index])`, which that package has since withdrawn; the anchored `SELECT` it already named is now the only one, and a wider one meets the polyfill's own `urn:ng-eventually:` registers, which you skip.
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. Cut on `main`: pin `4.0.1`, and anchor your `usage_` leaf on `against: @ng-helpers/indexing@4.0.1`.
**No changelog file and no deprecation window: the sections above are the release note.** Diff `## Guarantees`, `## Non-guarantees` and `## Surface` between two pulls.
@@ -0,0 +1,169 @@
---
type: contract
summary: The machinery ng-e2e-helpers offers a NextGraph application's end-to-end suite — wallets, the broker crossing, per-run profiles, bounds, and a bounded report
pulled_from: git@gitea.reconnexion.apps.gueraud.net:Reconnexion/ng-eventually.git/.project/concepts/e2e-harness/ng-e2e-helpers/contract_ng-e2e-helpers.md
pulled_version: d44063961682f84d4b804e5b349cd0a791dd9550
pulled_at: 2026-09-21
---
# contract_ng-e2e-helpers — `ng-e2e-helpers`
## Scope
This package is the end-to-end testing machinery a NextGraph application needs to get a real person into itself: minting a wallet by driving the wallet application, crossing the broker, and coming back inside the iframe the application runs in — plus per-run browser profiles, bounds that turn a hang into a named failure, and a run report whose size does not depend on what failed.
It covers **only what is generic to NextGraph**. It knows nothing about any one application, and nothing about any compatibility layer: an application calling the NextGraph SDK directly is its intended consumer. What is specific to your repository — the page that carries your application, how you build your bundle, which journeys you run — is yours to write, and this package deliberately offers no place to put it.
It is not a test runner, not an assertion library, and not a fixture system. You keep your own runner and your own `main()`.
### Deployment requirements
`playwright` and `@ng-org/web` are **peer dependencies**, and you own both versions: browser binaries have to match the driver, and the SDK the export page opens a session with must be the one your application and your broker agree on. Import the browser types from the helpers that return them rather than from `playwright` directly — a second resolution of the driver produces a structurally different `BrowserContext`, and a context you opened then cannot be handed back to the helper that opens contexts.
A machine running this needs a real Chromium, network reach to the wallet application and to the broker, and a writable temporary directory for the per-run profiles.
## Surface
Full typed shape: the package's `types` entry, `ng-e2e-helpers`. It also ships one executable, `ng-mint-wallet`, for provisioning a deployment's wallet file outside any run. `BrowserContext`, `Frame`, and `Page` — every Playwright type this surface mentions, no more — are re-exported as types from the same entry point, so a consumer can type its own helper functions against these signatures without a second, independently-versioned import of `playwright`. The load-bearing signatures:
```ts
// ── bounds: a hang becomes a named failure ───────────────────────────────────
export function within<T>(what: string, ms: number, task: () => Promise<T>): Promise<T>;
export class DeadlineExceeded extends Error {} // thrown by `within` — names `what`
export class BrowserGone extends Error {} // the browser died; not the task's fault
export function enclosingBound(steps: readonly number[], margin: number): number;
export function closeQuietly(what: string, close: () => Promise<unknown>): Promise<void>;
export function armSuiteDeadline(suite: string, ms: number, thenReport?: () => void): void;
export function browserLost(reason: string): void; // declare it, once
export function lossDeclared(): string | null; // has it been declared?
export function firstLine(e: unknown): string;
export const CLOSE_MS: number, CONTEXT_ACTION_MS: number, CONTEXT_NAVIGATION_MS: number;
// ── measurement: how a bound gets its number ─────────────────────────────────
export function measured<T>(what: string, bound: number, task: (ms: number) => Promise<T>): Promise<T>;
export function record(what: string, ms: number, ok: boolean, bound: number): void;
export function timingsWanted(): boolean; // true under E2E_TIMINGS=1
export function printTimings(): void;
// ── browser and profiles: one run owns its own ───────────────────────────────
export interface RunProfile { readonly dir: string; readonly purpose: string; discard(): void }
export function newRunProfile(purpose: string): RunProfile;
export function isAlive(pid: number): boolean;
export function launchWatchedContext(label: string, dir: string): Promise<BrowserContext>;
export function closeContext(label: string, ctx: BrowserContext): Promise<void>;
export function newPage(label: string, ctx: BrowserContext): Promise<Page>;
export const LAUNCH_MS: number, NEW_PAGE_MS: number;
// ── the wallet: minted, carried, imported ────────────────────────────────────
export interface WalletCredentials { readonly name: string; readonly password: string }
export const DEFAULT_WALLET_NAME: string; // the password never has a default
export function mintWalletProfile(purpose: string, c: WalletCredentials): Promise<RunProfile>;
export function mintWalletProfileKeepingContext(
purpose: string, c: WalletCredentials): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
export function mintWalletBytes(password: string, name?: string): Promise<Uint8Array>;
export function createWalletInContext(ctx: BrowserContext, c: WalletCredentials): Promise<void>;
export function emptyProfileContext(
purpose: string): Promise<{ ctx: BrowserContext; profile: RunProfile }>;
export function exportWalletBytes(ctx: BrowserContext, walletPassword: string): Promise<Uint8Array>;
export function exportWalletFile(ctx: BrowserContext, ngwPath: string, pw: string): Promise<number>;
export function importWalletFile(page: Page, ngwPath: string, password: string): Promise<void>;
// ── the broker crossing ──────────────────────────────────────────────────────
export function setupBrokerPage(page: Page, appUrl: string, walletPassword: string): Promise<Frame>;
export function completeBrokerLogin(page: Page, appUrl: string, pw: string): Promise<Frame>;
export const BROKER_LOGIN_MS: number, BROKER_ROUND_TRIP_MS: number;
export function brokerRedirectFor(appUrl: string): string;
export const BROKER_SCREENS: readonly BrokerScreenSpec[]; // the screens, in test order
export const WALLET_APP, WALLET_CREATION, WALLET_IMPORT; // the wallet application's own pages
export type BrokerScreen = "choose-broker" | "login-offered" | "wallet-list"
| "password" | "working" | "error";
export type { BrokerScreenSpec, ScreenSignature, ScreenResponse, TextPattern };
// ── serving your application to the browser ──────────────────────────────────
export function serveOnEphemeralPort(
handler: (req: IncomingMessage, res: ServerResponse) => void,
): Promise<{ url: string; close: () => void }>;
// ── failures that are not the application's ──────────────────────────────────
export function browserTrouble(label: string, ctx: BrowserContext): Promise<string | null>;
export function frameTrouble(id: string, page: Page, frame: Frame, marker: string): Promise<string | null>;
export const BROWSER_PROBE_MS: number, FRAME_PROBE_MS: number;
// ── the report ───────────────────────────────────────────────────────────────
export interface JourneyDeclaration { readonly name: string; readonly checks: readonly string[] }
export type Prerequisite = () => Promise<string | null> | (string | null);
export interface JourneySpec {
readonly name: string; // must be a declared journey
readonly needs?: readonly Prerequisite[]; // each answers null, or why it cannot start
readonly run: () => Promise<void>;
}
export interface SuiteOptions {
readonly label: string;
readonly journeys: readonly JourneyDeclaration[];
readonly journeyBound: number;
readonly diagnose?: () => Promise<string | null>;
}
export interface SuiteReport {
check(name: string, ok: boolean, detail?: string): void; // throws on an undeclared name
journey(spec: JourneySpec): Promise<void>;
finish(fatal: string | null): never; // prints, then leaves
}
export function declareSuite(options: SuiteOptions): SuiteReport;
// ── playwright types this surface mentions, re-exported so a second import isn't needed ─────
export type { BrowserContext, Frame, Page };
```
## Guarantees
**A bound names what it was waiting for.** `within(what, ms, task)` either settles the task or throws a `DeadlineExceeded` carrying `what` verbatim — never a bare timeout with no subject. `armSuiteDeadline` does the same for a whole run, and reports before leaving. `enclosingBound(steps, margin)` returns a number no smaller than the sum of the bounds it encloses, so an enclosure can never fire before the step that actually hung.
**A run owns its profile and discards it.** `newRunProfile` returns a directory belonging to this run alone; `discard()` kills whatever still holds it and removes it, is idempotent, and also runs when the process leaves — including when the run is killed. Two runs never share local state, so nothing a previous run left can make this one pass or fail.
**A minted wallet is new.** `mintWalletProfile`/`mintWalletBytes` walk the wallet application to create a wallet that did not exist a moment ago. `mintWalletBytes` keeps only the bytes and discards the profile it minted in; `mintWalletProfile` keeps the profile for the length of the run. The password is always a parameter and never has a default; the name defaults to `DEFAULT_WALLET_NAME` because nothing in NextGraph keys off it.
**The crossing dispatches on the screen it can see**, not on a fixed sequence: `setupBrokerPage` reads the page against `BROKER_SCREENS` in order, answers it, and returns the application's `Frame` once the crossing has completed. It identifies the application by ORIGIN. A terminal screen ends the crossing as a failure rather than an action.
**An empty-profile context starts with nothing local.** `emptyProfileContext` gives a context whose profile holds no wallet and no repo cache, which is the reconnection cold start: the wallet's repos are on the broker and not in this profile. `importWalletFile` then puts the wallet in — but not the repos' cache — so the next session over it still hits that cold start.
**A closed server is closed.** `serveOnEphemeralPort` tracks its open sockets and destroys them on `close()`, so a connection nobody hung up cannot be blamed on whatever goes wrong next.
**A known failure mode is named instead of the innocent operation.** `browserTrouble`/`frameTrouble` answer a string when the browser or the application frame is the actual cause, and `null` when they are not. `SuiteOptions.diagnose` puts that answer in FRONT of a journey's own reason, never in place of it. A frame that is attached, on the right URL, and empty is reported as trouble — that is the state a naive check misses.
**The report's size does not depend on what failed.** Journeys and their checks are declared up front to `declareSuite`; `check` throws on a name the journey did not declare, `journey` runs one journey bounded and isolated so it cannot change the report's shape, and `finish` reports every declared check the run did not get to before printing and exiting. A journey whose prerequisite is provably dead is reported as such instead of being driven.
**Errors are values where a caller can act on them.** `browserTrouble`/`frameTrouble`/`Prerequisite` answer `string | null`, never throw for the condition they detect. `DeadlineExceeded` and `BrowserGone` are exported classes, so `instanceof` is a supported way to tell a hang from a dead browser.
## Non-guarantees
**No assertion library, no runner, no fixtures.** `SuiteReport.check` records a boolean you computed; nothing here decides what is true.
**`finish` never returns** — it exits the process. Do not put cleanup after it; put it in the `discard()`/`closeQuietly` path.
**No message text is stable.** The strings from `browserTrouble`, `frameTrouble`, `DeadlineExceeded` and the report are for a human reading a run. Do not parse them, match on them, or branch on them.
**No timing promise.** Every exported `*_MS` constant is a bound sized from a measurement on one machine, not a service level: they change when the measurements change, and a run slower than one of them is a failed run, not a broken guarantee. `E2E_TIMINGS=1` reprints the measurements so you can resize your own.
**Nothing survives a run.** Profiles are discarded, and no artifact, cache, or wallet is carried from one run to the next. The one exception is a file you write yourself with `exportWalletFile` or `ng-mint-wallet`.
**No concurrency contract.** The suite machinery assumes one run per process. Two suites sharing a process share `armSuiteDeadline`, the timings table and the loss declaration.
**No wallet-application version pinning.** The crossing is driven against the wallet application as deployed at the broker you point it at. When that application changes its screens, `BROKER_SCREENS` is what has to be updated — a crossing that fails there is not necessarily your application's fault, and is not this package's promise to hold across upstream redesigns.
## Change policy
**Semver, and majors are the normal case.** This machinery tracks a NextGraph and a wallet application that are both still moving, so a screen inventory, a bound, or a call shape changes whenever they do — 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, requires an argument that was optional, or returns something a caller must newly handle. A change to what a `SuiteReport` accepts (a check name, a journey shape) is a major, because your suite is written against it. 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 helper, a new screen in `BROKER_SCREENS`, a new optional field on `SuiteOptions`.
- **patch** — a fix that changes neither the exported surface nor anything above under `## Guarantees` — including a re-sized `*_MS` constant, 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.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `ng-e2e-helpers/v1.0.0-dev.2` is this package, `polyfill/v…` is the other one. A bare `v…` tag would say nothing about which surface it froze the day the two diverge — which is the day one of them takes a major and the other does not.
`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. What exists today is `1.0.0-dev.2`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it — `against: ng-e2e-helpers@1.0.0-dev.2`, the string you pinned, never the version it is heading for.
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.
@@ -1,49 +1,52 @@
--- ---
type: usage type: usage
summary: What this repo's end-to-end suite calls from ng-e2e-helpers, the peer-dependency constraint it must respect, and the gaps it fills itself summary: What this repository's end-to-end suite calls from ng-e2e-helpers - bounds, profiles, the minted wallet, the broker crossing, and the run report
against: ng-e2e-helpers@1.0.0-dev.1 against: "ng-e2e-helpers@1.0.0-dev.2 (pulled d44063961682f84d4b804e5b349cd0a791dd9550)"
--- ---
# usage_ng-helpers — `@ng-helpers/indexing`'s end-to-end suite on `ng-e2e-helpers` # usage_ng-helpers - `@ng-helpers/indexing`'s end-to-end suite on `ng-e2e-helpers`
The consumer is this repository's end-to-end suite: one run that builds a small indexing application, serves it, gets real people into it through the real broker, and drives depositing and curating between them. The consumer is this repository's end-to-end suite: one run that builds a small indexing application, serves it, gets two real people into it through the real broker, and drives depositing and curating between them.
Everything generic — the wallet lifecycle, the broker crossing, per-run profiles, bounds, the report shape, the recognition of the known browser failure modes — comes from the engagement and is **used, never reimplemented**. What is specific to this repository is the page that carries our application and the name its runs mint, and nothing else. Everything generic - the wallet lifecycle, the broker crossing, per-run profiles, bounds, the report shape, the recognition of the known browser failure modes - comes from the engagement and is **used, never reimplemented**. What is specific to this repository is the page that carries the application, the name its runs mint, and the journeys themselves. Two files touch the engagement: `e2e/harness-page.ts` and `e2e/run.ts`.
## Consumed surface ## Consumed surface
**Bounds** — `within`, `armSuiteDeadline`, `closeQuietly`, `firstLine`. - **Bounds** - `within`, `armSuiteDeadline`, `closeQuietly`, `firstLine`.
- **Measurement** - `measured`.
- **Browser and profiles** - `launchWatchedContext`, `closeContext`, `newPage`, and the type `RunProfile`.
- **Wallet** - `mintWalletProfile` and the type `WalletCredentials`. Each run mints its own.
- **Broker crossing** - `setupBrokerPage`.
- **Serving** - `serveOnEphemeralPort`, for the application bundle.
- **Known failure modes** - `browserTrouble`, wired as the suite's `diagnose`.
- **Report** - `declareSuite` and the type `Prerequisite`; on the handle it returns, all three of `check`, `journey` and `finish`. The `JourneyDeclaration`, `JourneySpec` and `SuiteOptions` shapes are consumed structurally, through those calls, rather than imported.
- **Constants** - `BROKER_ROUND_TRIP_MS`, `NEW_PAGE_MS`, each used as a term of a bound this suite composes.
- **Browser types** - `BrowserContext`, `Page` and `Frame`, obtained by DERIVING them from the helpers that return them (`Awaited<ReturnType<typeof ...>>`) rather than by importing them. See `## Constraints`.
**Measurement** — `measured`. **Not consumed**, and safely evolvable as far as this suite is concerned: `completeBrokerLogin`, `brokerRedirectFor`, `BROKER_SCREENS` and the screen inventory types, `WALLET_APP` / `WALLET_CREATION` / `WALLET_IMPORT`, `emptyProfileContext`, `importWalletFile`, `exportWalletBytes` / `exportWalletFile`, `mintWalletBytes`, `mintWalletProfileKeepingContext`, `createWalletInContext`, `newRunProfile`, `isAlive`, `browserLost`, `lossDeclared`, `enclosingBound`, `frameTrouble`, `record`, `timingsWanted`, `printTimings`, `DEFAULT_WALLET_NAME`, the exported error classes `DeadlineExceeded` and `BrowserGone`, the `ng-mint-wallet` executable, and the remaining `*_MS` constants.
**Browser and profiles** — `launchWatchedContext`, `closeContext`, `newPage`, and the type `RunProfile`.
**Wallet** — `mintWalletProfile` and the type `WalletCredentials`. Each run mints its own.
**Broker crossing** — `setupBrokerPage`.
**Serving** — `serveOnEphemeralPort`, for the application bundle.
**Known failure modes** — `browserTrouble`, wired as the suite's `diagnose`.
**Report** — `declareSuite` and the type `Prerequisite`.
**Constants** — `BROKER_ROUND_TRIP_MS`, `NEW_PAGE_MS`.
Everything else the engagement offers is NOT consumed here: the screen inventory and its types, `completeBrokerLogin`, `emptyProfileContext`, `importWalletFile`, `exportWalletBytes`/`exportWalletFile`, `mintWalletBytes`, `mintWalletProfileKeepingContext`, `createWalletInContext`, `newRunProfile`, `isAlive`, `browserLost`, `lossDeclared`, `enclosingBound`, `frameTrouble`, the exported error classes, and the remaining `*_MS` constants. It is safely evolvable as far as this suite is concerned.
## Constraints ## Constraints
**The browser types are DERIVED from the helpers, never imported from `playwright` here.** The engagement declares Playwright a peer dependency, and this package reaches it as symlinked files — so TypeScript resolves the helpers' `playwright` from where those files really live. Importing the driver in this repository as well produced two structurally different copies of `BrowserContext`, and a context this suite had opened could not be handed back to the helper that opens contexts. Taking the types from the calls that return them leaves exactly one set, and a version skew can no longer express itself as a type error in code that is correct. **The browser types are DERIVED from the helpers, never imported from `playwright` here.** The engagement declares Playwright a peer dependency and this package reaches this repository as symlinked files, so TypeScript resolves the helpers' `playwright` from where those files really live. Importing the driver in this repository as well produced two structurally different copies of `BrowserContext`, and a context this suite had opened could not be handed back to the helper that opens contexts. Taking the types from the calls that return them leaves exactly one set. The engagement now re-exports those same three types from its own entry point, which reaches the same single-set guarantee by a shorter road; what must keep holding either way is that there is exactly ONE resolution of the driver in this repository.
**One wallet per run, minted, never carried.** The run's wallet name is stable and its identity is not; nothing survives a run, and no result depends on a previous one. **One wallet per run, minted, never carried.** The run's wallet name is stable and its identity is not; nothing survives a run, and no result depends on a previous one. That is also what lets this suite run beside another repository's at the same time, against the same broker, without a lock.
**Nothing generic is reimplemented here.** Where a helper exists, it is called. That is a standing rule for this suite, not a preference — the crossing alone has cost days of misdiagnosis upstream, and a local copy of it would not carry those lessons. **Nothing generic is reimplemented here.** Where a helper exists, it is called. That is a standing rule for this suite, not a preference - the crossing alone has cost days of misdiagnosis upstream, and a local copy of it would not carry those lessons.
**Every wait is entered bounded.** No page or frame operation runs outside `within` or a helper that bounds it itself. **Every wait is entered bounded.** No page or frame operation runs outside `within` or a helper that bounds it itself.
**No failure text is parsed.** What `browserTrouble` answers and what a deadline carries are reported to a human reading the run and never matched on or branched on; a failed check carries an unexpected VALUE, and that is what the suite decides from.
**Two suites never share this run's machinery.** One suite deadline is armed and one timings table is kept, and nothing here assumes either can be shared - the engagement offers no concurrency guarantee and this suite asks for none.
## Frictions ## Frictions
**Nothing bounds a call into the application iframe.** `frame.evaluate` carries no timeout of its own, so this suite wraps every bridge call itself. The engagement offers no way to obtain that bound, so each consumer re-derives the same wrapper — and the derivation is not free: the calls this suite reached for outside its own wrapper are exactly the ones that can still hang it. A bounded `evaluate` here would delete the wrapper and close the gap in one move. **The acquisition channel carries no version, so the Change policy has nothing to bite on.** The engagement asks for a pinned version; this repository reaches the package as a filesystem link, so what it compiles and runs 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 semver discipline from a description into a mechanism.
**"Measured and bounded" is one intent and two calls.** Sizing a bound from its own measurement is the discipline the engagement itself prescribes, yet every step in this suite has to compose `measured(what, ms, (bound) => within(what, bound, task))` by hand. Two consumers writing the same three-line helper is the tell that the pair belongs on the engagement. **Nothing bounds a call into the application iframe.** `frame.evaluate` carries no timeout of its own, so this suite wraps every bridge call itself. The engagement offers no way to obtain that bound, so each consumer re-derives the same wrapper - and the derivation is not free: the calls this suite reached for outside its own wrapper are exactly the ones that can still hang a run. A bounded `evaluate` here would delete the wrapper and close the gap in one move.
**"Measured and bounded" is one intent and two calls.** Sizing a bound from its own measurement is the discipline the engagement itself prescribes, yet every step in this suite has to compose `measured(what, ms, (bound) => within(what, bound, task))` by hand, in a local helper written for that purpose alone. Two consumers writing the same three-line helper is the tell that the pair belongs on the engagement.
## Needs
**A bounded read-until-it-holds that reports what it saw.** Convergence in NextGraph is asynchronous everywhere - nothing on any surface says "it has arrived" - so a check that reads the instant after an act reads too early, and this suite has to carry its own loop: read, test, sleep, and on expiry throw naming what never arrived AND what was held instead, so the journey's remaining checks are reported unreached with an observation rather than a bare timeout. That is not specific to indexing; it is what any application's suite needs against a system that converges, and it sits exactly where the engagement's bounds already sit. What must not be lost if it moves onto the engagement: the assertions run ONCE on the reading handed back, never re-run inside the loop - a loop that retried the assertions would turn a flapping result into a green one.
@@ -0,0 +1,441 @@
---
type: contract
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
pulled_from: git@gitea.reconnexion.apps.gueraud.net:Reconnexion/ng-eventually.git/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md
pulled_version: ec3900077bfd090f5388e52b47a8132b2fc68d16
pulled_at: 2026-10-01
---
# contract_polyfill-surface — `@ng-eventually/polyfill`
## Scope
This package is a polyfill of NextGraph's SDK.
This package covers placement (creating an application's documents in one of its three stores), reading (a document's subjects, one-shot or reactive, by shape), writing one shape-typed object, sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
### Deployment requirements
An application using this package must:
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
- pass `configure` the SDK's shape query (`ormSubscription: RdfOrmSubscription.getOrCreate`, from `@ng-org/orm`) and its `initNg`, and call this package's `initNg(ng, session)` once a session has arrived, if it uses `useShapeQuery`. That read goes through the SDK now, and the SDK is injected here rather than imported; without the injection the read publishes `isError` naming the field;
- pass `configure` the SDK's `getObjects` and `insertObject` (from `@ng-org/orm`, as they stand) if it calls the calls of the same name here. Same reason and same shape as the shape query: they are injected rather than imported, and without the injection each throws naming the field to pass;
- call this package's `initNg`, not the SDK's, whenever it uses anything of the ORM. That call decides which `ng` the ORM reaches the engine through, and this package substitutes its own - the same object `ng` publishes - so that an ORM write meets the same guard every other write meets. Calling `@ng-org/orm`'s `initNg` directly leaves ORM writes ungoverned;
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first;
- **declare this package exactly once**, so that everything in the application resolves to one copy of it — see the single-copy rule under `## Non-guarantees`, which is the one packaging mistake this surface cannot protect you from.
**Obtaining it.** This package is not published to a public registry, and it is not distributed as built output: its published entry point is TypeScript source, so whatever builds the application is what compiles it, and a toolchain that accepts only JavaScript cannot consume it as it stands. By which channel the source reaches a given application is agreed with that application rather than fixed here; what this contract fixes is the version you pin and the rules below.
## Surface
Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type is published only when a published signature uses it. The load-bearing signatures:
```ts
// ── bootstrap ────────────────────────────────────────────────────────────
export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web
// The ORM's REACT hook. It is NOT on `@ng-org/orm`'s main entry - that resolves to
// `core`, which exports no `useShape` - so import it from `@ng-org/orm/react` (the
// default export) or as `reactUseShape` from `@ng-org/orm/all`. Pass it as it
// stands; no wrapper, no cast.
useShape: UseShapeLike;
// `RdfOrmSubscription.getOrCreate` from @ng-org/orm - the SDK's shape query, which
// `useShapeQuery` reads through. Pass it as it stands; no wrapper, no cast.
ormSubscription?: OrmSubscriptionLike;
getObjects?: GetObjectsLike; // `getObjects` from @ng-org/orm
insertObject?: InsertObjectLike; // `insertObject` from @ng-org/orm
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
initNg?: (...args: any[]) => any;
}
// ── identity — one await before the application renders ──────────────────
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
// ── addressing ───────────────────────────────────────────────────────────
export type Nuri = `did:ng:${string}`;
export type NuriLike = Nuri | string;
// The SDK's own scope, re-exported unchanged. It scopes a READ, on two axes and no third.
export type Scope = { graphs?: string[] | string; subjects?: string[] };
// ── placement: where an application's documents live ─────────────────────
// A store is named by its REFERENCE — one accessor per store, and `createEntityDoc` takes
// one of them. Omit it and the document goes to the private store.
export const storeRegistry: { // no identity parameter — a session is one user's
privateStore(): Promise<Nuri>;
protectedStore(): Promise<Nuri>;
publicStore(): Promise<Nuri>;
createEntityDoc(store?: NuriLike): Promise<Nuri>;
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
};
// ── reading ──────────────────────────────────────────────────────────────
// Reading a document BY NAME is `docs.sparqlQuery` below - one anchored query, one
// document. `readUnion` and `UnionSubject` were published here until `1.0.0-dev.8`;
// nothing takes their place, because they were that call in a loop plus policies of
// this package's own. See `## Change policy`.
export function useShape<T extends BaseType>( // read-filtered view
shapeType: ShapeType<T>,
conf: RdfOrmConfig<T> | string | undefined,
): ShapeResult; // { data, promise, subscription, isLoading, nextPage?, previousPage? }
// TWO positional arguments — the same pair `useShape` takes, both of them the SDK's own
// types. `ShapeQuery` is what you READ BACK (the snapshot), never what you pass in, and
// its items are the SHAPE's objects: `@id`, `@graph` and the shape's readable predicates.
export function useShapeQuery<T extends BaseType = BaseType>(
shapeType: ShapeType<T>,
scope: Scope,
): ShapeObservable<T>;
export interface ShapeObservable<T extends BaseType = BaseType> {
getSnapshot(): ShapeQuery<T>; // stable reference until the state changes
subscribe(onChange: () => void): () => void; // returns its own unsubscribe
refetch(): void; // ask again now; never polls
}
export interface ShapeQuery<T extends BaseType = BaseType> {
data: T[]; isPending: boolean; isSuccess: boolean; isError: boolean; error: unknown;
}
// The ORM's one-shot read, at the SDK's own name and signature. `config` is a `Scope`
// plus `where` and `orderBy`; the result is an ARRAY when it carries an `orderBy` and a
// `Set` otherwise, and it is a CLONE - mutating or deleting an item of it reaches nothing.
export async function getObjects<T extends BaseType>(
shapeType: ShapeType<T>,
config: Omit<RdfOrmConfig<T>, "pageSize" | "maxActivePages">,
): Promise<T[] | Set<T>>;
export type RdfOrmConfig<T> = Scope & { where?: ...; orderBy?: ... }; // the SDK's own
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
// Extracts the variant key from a DocChange - "State" / "Patch" / "TabInfo" / ... -
// a convenience over the verified payload shape, not an upstream call.
export function docChangeType(resp: DocChange): DocChangeType;
// ── low-level document / SPARQL primitives ───────────────────────────────
export const docs: {
// `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`).
// It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it
// fails for real (`Deserialization error of session_id JsValue("1")`).
docCreate(sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown): Promise<Nuri>;
sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
// Returns the commits the update produced, as upstream does (it typed this `void` until
// 2026-08-14 while already relaying the value). A caller that ignores it is unaffected.
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
};
// -- writing one shape-typed object ---------------------------------------
// The SDK's own name and signature. The object MUST carry `@graph` - it is the only
// thing that says which document the write lands in - and what comes back is the object
// with its `@id` filled in where one was left empty.
export async function insertObject<T extends BaseType>(shapeType: ShapeType<T>, object: T): Promise<T>;
// ── inbox: giving to read, and depositing ────────────────────────────────
export const inbox: {
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
// an alias with no call site, and no counterpart upstream. Use `read`.
};
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
// ── the wrapped SDK objects ──────────────────────────────────────────────
export const ng: NG; // call this instead of the `ng` passed to `configure`
// `NG` is upstream's own type (`@ng-org/web`), fully typed
// members; it was `Record<string, any>` until 2026-08-14
export function init(...args: any[]): any; // likewise — not the `init` passed to `configure`
export function initNg(...args: any[]): any; // likewise, and position 0 is DISCARDED - see below
```
**A shape, in full, because one has to be written by hand.** `ShapeType` asks for `readablePredicate`, `minCardinality` and `maxCardinality` on every predicate, a `schema` keyed by the shape's own IRI, and objects carrying `@id` and `@graph` - and assembling that from the SDK's type declarations is a read this leaf exists to spare you. The shape below is `examples/notebook/app.ts`'s, predicate for predicate - that application reads with it against a deployed broker - and `rdf-orm build` generates exactly this object from a `.shex` file, so nothing here depends on the generator. The object interface and the type argument beside it are not that application's: it reads an untyped snapshot and narrows at the call site, which works and types nothing.
```ts
import { useShapeQuery } from "@ng-eventually/polyfill";
import type { BaseType, ShapeType } from "@ng-eventually/polyfill";
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
// What a read HANDS BACK: `@id` and `@graph` come from `BaseType`, plus one field per
// predicate, named by that predicate's `readablePredicate`.
interface NoteObject extends BaseType {
type: string;
title: string;
body: string;
}
const NoteShape = {
shape: "urn:notebook:NoteShape", // the shape's own IRI...
schema: {
"urn:notebook:NoteShape": { // ...which is also the `schema` key...
iri: "urn:notebook:NoteShape", // ...and the shape's own `iri`
predicates: [
// The matching constraint: a subject carrying `rdf:type urn:notebook:Note`.
{ iri: RDF_TYPE, readablePredicate: "type",
dataTypes: [{ valType: "iri", literals: ["urn:notebook:Note"] }],
minCardinality: 1, maxCardinality: 1 },
// And the fields that come BACK. A predicate the shape does not NAME is not
// projected, so a shape written only to match returns objects with no fields.
{ iri: "urn:notebook:title", readablePredicate: "title",
dataTypes: [{ valType: "string" }], minCardinality: 1, maxCardinality: 1 },
{ iri: "urn:notebook:body", readablePredicate: "body",
dataTypes: [{ valType: "string" }], minCardinality: 1, maxCardinality: 1 },
],
},
},
} satisfies ShapeType<NoteObject>;
// Pass the object type as the TYPE ARGUMENT. `ShapeType<T>` mentions `T` in none of its
// members (it is `{ schema, shape }`), so nothing infers it from the shape: left out, `T`
// is `BaseType` and the snapshot is untyped. MEASURED - `tsc` refuses the assignment.
const notes = useShapeQuery<NoteObject>(NoteShape, { graphs: "did:ng:i" });
```
`minCardinality: 1` is what turns a predicate into a required triple pattern, so a shape whose every predicate is optional matches every subject in reach. `maxCardinality: -1` is unbounded. `valType` is one of `"number" | "string" | "boolean" | "iri" | "shape"`, and `literals` pins the required value - which is how the `rdf:type` entry above becomes the match rather than a field you read.
**`initNg`'s FIRST ARGUMENT IS DISCARDED, and what goes in its place is the `ng` above.** The Deployment requirements say to call `initNg(ng, session)` while `## Surface` says to use this package's `ng` rather than the one `configure` received, so there is a choice to make - and it makes none: this call drops whatever sits in position 0 and hands NextGraph's ORM the published `ng`, the same object, then passes every remaining argument and the return value straight through. MEASURED on `1.0.0-dev.14`: a sentinel object passed in position 0 never arrives, and what does arrive is `===` the exported `ng`. That is deliberate rather than a defect - the ORM reaches the engine through whatever this call resolves, an ORM write leaves by `ng.graph_orm_update`, and that is where the write guard sits - but it is not something to have to establish by experiment. The parameter stays in the signature because upstream's `initNg(ngImpl, session)` has it, and a call site written for the target must keep compiling.
**`ShapeObservable` is on the deletion list, and it goes WITH the shared wallet, not before it.** It is not a framework adapter: it carries the capability filter (`getSnapshot()` returns a view filtered by the capabilities held) and the capability-arrival re-publish (a subscription that re-publishes when a capability arrives). A capability arrives mid-session only because the wallet is shared; in the target an identity holds its own wallet and its capabilities from the start, so there is nothing to filter and no arrival to re-publish on. The announced form, a hook returning `{ data, isPending, isSuccess, isError, error }`, has nowhere to hang such a subscription, because the target does not need one. The type stays exported and you may write it down; what you cannot do is plan to drop it on its own ahead of the rest of the shared-wallet code.
**`refetch()` has NO RECORDED COUNTERPART.** It is the only published way out of a read that is stuck pending. The announcement recorded in this repository lists five fields, `{ data, isPending, isSuccess, isError, error }`, and `refetch` is not among them. That is a statement about the RECORD, nothing more: TanStack Query's own `useQuery` does return a `refetch`, so a successor may well exist, but this repository holds no record of one and cannot name what an application leaning on `refetch()` would migrate onto. Treat it as an open migration point, not as a call that has a mapped replacement.
## Guarantees
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
A returned reference carries no key — not `createEntityDoc`, not the three store accessors. A reference found inside a document yields a name, not a key.
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
What was shared with you becomes readable after `ensureIdentity()`.
**An anchored `docs.sparqlQuery` reads exactly the document you name, and returns its triples as written.** The anchor makes that document's graph the query's default graph, so a body with no `GRAPH <...>` wrapper reads that document and nothing else; a body that DOES carry one iterates the named graphs regardless of the anchor, so do not write one. Subjects come back as the document carries them — several subjects in one document is allowed and each stays its own, since a subject may be any IRI. Reading several documents is this call once per document; there is no call that takes a list, and composing one with an anchorless query instead would silently widen what the read reaches. Recommended placement is one document per business entity: access is granted per document.
**And a document it cannot read is refused OUT LOUD, never swallowed into an empty answer.** A document you hold no capability for - and whose public store has none to give - is refused before the read leaves, naming the document; a public store that could not be ASKED rejects with that failure instead, because both stop the read and only one of them tells you to try again. Nothing is skipped on your behalf: tolerating one unreadable document was the removed `readUnion`'s own policy, and it went with it in `1.0.0-dev.8`, so whether one ends a loop over several is now your decision at your call site. A rejection means "unknown", never "absent", here as everywhere.
**Which reads reach a document in a public store from nothing but its `reference`.** This is the stranger's case - the document was shared with nobody, its `reference` circulated, and the reader holds no key for it - and two calls serve it. An anchored `docs.sparqlQuery` asks the public store for the key before it reads, and already did so before `1.0.0-dev.6`: the removed `readUnion` was that same ask, once per document, so a one-shot `document read` of one named public document is exactly what it always was. `useShapeQuery` asks too, since `1.0.0-dev.9`, once per document NAMED in `scope.graphs` - so a `shape read` serves the same case now, reactively and typed, and a holder of nothing opens a public document named that way (measured in the unit suite, and live against the broker). The three that do NOT ask, and in which such a document therefore does not appear: `useShape`, `getObjects`, and `useShapeQuery` under `graphs: "did:ng:i"` or `""` - naming no document, there is nobody to ask about. Read it once by naming it and the key lands in what you hold, which all three re-read on.
`urn:ng-eventually:` is reserved, and the reservation has two faces. In a document: this package writes its own registers under subjects carrying that prefix, in the same graph as your data — so **an anchored `docs.sparqlQuery` returns them alongside your triples, and it is yours to ignore them**. Until `1.0.0-dev.8` the removed `readUnion` dropped them for you; nothing does now. This is not a gain of rights: the two registers an ordinary document carries are a document's inbox address and, for a public document, the read key its store already serves to whoever asks. Nothing else of this package's is written into your documents. Do not write under that prefix yourself. In a deposit: two payload shapes are this package's own and it acts on them — a payload that IS a reference (a bare `did:ng:…` string and nothing else), and an object whose `kind` property is a string starting with `urn:ng-eventually:inbox:`. **Every other payload is yours, and is delivered to you untouched.**
**Do not use either shape to carry your own data.** Depositing one is still accepted and it is still returned to you by `inbox.read` and its variants — nothing is withheld — but this package now also acts on it, and what you observe is one of two things: it writes an entry into the document the inbox belongs to, if that document is one it can index; otherwise it reports a refusal on its own log stream (`console.error`, ungated, as below). That refusal is reported ONCE and then the deposit is gone: this package consumes what it claims, whether or not the arm succeeded. Until `1.0.0-dev.4` the same refusal came back at every arrival on that inbox and at every connection, for as long as the deposit sat there. The same now applies when the referenced document resolves but carries **nothing for the field its index declares** — a draft, say: the reference is discarded and reported, where before it sat in the queue and would have been indexed by a later drain once the document gained that field. Deposit the reference again after the document says what it needs to say. Carry your own data in an object with a `kind` of your own — `{ kind: "join", … }` is yours and stays yours — and never as a bare reference on its own.
Only a document's owner writes to it. Holding its read key never grants a write. **Since `1.0.0-dev.7` that covers what you write through NextGraph's ORM too** - `insertObject`, and a mutation of the live set a shape query handed you (`set.add(...)`, `set.delete(...)`, assigning a property). It used to cover only `docs.sparqlUpdate`: an ORM write leaves by a different door, and nothing was standing at it. Refusal happens before the write is attempted, and one document you may not write refuses the whole batch the ORM was flushing.
**`getObjects(shapeType, config)` answers with what you hold, and nothing else.** Objects living in documents you have no key for are not in the result - the same rule the two shape reads follow, applied per document on what comes back. The collection keeps the shape the SDK gave it: an `orderBy` in the config yields an array, anything else a `Set`. What you get is a CLONE, detached from the database: changing an item of it changes nothing anywhere, and deleting an item of it removes nothing and reports nothing.
**`insertObject(shapeType, object)` needs the object's `@graph`**, and refuses a document you do not own. The `@graph` is the only thing that says which document the write lands in - upstream the insert subscribes to no document at all - and it is also what decides the refusal, so there is nothing extra to pass. An object with no `@graph` is forwarded and NextGraph refuses it in its own words.
**"This document has no inbox" is an answer, and only an answer, since `1.0.0-dev.9`.** `inbox.postToDocument` refuses a document whose owner never opened an inbox, and `inbox.readForDocument` / `readSyncedForDocument` answer `[]` for one - both unchanged, both still the ordinary case. What changed is that a read which could not HAPPEN no longer arrives as one of those: a broker that cannot answer for the document, or a public store that cannot be asked for its key, now rejects. Until this version a depositor was told the owner had opened nothing, and a reader was handed an empty list, for a document whose inbox is full.
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
**You never resolve an inbox address, on either side.** You deposit by naming a document (`inbox.postToDocument`), and you read what was left on your own by naming it too — `inbox.readForDocument(doc)` at any time, or `inbox.readSyncedForDocument(doc)` on a page that has just loaded. The second is the one to call when an empty answer has to MEAN empty: a session that has just loaded has synced neither the document nor its inbox, and an unsynced read of either comes back empty with no error — so the ungated form can answer `[]` for a document whose inbox holds messages. `readSyncedForDocument` waits for both before answering. `inbox.readSynced` is the same guarantee on an inbox you already hold the address of, which no application does: it takes an address, so nothing here hands you one.
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, it throws instead of returning, and a rejected call must not be rendered past — the interface would show an empty account rather than an empty screen. A single queue that could not be drained is reported and does not reject: reaching your queues is infrastructure and must succeed, applying one deposit is data and must never cost you the session. So a resolved call means your own capabilities are restored; it does not promise that every deposit waiting for you has been applied, and those that were not stay in their queue.
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it stays down, provided the identifier reached the other side. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
**What decides which identity you get.** No call takes an identifier — not `configure`, not `init`, not `ensureIdentity` — so an application never chooses one, never keeps one, and never hands one over. It is settled once per page load, from the page itself, and the answer depends on which side of the broker round-trip is asking:
- **Before the round-trip, on your own top-level page — the barrier decides.** Whatever is already known fills the field, and the person may change it; the identity is the value they confirm. Confirming publishes that value into the address bar as `?ng-id=` and records it in this browser.
- **After the round-trip, on the page the broker loads back — `?ng-id=` decides**, and the barrier stays down. The parameter also wins over anything the browser remembers, on either side: it is read first, and reading it replaces what was remembered. Arrive with neither — a URL that dropped the parameter, and a partition that remembers nothing — and the barrier asks on that side too.
- **Failing both — whatever this browsing context last recorded.** This is the only path on which an identity is adopted with nobody confirming it. If the address bar lost `?ng-id=` while a *different* identifier was on record here, that different identity is adopted, and nothing is raised anywhere; if nothing was on record either, the barrier asks again on that side too.
**`?ng-id=` exists because it is the only thing that crosses.** Your page before the round-trip and your page after it sit in two separate storage partitions — nothing the browser remembers on one side is visible on the other, and the address bar is the sole channel between them. The package writes the parameter itself, without navigating. **An application that owns its URL must let it survive**: a router that drops query parameters it does not know, or a redirect that rebuilds the URL, sends the round-trip off without the identifier, and the consequence is the silent mis-identification above rather than an error. In a context that can neither write the address bar nor use storage, the barrier simply asks on both sides.
**Being remembered is a prefill, never a decision.** A top-level reload asks again every time, with the field already filled — one click, no typing. A remembered identifier is therefore not a signed-in state, and two tabs, two browsers or two devices do not share one: each keeps its own record, and only a URL carrying `?ng-id=` puts a second context under the same identity.
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
**A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds nothing of that shape — the distinction the surface exists for, and the one thing it will never do is publish that snapshot for a read that did not happen. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
**A document you NAME in `graphs` is reached before the read opens, since `1.0.0-dev.9`.** Naming a document in a scope is the same act as anchoring `docs.sparqlQuery` on it, and it is now answered the same way: the key is asked of a public store if you do not already hold it, the document is refused out loud if you hold no key for it, and its repo is synced before the question is put. A named document you cannot read publishes `isError`, where until this version it reached the engine, came back, and was emptied by the per-document filter into `isSuccess` with `data: []` - the one snapshot this surface reserves for "this scope holds nothing". A named document that has never synced published that same snapshot for a document that is not empty; it now stays **PENDING** instead - the sync barrier is consulted on every publication, and a named document whose barrier was not crossed is not an answer. **Since `1.0.0-dev.10` that half carries no qualification.** It held only within one ordinary session until then, and the two situations that broke it - a second `configure(...)`, which published an empty or stale success, and a broker refusing a subscription after this package had stopped waiting for the document's first push, which left the read withheld with no way back - are closed: "is this document open in this session" now has exactly one answer, the state of the document's one real subscription, rather than three records that could disagree. Nothing failed there, so it is not `isError`: the subscribe succeeded and the read did not throw, what is missing is the authority to say the document is empty. If the document later syncs and brings data, the read answers by itself. If it never does, the read stays pending for that session - handle a `isPending` that does not end, as you already must for a shape query the engine refuses. **Naming SEVERAL documents fails as a whole** when one of them cannot be reached: a short list is the same lie as an empty one, so nothing is answered rather than half of it. What is NOT touched, and each stays a success: `graphs: "did:ng:i"` or `""` (you named no document - the per-document filter is the whole of the answer there, and an empty result means you hold nothing of that shape), `graphs: []` (no objects, by NextGraph's own definition), and a named document you can read that holds nothing of the shape.
**And it converges by itself.** A named scope refused this way is not a dead view. Whether you may reach the documents you named is re-read on every publication rather than decided once, so a key arriving turns the refusal into the document through the same channel that already re-published a filtered read - and a scope whose barrier failed outright is attempted again on that signal. The ordinary sequences all converge with no call from the application: a page whose components subscribe before the connection work has restored its keys, a document shared with you while you are looking at the screen, a broker that hiccupped once and healed, and a key arriving in the same instant the read was deciding. One consequence to design for: an interface that renders `isError` will briefly show it where it used to show an empty success, so branch on the load state rather than on `data` alone. Nothing here polls, so a scope refused in a session whose keyring never moves again stays `isError` until your own `refetch()` - which is what `isError` has always meant here, and what it means for every other failure this call reports.
**A named scope goes back to PENDING when the session changes under it.** Signing in again inside the same page gives a new session, whose repos this package has synced nothing for yet - so a named scope that was answering returns to its loading state until the documents it names are confirmed again, and `refetch()` is what confirms them. Withholding is deliberate: a barrier crossed in the session that has just gone is not one the new session has crossed, and the alternative is answering from a subscription nothing re-established. This is the one case where a read that had answered stops answering, and it does not arise on an ordinary page load - there the whole page is new.
**A standing failure is the SAME snapshot.** While a refusal persists unchanged, the snapshot object does not change either, so `useSyncExternalStore` and anything else comparing by identity see no update - even though the read is being attempted again underneath. A failure that says something different is published normally.
**What it costs.** One public-store ask and one sync barrier per document named, once per session. The opens for a scope naming several documents run together, so the wait is the slowest of them rather than their sum. The wait itself is bounded internally, so a document that never answers does not hold the open for ever - but the READ over it stays pending, because a barrier that was never crossed is not authority to call anything empty.
**Which failure lands in `isError` changed in `1.0.0-dev.6`, and one of them now stays PENDING.** The read goes through the SDK's own shape query, so what `isError` carries is the query failing to open: a shape the ORM cannot subscribe, or a `configure` that was never given `ormSubscription`. A start the underlying engine REFUSES does not reach it: NextGraph catches that failure and logs it, and its readiness signal is only ever resolved, never rejected, so such a read stays `isPending` indefinitely. The guarantee above is unaffected — you are still never told "there is nothing" for "nobody answered" — but an interface that handles only `isError` will show its loading state where it used to show an error. Handle a `isPending` that does not end.
**A read is scoped by document and by subject, never by store.** `Scope` has two axes and there is no third: `graphs` names one document's graph per entry — `did:ng:i` or `""` for the whole local dataset, `[]` or `undefined` for no objects at all — and `subjects` filters subject IRIs literally, with no prefix or hierarchy matching. There is no call that lists the documents in a store, and naming a store in `graphs` is not the question you meant: a store's graph holds its containment triples and nothing of the shape you asked for, so it answers empty rather than raising. That is the target's own shape, not a narrowing this package chose — and since `1.0.0-dev.6` nothing is translated on the way: the `Scope` object you write is the object the SDK receives.
**A deposit made while a person is looking at the page arrives while they are looking at it.** For as long as an identity is connected, every inbox it may read is watched and what lands in one is applied as it lands — its own inbox, and the inbox of every document it has opened one on, including a document whose inbox it opens later in the same session. So a `ReadCap` sent with `inbox.share` becomes usable in the recipient's live session with no reload and no call from the application, and a `useShapeQuery` that was empty for want of that `ReadCap` re-reads and publishes the document it now opens. Until 2026-08-17 only the backlog waiting at connection was applied, and a deposit made in front of its recipient converged only when that person reloaded the page.
**The watching is in place by the time `ensureIdentity()` resolves**, whatever else that call made of its own work: a connection that could not restore something still rejects, and the identity it settled is watched all the same. It lasts exactly as long as that identity stays connected — changing identity or clearing it stops it, and whoever connects next is watched in their own right, so nothing of the previous one keeps applying.
**A deposit this package applies is delivered ONCE, and a failure is not retried.** It is taken out of its inbox before the arm that handles it runs, so it is gone whether that arm succeeded or not — the same order the target has, where the broker removes a message from the queue as it hands it over and the verifier merely logs what it could not apply. A failure is reported on this package's own log stream (`console.error`, carrying this package's prefix), never gated by `debugAccessLog` — a diagnostic may be opt-in, a failure may not — and then the next deposit is processed. **Do not count on a failed deposit being applied later: there is no retry, no dead-letter queue and no way to ask what was lost.** If an arrival matters to your application, the record you keep of it must be your own. What this buys is the end of the opposite behaviour: an inbox that grew for ever, and one malformed deposit re-reported at every arrival and every connection for the life of the session.
**An unapplicable item and a failed one are not the same event.** A deposit this package cannot make sense of is reported and the next one is processed — one malformed item never holds up the queue behind it. A deposit whose handling could not be COMPLETED is different: the drain STOPS, the inbox is reported, and everything not yet taken out is preserved. Only the deposit in flight is lost. So a failing broker costs one deposit per attempt, not the whole queue, and a drain that stopped is retried by your next `ensureIdentity()`.
**A reference this package cannot read counts as failed, not as absent** — including a document you hold no capability for. That is deliberate and it costs latency: such a deposit stops one drain, and the deposits behind it land on the next connection instead of this one. The alternative was worse and was measured: treating "cannot read it" as "it is not there" destroyed deposits on a transient outage, and destroyed the re-deposits sent to repair them, because a failed capability fetch is remembered for the session. Only a document actually queried, from a synced repo, that still reads empty is called absent. **Consequence to plan for:** a queue holding many references you cannot read drains one per connection.
**`inbox.processInbox` now REJECTS on a broker failure**, where it used to resolve — including when a document a deposit referenced could not be READ. A referenced document that is merely absent is a different case: that is reported and the queue carries on. Awaiting it, handle the rejection: the deposit it was applying at that moment is already consumed, the rest of that inbox is intact, and calling again resumes from the next one. Connection-time draining is unaffected — `ensureIdentity()` reports such a queue and still grants the session, exactly as before.
**Failing to apply one inbox still denies nothing else.** The other inboxes were never involved, the session is not refused, and deposits this package does not claim — your own payloads — are untouched and stay readable through `inbox.read` and `inbox.watch`.
**Any number of subscriptions on one document coexist.** Opening a document, watching an inbox and following a scope no longer silence one another. Until 2026-08-17 a second `subscribeDoc` on a document killed the first, silently — nothing rejected, the first caller's unsubscribe still appeared to work, and what an application saw was a view that stopped re-reading and an inbox that stopped notifying, with no trace anywhere near the cause. A subscriber that joins a document somebody else already opened is handed the initial `State` its own subscription would have pushed it, so joining late is not the same as never firing; and unsubscribing silences that caller and no other, including when it happens from inside a push.
## Non-guarantees
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
**Naming an identity proves nothing about who named it.** Any visitor may type any identifier at the barrier, or arrive on a URL that already carries one, and act as that identity — the wallet and its password are handed out on the barrier itself. The identifier is a choice of space, never a proof: anyone who knows one can act as it, so do not treat `ensureIdentity()`'s answer as an authenticated subject.
**The wrapped `ng` is a wrapper, and a wrapper can be wrong.** It forwards every member to the object you gave `configure`, adding two refusals of its own and nothing else. Until `1.0.0-dev.7` it did not forward them at all - every member came back as a promise, and calling one failed with NextGraph's own `<member> is not a function`. The cause, for anyone who met it: the passthrough did `Reflect.get(ng, prop).bind(ng)`, and `@ng-org/web`'s `ng` is ITSELF a proxy whose `get` trap answers every key - `bind` included - with a fresh path-carrying callable, so `bind` never reached `Function.prototype.bind` and what came back was a Promise. It is `Reflect.apply` since `1.0.0-dev.7`, pinned by a unit test that drives a proxy-shaped `ng` rather than the plain objects every other fake in the suite is. **What it cost most was a `shape read`**, and that is the part to read if you hit it: an application that passed this `ng` to `initNg` had the ORM call `ng.orm_start_graph`, which is where the Promise landed, and `@ng-org/orm` catches that to the console - so `orm_start_graph is not a function` on the console, with a `useShapeQuery` that never leaves `isPending`, is THIS defect and not the engine refusing your shape. Pin `1.0.0-dev.7` or later and it is gone. The lesson to keep is that a member of `ng` that misbehaves is worth reporting here rather than worked around: nothing in this package's own code goes through that wrapper, so this surface is the only place such a defect shows.
**A shape query the engine refuses never finishes loading.** Both shape reads — `useShape` and `useShapeQuery` — open a subscription through NextGraph's ORM, and a start the engine refuses is caught there, written to the console and nothing more: the set stays empty, its readiness never settles, and the only trace is that one console line. `useShapeQuery` reports it as a `isPending` that does not end (see the guarantee above); `useShape` answers with the ORM's own two states, which do not carry the distinction - its `isLoading` says whether the initial payload has arrived and has no value for "it never will". The cause is upstream and nothing in this package can work around it, and it is MEASURED in the release this package builds against rather than inferred: in `@ng-org/orm@0.1.2-alpha.21`'s shipped `dist/connector/RdfOrmSubscription.js`, `readyPromise_` is built with `resolve` alone and nothing anywhere rejects it, `resolveReady` is called on one path only (initial data arriving), and `ng.orm_start_graph` is awaited inside a `catch` whose whole body is `console.error(e)`. This was a total outage until the client packages caught up with the deployed broker on 2026-09-21; it is the residual failure mode, not the ordinary case. Since `1.0.0-dev.9` the commonest way in is closed for `useShapeQuery`: a document NAMED in `graphs` is opened and refused before the query is handed over, so an unreachable named document reads `isError` rather than pending for ever. What remains is the start the engine refuses for a document you can perfectly well reach, and everything under `graphs: "did:ng:i"`, where there was no name to check. **So the two faults you can tell apart today are the BARRIER refusing (named document, `isError`, naming the document) and the wiring being absent (`isError` naming the `configure` field); the two you cannot are the engine rejecting your shape and the ORM never having started, which arrive as the same unending `isPending`.** A way to separate those last two has been asked for and is not decided: it would have to be invented here, since there is nothing upstream to mirror and nothing upstream to read it from.
**No object removal call, and no document deletion at all.** There is no `deleteObject` and no `removeDocument` at any level of NextGraph, so there is none here. You remove an object by deleting it from the LIVE set a shape query handed you - the item the set is holding, reached by iterating it, by `.first()` or by `.getBy(graph, subject)`. What that removes is the predicates your SHAPE declares on that subject: triples under a predicate the shape does not name survive, the subject survives, and the document survives. Deleting an item of a `getObjects` result removes nothing, because that result is a clone. `@ng-org/orm@0.1.2-alpha.21` ships a `removeObject(graphNuri, subjectIri)`; it is not published here, because it exists in the package and not in the source this package is verified against, and a symbol whose only evidence is what an npm build happens to export is not something to build a guarantee on.
**A refused ORM write does not always reach you as a failed call.** `insertObject` rejects, and you can await it. A mutation of a live set does not: NextGraph batches those into one call it does not await, so a refusal surfaces as an unhandled rejection in the console rather than as an error at the line that made the change - the write did not happen, and nothing in your code was told. Write into documents you own, and treat a document you merely hold the key for as read-only.
**Nothing of NextGraph's ORM is re-exported as it stands.** `getObjects` and `insertObject` are the two this package offers, wrapped. `RdfOrmSubscription`, `DiscreteOrmSubscription`, `ngSession`, `getRaw`, `watch` and `effect` are NOT published and importing them from `@ng-org/orm` defeats everything above: one wallet hosts every identity here, so an unwrapped read returns every identity's objects and an unwrapped write reaches any document. `RdfOrmSubscription` in particular pools its subscriptions on the shape and the scope alone, with no identity in the key - what makes that safe is the filtering this package does on the way out.
**No revocation.** `inbox.share` cannot be undone.
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
**No mailbox model.** Do not build on the raw deposit list.
**No cross-broker reference.** A returned reference resolves for users of the same broker.
**`openDocumentInbox` does not coalesce across PAGES.** Two tabs — or two sessions of the same person — that open the same document's inbox in the same moment can each create one, and the document is left with two: its owner drains one while deposits arrive in the other. Nothing raises, nothing reports it, and neither page can detect it afterwards. It is not an oversight to be patched later: a branch MERGES records rather than refusing the second, so there is no "create only if absent" to build the guarantee on, and the address a depositor reads is a separate record from the one the owner resolves — so the two cannot even be made to agree on which of the pair won. Open a document's inbox from one place: the page that creates the document, or one call the rest of the interface waits on.
**TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named.
**No unfiltered read through `useShape`, and no way round it through the object it hands back.** What the call returns is NextGraph's own result, filtered: `data` and `promise` carry only items in documents you hold the key for, `isLoading` passes through, the two page commands run and whatever they hand back is held to the same rule as any other member, and `subscription` - the ORM's own handle, which reads and writes with no capability filter at all - is refused out loud rather than handed on. **Any member this package has not DECIDED is refused, whatever its type** - that is how a member a later `@ng-org/orm` adds is settled before anyone has seen it. It is not decided by type: a COUNT is a plain number that describes the items, and nothing separates it from a page size, so nothing undecided passes. A member the SDK does not carry at all reads `undefined`, since there is nothing behind it. Inside `data`, every member that yields items is filtered, and an ordered result addresses and counts the items you may read: `size`, `length`, an index, `Object.keys`, `in` and `JSON.stringify` all agree, and none of them says how many you may not. **Reading a member's DESCRIPTOR answers exactly what reading the member answers**, which is not a detail: `Object.getOwnPropertyDescriptor` - and the spread, `Object.entries` and console inspection built on it - reads a property without going through the view's own read, so a refused member throws there too rather than handing its value over. That holds for an ACCESSOR as much as for a value: the getter you are handed reads through the view, and its setter is refused. **A mutation reaches the collection underneath, and neither hands back nor acts past what the view shows.** Writing is not broken - your own `add` reaches the document and the reactivity it drives. **All three ask the same question**: `add` THROWS for an object naming a document you hold no key for, and for one naming NO document (there is then nothing that owns it, and it would land in a collection every identity shares - set `@graph` to a document you hold) (it returns the collection whether or not it did anything, so it has no value meaning "that did not happen"), `delete` of an item the view does not show answers `false` - the Set contract's own word for "it was not there", and true of the view - and `clear` empties the view rather than the collection every identity shares. `add` hands back the VIEW rather than the collection, because `Set.prototype.add` returns the collection so calls can chain and chaining onto the unfiltered one gives it away entirely. **Writing to what you are handed is refused** - `data[0] = x`, `delete data[0]`, `data.length = 0`, `Object.defineProperty`, `Object.setPrototypeOf` and `Object.freeze` - on the RESULT OBJECT as much as on `data`, and on this package's own account rather than because the ordered collection upstream happens to refuse some of them too. **Every member `@ng-org/alien-deepsignals` declares non-mutating answers**, computed over the items you may read: the iterator helpers, the array reads (`at`, `slice`, `join`, `indexOf`, `toSorted`, `with`) and the ES2025 set combinators (`union`, `difference`, `isSubsetOf`). The one exception is `copyWithin`, which that list names and which WRITES: it is refused here, and that is STRICTER than NextGraph's own ordered collection, which runs it and destroys an element (its guard is built from the same mislabelled list). A deliberate difference, stated so you do not discover it as a surprise. A document reached through that view alone, read nowhere else first, does not appear.
**`isLoading` does not mean what its name says, and the fault is upstream's.** `@ng-org/orm`'s declaration describes it as "`true` when no data is available yet"; the hook that ships computes it as the subscription's `isReady`, which is the inverse. Both artifacts were read and they disagree. This package forwards the value it is handed rather than correcting it, because a layer that quietly improves the target teaches you a behaviour you would have to unlearn. **Branch on `data === undefined`**, which is unambiguous, and treat `isLoading` as unusable until upstream settles it.
**`getObjects` does not fetch a public store's key for you either**, for the same reason as the two shape reads below: it goes through the same ORM query and spends no round-trip asking. Read the document once by naming it — `docs.sparqlQuery` anchored on it — and it appears.
**`useShape` does not fetch a public store's key for you, and `useShapeQuery` does so only for what you NAME.** A document in a public store gives up its key to whoever asks, but asking is a round-trip: `useShape`'s signature is NextGraph's own and has no await to spend, so a public document you have read nowhere else never appears in it. `useShapeQuery` spent none either until `1.0.0-dev.9`; it now asks once per document named in `graphs`, which is what lets a bare reference to a public document be read through that call. It still asks nothing under `graphs: "did:ng:i"` - the whole local dataset names no document, so there is nobody to ask about. For those two cases, read the document once by naming it - `docs.sparqlQuery` anchored on it, which is the whole of what `readUnion` did for this before it was removed in `1.0.0-dev.8` - and both reads pick it up: the key lands in what you hold, and that is a change both of them re-read on.
**A watch on one inbox that could not be opened does not come back on its own.** Opening it can fail — a broker that does not answer in that moment — and the failure is reported rather than passed over, but what follows is event-driven and this package deliberately never polls. The watch is opened again at the next moment this identity comes to hold something it did not: it creates a document, it opens an inbox on a document, a `ReadCap` reaches it through an inbox still being watched, it reads a public-store document for the first time. A session that does none of those goes on without that inbox — deposits made into it are not applied, nothing raises, and they wait unconsumed for the next connection. The exposed case is the identity that only ever READS, since it produces none of those events, where an identity that goes on creating recovers as a by-product of its own work. Awaiting `ensureIdentity()` again applies what is waiting — it drains every one of this identity's queues before it resolves, and shows no barrier a second time — but the watching itself comes back only on a fresh page.
### Asked for, not decided
Each of these is a standing request from an application, recorded here rather than answered in a message. None is refused on principle; each is an addition to a published surface, so each waits on a decision that is this package's to make. They are listed so that an application knows it is not missing a call it simply failed to find.
**`configure`'s `useShape` is REQUIRED even for an application that never calls it.** `@ng-org/orm` publishes `useShape` only through its react / svelte / vue adapters, so a page with no framework can produce none of them - and the field is not optional in `EventuallyConfig`, so such an application invents a value to satisfy the signature. What it does with that value is unobservable: the injected hook is consulted in exactly one place, inside this package's own `useShape`, so an application that reads through `useShapeQuery` never reaches it. This repository's own frameworkless example passes `useShape: (() => {}) as never` (`examples/notebook/app.ts`) and reads live with `useShapeQuery` beside it; a function that THROWS is equally sound and says more if anything ever does call it. Making the field optional is a change to a published signature, hence this list rather than a fix.
**No published NURI type guard, and no published escaping helpers.** Neither is an oversight. On the guard: every entry takes `NuriLike` and runs its own validation at the door - permissive in, precise out - so a guard would put validation in a caller's hands where the door already does it, and invite the cast the types exist to prevent. On the escaping: `escapeIri` / `escapeLiteral` were published until the surface was narrowed, and they came out under the rule that governs this whole leaf - a symbol with no counterpart at any level of the target is a symbol an application would have to unlearn, and neither `@ng-org/web` nor the ORM exposes an equivalent (the engine escapes ad hoc where it builds SPARQL). The consequence is stated rather than denied: an application composing SPARQL against `docs.sparqlUpdate` carries its own escaper, which is a second implementation of one rule, and the one running there is not the one this package tests.
**No published way to write triples above `docs.sparqlUpdate`.** The write that IS published above the raw primitive is `insertObject(shapeType, object)` - the SDK's own name and signature, which takes a shape-typed object, needs its `@graph`, and composes no SPARQL at all, so it removes the query building, the escaping and the NURI validation from a caller in one move. What it costs is the ORM: it is injected through `configure` (`insertObject`), so a library that wires no `@ng-org/*` of its own cannot reach it, and it writes shape-typed objects rather than arbitrary triples. An "add these triples to this document" entry, taking triples and no shape, is what has been asked for and is not decided.
**No call says WHICH STORE holds a document.** Not the enumeration that `storeRegistry.listMyEntityDocs` was - that was removed in `1.0.0-dev.5` because nothing upstream lists a store's documents - but the narrower question about a document whose `reference` the caller already holds. There is no accessor for it, and the consequence is worth stating plainly: an application cannot verify its own placement, so a document written to the wrong store is undetectable from here until some reader fails to open it.
**No per-deposit outcome, and no count that says a queue has been drained.** `## Guarantees` says a deposit this package applies is consumed whether or not the arm succeeded, that a refusal goes to this package's own log stream, and that there is no way to ask what was lost. The gap an application feels is the one before that: nothing distinguishes "this deposit was examined and discarded" from "this deposit has not been examined yet", so a check that an ineligible deposit produced NO entry cannot be waited for - only timed out, which makes it weaker than its name. Anything observable would close it, a per-deposit outcome or merely a counter that moves when a queue has been drained; neither exists.
## Change policy
**Semver, and majors are the normal case.** This surface converges on a NextGraph that does not ship yet, so most steps toward the target remove or narrow something — 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** — a published symbol is removed (`getSession`, `normalizeId`, `currentUser`, `RegistrySession`, `inbox.materialize`, `storeRegistry.listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `readUnion` and `UnionSubject` all left this way), **or** a published symbol is renamed (`watchShape` became `useShapeQuery`), **or** an existing call narrows: it now throws where it returned (`ensureIdentity` rejects rather than resolve on an incomplete restore), it reports a state you did not have to handle before (`useShapeQuery` publishing `isError` where it used to publish a synced-empty snapshot), or what it HANDS BACK changes shape (`useShapeQuery`'s `data` carrying the shape's own objects where it carried property bags). 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 (`inbox.readSyncedForDocument` arrived this way).
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
**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.15`), 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.
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.15` is this package, `ng-e2e-helpers/v...` is the other one. A bare `v...` tag would say nothing about which surface it froze the day the two diverge - which is the day one of them takes a major and the other does not.
**THE TAG IS THE PIN, and here is exactly what exists to pin.** The acquisition channel is agreed per application and carries no version of its own, so the mechanical anchor this engagement offers is the git tag `polyfill/v<version>` on this repository, cut over the commit at which `packages/polyfill/package.json` reads `<version>`. What an application should pin is that tag, and what its `usage_` leaf's `against:` should name is the version string the tag carries - `against: @ng-eventually/polyfill@1.0.0-dev.15`. There is deliberately no promise beyond that: **nothing is published to a registry, nothing is fetchable by version, and no range resolves** - a `*` peer range against a filesystem checkout compiles whatever is checked out at the other end, uncommitted edits included, and no pin on this side can prevent that. **And the tags lag the versions.** As of 2026-10-01 the newest tag cut on this repository is `polyfill/v1.0.0-dev.3`; every version from `1.0.0-dev.4` onward exists as a package.json value on a branch and has no tag. Until the tag for a version is cut, the only pin that names a version exactly is the COMMIT at which package.json reads it - which is what a `usage_` leaf's `against:` line names in parentheses, and it is a real pin, not a workaround. Pin the tag where there is one, the commit where there is not, and say which in the `against:` line either way.
`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, so none of the changes named above is a bump from anything - but the next release very likely is a major. What exists today is `1.0.0-dev.15`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.15`, the string you pinned, never the version it is heading for.
`1.0.0-dev.15` is a **patch** (2026-10-01), and it answers a consumer's standing requests. No signature changed, nothing was added or removed from the entry, and no behaviour moved - what moved is what this leaf SAYS, which is the only channel such a request has. `## Surface` gains a complete hand-written shape, with the type argument that types the snapshot, and the statement that `initNg` discards its first argument and substitutes the published `ng` (MEASURED, not inferred). `## Guarantees` now says out loud that an anchored `docs.sparqlQuery` refuses a document it cannot read instead of swallowing it - true since `1.0.0-dev.8`, stated only in that version's release note until now - and names, in one place, which reads reach a document in a public store from nothing but its `reference` (the anchored query, always; `useShapeQuery` on a document NAMED in `graphs`, since `1.0.0-dev.9`) and which do not. `## Non-guarantees` says what the `orm_start_graph is not a function` outage actually was (this package's own `ng` wrapper, fixed in `1.0.0-dev.7`, NOT the engine refusing a shape), pins the upstream swallow to the shipped artifact it was read in, and gains an **Asked for, not decided** list so that a request an application made leaves a trace here rather than in a message. `## Change policy` says which pin exists, and that the tags lag the versions.
`1.0.0-dev.14` is a **patch** (2026-10-01): `ShapeObservable` moved from the entry's SDK-SHAPED section to its POLYFILL-ERA one, and `## Surface` now states that `refetch()` has no recorded counterpart. No signature changed - same name, same shape, still exported from this one entry - so nothing under `## Guarantees` moved. The type was already annotated `kind=invention level=none ref=none` (no upstream observable exists), under a heading that promises a target counterpart for every symbol it lists. It stays exported because applications genuinely write the type down. What the move records is that it is not a framework adapter: it carries the capability filter and the capability-arrival re-publish, both of which exist only because one wallet is shared, so it is deleted when the shared wallet is, as one coupled migration. `ShapeQuery` stays SDK-SHAPED: its record exists and states `{ data, isPending, isSuccess, isError, error }` field for field.
`1.0.0-dev.13` is a **patch**: `NgLike`, `UseShapeLike`, `OrmSubscriptionLike`, `GetObjectsLike` and `InsertObjectLike` moved from the entry's SDK-SHAPED section to its POLYFILL-ERA one, beside `EventuallyConfig`, whose fields they type. No signature changed - same five names, same five shapes, all still exported from this one entry - so nothing under `## Surface` or `## Guarantees` moved, which is the patch/major line this leaf draws itself. What changed is classification only: each was already honestly annotated `kind=invention level=none ref=none` (no target counterpart, at any level), sitting under a heading that promises one for every symbol it lists, because each exists solely to describe what `configure` receives and `configure` was already on the deletion list. An application that already understood it would stop typing these five the day it deletes its `configure()` call was not misled about behaviour; this patch only makes the surface say so, instead of implying the opposite.
`1.0.0-dev.12` is a **major**, and it is one removal: **`Schema` (`@ng-org/shex-orm`) and `DeepSignalSet` (`@ng-org/alien-deepsignals`) are gone from the entry, and nothing replaces them.** Both were re-exported type-only under `## Surface`'s own rule - a type is published only when a published signature uses it - and neither was: `DeepSignalSet` is named only in prose describing what `useShape`'s result carries, never as an actual type anywhere in this package, and `Schema` was named nowhere at all beyond the re-export itself. The same rule already removed `ReadCap` and `InboxScope` from this list; this is that rule catching a second, later recurrence. If your code names either type, it stays DEFINED nowhere in this package to fall back on - name the SDK package directly (`@ng-org/shex-orm`, `@ng-org/alien-deepsignals`) if you need it.
`1.0.0-dev.11` is a **major**, and all of it is one rule: **`useShape` hands back NextGraph's own RESULT OBJECT, filtered - and the filter no longer decides what to hand on by what a member is CALLED.** Nothing is added, renamed or removed from the entry.
- **Read your objects through `data`, not by iterating what the call returned.** `@ng-org/orm`'s React hook returns `{ data, promise, subscription, isLoading, nextPage?, previousPage? }` around the collection, and it has for every release this package builds against. This package went on treating the return AS the collection: `[...result]` and `result.size` threw, and `result.data`, `result.promise` and `result.subscription` each handed back **every identity's items**, with nothing raised anywhere. If you were reading `.data`, you were reading unfiltered. That is the defect this version closes, and it is why this entry is worth reading before upgrading rather than after.
- **`subscription` is REFUSED where it used to be handed over.** It is the ORM's own `RdfOrmSubscription`, which reads and writes with no capability filter at all and which `## Non-guarantees` has always said this package does not publish. Reading it now throws, naming itself. `data`, `promise`, `isLoading` and the two page commands are what remains, and they are what a caller needs: the SDK's own example destructures `data` and `isLoading` and nothing else.
- **A member of that object that this package has not DECIDED is refused, whatever its type.** All six of the members `@ng-org/orm` ships are decided; a seventh that a later version adds refuses until someone decides it. Two earlier rules were tried and both let a leak through - "anything that is not a function is harmless", which handed over three members of the current shape, and "a plain scalar cannot reach an item", which handed over any count not spelled `size` or `length`. There is no test on a value's type that separates a count derived from your data from a page size, so the rule is not about types at all: it is that nothing undecided is answered.
- **The two arguments are the SDK's own types**, where the signature took `unknown` for both. `useShape(shapeType: ShapeType<T>, conf: RdfOrmConfig<T> | string | undefined)`, which is what the surface already said the pair was. A caller passing the SDK's shape and config compiles unchanged; one passing something else now does not, which is why this counts.
- **`size` and `length` count what you may read, and so does every other way of asking.** An ordered config (`orderBy`) makes the collection an array, which says how many items it holds in four places - `length`, `Object.keys`, `in`, and the descriptor of each index - and only the first was filtered. `data[0]` now names the first item you may read rather than the first the engine returned, and the positions past them do not exist.
- **A member's DESCRIPTOR answers what reading it answers.** `Object.getOwnPropertyDescriptor(result, "data")` returned the collection UNFILTERED, and `...(result, "subscription")` the handle that reading it refuses: that call does not go through the view's own read. Neither does a spread, `Object.entries`, `JSON.stringify` or a console inspection, so all of them were reading round the filter. They now read through it - which also means `{...result}` and `Object.keys(result)` THROW on `subscription`, where they used to hand it over. **But do not read that as "logging is filtered".** A console is the HOST's, not this package's: `console.log(result)` in Bun and in a browser devtools inspects the object BEHIND the view and prints `data` and `subscription` in full, for every identity, and no interception can prevent it - measured. That is a property of runtime introspection, not a hole a version can close. **Do not log or inspect what a shape read hands you when more than one identity shares the page**; iterate `data` and log what you mean to show.
- **A mutation no longer hands back the collection, nor acts past the view, and all three ask the same question.** `add` returned the collection - `Set.prototype.add` returns `this` so calls chain, measured on the plain `Set` and on the real `DeepSignalSet` alike - so `view.add(x)` gave every identity's items to a caller whose every way IN was filtered, through the first member the ORM's own example reaches for. It now hands back the view, and it now REFUSES an object naming a document you hold no key for: ungated, it let you plant an object into someone else's document, never see it again, and have the two-way binding commit it there. `delete` removed an item the view declines to confirm the existence of; `clear` emptied the collection every identity shares. **Your own writes still reach the document** - that is the one thing that must not change, and `e2e/orm-live-read.ts` measures it against the deployed broker.
- **A write to the collection itself is refused.** `data[0] = x`, `delete data[0]` and `data.length = 0` all went through against a collection that allowed them, the last defeating the emptying rule outright; `Object.setPrototypeOf(data, ...)` replaced the real collection's prototype, after which iterating it throws for every identity, and `Object.freeze(data)` made it non-extensible. The view refuses all of them itself now, rather than relying on the ordered collection upstream refusing some of them for it.
- **The members upstream declares non-mutating answer again.** Eighteen array reads on an ordered result, plus `includes` and the seven set combinators, were throwing for the holder whose data it was - a leak traded for an outage. They are served over the items you may read.
- **A member this package has not decided is refused, whatever its type.** The rule used to be "a plain scalar cannot reach an item, so it passes", and a COUNT is a plain scalar that describes the items: a member spelled `totalCount` published how many objects exist while you could read one. `size` and `length` were right only because they were computed by name. Nothing undecided passes now - what a later version adds refuses, loudly, naming itself.
- **The RESULT OBJECT is as write-protected as `data`.** `delete result.data`, `result.isLoading = x`, `Object.setPrototypeOf(result, ...)` and `Object.preventExtensions(result)` all reached the SDK's own object; the last two had been closed on `data` one version earlier and not here.
- **`add` refuses an object that names no document.** The gate asked the same question a READ asks, where an absent `@graph` means "names no document, nothing to check" - which let an object with no `@graph` into the collection every identity shares. Set `@graph` to a document you hold.
- **An ordered result yields indices where it used to yield items.** `keys()` handed back items, `entries()` handed back `[item, item]` and `forEach` handed the item as its second argument - the Set contract, applied to an array. No leak, and wrong output on your own data.
- **Unchanged**: the read still spends no round-trip asking a public store for a key, a document you have read nowhere else still does not appear, and nothing about `useShapeQuery`, `getObjects` or `insertObject` moves.
`1.0.0-dev.10` is a **major**, and all of it is one rule: **"is this document open in this session" now has exactly one answer.** This package kept three records of that fact - a set of live bootstrap subscriptions, a per-document barrier state, and the subscription layer's own listeners - and the first two outlived the third. Only one remains, the state of the document's one real subscription; the other two are gone rather than reconciled, because two records of one fact can disagree and these did. Nothing is added, renamed or removed from the entry.
- **The ARRIVAL half of a named scope's guarantee is delivered in full**, and the two non-guarantees `1.0.0-dev.9` carried against it are withdrawn. A second `configure(...)` no longer leaves a document recorded as synced against a subscription it tore down - that one DID publish an empty or stale success, and now stays pending until the document is heard from. A subscription the broker refuses no longer leaves the document recorded as open, in the one case where that was unbounded: when the refusal arrives after the subscription layer has been torn down from under it, nothing was ever told, and the document could not be opened again for the rest of the session. In both, the document is now re-opened and the read answers once it has actually been heard from.
- **What a caller sees change, and the two situations differ - said per situation rather than lumped together.** After a second `configure(...)` a named scope publishes `isPending` where it published `isSuccess`, and that is why this is counted a **major** on the conservative reading: `isPending` is a state this call already reports and this surface already requires you to handle, so no new state arrives at the caller, but an interface branching on `isSuccess` is handed a different value. When the broker refuses late, the read was already withheld - it is the RECOVERY that changes: the retry now subscribes again instead of being blocked for the rest of the session, so `refetch()` is an exit where it was not. **Calling `configure` once, at start-up, is still what the deployment requirements ask for**; this makes a second call recoverable rather than supported.
- **Unchanged, and each is still a success**: a named document you can read that holds nothing of the shape, `graphs: []`, and `graphs: "did:ng:i"` or `""`. Turning a genuine emptiness into a failure would only move the lie, and nothing here does.
`1.0.0-dev.9` is a **major**, and all of it is one rule: **a read that could not reach its document now FAILS, where it used to answer as though the document were empty.** Nothing is added, nothing is renamed, nothing is removed from the entry - three existing calls narrow.
- **`useShapeQuery` publishes `isError` for a document you NAME and cannot read.** Until this version a document named in `scope.graphs` was handed straight to the engine; whatever came back was filtered per document by what you hold, and a document you hold no key for was emptied out of the result. The snapshot was `isSuccess` with `data: []` - indistinguishable, byte for byte, from the scope genuinely holding nothing. It now asks the public store for the key, refuses out loud if there is none to be had, and crosses the sync barrier first, so the same read answers `isError`. **Handle `isError` on a named scope**; an interface that only branched on `isPending` / `isSuccess` will show its success state with nothing in it. Naming several documents fails as a whole when one of them fails.
- **A named document that has not SYNCED keeps the read pending**, where it used to publish the same empty success. Reaching a document and hearing back from it are two questions, and only the first was being asked. An interface must therefore expect a named scope to sit in its loading state while a document it named is not answering, rather than reading an empty success as "nothing there". The same applies after an in-page re-login, which returns a named scope to pending until its documents are confirmed under the new session - see `## Guarantees`. Complete since `1.0.0-dev.10`; in `1.0.0-dev.9` it held only within one ordinary session, and the two situations that broke it were carried as non-guarantees.
- **A named scope now costs round-trips it did not cost before**, once per named document per session: the key fetch, and the subscribe that syncs the repo. The opens run together, so several named documents cost the slowest wait rather than the sum. `graphs: "did:ng:i"` is unchanged and spends nothing.
- **A refused named scope RECOVERS on its own.** Reachability is re-read on every publication rather than remembered, and a scope whose barrier failed is attempted again when the keyring moves - including when the move happened while the read was still deciding. Without that a refusal would be terminal, and trading a silent empty answer for a permanently stuck error would have been no improvement at all. Expect to see `isError` briefly on a page that subscribes before its keys are restored. There is no polling: in a session whose keyring never moves again, `refetch()` is the recovery, as it is for every other failure this call reports.
- **`inbox.postToDocument`, `inbox.readForDocument` and `inbox.readSyncedForDocument` reject where they used to report "no inbox".** Resolving a document's inbox address reads that document, and when the read could not happen - the broker could not answer for it, or its public store could not be asked for the key - all three used to render the breakdown as the document's owner having opened nothing: a refusal on the deposit side, an empty list on the read side. They now reject. A document that genuinely has no inbox is unchanged, and still answers exactly as before.
- **Nothing that was already an ANSWER moves.** An empty document is a success holding zero objects, `graphs: []` is a success holding none, a document in a public store that exposes no key is a plain "no", and a document with no inbox answers `[]`. The whole change is that a *failure* stopped borrowing those shapes.
- **Why this direction, and not a richer set of outcomes.** The step that collapsed the two was this package's own emulation of a public store serving its key; NextGraph has no counterpart for it, because upstream nothing consults a capability on the read path at all. So there is no signature to mirror, and the alignment falls to the engine's own observable: an anchored query on a repo the verifier does not hold answers `NgError::RepoNotFound`, a rejected promise - **a read rejects, or it does not**. Inventing a third outcome for a caller to branch on would have been a vocabulary the target will never grow.
`1.0.0-dev.8` is a **major**, and it is one removal: **`readUnion` and `UnionSubject` are gone from this surface, and nothing replaces them.**
- **What they were.** `readUnion(docs)` issued one anchored `docs.sparqlQuery` per document, in parallel, and added four policies of its own on top: it swallowed a document it could not read into an empty answer, it dropped a document whose key you do not hold instead of refusing it, it grouped the rows into per-(document, subject) property bags, and it removed the reserved-prefix registers from the result. Nothing at any level of NextGraph answers that composition. Its own provenance annotation cited `sparql_query`, which is a citation a reader checks, finds correct, and moves on from - while the claim attached to it was false. **A composition cannot be aligned on a single function**, and that is the rule this removal serves.
- **What to call instead.** `docs.sparqlQuery(sessionId, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, doc)` - the primitive `readUnion` was built on, published beside it all along. It is the same anchored query and the same public-store key fetch, so **a bare `reference` is still enough to read a document in a public store**: nothing about that property moves. Reading several documents is this call once per document.
- **What comes back to you, and it is not a detail.** Three of the four policies were the batch's, and they are now yours: a read that FAILS rejects instead of reading as an empty document, so whether one unreadable document ends your loop is your decision at your call site; a document you hold no key for is REFUSED out loud instead of being dropped from a result; and the rows are rows - grouping them into objects is yours, or use `getObjects` / `useShapeQuery`, which return the SDK's own typed objects. The fourth is in `## Guarantees`: subjects under `urn:ng-eventually:` are no longer filtered out for you.
- **Why the losses are alignment rather than regression.** Upstream, an anchored query on a repo the verifier does not hold REJECTS (`resolve_target_for_sparql` -> `NgError::RepoNotFound`), which reaches JavaScript as a rejected promise. A "skip this one and carry on" policy is something the target does not have and will not grow, so a caller that learned it would have had to unlearn it. The one thing that genuinely disappears is the convenience, and it is three lines at your call site.
`1.0.0-dev.7` is a **major**, and what makes it one is not the two calls it adds but the rule it starts enforcing. Symbol by symbol:
- **`getObjects` and `insertObject` are published**, at NextGraph's own names, parameters and returns, wrapping the SDK's. That part is a minor on its own: nothing existing moves. Import them from this package rather than from `@ng-org/orm` - which is the point, since the unwrapped ones bypass the capability filter and the shared-wallet identity boundary entirely. Both are injected through `configure` (`getObjects`, `insertObject`), like the shape query beside them.
- **An ORM write into a document you do not own is now refused**, where it used to succeed. This is the major. It applies to `insertObject` and to every mutation of a live set a shape query handed you, because both leave by a door that had no guard on it. The guarantee itself did not move - *only a document's owner writes to it* has been in this contract from the start - but the implementation only enforced it on `docs.sparqlUpdate`. Code that relied on writing through the ORM into somebody else's document was relying on a hole. Note that a live-set mutation is refused WITHOUT your call failing: see the non-guarantee above.
- **`initNg` must be this package's**, and it now substitutes the `ng` it hands NextGraph's ORM. An application that calls `@ng-org/orm`'s `initNg` directly still works and is still unguarded - which is exactly the state being left behind, so call this package's.
- **`RdfOrmConfig` joins the published types**, because `getObjects`'s signature names it. It is re-exported from the SDK's `/all` subpath, which is where the SDK puts it.
- **`ng` works.** Until this version the published `ng` answered every one of its members with a promise instead of the function, so `ng.<anything>(...)` failed with NextGraph's own `<member> is not a function`. If you passed it anywhere - and the surface tells you to use it instead of the one you gave `configure` - that is what you were hitting, including a `useShapeQuery` that never left `isPending` because NextGraph swallows the failure it gets from `orm_start_graph`. Nothing about the call changes; it simply answers now.
`1.0.0-dev.6` is a **major**, and all of it is one change: `useShapeQuery` reads through NextGraph's OWN shape query instead of a read this package built. Nothing was renamed and nothing was removed from the entry, but three things move under a caller:
- **`data` items are the shape's objects, not property bags.** `ShapeQuery<T>`'s `T` is now the shape's type: each item carries `@id`, `@graph` and one field per predicate the shape DECLARES, under that predicate's readable name. `note.props["urn:notebook:title"]?.[0]` becomes `note.title`, and `note.graph` becomes `note["@graph"]`. **A predicate your shape does not name is not projected**, so a shape written only to match — an `rdf:type` constraint and nothing else — now comes back with no fields at all: declare the ones you read. (`readUnion` was unchanged by this version, and was removed in `1.0.0-dev.8`.)
- **`shapeType` is typed.** It was `unknown`; it is `ShapeType<T>` (the SDK's own, re-exported here), so the snapshot is typed without a cast — and a hand-written shape object must now satisfy that type, which means `readablePredicate`, `minCardinality` and `maxCardinality` on every predicate.
- **`configure` takes one more thing.** `ormSubscription: RdfOrmSubscription.getOrCreate`, plus `initNg` and a call to this package's `initNg(ng, session)` once the session has arrived. Optional in the type, required in practice for this read: without it `useShapeQuery` publishes `isError` naming the field.
And one guarantee narrows, which is the part to read twice: a shape query the engine REFUSES no longer reaches `isError` — it stays `isPending` for ever, because NextGraph swallows that failure. See `## Guarantees`.
`1.0.0-dev.5` before it was the largest **major** of the line: it renames one call, changes the parameter of two, and removes three. Every change is one step toward the surface NextGraph will ship, so none of them is reversible by asking. Symbol by symbol, with what replaces it:
- **`watchShape(shapeType, scope)` → `useShapeQuery(shapeType, scope)`.** The name is the target's own; `watchShape` appears nowhere in NextGraph and spends a verb the ORM already uses for signal observation. Everything you read back is unchanged — the same `ShapeObservable`, the same `ShapeQuery` snapshot, the same three states.
- **`Scope` is no longer `"public" | "protected" | "private"`.** The published `Scope` is now the SDK's own, `{ graphs?: string[] | string; subjects?: string[] }`, re-exported from `@ng-org/orm`, and it is what `useShapeQuery` takes. `watchShape(Shape, "protected")` becomes `useShapeQuery(Shape, { graphs: "did:ng:i" })` for everything you hold, or `useShapeQuery(Shape, { graphs: [doc1, doc2] })` for named documents. **There is no translation for "the documents in my protected store"**: that question is not expressible against the target, and the three strings were this package's own way of asking it.
- **`storeRegistry.createEntityDoc(scope)` → `createEntityDoc(store?)`.** `store` is a reference, not a word: `await storeRegistry.protectedStore()`, `publicStore()` or `privateStore()`. Omitting it places the document in the private store, exactly as `doc_create` does when no store is given. `createEntityDoc("protected")` becomes `createEntityDoc(await storeRegistry.protectedStore())`.
- **`storeRegistry.listMyEntityDocs(scope)` is REMOVED, with no replacement call.** There is no `doc_list` upstream, at any level. What replaces it is the read itself: ask `useShapeQuery` for a shape over `{ graphs: "did:ng:i" }` and what you hold of that shape comes back. An application that listed its documents in order to read them was doing in two steps what the read does in one — but it must now write something the shape can match, typically an `rdf:type` triple, since nothing indexes a document for you.
- **`storeRegistry.resolveScopeGraph(scope)` is REMOVED.** It handed out a value whose stated use was a read scope, and a store's graph holds its containment triples and nothing else — so pointing a shape query at it matches nothing and says so with an empty set, silently. If you were using it as a write anchor, use the store's reference from the accessor of the same name.
- **`storeRegistry.resolveWriteGraph(scope)` is REMOVED.** It returned the very reference `privateStore()` / `protectedStore()` / `publicStore()` return. One value, one published name.
`1.0.0-dev.4` before it narrowed a **guarantee** and added a rejection: a deposit this package applies is now consumed, so a failure that used to be retried at the next arrival or the next connection is now final and reported once; and `inbox.processInbox` rejects on a broker failure where it used to resolve. An application that leaned on the retry — however implicitly — has to keep its own record instead, and one that awaited `processInbox` without a `catch` now has a rejection to handle. `1.0.0-dev.3` before that narrowed an existing call the same way: two payload shapes an application could carry freely became this package's own, and one it cannot apply is reported. All of them land inside the pre-release line rather than on a new major number because `1.0.0` has not been cut, so there is nothing yet to break away from.
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, and a symbol is never left published-but-dead as a courtesy. Diff this leaf between two pulls — `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
@@ -1,51 +1,80 @@
--- ---
type: usage type: usage
summary: The seven 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 summary: The polyfill entries the indexing layer and its end-to-end application stand on - placement by store reference, one anchored write, the deposit that becomes an entry, the anchored read, and one shape query
against: "@ng-eventually/polyfill@1.0.0-dev.1" against: "@ng-eventually/polyfill@1.0.0-dev.15 (tag polyfill/v1.0.0-dev.15, commit ec3900077bfd090f5388e52b47a8132b2fc68d16)"
--- ---
# 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 curates it. 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 ## Consumed surface
**Placement** — `storeRegistry.createEntityDoc("public")`, for the index document itself; `storeRegistry.openDocumentInbox(doc)`, called once at creation. **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.publicStore()` then `storeRegistry.createEntityDoc(store)` - the index document, in its creator's public store. A store is named by the REFERENCE the accessor hands back, and the argument is never omitted: omitting it places the document in the private store, and an index nobody but its owner can open is not an index. This is the call that was still passing the withdrawn string `"public"` against the previous version.
- `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, reading, and one observation** (`e2e/indexing-app.ts`, `e2e/bridge.ts`)
**Inbox** — `inbox.postToDocument(doc, { payload })` for depositing, `inbox.readForDocument(doc)` for the owner draining it, and the shape of `Deposit` (`from` / `payload` / `ts`), which our `IncomingDeposit` mirrors. - `configure({ ng, useShape, ormSubscription, init, initNg, sharedWallet })`, this package's own `init(callback, true, [])` for the `session_id` its callback delivers, this package's `initNg(ng, session)` once that session has arrived, and `ensureIdentity()`. `ormSubscription` is `RdfOrmSubscription.getOrCreate` and the injected `initNg` is the SDK's, both from `@ng-org/orm@0.1.2-alpha.21`, passed as they stand; the `ng` and `init` handed to `configure` are `@ng-org/web@0.1.2-alpha.14`'s.
- The package's own published `ng`, as the argument `initNg` is given.
- `docs.sparqlQuery(sessionId, query, undefined, anchor)` - the anchored SELECT, and the ONLY way anything here reads a document by name: an index, a referenced object, and the stranger's case where the reader holds no key and the public store is asked for one. Two forms: the narrow `SELECT ?object ?value` a consumer of `@ng-helpers/indexing` is told to write, and `SELECT ?s ?p ?o`, whose rows the application groups by subject itself, skipping the polyfill's `urn:ng-eventually:` registers.
- `useShapeQuery(shape, { graphs: "did:ng:i" })`, `ShapeObservable`'s three members (`getSnapshot`, `subscribe`, `refetch`), the three states of `ShapeQuery` and its `data`, and the types `Scope` and `ShapeType`. ONE use, and it is an observation rather than a business read: after a failure is injected into the inbox step of `create`, this is how the suite finds the document that was left behind. The shape is hand-written and names one predicate - the descriptor `create` writes - which is what makes an index document matchable at all.
**Types** — `Nuri`, `NuriLike`, `PrincipalId`, `UnionSubject`. **Not consumed**, and safely evolvable as far as this repository is concerned: `useShape` (a function that REFUSES is handed to `configure`, and nothing calls it - see `## Frictions`), `subscribeDoc` / `subscribeDocs`, `docs.docCreate`, `storeRegistry.privateStore` / `protectedStore`, `inbox.share` / `post` / `read` / `readForDocument` / `readSynced` / `readSyncedForDocument` / `processInbox` / `watch`, and the types `Deposit` and `PrincipalId`.
**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.listMyEntityDocs`/`resolveScopeGraph`/`resolveWriteGraph`, `inbox.share`/`post`/`read`/`readSynced`/`readSyncedForDocument`/`processInbox`/`watch`, `ng`, `initNg`. It is safely evolvable as far as this layer is concerned.
## Constraints ## 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. **An index is created in its creator's PUBLIC store, named by that store's reference.** The public store is what serves an index's read key to whoever asks, and that is the whole reason a stranger can reach one from its reference alone. The unit suite pins both halves - where the document landed, and that the store travelled as the reference an accessor produced - so the private placement that omitting the argument would give is red rather than silent.
**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. **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 inbox is opened at creation, from one place.** The engagement disclaims coalescing `openDocumentInbox` across pages, so we never open an index's inbox anywhere but in `createIndex`, under its owner, at the moment the document is created. **`sessionId` is relayed, never converted.** It is carried at the engagement's own `string | number` and handed back untouched.
**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. **The ORM is the APPLICATION's to wire, and the library never touches it.** `src/` performs no shape read of any kind, so a consumer of `@ng-helpers/indexing` that wants none wires nothing and declares no `@ng-org/*` dependency of its own. Only the end-to-end application here wires it, and only because one observation has no other door.
**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. **A reactive read's load state is read before its data, and a pending read is bounded.** An empty `data` means "this scope holds nothing" only under `isSuccess`; under anything else it means nobody answered, and the two never reach a check as one value - the state travels out to the report beside the result. The bound exists because a shape query the engine refuses never finishes loading, so without one that failure would be a hang rather than an observation.
**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.
**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.
**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.
**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.
**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 - which is what lets a reader skip every subject under that prefix without losing anything of an index.
**A document that cannot be read is refused, not answered empty.** Every read here rests on the anchored query rejecting for a document the reader cannot reach, rather than returning no rows; the end-to-end suite lets that rejection surface as a failure and never reads it as an index holding nothing.
**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 ## 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. **`configure` requires `useShape`, and a frameworkless application has nothing to give it.** `@ng-org/orm` publishes `useShape` only through its react / svelte / vue adapters; a page with no framework can produce none of them. The field is not optional in `EventuallyConfig`, so this application hands over a function that THROWS if anything ever calls it - a value invented to satisfy a signature. Making the field optional removes it.
**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.** Every entry validates at its own door, but this layer composes SPARQL before it reaches one, so it carries its own guard. 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.
**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. **No published way to write triples above the raw SPARQL primitive that a library without the ORM can reach.** `insertObject` is the write above `docs.sparqlUpdate`, but it is injected through `configure` and this library wires no `@ng-org/*` by design (see `## Constraints`), so `docs.sparqlUpdate` remains the level it aligns on - which is where the two frictions above come from. An "add these triples to this document" entry, taking no shape, would delete the query composition, the escaping and the NURI validation from this package in a single move.
## Needs
**A way to tell which store holds a document whose reference is already held.** The withdrawn `listMyEntityDocs` had one use here, and the shape read now covers most of it: after a failure is injected into the inbox step of `create`, the suite finds the document left behind by asking for every index document this identity HOLDS and diffing. What that cannot say is WHERE it sits, so the check it feeds now establishes that a document survived, and no longer that it survived in the public store. This is not the question the Change policy declined: it enumerates nothing, it names a document the caller already holds, and a store's graph is said to hold exactly the containment triples that would answer it. As it stands an application cannot verify its own placement at all - a document written to the wrong store is undetectable until some reader fails to open it.
**A way to tell a shape the ENGINE refused from a shape the application wrote wrong.** `## Non-guarantees` says a refused shape query is caught upstream, written to the console and nothing more: the set stays empty and its readiness never settles. From an application both faults look identical - an `isPending` that does not end - and the console line is not something a page can act on. That is the whole diagnostic surface for the one thing an application must hand-write itself, so the first shape anybody writes has no way of telling "the engine would not take this" from "the ORM was never started". Anything that separates them - a refusal that reaches `isError`, or a state that says "never opened" - turns a silent hang into something an interface can report.
**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.
+11 -1
View File
@@ -19,13 +19,23 @@
publish: publish:
# paths are relative to `.project/` # paths are relative to `.project/`
#
# The engagement we OFFER, plus the two declarations we author as a CONSUMER. Listing a
# `usage_` leaf here is what lets each provider re-find it when it moves - and, more to
# the point, what makes it visible at all: a declaration nobody can discover leaves the
# provider with nothing but our source code to read.
#
# Both declarations are about this same repository, so the keys are qualified by the
# interface they speak on; publish keys must be unique.
indexing-layer: concepts/indexing/indexing-layer/contract_indexing-layer.md indexing-layer: concepts/indexing/indexing-layer/contract_indexing-layer.md
polyfill-surface--ng-helpers: concepts/indexing/polyfill-surface/usage_ng-helpers.md
ng-e2e-helpers--ng-helpers: concepts/indexing/ng-e2e-helpers/usage_ng-helpers.md
consume: consume:
- contract: polyfill-surface - contract: polyfill-surface
type: git type: git
pullFrom: git@gitea.reconnexion.apps.gueraud.net:Reconnexion/ng-eventually.git/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md pullFrom: git@gitea.reconnexion.apps.gueraud.net:Reconnexion/ng-eventually.git/.project/concepts/app-contract/polyfill-surface/contract_polyfill-surface.md
ref: caps-p1a-and-virtual-user-boundary ref: polyfill/v1.0.0-dev.15
into: concepts/indexing/polyfill-surface/ into: concepts/indexing/polyfill-surface/
- contract: ng-e2e-helpers - contract: ng-e2e-helpers
+27 -40
View File
@@ -6,74 +6,57 @@ Helpers built **on top of** NextGraph. The first — and for now the only — on
NextGraph has no indexing concept, at any level, and will not grow one. This is not scaffolding standing in for something that will arrive upstream; it is a construction *above* NextGraph, and it stays in its own repository so that the two are never confused. NextGraph has no indexing concept, at any level, and will not grow one. This is not scaffolding standing in for something that will arrive upstream; it is a construction *above* NextGraph, and it stays in its own repository so that the two are never confused.
So the dependency runs one way and only one way: **this repo depends on `@ng-eventually/polyfill`, and the polyfill must never learn anything about indexing.** No index vocabulary, no index type, no inbox variant that knows what a deposit means. The polyfill's inbox stays generic and carries opaque deposits; what a deposit *means* is decided here. So the dependency runs one way and only one way: **this repo depends on `@ng-eventually/polyfill`, never the reverse.**
That boundary is held by one file. `src/polyfill-adapter.ts` is the only place that imports the polyfill at runtime; everything else is written against `NextGraphPort` (`src/port.ts`), a small interface describing what this layer needs. Only entries listed in that package's published `contract_polyfill-surface` are used — nothing is reached for inside it. That boundary is held by one file. `src/polyfill-adapter.ts` is the only place that imports the polyfill at runtime; everything else is written against `NextGraphPort` (`src/port.ts`), a four-operation interface describing what this layer needs. Only entries listed in that package's published `contract_polyfill-surface` are used — nothing is reached for inside it.
## What an index is ## What an index is
**An ordinary document.** Nothing marks it as an index. What makes it one is that an application references its NURI in its own source. **An ordinary document.** Nothing marks it as an index. What makes it one is that it declares a field, and that an application references its NURI in its own source.
- Any user may create one, and its creator owns it. - Any user may create one, and its creator owns it.
- It lives in the creator's **public store**, so any reader can open it from the reference alone. - It lives in the creator's **public store**, so any reader can open it from the reference alone.
- It declares, on its own subject, the **field** it indexes by — a predicate, since the objects are RDF. An index "by a date" is simply an index whose field is a date predicate: there is no separate kind of index. Entries come out in chronological order because ISO-8601 sorts as a string. - It declares, on its own subject, the **field** it indexes by — a predicate, since the objects are RDF. An index "by a date" is simply an index whose field is a date predicate: there is no separate kind of index. Entries come out in chronological order because ISO-8601 sorts as a string.
- Each entry is a subject keyed by the indexed object's NURI, carrying that object's value for the field. - Each entry is a subject keyed by the indexed object's NURI, carrying that object's value for the field.
**Contributing is a deposit, not a write.** Creating an object that belongs in an index means depositing into the index document's inbox — `inbox.postToDocument`, which is exactly "reach this document's owner" and is open to anyone. Nobody but the owner ever writes the index. **Contributing is a deposit, not a write.** Handing an index a reference means depositing into the index document's inbox — `inbox.postToDocument`, which is exactly "reach this document's owner" and is open to anyone. Nobody but the owner ever writes the index.
**A deposit is a bare reference. Nothing else.** It states no claim and gives no instruction: no operation, no index reference (the inbox address already identifies the index), no copy of the indexed value. When the owner curates, it resolves the reference and opens the object itself — which it can, because indexing is limited to public data for now. What the object says is what goes in. This is the shape NextGraph already uses upstream, where a `SocialQueryRequest` carries a reference to an RDF definition and the recipient composes its own update; a payload carrying an operation would be a licence for anyone to rewrite someone else's document. **What travels is a bare reference. Nothing else.** It states no claim and gives no instruction: no operation, no index reference (the inbox address already identifies the index), no copy of the indexed value. Whoever processes it opens the object itself and takes what the object says. This is the shape NextGraph already uses upstream, where a `SocialQueryRequest` carries a reference to an RDF definition and the recipient composes its own update; a payload carrying an operation would be a licence for anyone to rewrite someone else's document.
**Reading needs nothing new.** An application that knows the NURI calls `readUnion([indexNuri])` and gets the entries as subjects. `Indexing.read` is sugar over exactly that, dropping the index's own declaration subject. **Reading needs nothing new.** An application that knows the NURI runs a `SELECT` anchored on it — `docs.sparqlQuery`, the same call it reads any other document with — and gets the entries. There is no read helper here, and that absence is deliberate: one would only teach a shape a caller has to unlearn.
## An index only ever grows ## What this package does, and what it deliberately does not
**Nothing is ever removed from an index — by anyone, including its owner.** An index only ever grows. There is no removal function, and there never was one: removal was deliberately **never built**, not built and then withdrawn, and nothing is planned. This is written down here rather than left to be deduced from a missing function, because someone who needs an entry gone should learn that it was never possible instead of hunting for the call that does it. Today the only answer to that need is a fresh index. Two acts — `create(field)` and `add(index, object)` — and its whole job is to hide that an index is implemented by an inbox. An application names documents and never an address.
It is what makes the failure story trivial. Since the only write is an addition, a reference that does not resolve — the object is gone, or unreadable, or the broker simply did not answer — can only ever mean *not added this time*. It cannot damage what is already there, and a later deposit adds it. Nothing has to tell an absence from a failure, so nothing can get that wrong. **Nothing here makes an entry of what an index receives.** No resolving a reference, no opening the object, no writing an entry, no reading a document or an inbox, no going through a store to find one's own indexes, nothing watching. That work belongs to the layer that processes an index's inbox, which is where it now lives — and this package has no call that does it, aims it, or asks about it. A reference handed to an index becomes an entry without anything on this surface being involved.
That distinction is worth stating, because getting it wrong is a defect this family of code has already produced: in `ng-eventually-js`, `resolveAccount` returned `null` on a failed lookup, so a read that *failed* came back as "this account does not exist" — a failure disguised as an absence, which silently broke document sharing (fixed in `8c8ade7` and `e32b6d0`). That is why `indexing(sessionId)` is not a promise and reaches nothing: a handle is a session id and two acts.
`NextGraphPort` has no operation that removes, and nothing in `src/` can express a deletion — but **the write path was never where this went wrong.** Three rounds of adversarial review broke the guarantee five times, and not one of the breaks involved deleting anything. Every one was on the **read** path: **An index still only ever grows, and this package still cannot express a removal.** `src/sparql.ts` builds exactly one statement — an anchored `INSERT DATA` — and there is no delete builder. What enforces that is not a pattern over source: `test/adapter.test.ts` runs the real adapter on `test/fake-polyfill.ts`, an in-memory polyfill whose SPARQL is **executed** by an engine that understands one statement and refuses everything else. A removal is therefore not *detected*, it is **unrunnable**: `DELETE WHERE …` fails on the first keyword, a second statement smuggled after the closing brace fails on the trailing text, a keyword hidden inside a literal stays inside the literal because a parser tokenises where a regex only matches, and splitting the keyword across concatenated strings buys nothing, since it is one string by the time it arrives. The regex over `src/` in `test/units.test.ts` stays as a cheap tripwire that names the file early; it is not the proof.
- reading an entry required *exactly one* value, so a subject carrying two read as **absent** — and a second `addLiteralProperty`, the only write this package has, made an entry vanish while both triples sat in the document. Two curation runs racing each other produce exactly that; **The lesson that came out of the removed half is worth keeping**, because it was learned the expensive way: *"nothing removes" is a claim about the write path, and an invariant about what a reader can see has to be checked on the read path too.* Three rounds of adversarial review broke the guarantee five times and not one break involved deleting anything — every one was on the read path (an entry requiring exactly one value read as absent when a race gave it two; the same rule on the declared field took every entry with it; one non-NURI subject threw out of the whole listing; a field named `constructor` read back an inherited function). It belongs with whoever holds the read path now.
- the same rule on the declared field was worse: one add-only write of a second `INDEX_FIELD` made the descriptor unreadable and took **every entry in the document** with it, permanently;
- one subject that is not a NURI threw out of `entriesOf` and made every real entry unreadable at once;
- a field named `constructor` or `toString` read back an inherited function from the plain object `readUnion` builds, crashing curation for every remaining deposit;
- and the fix for the second of these silently **corrupted** the index instead: "smallest wins" switched the field while entries already written kept their old one, so `read()` returned a single list "ordered by value" whose values came from two different properties.
What holds now: entries take **at least one value, smallest wins**, deterministically. Reading is **per-entry tolerant** — a stray subject is skipped, never thrown on — and reads **own properties only**. Reading an index asks only *is this an index?*; the field is required only to **curate**, and an ambiguous one refuses loudly rather than picking, because a quiet wrong answer is worse than a loud stop. That refusal is permanent, which is the honest price of having no delete, and the message says so instead of suggesting a retry.
The lesson is worth keeping: **"nothing removes" is a claim about the write path, and an invariant about what a reader can *see* has to be checked on the read path too.**
What enforces the write half is no longer a pattern over source. `test/adapter.test.ts` runs the real adapter on `test/fake-polyfill.ts`, an in-memory polyfill whose SPARQL is **executed** by an engine that understands one statement — an anchored `INSERT DATA` of literal triples — and refuses everything else. A removal is therefore not *detected*, it is **unrunnable**: `DELETE WHERE …` fails on the first keyword, a second statement smuggled after the closing brace fails on the trailing text, a keyword hidden inside a literal stays inside the literal because a parser tokenises where a regex only matches, and splitting the keyword across concatenated strings buys nothing, since it is one string by the time it arrives. The regex over `src/` in `test/units.test.ts` stays as a cheap tripwire that names the file early; it is not the proof.
**A failed resolve is still a failure, and still surfaces.** Harmless is not the same as invisible. Every reference that could not be resolved comes back as an `unresolved` outcome in the curation report and is warned about — a failure that looks exactly like a normal outcome teaches nobody anything.
## Open questions ## Open questions
Deliberately not settled. Each is implemented in its narrowest form and reported rather than generalised. Deliberately not settled.
- **An object that carries nothing for the index's field.** Narrow behaviour: it is not added, and reported as `skipped: "no-field"`. There is no key to index it by, and inventing one — a placeholder, the deposit's timestamp — would put something in the index that the object does not say. Whether it should instead be indexed under an absent key, or refused louder, is open.
- **An object that carries several values for the field.** Not added, reported as `skipped: "several-values"`. Which of them the entry would hold has not been decided.
- **An already-indexed object is never re-read.** Curation skips it outright, so an object whose field value changes later keeps its original value in the index. Refreshing it would be a write nobody asked for, and it is the same question as removal.
- **Which value a raced entry should keep.** Two curation runs racing each other can leave an entry with two values; the smallest is chosen so that readers agree and the entry stays visible. That the entry must survive is settled; *which* of the two it should hold is not.
Note what this means, since the two points above are easy to read as one: an entry's value **can** change after it is made. Not by re-reading the object — that never happens — but because a *second* value arriving for the same entry can be the smaller one, and `read()` returns the smallest. Index an object at `2026-05-05`, let a raced run add `2026-01-01`, and `read()` answers `2026-01-01`. What never changes is the set of entries and the fact that each stays visible; the value one of them reports is settled by "smallest wins", not by arrival order.
- **How an index recovers from an ambiguous declaration.** Today it does not: curation refuses for good and the only way forward is a fresh index. Since nothing here removes anything, giving it a way back needs a mechanism that does not exist yet.
- **Deposits are never retired.** Every curation run sees every deposit ever made. That is affordable because re-applying one is a no-op, but it is linear in the history. How a curator retires an applied deposit is open — `inbox.processInbox` may be the answer, but its semantics are not published.
- **What an entry holds besides the object reference and the field value**, and **how several index kinds would coexist**, are both untouched. - **What an entry holds besides the object reference and the field value**, and **how several index kinds would coexist**, are both untouched.
- **Nothing marks a document as an index** except the field it declares. That is enough to recognise one you already hold a reference to, and not enough to discover one.
The questions about what becomes of a reference — an object carrying nothing for the field, or several values; whether an already-indexed object is ever re-read; what a raced entry keeps; whether a processed deposit is ever retired — are no longer this package's to answer, and moved with the code that answered them.
## Layout ## Layout
| Path | What it is | | Path | What it is |
| --- | --- | | --- | --- |
| `src/index.ts` | Everything published: one function, two acts, two IRIs |
| `src/indexing.ts` | The two acts, bound to one identity |
| `src/port.ts` | What this layer needs from NextGraph, and nothing more | | `src/port.ts` | What this layer needs from NextGraph, and nothing more |
| `src/polyfill-adapter.ts` | The only runtime import of `@ng-eventually/polyfill` | | `src/polyfill-adapter.ts` | The only runtime import of `@ng-eventually/polyfill` |
| `src/deposit.ts` | The deposit's shape: a bare reference | | `src/nuri.ts` | What a reference is, checked before it goes anywhere |
| `src/index-document.ts` | An index's declaration and its entries |
| `src/curator.ts` | Resolving references and adding what is there |
| `src/indexing.ts` | The public surface, bound to one identity |
| `src/sparql.ts` | The one statement this package writes — no deletion exists | | `src/sparql.ts` | The one statement this package writes — no deletion exists |
| `src/vocabulary.ts` | The two IRIs an index document is written with |
| `test/fake-nextgraph.ts` | An in-memory NextGraph behind `NextGraphPort`, enforcing the polyfill's published guarantees | | `test/fake-nextgraph.ts` | An in-memory NextGraph behind `NextGraphPort`, enforcing the polyfill's published guarantees |
| `test/fake-polyfill.ts` | An in-memory `@ng-eventually/polyfill` that **executes** the SPARQL — an engine that can only add | | `test/fake-polyfill.ts` | An in-memory `@ng-eventually/polyfill` that **executes** the SPARQL — an engine that can only add |
| `test/adapter.test.ts` | Runs the real adapter on it: behaviour, every query emitted, and every method driven | | `test/adapter.test.ts` | Runs the real adapter on it: behaviour, every query emitted, and every method driven |
@@ -82,14 +65,18 @@ Deliberately not settled. Each is implemented in its narrowest form and reported
`@ng-eventually/polyfill`, declared as a **peer** dependency: an application using this package supplies it, so exactly one copy of it exists in that application. That is a requirement of the polyfill itself, which keeps its state in the package — two copies mean two subscription registries and two current identities, and nothing detects it. `@ng-eventually/polyfill`, declared as a **peer** dependency: an application using this package supplies it, so exactly one copy of it exists in that application. That is a requirement of the polyfill itself, which keeps its state in the package — two copies mean two subscription registries and two current identities, and nothing detects it.
For this repository's own tests and typecheck it is *also* a `devDependency` by local path (`file:../ng-eventually-js/packages/polyfill`), which expects that repository to sit beside this one. A dev dependency is not installed by a consumer, so this local path never reaches one. `ng-e2e-helpers` is a `devDependency` by local path on the same expectation. For this repository's own tests and typecheck it is *also* a `devDependency` by local path (`link:../ng-eventually-js/packages/polyfill`), which expects that repository to sit beside this one. A dev dependency is not installed by a consumer, so this local path never reaches one. `ng-e2e-helpers` is a `devDependency` by local path on the same expectation.
**`link:`, not `file:`, and pnpm makes that a real difference.** pnpm COPIES a `file:` directory into its virtual store, and a copy is cut off from the sibling checkout's own `node_modules` — the polyfill's optional peers (`@ng-org/shex-orm`, `@ng-org/alien-deepsignals`) stop resolving and the typecheck fails on them. `link:` symlinks the sibling package where it lives, so it keeps its own dependencies and an edit made there is the one this repository tests against.
## Running it ## Running it
```sh ```sh
npm install # or: pnpm install pnpm install
bunx tsc --noEmit -p tsconfig.json bunx tsc --noEmit -p tsconfig.json
bun test bun test
``` ```
**`bun install` does not work in this repository** (checked with bun 1.3.9): bun resolves a mandatory peer dependency against the npm registry whatever local path provides it, and `@ng-eventually/polyfill` is published to no registry, so the install stops on `GET https://registry.npmjs.org/@ng-eventually%2fpolyfill - 404`. `npm install` and `pnpm install` both resolve it from the sibling checkout. `bun test` itself is unaffected — it is only the installer that cannot express this. **pnpm installs; bun runs.** `pnpm-lock.yaml` is the committed lockfile and `pnpm install` is the only install path — the same package manager the sibling `ng-eventually-js` uses. `bun` stays the test runner and `bunx tsc` the typechecker; neither reads a lockfile, so nothing about that changed.
**`bun install` does not work in this repository** (checked with bun 1.3.9): bun resolves a mandatory peer dependency against the npm registry whatever local path provides it, and `@ng-eventually/polyfill` is published to no registry, so the install stops on `GET https://registry.npmjs.org/@ng-eventually%2fpolyfill - 404`. That is why no `bun.lock` is kept here: bun cannot regenerate one, so the file that was here could only rot. `bun test` itself is unaffected — it is only the installer that cannot express this.
+91 -15
View File
@@ -14,16 +14,73 @@
* ends up typed as a reference. * ends up typed as a reference.
*/ */
import type { CurationReport, IndexEntry, UnionSubject } from "../src/index"; /**
* One subject of a document and everything the document says about it, keyed by
* predicate. Declared here because nothing publishes it: the polyfill withdrew its own
* (`UnionSubject`, with `readUnion`) in `1.0.0-dev.8`, and grouping the rows of an
* anchored query is the application's business -- see `readDocument` in the page.
*/
export interface DocumentSubject {
readonly subject: string;
readonly props: Readonly<Record<string, readonly string[]>>;
}
/**
* One entry, as THIS APPLICATION decodes one out of the index document.
*
* It is declared here and not imported, because `@ng-helpers/indexing` publishes no
* such type and no call that produces one: an index is an ordinary document, so its
* contents are whatever an anchored query hands back, read with the two published IRIs.
* A subject carrying several values shows up as several rows — the document's own
* truth, and the application's business to make something of.
*/
export interface IndexRow {
readonly object: string;
readonly value: string;
}
/** What the leak probe observed — see `run.ts`'s last journey. */ /** What the leak probe observed — see `run.ts`'s last journey. */
export interface BrokenInboxOutcome { export interface BrokenInboxOutcome {
/** The message `createIndex` rejected with, or `null` if it did not reject. */ /** The message `create` rejected with, or `null` if it did not reject. */
readonly rejected: string | null; readonly rejected: string | null;
/** What `createIndex` returned, on the impossible branch where it did not reject. */ /** What `create` returned, on the impossible branch where it did not reject. */
readonly returned: string | null; readonly returned: string | null;
/** The documents that appeared in this identity's public store despite the failure. */ /**
* The index documents this identity did not hold before, and holds after the failure.
*
* It does NOT enumerate a store, because there is no longer such a call: the documents in
* a store are not askable for, so what is read instead is every index document this
* identity HOLDS, through the shape query. The difference matters when reading the
* check it feeds: what it establishes is that a document survived, not where it sits.
*/
readonly appeared: readonly string[]; readonly appeared: readonly string[];
/**
* Which of the read's three states that observation came out of -- `success`, or the
* state it gave up in.
*
* An empty `appeared` means "nothing turned up" only under `success`. Under `pending`
* or `error` it means nobody answered, and a report that folded the two together would
* let a read that never started pass for a leak that never happened.
*/
readonly observed: string;
}
/**
* What one anchored SPARQL SELECT answered — or how it failed.
*
* The three fields are kept apart deliberately. "Nothing came back" and "the call failed"
* are different answers, and a shape that folded them together would let a failure read as
* an empty index — the defect class this repository keeps finding. `raw` carries the answer
* BEFORE anything here decodes it, so a decoder that is wrong about the result's shape
* cannot pass its own blindness off as a query that returned nothing.
*/
export interface SelectOutcome {
/** The message the query rejected with, or `null` when it returned. */
readonly failed: string | null;
/** Whatever came back, rendered as JSON — the answer before any decoding of it. */
readonly raw: string;
/** The SELECT's bindings, decoded to plain `variable → value` rows. */
readonly rows: ReadonlyArray<Readonly<Record<string, string>>>;
} }
/** /**
@@ -57,21 +114,40 @@ export interface IndexingBridge {
/** Publish a public document carrying one value for one predicate. */ /** Publish a public document carrying one value for one predicate. */
publishObject(predicate: string, value: string): Promise<string>; publishObject(predicate: string, value: string): Promise<string>;
/** Hand the CONFIGURED index a reference to an object. Anyone may. */ /** Hand the CONFIGURED index a reference to an object. Anyone may. */
referConfigured(object: string): Promise<void>; addToConfigured(object: string): Promise<void>;
/** Hand a NAMED index a reference — used where no identity boundary is crossed. */ /** Hand a NAMED index a reference — used where no identity boundary is crossed. */
referTo(index: string, object: string): Promise<void>; addTo(index: string, object: string): Promise<void>;
/** Resolve the references this index received and add what can be added. Owner only. */ /**
curate(index: string): Promise<CurationReport>; * Obtain a fresh `Indexing` handle, which is what a page load does.
/** The index's entries, ordered by value. */ *
read(index: string): Promise<IndexEntry[]>; * It reaches NOTHING — a handle is a session id and two acts, and building one talks
* to nobody. That is precisely what one journey asserts: taking a handle again changes
* nothing an index holds. It is NOT a settle point and cannot be used as one; what a
* journey waits on after a deposit is the INDEX, read like any other document (`run.ts`,
* `settled`).
*/
rebuildHandle(): Promise<void>;
/**
* The index's entries, ordered by value — decoded BY THIS APPLICATION, out of an
* ordinary anchored read of the index document, with nothing from the library but the
* two IRIs it publishes. That the suite can do this at all is the claim under test.
*/
read(index: string): Promise<IndexRow[]>;
/** What a document literally holds, straight off `readUnion` — the write-form probe. */ /** What a document literally holds, grouped by subject — the write-form probe. */
readRaw(doc: string): Promise<UnionSubject[]>; readRaw(doc: string): Promise<DocumentSubject[]>;
/** This identity's public documents. How an owner discovers a document it did not keep. */ /**
listPublicDocs(): Promise<string[]>; * Run a SPARQL SELECT anchored on a document, through `docs.sparqlQuery`.
*
* An index is claimed to be an ORDINARY document, which means an ordinary query must
* reach it. Nothing in `src/` ever issues one — this layer composes SPARQL only to
* write — so this is the one act here that no code of this package performs, and it is
* on the bridge because the claim had never been measured against a broker.
*/
select(anchor: string, query: string): Promise<SelectOutcome>;
/** /**
* `createIndex` with its inbox step made to fail — everything else real, against the * `create` with its inbox step made to fail — everything else real, against the
* real broker. Answers whether a half-created index is left behind. * real broker. Answers whether a half-created index is left behind.
*/ */
createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome>; createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome>;
+369 -84
View File
@@ -2,63 +2,103 @@
* The application the end-to-end suite drives — written the way a consumer of * The application the end-to-end suite drives — written the way a consumer of
* `@ng-helpers/indexing` writes one, and nothing more. * `@ng-helpers/indexing` writes one, and nothing more.
* *
* ── Why an application and not a bag of library calls ────────────────────── * Why an application and not a bag of library calls
* The 69 unit tests in `test/` run against a fake this repository wrote. They prove the * The 80 unit tests in `test/` run against a fake this repository wrote. They prove the
* indexing RULES are consistent; they cannot prove that NextGraph does what the fake * indexing RULES are consistent; they cannot prove that NextGraph does what the fake
* pretends, because the fake is the thing being asked. This page closes that gap by * pretends, because the fake is the thing being asked. This page closes that gap by
* putting the real broker underneath: it imports `@ng-eventually/polyfill` for real, * putting the real broker underneath: it imports `@ng-eventually/polyfill` for real,
* crosses the real broker, and calls `indexing(polyfillPort(...))` exactly as an * crosses the real broker, and calls `indexing(sessionId)` exactly as an application
* application would. * would.
* *
* It reaches nothing private. Every import below is a published entry — of the polyfill * What this application takes from `@ng-helpers/indexing` is its WHOLE published
* (`configure`, `ensureIdentity`, `init`, `readUnion`, `storeRegistry`) or of this * surface: `indexing(sessionId)`, the two acts on what it hands back, and the two IRIs.
* package (`indexing`, `polyfillPort`). If something here is awkward, it is awkward for * Nothing here makes an entry of a deposit, and nothing here can ask for one: that is
* every consumer, which is the second reason to write it this way. * the business of the layer below, and this page is where that shows or does not.
* Everything else here is the polyfill (`configure`, `ensureIdentity`, `init`, `initNg`,
* `ng`, `docs`, `useShapeQuery`) -- including reading the index, which is an ordinary
* anchored `docs.sparqlQuery` of an ordinary document. If something here is awkward, it
* is awkward for every consumer, which is the second reason to write it this way.
* *
* ── The one thing here no application does ───────────────────────────────── * This page WIRES THE ORM, because one thing here has no other door: the store enumeration it
* `createIndexWithBrokenInbox` injects a failure into the inbox step of `createIndex`. * used to discover a leaked document left the engagement, and what replaces it is a shape
* That is a probe, it is named for what it is, and it exists because the question it * query, which reads through NextGraph's own and therefore needs `ormSubscription`,
* answers — does a failed `openInbox` leave a document behind? — cannot be reached from * `initNg` and a hand-written shape. An application that hands `configure` a stub gets
* outside: nothing a caller controls makes a real `openDocumentInbox` fail on demand. * none of it.
* Everything around the injection is real, including the broker and the document. *
* The two things here no application does, and why they reach past the surface
* `createIndexWithBrokenInbox` injects a failure into the inbox step of `create`: the
* question it answers — does a failed `openInbox` leave a document behind? — cannot be
* reached from outside, because nothing a caller controls makes a real
* `openDocumentInbox` fail on demand. And `publishObject` writes through the very
* primitive `create` declares an index's field with, which is what makes it the CONTROL
* for the write-form question. Both therefore build the package's INTERNAL port, and
* both are named for what they are. Everything around them is real, including the
* broker.
*/ */
import { import {
configure, configure,
docs,
ensureIdentity, ensureIdentity,
init, init,
readUnion, initNg,
storeRegistry, ng,
type Nuri, useShapeQuery,
type UnionSubject, type Scope,
type ShapeObservable,
type ShapeQuery,
type ShapeType,
} from "@ng-eventually/polyfill"; } from "@ng-eventually/polyfill";
import { ng as realNg, init as realInit } from "@ng-org/web"; import { ng as realNg, init as realInit } from "@ng-org/web";
import { RdfOrmSubscription, initNg as ormInitNg } from "@ng-org/orm";
import { indexing, polyfillPort } from "../src/index"; // The whole published surface of the package under test.
import { ENTRY_VALUE, INDEX_FIELD, indexing, type Indexing } from "../src/index";
// NOT published, and reached only by the two probes above — see the header.
import { indexingOn } from "../src/indexing";
import type { NextGraphPort } from "../src/port";
import { polyfillPort } from "../src/polyfill-adapter";
import type { import type {
CurationReport, BrokenInboxOutcome,
IndexEntry, DocumentSubject,
Indexing, IndexRow,
NextGraphPort, IndexingBridge,
} from "../src/index"; SelectOutcome,
import type { BrokenInboxOutcome, IndexingBridge } from "./bridge"; } from "./bridge";
// ── bootstrap: the one polyfill-era call, then the SDK-shaped ones ────────── // bootstrap: the one polyfill-era call, then the SDK-shaped ones
// //
// `sharedWallet` is declared because the access gate wants somewhere to point when it // `sharedWallet` is declared because the access gate wants somewhere to point when it
// has to render, and never used: this suite always enters through the broker's redirect, // has to render, and never used: this suite always enters through the broker's redirect,
// where the wallet is already open in the run's profile. Nothing is served at that path. // where the wallet is already open in the run's profile. Nothing is served at that path.
//
// THE ORM IS WIRED HERE, and it is not optional dressing: `useShapeQuery` reads through
// NextGraph's own shape query, which arrives through `ormSubscription` and is started by
// `initNg`. Without them that read publishes `isError` naming the field it never got.
// `RdfOrmSubscription.getOrCreate` is passed as it stands -- no wrapper, no cast.
configure({ configure({
ng: realNg, ng: realNg,
useShape: () => undefined, // this application reads through `readUnion`, not the ORM // Required by `configure`, and `@ng-org/orm` publishes `useShape` only through its
// framework adapters (react / svelte / vue). This page has no framework, so there is
// nothing honest to hand over: it REFUSES instead of answering `undefined`, which a
// caller could not tell from an empty read-filtered view. Nothing here calls it, and
// the gap is declared in `usage_ng-helpers`'s `## Frictions`.
useShape: () => {
throw new Error(
"[e2e] useShape was never wired: this page has no framework adapter to give it, " +
"and it reads through docs.sparqlQuery and useShapeQuery instead",
);
},
ormSubscription: RdfOrmSubscription.getOrCreate,
init: realInit, init: realInit,
initNg: ormInitNg,
sharedWallet: { fileUrl: "/wallet-never-served.ngw", password: "" }, sharedWallet: { fileUrl: "/wallet-never-served.ngw", password: "" },
}); });
// The library's `init`, not the injected one: it settles the identity BEFORE handing the // The library's `init`, not the injected one: it settles the identity BEFORE handing the
// page to the broker, so the round-trip leaves with `?ng-id=` in the address it carries. // page to the broker, so the round-trip leaves with `?ng-id=` in the address it carries.
// The callback is this application's own business — it keeps the session because // The callback is this application's own business — it keeps the session because
// `polyfillPort` takes a session id, exactly as the real SDK's primitives do. // `indexing(sessionId)` takes one, exactly as the real SDK's primitives do.
const sessionReady = new Promise<{ session_id: string }>((resolve) => { const sessionReady = new Promise<{ session_id: string }>((resolve) => {
init( init(
(event: { status: string; session?: { session_id: string } }) => { (event: { status: string; session?: { session_id: string } }) => {
@@ -69,7 +109,7 @@ const sessionReady = new Promise<{ session_id: string }>((resolve) => {
); );
}); });
// ── this application's state ─────────────────────────────────────────────── // this application's state
const state: { status: string; error: string | null; who: string } = { const state: { status: string; error: string | null; who: string } = {
status: "connecting", status: "connecting",
@@ -78,7 +118,6 @@ const state: { status: string; error: string | null; who: string } = {
}; };
let api: Indexing | null = null; let api: Indexing | null = null;
let port: NextGraphPort | null = null;
/** The index this deployment contributes to, read off its own configuration. */ /** The index this deployment contributes to, read off its own configuration. */
function configuredIndex(): string | null { function configuredIndex(): string | null {
@@ -90,8 +129,18 @@ async function boot(): Promise<void> {
// and the identity comes back. The application keeps it only to show it. // and the identity comes back. The application keeps it only to show it.
state.who = await ensureIdentity(); state.who = await ensureIdentity();
const session = await sessionReady; const session = await sessionReady;
port = polyfillPort({ sessionId: session.session_id }); // The ORM is started once the session exists and not before: `useShapeQuery` reads
api = indexing(port); // through it, and this package's own `initNg` is the one to CALL -- not the SDK's,
// which was handed to `configure` and is reached from there. The `ng` it is given is
// this package's too, the one the surface says to call instead of the one `configure`
// was passed. The engagement states the argument is discarded either way: the call
// substitutes that same published `ng`.
initNg(ng, session);
// The session id, and nothing else — this application never names a port, and never
// awaits anything here: a handle reaches nothing. A reference this application hands
// an index becomes an entry because the layer below processes that index's inbox,
// and there is nothing to call, schedule or configure for it.
api = indexing(session.session_id);
state.status = "ready"; state.status = "ready";
} }
@@ -108,35 +157,213 @@ function ready(): Indexing {
return api; return api;
} }
function readyPort(): NextGraphPort { /**
if (port === null) { * The package's INTERNAL port, for the two probes that need one. Never used by an act
throw new Error(`[e2e] the application is not ready (${state.status}): ${state.error ?? "still connecting"}`); * this application performs as an application — see the header for why each needs it.
} */
return port; async function probePort(): Promise<NextGraphPort> {
const session = await sessionReady;
return polyfillPort({ sessionId: session.session_id });
} }
/** /**
* Wait for a document to appear in this identity's public store. * THE SHAPE AN INDEX DOCUMENT MATCHES, hand-written here because there is no other way
* to ask this question any more.
* *
* A store listing is a read like any other, and a document written a moment ago is not * `storeRegistry.listMyEntityDocs` left the engagement, and what replaces it is the read
* owed to be in it instantly. Polling is therefore what an owner would actually do, and * itself: a shape over `{ graphs: "did:ng:i" }` brings back what this identity HOLDS of
* it is bounded: an empty answer at the end is evidence, not a hang. * that shape. So the descriptor `create` writes -- one triple, on the index document's
* own subject -- is what makes an index findable, and it is declared here as the one
* predicate this shape names. A predicate a shape does not name is not projected, so
* `field` is both the match and the whole of what comes back.
*
* NOT A STORE LISTING, and the difference is recorded rather than glossed: `Scope` has
* two axes, neither of them a store. This finds an index document wherever it sits, and
* says nothing about which of the three stores holds it.
*/ */
async function publicDocsAfter( type IndexDescriptor = {
before: ReadonlySet<string>, "@id": string;
"@graph": string;
field: string;
};
const INDEX_SHAPE = "urn:ng-helpers-e2e:shape:index";
const IndexShape: ShapeType<IndexDescriptor> = {
shape: INDEX_SHAPE,
schema: {
[INDEX_SHAPE]: {
iri: INDEX_SHAPE,
predicates: [
{
iri: INDEX_FIELD,
readablePredicate: "field",
dataTypes: [{ valType: "string" }],
minCardinality: 1,
maxCardinality: 1,
},
],
},
},
};
/** Everything this identity holds, which is as close to "my documents" as `Scope` goes. */
const EVERYTHING_HELD: Scope = { graphs: "did:ng:i" };
/**
* Which of the three states a reactive read is in.
*
* Read BEFORE the data, always: an empty `data` under `isSuccess` means this scope holds
* nothing of that shape, and an empty `data` under anything else means nobody answered.
* The two must never be reported as one, which is the whole reason this is carried out
* to the report instead of being collapsed into an empty list.
*/
type LoadState = "pending" | "success" | "error";
function loadStateOf(snapshot: ShapeQuery<IndexDescriptor>): LoadState {
if (snapshot.isError) return "error";
if (snapshot.isSuccess) return "success";
return "pending";
}
/** The documents the snapshot's objects came out of -- one entry per index document. */
function documentsIn(snapshot: ShapeQuery<IndexDescriptor>): string[] {
return snapshot.data.map((descriptor) => descriptor["@graph"]);
}
/**
* Wait for the read to ANSWER -- to reach `isSuccess` or `isError`.
*
* A shape query the engine refuses stays `isPending` for ever: NextGraph catches that
* failure, logs it, and its readiness signal is only ever resolved. So a bound here is
* not impatience, it is the only way that failure becomes a named observation instead of
* a hang, and what it returns is the state it gave up in.
*/
async function answered(
held: ShapeObservable<IndexDescriptor>,
budgetMs: number, budgetMs: number,
): Promise<readonly string[]> { ): Promise<LoadState> {
const deadline = Date.now() + budgetMs; const deadline = Date.now() + budgetMs;
let appeared: readonly string[] = [];
for (;;) { for (;;) {
const now = await storeRegistry.listMyEntityDocs("public"); const state = loadStateOf(held.getSnapshot());
appeared = now.filter((d) => !before.has(d)); if (state !== "pending" || Date.now() >= deadline) return state;
if (appeared.length > 0 || Date.now() >= deadline) return appeared;
await new Promise((r) => setTimeout(r, 500)); await new Promise((r) => setTimeout(r, 500));
} }
} }
// ── the acts ─────────────────────────────────────────────────────────────── /**
* Wait for an index document this identity did not hold before to turn up.
*
* A document written a moment ago is not owed to be readable instantly, and this read is
* push-driven rather than polled -- so the loop asks the OBSERVABLE again (`refetch`)
* rather than issuing a fresh read, and it is bounded: an empty answer under `success` at
* the end is evidence, not a hang. An empty answer under any other state is neither, and
* that is why the state travels back with it.
*/
async function indexDocsAfter(
held: ShapeObservable<IndexDescriptor>,
before: ReadonlySet<string>,
budgetMs: number,
): Promise<{ appeared: readonly string[]; observed: LoadState }> {
const deadline = Date.now() + budgetMs;
for (;;) {
const snapshot = held.getSnapshot();
const observed = loadStateOf(snapshot);
const appeared =
observed === "success" ? documentsIn(snapshot).filter((d) => !before.has(d)) : [];
if (appeared.length > 0 || Date.now() >= deadline) return { appeared, observed };
await new Promise((r) => setTimeout(r, 500));
held.refetch();
}
}
/**
* How long the reactive read has, both to answer at all and to notice what turned up.
*
* It replaces the bound the withdrawn store enumeration was polled under, and it carries one
* more job than that one did: a shape query the engine refuses never finishes loading, so
* this is also what turns that failure into a reported state.
*/
const OBSERVE_MS = 15_000;
/**
* How much of an answer a report carries. Bounded so a report stays one, generous enough
* that the answer is readable rather than merely counted.
*/
const RAW_LIMIT = 2000;
/** Whatever came back, as JSON — `undefined` and a value that will not render included,
* because both of those are answers too and a report that hides them is worth nothing. */
function render(result: unknown): string {
let text: string;
try {
text = JSON.stringify(result) ?? String(result);
} catch (e: unknown) {
text = `(did not render: ${String((e as Error)?.message ?? e)})`;
}
return text.length <= RAW_LIMIT ? text : `${text.slice(0, RAW_LIMIT)}…(${text.length} chars)`;
}
/**
* The SELECT's bindings, in the shape the SPARQL results JSON specifies — the same
* `results.bindings` the polyfill itself reads out of this very call (`surface/inbox.ts`,
* `surface/read-model.ts`). A binding whose term carries no string `value` is dropped
* rather than guessed at; `raw` beside it is what keeps that honest.
*/
function compare(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
function rowsOf(result: unknown): Array<Record<string, string>> {
if (result === null || typeof result !== "object") return [];
const answered = result as {
results?: { bindings?: ReadonlyArray<Record<string, { value?: unknown } | undefined>> };
};
const bindings = answered.results?.bindings ?? [];
return bindings.map((binding) => {
const row: Record<string, string> = {};
for (const [variable, term] of Object.entries(binding)) {
if (term !== undefined && typeof term.value === "string") row[variable] = term.value;
}
return row;
});
}
/**
* Everything a document holds, grouped by subject -- what an application writes in place
* of the `readUnion` the polyfill withdrew in `1.0.0-dev.8`.
*
* One anchored `SELECT ?s ?p ?o`: the anchor makes the document the query's default graph,
* so the body names no graph and reads that document and nothing else. A document this
* identity cannot read REJECTS, and the rejection is left to reach the caller -- an
* unreadable document is "unknown", never "empty". The polyfill's own registers live
* under `urn:ng-eventually:` in the same graph and come back with the rest; they are the
* polyfill's, not the document's, so they are dropped here.
*/
async function readDocument(doc: string): Promise<DocumentSubject[]> {
const session = await sessionReady;
const result = await docs.sparqlQuery(
session.session_id,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
doc,
);
const bySubject = new Map<string, Record<string, string[]>>();
for (const row of rowsOf(result)) {
const { s, p, o } = row;
if (s === undefined || p === undefined || o === undefined) continue;
if (s.startsWith(RESERVED_PREFIX)) continue;
const props = bySubject.get(s) ?? {};
(props[p] ??= []).push(o);
bySubject.set(s, props);
}
return [...bySubject].map(([subject, props]) => ({ subject, props }));
}
/** The polyfill's reserved prefix: what it writes into a document under it is its own. */
const RESERVED_PREFIX = "urn:ng-eventually:";
// the acts
const bridge: IndexingBridge = { const bridge: IndexingBridge = {
status: () => state.status, status: () => state.status,
@@ -145,76 +372,134 @@ const bridge: IndexingBridge = {
configuredIndex, configuredIndex,
async createIndex(field: string): Promise<string> { async createIndex(field: string): Promise<string> {
return ready().createIndex(field); return ready().create(field);
}, },
/** /**
* Publish a public document carrying one value for one predicate. * Publish a public document carrying one value for one predicate.
* *
* It goes through the SAME primitive the curator writes an entry with * It goes through the SAME primitive `create` declares an index's field with
* (`addLiteralProperty`), with the document as its own subject. That makes it the * (`addLiteralProperty`), with the document as its own subject. That makes it the
* CONTROL for the write-form question: if this round-trips and an index entry does * CONTROL for the write-form question: if this round-trips and an index entry does
* not, the difference is the foreign subject and nothing else. * not, the difference is the foreign subject and nothing else.
*/ */
async publishObject(predicate: string, value: string): Promise<string> { async publishObject(predicate: string, value: string): Promise<string> {
const p = readyPort(); const p = await probePort();
const doc = await p.createPublicDocument(); const doc = await p.createPublicDocument();
await p.addLiteralProperty(doc, doc, predicate, value); await p.addLiteralProperty(doc, doc, predicate, value);
return doc; return doc;
}, },
async referConfigured(object: string): Promise<void> { async addToConfigured(object: string): Promise<void> {
const index = configuredIndex(); const index = configuredIndex();
if (index === null) { if (index === null) {
throw new Error("[e2e] this application was not configured with an index reference"); throw new Error("[e2e] this application was not configured with an index reference");
} }
await ready().refer(index, object); await ready().add(index, object);
}, },
async referTo(index: string, object: string): Promise<void> { async addTo(index: string, object: string): Promise<void> {
await ready().refer(index, object); await ready().add(index, object);
}, },
async curate(index: string): Promise<CurationReport> { async rebuildHandle(): Promise<void> {
return ready().curate(index); const session = await sessionReady;
api = indexing(session.session_id);
}, },
async read(index: string): Promise<IndexEntry[]> { /**
return ready().read(index); * The index read BY THIS APPLICATION, with nothing the package publishes but
* `ENTRY_VALUE` — the claim "an index is an ordinary document" performed rather than
* repeated. `readDocument(index)` is the same read this page makes of any other
* document; the index's own subject is the one that is not an entry, told apart by
* being the document itself.
*/
async read(index: string): Promise<IndexRow[]> {
const subjects = await readDocument(index);
const rows: IndexRow[] = [];
for (const subject of subjects) {
if (subject.subject === index) continue;
for (const value of subject.props[ENTRY_VALUE] ?? []) {
rows.push({ object: subject.subject, value });
}
}
// Ordered by value, ties broken on the object, so two readers of the same document
// see the same order. The package used to do this; an application does it in four
// lines, and gets to choose differently.
rows.sort((a, b) =>
a.value === b.value ? compare(a.object, b.object) : compare(a.value, b.value),
);
return rows;
}, },
async readRaw(doc: string): Promise<UnionSubject[]> { async readRaw(doc: string): Promise<DocumentSubject[]> {
return readUnion([doc]); return readDocument(doc);
}, },
async listPublicDocs(): Promise<string[]> { /**
const docs: Nuri[] = await storeRegistry.listMyEntityDocs("public"); * A SPARQL SELECT anchored on a document, through the polyfill's published `docs`.
return [...docs]; *
* The session id is the one this application already holds — the same one every write
* of this layer is made with. Nothing is caught and rethrown: a rejection is REPORTED,
* because "the query failed" is a different answer from "the query found nothing" and
* the whole point of this probe is to tell them apart.
*/
async select(anchor: string, query: string): Promise<SelectOutcome> {
const session = await sessionReady;
try {
const result = await docs.sparqlQuery(session.session_id, query, undefined, anchor);
return { failed: null, raw: render(result), rows: rowsOf(result) };
} catch (e: unknown) {
return { failed: String((e as Error)?.message ?? e), raw: "(the query rejected)", rows: [] };
}
}, },
async createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome> { async createIndexWithBrokenInbox(field: string): Promise<BrokenInboxOutcome> {
const p = readyPort(); const p = await probePort();
const before = new Set<string>(await storeRegistry.listMyEntityDocs("public"));
// Everything real except the inbox step. The failure is injected at the exact moment // The read that will notice the leak, opened BEFORE anything is created so that
// the question is about: after the document exists and carries its descriptor, before // "what was already held" is a reading and not an assumption. It is subscribed
// anyone can deposit into it. // because an observable with nobody listening has nowhere to push what arrives,
const broken = indexing({ // and it is released at the end: the probe is its only caller.
...p, const held = useShapeQuery(IndexShape, EVERYTHING_HELD);
openInbox: async (): Promise<void> => { const release = held.subscribe(() => {});
throw new Error("[e2e] injected: the inbox could not be opened");
},
});
let rejected: string | null = null;
let returned: string | null = null;
try { try {
returned = await broken.createIndex(field); // The opening read has to ANSWER first. A `before` taken from a read that never
} catch (e: unknown) { // happened is empty, and an empty `before` would make the first document this
rejected = String((e as Error)?.message ?? e); // identity has ever held look like something the failure left behind.
} const opening = await answered(held, OBSERVE_MS);
const before = new Set<string>(
opening === "success" ? documentsIn(held.getSnapshot()) : [],
);
return { rejected, returned, appeared: await publicDocsAfter(before, 15_000) }; // Everything real except the inbox step. The failure is injected at the exact moment
// the question is about: after the document exists and carries its descriptor, before
// anyone can deposit into it.
const broken = indexingOn({
...p,
openInbox: async (): Promise<void> => {
throw new Error("[e2e] injected: the inbox could not be opened");
},
});
let rejected: string | null = null;
let returned: string | null = null;
try {
returned = await broken.create(field);
} catch (e: unknown) {
rejected = String((e as Error)?.message ?? e);
}
const after = await indexDocsAfter(held, before, OBSERVE_MS);
return {
rejected,
returned,
appeared: after.appeared,
observed: opening === "success" ? after.observed : `${opening} before the probe ran`,
};
} finally {
release();
}
}, },
}; };
+323 -93
View File
@@ -1,7 +1,7 @@
/** /**
* `@ng-helpers/indexing` against the REAL broker. * `@ng-helpers/indexing` against the REAL broker.
* *
* ── What this suite is for ───────────────────────────────────────────────── * What this suite is for
* The unit suite proves the indexing rules are consistent with a fake this repository * The unit suite proves the indexing rules are consistent with a fake this repository
* wrote. It cannot prove NextGraph behaves the way that fake pretends, because the fake * wrote. It cannot prove NextGraph behaves the way that fake pretends, because the fake
* is the very thing in question. Two claims in particular had never met a broker: * is the very thing in question. Two claims in particular had never met a broker:
@@ -14,12 +14,12 @@
* makes it the control: if one round-trips and the other does not, the difference is * makes it the control: if one round-trips and the other does not, the difference is
* the foreign subject and nothing else. * the foreign subject and nothing else.
* *
* 2. **A half-created index.** `createIndex` creates a document, writes its descriptor, * 2. **A half-created index.** `create` creates a document, writes its descriptor,
* then opens its inbox. If the last step fails the caller gets an exception and no * then opens its inbox. If the last step fails the caller gets an exception and no
* reference — but the document exists. The last journey injects that failure and * reference — but the document exists. The last journey injects that failure and
* asks the broker what was left behind. * asks the broker what was left behind.
* *
* ── Two identities, and how the index reference reaches the second ───────── * Two identities, and how the index reference reaches the second
* The whole point of an index is that STRANGERS contribute to it. So Bob must reach * The whole point of an index is that STRANGERS contribute to it. So Bob must reach
* Alice's index — and he must reach it the way an application would, not through a * Alice's index — and he must reach it the way an application would, not through a
* variable in this file. An index is an ordinary document whose NURI an application * variable in this file. An index is an ordinary document whose NURI an application
@@ -28,7 +28,24 @@
* never happen — and does not happen here — is an inbox address crossing the identity * never happen — and does not happen here — is an inbox address crossing the identity
* boundary through a channel no deployment has. * boundary through a channel no deployment has.
* *
* ── Reading a failure ────────────────────────────────────────────────────── * Waiting, because nothing here says when processing is done
* The two acts are `create` and `add`; what becomes of what is added is the business of
* the layer below, which applies a deposit when the owner's session is PUSHED one. There
* is no call that forces it, no receipt, and no `await` that covers it — by design. So a
* journey that has deposited WAITS for the index to hold the entry, reading it the way an
* application reads one (`settled`), and only then asserts. Before that wait existed the
* checks read the instant after the deposit and seven of them reported an index that was
* merely not written YET.
*
* The one claim that wait cannot carry: "an object carrying nothing for the field is not
* indexed". A deposit that is deliberately not indexed writes nothing, so no reading tells
* "examined and skipped" from "not examined yet", and there is nothing to wait for that is
* not a sleep. That journey reads straight away and its check is therefore weaker than its
* name — recorded here rather than hidden. What partly holds it up is order: that deposit
* precedes the hostile one, and the hostile one IS waited for, so by the end of the run the
* queue holding it has demonstrably been applied at least once.
*
* Reading a failure
* A named deadline, or a message `ng-e2e-helpers` recognises as a browser or frame * A named deadline, or a message `ng-e2e-helpers` recognises as a browser or frame
* failure, is the HOST. A failed check carrying an unexpected value is this code. The * failure, is the HOST. A failed check carrying an unexpected value is this code. The
* report says which, and the run is repeated rather than anything being loosened. * report says which, and the run is repeated rather than anything being loosened.
@@ -72,15 +89,15 @@ type BrowserContext = Awaited<ReturnType<typeof launchWatchedContext>>;
type Page = Awaited<ReturnType<typeof newPage>>; type Page = Awaited<ReturnType<typeof newPage>>;
type Frame = Awaited<ReturnType<typeof setupBrokerPage>>; type Frame = Awaited<ReturnType<typeof setupBrokerPage>>;
// ── the domain this suite indexes by ─────────────────────────────────────── // the domain this suite indexes by
// //
// A date, so the suite exercises the case the package is built around: an index "by a // A date, so the suite exercises the case the package is built around: an index "by a
// date" is just an index whose field is a date predicate, and ISO-8601 sorts as a string. // date" is just an index whose field is a date predicate, and ISO-8601 sorts as a string.
const PUBLISHED_AT = "urn:ng-helpers-e2e:published-at"; const PUBLISHED_AT = "urn:ng-helpers-e2e:published-at";
/** A predicate an index does NOT curate on — for the object that carries nothing usable. */ /** A predicate an index is NOT built on — for the object that carries nothing usable. */
const UNRELATED = "urn:ng-helpers-e2e:unrelated"; const UNRELATED = "urn:ng-helpers-e2e:unrelated";
// ── bounds ───────────────────────────────────────────────────────────────── // bounds
// //
// Sized to be generous rather than tight. A bound exists to turn a hang into a named // Sized to be generous rather than tight. A bound exists to turn a hang into a named
// failure; sized to the median it would instead fail on a slow-but-healthy broker, which // failure; sized to the median it would instead fail on a slow-but-healthy broker, which
@@ -93,14 +110,53 @@ const BRIDGE_UP_MS = 60_000;
const READY_MS = 180_000; const READY_MS = 180_000;
/** One sign-in: a page, the broker round trip, and the application booting behind it. */ /** One sign-in: a page, the broker round trip, and the application booting behind it. */
const SIGN_IN_MS = NEW_PAGE_MS + BROKER_ROUND_TRIP_MS + READY_MS; const SIGN_IN_MS = NEW_PAGE_MS + BROKER_ROUND_TRIP_MS + READY_MS;
/** One call across the bridge. The slowest here are curations, which round-trip per deposit. */ /** One call across the bridge. The slowest cross the broker once per deposit. */
const BRIDGE_MS = 4 * 60_000; const BRIDGE_MS = 4 * 60_000;
/** One journey. The longest holds two sign-ins' worth of work behind it. */ /**
* How often the index is READ while waiting for a deposit to have become an entry.
*
* Not a sleep standing in for the wait: it is the interval at which the CONDITION is asked,
* and the wait ends on the first reading that holds. `indexing-app.ts` polls its own store
* at the same interval, for the same reason.
*/
const SETTLE_POLL_MS = 500;
/**
* How long a deposit has to become an entry before the wait gives up and says what the
* index held instead.
*
* MEASURED on a green run (`E2E_TIMINGS=1`, 2026-08-21, one sample each): 0.6s for Bob's
* first deposit becoming an entry, 0.7s for the hostile one, and 0.0s for the entry
* reaching a stranger's own session — that last one had already converged by the time it
* was asked. The layer below is push-driven, so what is waited on is one push and one small
* write, not a broker crossing; that is why these are sub-second while a deposit or a
* publish measures 0.1–0.9s.
*
* Bounded at 60s ≈ 85x the slowest of them, generous on purpose. It must never fire on a
* slow-but-healthy broker, and it is what turns "the entry never came" into a named failure
* carrying the last reading — instead of a check that merely read too early, which is what
* the seven failures it was written for looked like.
*/
const SETTLE_MS = 60_000;
/**
* One journey. The longest holds two sign-ins' worth of work behind it.
*
* NOT the sum of the steps it encloses, and that is deliberate — the same arbitration
* `packages/polyfill/e2e/notebook.ts` records for its own: the longest journey here sums to
* 16 min of step bounds (four bridge calls), and a journey bounded above that would outlast
* the SUITE's own clock, so one hung journey would take the summary down with it. Every step
* inside a journey already carries a bound and names itself, so this catches only a hang in
* code no step wraps. What DOES have to hold is that this enclosure cannot fire BEFORE the
* settle point it now encloses, or a settle that gave up would report as an anonymous
* journey timeout: measured on a green run, the longest journey holding a settle takes 1.6s
* and the longest of all (a sign-in) 9.8s, so a journey that spends SETTLE_MS (60s) waiting
* still has more than eight minutes of this bound left — the settle is always the one that
* reports.
*/
const JOURNEY_MS = 10 * 60_000; const JOURNEY_MS = 10 * 60_000;
/** The whole run. A budget that cannot interrupt anything is not a budget. */ /** The whole run. A budget that cannot interrupt anything is not a budget. */
const SUITE_MS = 30 * 60_000; const SUITE_MS = 30 * 60_000;
// ── the report ───────────────────────────────────────────────────────────── // the report
let actors: BrowserContext | null = null; let actors: BrowserContext | null = null;
@@ -126,12 +182,11 @@ const { check, journey, finish } = declareSuite({
], ],
}, },
{ {
name: "Bob hands the index a reference, and Alice curates it", name: "Bob hands the index a reference, and it becomes an entry of Alice's index",
checks: [ checks: [
"a stranger's deposit into the index's inbox is accepted", "a stranger's deposit into the index's inbox reached its owner and became an entry",
"curation reports Bob's object as indexed",
"the indexed value was read off Bob's object, and never travelled in his deposit",
"the entry is stored under Bob's object's own reference as its subject", "the entry is stored under Bob's object's own reference as its subject",
"the indexed value was read off Bob's object, and never travelled in his deposit",
], ],
}, },
{ {
@@ -139,21 +194,20 @@ const { check, journey, finish } = declareSuite({
checks: [ checks: [
"Alice reads exactly one entry, and it is Bob's object", "Alice reads exactly one entry, and it is Bob's object",
"Bob, who does not own the index, reads the same entry", "Bob, who does not own the index, reads the same entry",
"curating a second time changes nothing, and the index still holds one entry", "connecting a second time changes nothing, and the index still holds one entry",
], ],
}, },
{ {
name: "An object carrying nothing for the field is not indexed", name: "An object carrying nothing for the field is not indexed",
checks: [ checks: [
"curation reports it skipped for want of the field, rather than indexed", "the unrelated object is not indexed, and the index still holds exactly one entry",
"the index still holds exactly one entry",
], ],
}, },
{ {
name: "An index whose inbox cannot be opened leaves a document behind", name: "An index whose inbox cannot be opened leaves a document behind",
checks: [ checks: [
"createIndex refuses when the inbox cannot be opened", "createIndex refuses when the inbox cannot be opened",
"a document was nevertheless created in the owner's public store", "a document was nevertheless created, and its owner still holds it",
"the leaked document carries a descriptor but accepts no deposit", "the leaked document carries a descriptor but accepts no deposit",
], ],
}, },
@@ -164,15 +218,97 @@ const { check, journey, finish } = declareSuite({
"the index holds it as one entry, and its own descriptor is untouched", "the index holds it as one entry, and its own descriptor is untouched",
], ],
}, },
{
name: "The index answers an ordinary SPARQL query",
checks: [
"reading the document returns both entries and the index's own declaration",
"a SELECT for the entry predicate returns both entries, with their values",
"a SELECT of the index's own subject returns the field it declares",
"a stranger's SELECT returns the same entries",
],
},
], ],
}); });
/**
* Two readings of the same entries — the same objects, carrying the same values.
*
* Compared as a WHOLE: a query that answered with a subset, or with a value that changed
* shape crossing the round trip, is not the same answer as the document's own content.
*/
function sameEntries(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): boolean {
if (a.size !== b.size) return false;
for (const [object, value] of a) {
if (b.get(object) !== value) return false;
}
return true;
}
/** The `?object`/`?value` rows of an entries SELECT, as the entries they claim to be. */
function entriesOf(rows: ReadonlyArray<Readonly<Record<string, string>>>): Map<string, string> {
const found = new Map<string, string>();
for (const row of rows) {
const object = row["object"];
const value = row["value"];
if (object !== undefined && value !== undefined) found.set(object, value);
}
return found;
}
/** A named step that is both measured and bounded — `evaluate` carries no timeout of its own. */ /** A named step that is both measured and bounded — `evaluate` carries no timeout of its own. */
function step<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> { function step<T>(what: string, ms: number, task: () => Promise<T>): Promise<T> {
return measured(what, ms, (bound) => within(what, bound, task)); return measured(what, ms, (bound) => within(what, bound, task));
} }
// ── an actor ─────────────────────────────────────────────────────────────── /**
* Read the index until it HOLDS what the checks about to run expect, and hand that reading
* back to them.
*
* ── Why the harness needs this at all ───────────────────────────────────────
* Nothing on this package's surface says "processing is done", deliberately: the two acts
* are `create` and `add`, and what becomes of what is added belongs to the layer below,
* which applies a deposit when the owner's session is PUSHED one. There is no call that
* forces it and no receipt to hold. So a check that reads the instant after a deposit reads
* too early — which is exactly what seven of these checks were reporting.
*
* ── A wait is not a retry of the assertion ──────────────────────────────────
* `holds` asks one thing only: has the deposit ARRIVED. The checks then assert their own
* claims ONCE, on the reading this returns. A loop that re-ran the checks until one of them
* passed would turn a flapping result into a green one and report the reading that happened
* to suit it.
*
* ── It reads the way an application reads ───────────────────────────────────
* `read` is an ordinary anchored read of an ordinary document, or a narrower SELECT — the
* only two ways anyone has of reading an index, the harness included. Nothing here reaches
* for a way to hurry the layer below, because a consumer has none either.
*
* On expiry it throws, naming what never arrived and WHAT THE INDEX HELD INSTEAD: the
* journey's remaining checks are then reported unreached with that reason, so the run still
* reports its thirty rows and the reason is the observation rather than a bare "timeout".
*/
async function settled<T>(
what: string,
read: () => Promise<T>,
holds: (seen: T) => boolean,
describe: (seen: T) => string,
): Promise<T> {
return measured(what, SETTLE_MS, async (bound) => {
const deadline = Date.now() + bound;
for (;;) {
const seen = await within(what, bound, read);
if (holds(seen)) return seen;
if (Date.now() >= deadline) {
throw new Error(
`[e2e] ${what}: nothing arrived within ${(bound / 1000).toFixed(0)}s — ` +
`the last reading was ${describe(seen)}`,
);
}
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
}
});
}
// an actor
interface Actor { interface Actor {
readonly id: string; readonly id: string;
@@ -252,7 +388,7 @@ function actorIsUp(id: string, actor: () => Actor | null): Prerequisite {
return () => (actor() === null ? `${id} never signed in` : null); return () => (actor() === null ? `${id} never signed in` : null);
} }
// ── the run ──────────────────────────────────────────────────────────────── // the run
async function main(): Promise<void> { async function main(): Promise<void> {
armSuiteDeadline("ng-helpers indexing e2e", SUITE_MS, () => armSuiteDeadline("ng-helpers indexing e2e", SUITE_MS, () =>
@@ -319,9 +455,9 @@ async function main(): Promise<void> {
const declared = self?.props[INDEX_FIELD] ?? []; const declared = self?.props[INDEX_FIELD] ?? [];
check( check(
"the index document declares the field it indexes by", "the index document declares the field it indexes by",
declared.length === 1 && declared[0] === PUBLISHED_AT && self?.graph === index, declared.length === 1 && declared[0] === PUBLISHED_AT,
`subjects=${raw.length} self=${self === undefined ? "(not found)" : "found"} ` + `subjects=${raw.length} self=${self === undefined ? "(not found)" : "found"} ` +
`graph=${self?.graph} declared=${JSON.stringify(declared)}`, `declared=${JSON.stringify(declared)}`,
); );
}, },
}); });
@@ -365,7 +501,7 @@ async function main(): Promise<void> {
}); });
await journey({ await journey({
name: "Bob hands the index a reference, and Alice curates it", name: "Bob hands the index a reference, and it becomes an entry of Alice's index",
needs: [ needs: [
aliceIsUp, aliceIsUp,
bobIsUp, bobIsUp,
@@ -376,47 +512,46 @@ async function main(): Promise<void> {
// Bob names his OWN object, and the index he was configured with. Nothing about // Bob names his OWN object, and the index he was configured with. Nothing about
// the value travels: the deposit is the reference and nothing else. // the value travels: the deposit is the reference and nothing else.
await step("Bob depositing a reference", BRIDGE_MS, () => await step("Bob depositing a reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), bobsObject!), bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), bobsObject!),
); );
const report = await step("Alice curating", BRIDGE_MS, () => // NOBODY PROCESSES ANYTHING HERE, and nothing says when it is done: there is no
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!), // such call on the surface, and no receipt. The reference becomes an entry because
); // the layer below applies the inbox of the index Alice owns, when her session is
// The deposit is proven ARRIVED, by the only person who can see it. That the post // pushed it — so the suite WAITS for the index to hold it, reading the document the
// did not throw is a weaker claim entirely — it says the call returned, not that // way any application reads one.
// anything crossed the identity boundary — and asserting it would be asserting a //
// constant. Alice reads her own inbox; one outcome means one deposit reached it. // THE WRITE FORM, answered by the same reading. An entry is a triple whose subject
check( // is another document, written into this one's anchored default graph. "The write
"a stranger's deposit into the index's inbox is accepted", // did not throw" is not the same claim as "oxigraph stored it": this reads it back.
report.outcomes.length === 1, const raw = await settled(
`from=${BOB} outcomes=${report.outcomes.length}`, "Bob's deposit becoming an entry of Alice's index",
); () => alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
const forBob = report.outcomes.find( (subjects) => subjects.some((s) => s.subject === bobsObject),
(o) => "object" in o && o.object === bobsObject, (subjects) => `subjects=${JSON.stringify(subjects.map((s) => s.subject))}`,
);
check(
"curation reports Bob's object as indexed",
forBob?.result === "indexed",
`outcomes=${JSON.stringify(report.outcomes)}`,
);
check(
"the indexed value was read off Bob's object, and never travelled in his deposit",
forBob?.result === "indexed" && forBob.value === "2026-08-17T09:00:00Z",
`value=${forBob !== undefined && "value" in forBob ? forBob.value : "(none)"}`,
);
// THE WRITE FORM, answered. An entry is a triple whose subject is another
// document, written into this one's anchored default graph. "The write did not
// throw" is not the same claim as "oxigraph stored it": this reads it back.
const raw = await step("Alice reading the index document back", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
); );
const entry = raw.find((s) => s.subject === bobsObject); const entry = raw.find((s) => s.subject === bobsObject);
// The deposit is proven ARRIVED by its only possible effect: nobody but Alice
// reads that inbox, so an entry for Bob's object means his deposit crossed the
// identity boundary and her session found it. That the post did not throw is a
// weaker claim entirely — it says the call returned, and nothing more.
check(
"a stranger's deposit into the index's inbox reached its owner and became an entry",
entry !== undefined,
`from=${BOB} subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
);
check( check(
"the entry is stored under Bob's object's own reference as its subject", "the entry is stored under Bob's object's own reference as its subject",
(entry?.props[ENTRY_VALUE] ?? []).includes("2026-08-17T09:00:00Z"), entry !== undefined && Object.hasOwn(entry.props, ENTRY_VALUE),
`subjects=${JSON.stringify(raw.map((s) => s.subject))}`, `subjects=${JSON.stringify(raw.map((s) => s.subject))}`,
); );
// The value never travelled: a deposit is the reference and nothing else, so its
// presence here means whatever processed that inbox read it off Bob's object itself.
check(
"the indexed value was read off Bob's object, and never travelled in his deposit",
(entry?.props[ENTRY_VALUE] ?? []).includes("2026-08-17T09:00:00Z"),
`entry=${JSON.stringify(entry?.props ?? {})}`,
);
}, },
}); });
@@ -435,8 +570,15 @@ async function main(): Promise<void> {
// A public index is read by whoever holds its reference — including someone who // A public index is read by whoever holds its reference — including someone who
// owns neither it nor anything in it. This is the act an application performs. // owns neither it nor anything in it. This is the act an application performs.
const theirs = await step("Bob reading the index he does not own", BRIDGE_MS, () => //
bob!.frame.evaluate((i) => window.__indexing.read(i), index!), // Waited for on its OWN account: the journey above settled ALICE's session, and
// Bob's is a different one reading a document he does not own. "The owner sees it"
// and "a stranger sees it" are two arrivals, and only one of them has happened.
const theirs = await settled(
"the entry reaching a stranger's session",
() => bob!.frame.evaluate((i) => window.__indexing.read(i), index!),
(rows) => rows.some((r) => r.object === bobsObject),
(rows) => `entries=${JSON.stringify(rows)}`,
); );
check( check(
"Bob, who does not own the index, reads the same entry", "Bob, who does not own the index, reads the same entry",
@@ -446,16 +588,16 @@ async function main(): Promise<void> {
// Deposits are never retired, so every run sees every deposit again. Convergence // Deposits are never retired, so every run sees every deposit again. Convergence
// is what makes that affordable. // is what makes that affordable.
const again = await step("Alice curating a second time", BRIDGE_MS, () => await step("Alice loading her page a second time", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!), alice!.frame.evaluate(() => window.__indexing.rebuildHandle()),
); );
const still = await step("Alice reading the index again", BRIDGE_MS, () => const still = await step("Alice reading the index again", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!), alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
); );
check( check(
"curating a second time changes nothing, and the index still holds one entry", "connecting a second time changes nothing, and the index still holds one entry",
again.outcomes.every((o) => o.result === "unchanged") && still.length === 1, still.length === 1 && still[0]?.object === bobsObject,
`outcomes=${JSON.stringify(again.outcomes)} entries=${still.length}`, `entries=${JSON.stringify(still)}`,
); );
}, },
}); });
@@ -475,25 +617,23 @@ async function main(): Promise<void> {
), ),
); );
await step("Bob depositing the unrelated reference", BRIDGE_MS, () => await step("Bob depositing the unrelated reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), other), bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), other),
);
const report = await step("Alice curating the unrelated reference", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
);
const forOther = report.outcomes.find((o) => "object" in o && o.object === other);
check(
"curation reports it skipped for want of the field, rather than indexed",
forOther?.result === "skipped" && forOther.reason === "no-field",
`outcome=${JSON.stringify(forOther)}`,
); );
// NOTHING TO WAIT FOR, and it is worth saying rather than dressing up. A deposit
// that is deliberately not indexed writes nothing, so the index holds afterwards
// exactly what it held before and no reading can tell "examined and skipped" from
// "not examined yet". A wait for the state the check expects would return on its
// first reading and prove nothing; a wait for a duration would be a sleep. So this
// reads straight away, and the check is a weaker claim than it reads as — see the
// suite's header. What DOES hold it: the deposit is applied before the hostile one
// below, and that one is waited for.
const entries = await step("Alice reading the index once more", BRIDGE_MS, () => const entries = await step("Alice reading the index once more", BRIDGE_MS, () =>
alice!.frame.evaluate((i) => window.__indexing.read(i), index!), alice!.frame.evaluate((i) => window.__indexing.read(i), index!),
); );
check( check(
"the index still holds exactly one entry", "the unrelated object is not indexed, and the index still holds exactly one entry",
entries.length === 1, entries.length === 1 && !entries.some((e) => e.object === other),
`entries=${JSON.stringify(entries)}`, `entries=${JSON.stringify(entries)}`,
); );
}, },
@@ -514,15 +654,20 @@ async function main(): Promise<void> {
outcome.rejected !== null && outcome.returned === null, outcome.rejected !== null && outcome.returned === null,
`rejected=${outcome.rejected} returned=${outcome.returned}`, `rejected=${outcome.rejected} returned=${outcome.returned}`,
); );
// WHAT IS OBSERVED HERE, and what is not. There is no call that lists a store any
// more, so the leak is found by reading every index document Alice HOLDS and
// diffing: that establishes the document survived, not which store it sits in.
// `observed` travels with it because an empty answer only means "nothing turned
// up" under `success` -- under any other state it means nobody answered.
check( check(
"a document was nevertheless created in the owner's public store", "a document was nevertheless created, and its owner still holds it",
outcome.appeared.length === 1, outcome.appeared.length === 1 && outcome.observed === "success",
`appeared=${JSON.stringify(outcome.appeared)}`, `appeared=${JSON.stringify(outcome.appeared)} observed=${outcome.observed}`,
); );
// What the leaked document IS: an index in every respect but the one that makes // What the leaked document IS: an index in every respect but the one that makes
// it usable. Alice found it in her own store — the only way anyone can, since // it usable. Alice found it among the documents she holds -- the only way anyone
// `createIndex` threw its reference away. // can, since `createIndex` threw its reference away.
const leaked = outcome.appeared[0]; const leaked = outcome.appeared[0];
if (leaked === undefined) { if (leaked === undefined) {
check( check(
@@ -541,7 +686,7 @@ async function main(): Promise<void> {
const refused = await step("Alice trying to deposit into it", BRIDGE_MS, async () => { const refused = await step("Alice trying to deposit into it", BRIDGE_MS, async () => {
try { try {
await alice!.frame.evaluate( await alice!.frame.evaluate(
([i, o]) => window.__indexing.referTo(i!, o!), ([i, o]) => window.__indexing.addTo(i!, o!),
[leaked, bobsObject ?? leaked], [leaked, bobsObject ?? leaked],
); );
return null; return null;
@@ -591,18 +736,17 @@ async function main(): Promise<void> {
); );
await step("Bob depositing the hostile reference", BRIDGE_MS, () => await step("Bob depositing the hostile reference", BRIDGE_MS, () =>
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), object), bob!.frame.evaluate((o) => window.__indexing.addToConfigured(o), object),
); );
await step("Alice curating the hostile reference", BRIDGE_MS, () => // Waited for as the first deposit was, and read RAW: the index must still declare
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!), // its own field. An injected `DROP ALL` that had taken effect would show up exactly
); // here, as a descriptor that is no longer there — and `read()` alone could not tell
// that apart from an ordinary failure.
// Read the index document RAW: it must still declare its own field. An injected const after = await settled(
// `DROP ALL` that had taken effect would show up exactly here, as a descriptor "the hostile deposit becoming an entry",
// that is no longer there — and `read()` alone could not tell that apart from an () => alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
// ordinary failure. (subjects) => subjects.some((s) => s.subject === object),
const after = await step("Alice reading the index after the hostile entry", BRIDGE_MS, () => (subjects) => `subjects=${JSON.stringify(subjects.map((s) => s.subject))}`,
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
); );
const entry = after.find((s) => s.subject === object)?.props[ENTRY_VALUE] ?? []; const entry = after.find((s) => s.subject === object)?.props[ENTRY_VALUE] ?? [];
const descriptor = after.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? []; const descriptor = after.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? [];
@@ -613,6 +757,92 @@ async function main(): Promise<void> {
); );
}, },
}); });
// AFTER the hostile journey, and read-only: by here the index holds SEVERAL entries,
// which is the state the question is about — one entry cannot tell a query that
// returns everything from one that returns the first thing it finds.
//
// WHAT IS BEING ASKED. This package documents how to read an index and issues no
// query of its own, so "an index is an ordinary document
// anyone queries normally" has never been anything but plausible. These four checks
// are a measurement of that sentence, not a feature: an empty answer is a RESULT and
// is reported as one, and a rejection is reported apart from it, because "the query
// found nothing" and "the query failed" are the two answers this repository keeps
// finding folded into one.
await journey({
name: "The index answers an ordinary SPARQL query",
needs: [aliceIsUp, bobIsUp, indexExists],
run: async () => {
// The REFERENCE the queries below are judged against. "The SELECT came back with
// the entries" is only a claim if something independent says what the entries
// are — and it is what makes an empty answer below mean something instead of
// being indistinguishable from an index that holds nothing.
const raw = await step("Alice reading the index before querying it", BRIDGE_MS, () =>
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
);
const held = new Map<string, string>();
for (const subject of raw) {
const value = subject.props[ENTRY_VALUE] ?? [];
if (value.length === 1 && value[0] !== undefined) held.set(subject.subject, value[0]);
}
const declares = (raw.find((s) => s.subject === index)?.props[INDEX_FIELD] ?? []).includes(
PUBLISHED_AT,
);
check(
"reading the document returns both entries and the index's own declaration",
held.size === 2 && declares,
`entries=${JSON.stringify([...held.keys()])} declares=${declares}`,
);
// The shape a reader would write, with nothing of this package in it: the entry
// predicate, and the anchored default graph the index document is.
const entriesQuery = `SELECT ?object ?value WHERE { ?object <${ENTRY_VALUE}> ?value }`;
const mine = await step("Alice querying the index for its entries", BRIDGE_MS, () =>
alice!.frame.evaluate(
([a, q]) => window.__indexing.select(a!, q!),
[index!, entriesQuery],
),
);
const answered = entriesOf(mine.rows);
check(
"a SELECT for the entry predicate returns both entries, with their values",
mine.failed === null && sameEntries(held, answered),
`failed=${mine.failed} rows=${mine.rows.length} ` +
`objects=${JSON.stringify([...answered.keys()])} raw=${mine.raw}`,
);
// The index document's OWN subject, which is the other half of what an index
// holds — and the half a reader needs to know what the values mean.
const fieldQuery = `SELECT ?field WHERE { <${index!}> <${INDEX_FIELD}> ?field }`;
const declared = await step("Alice querying the index's declaration", BRIDGE_MS, () =>
alice!.frame.evaluate(([a, q]) => window.__indexing.select(a!, q!), [index!, fieldQuery]),
);
check(
"a SELECT of the index's own subject returns the field it declares",
declared.failed === null &&
declared.rows.length === 1 &&
declared.rows[0]?.["field"] === PUBLISHED_AT,
`failed=${declared.failed} rows=${declared.rows.length} raw=${declared.raw}`,
);
// The reader who matters: an index exists to be read by people who own neither it
// nor anything in it. Reading the whole document already answers him (the journey above); whether
// a query does is a separate question, and it is the one an application asks.
const theirs = await step("Bob querying the index he does not own", BRIDGE_MS, () =>
bob!.frame.evaluate(
([a, q]) => window.__indexing.select(a!, q!),
[index!, entriesQuery],
),
);
const strangers = entriesOf(theirs.rows);
check(
"a stranger's SELECT returns the same entries",
theirs.failed === null && sameEntries(held, strangers),
`failed=${theirs.failed} rows=${theirs.rows.length} ` +
`objects=${JSON.stringify([...strangers.keys()])} raw=${theirs.raw}`,
);
},
});
} finally { } finally {
if (ctx !== null) await closeContext("actors", ctx); if (ctx !== null) await closeContext("actors", ctx);
if (closeServer !== null) { if (closeServer !== null) {
+6 -5
View File
@@ -1,9 +1,9 @@
{ {
"name": "@ng-helpers/indexing", "name": "@ng-helpers/indexing",
"version": "1.0.1", "version": "4.0.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "An indexing layer built on top of NextGraph, via @ng-eventually/polyfill. An index is an ordinary public document; contributions reach it through its inbox; its owner curates it.", "description": "An indexing helper built on top of NextGraph, via @ng-eventually/polyfill. An index is an ordinary public document; anyone may hand it a reference to an object, and that reference becomes an entry through the layer below — this package hides that an index is implemented by an inbox.",
"main": "./src/index.ts", "main": "./src/index.ts",
"types": "./src/index.ts", "types": "./src/index.ts",
"exports": { "exports": {
@@ -13,10 +13,11 @@
"@ng-eventually/polyfill": "*" "@ng-eventually/polyfill": "*"
}, },
"devDependencies": { "devDependencies": {
"@ng-eventually/polyfill": "file:../ng-eventually-js/packages/polyfill", "@ng-eventually/polyfill": "link:../ng-eventually-js/packages/polyfill",
"@ng-org/web": "0.1.2-alpha.13", "@ng-org/orm": "0.1.2-alpha.21",
"@ng-org/web": "0.1.2-alpha.14",
"@types/bun": "latest", "@types/bun": "latest",
"ng-e2e-helpers": "file:../ng-eventually-js/packages/ng-e2e-helpers", "ng-e2e-helpers": "link:../ng-eventually-js/packages/ng-e2e-helpers",
"playwright": "1.61.1", "playwright": "1.61.1",
"typescript": "^5.6.0" "typescript": "^5.6.0"
}, },
+154
View File
@@ -0,0 +1,154 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@ng-eventually/polyfill':
specifier: link:../ng-eventually-js/packages/polyfill
version: link:../ng-eventually-js/packages/polyfill
'@ng-org/orm':
specifier: 0.1.2-alpha.21
version: 0.1.2-alpha.21
'@ng-org/web':
specifier: 0.1.2-alpha.14
version: 0.1.2-alpha.14
'@types/bun':
specifier: latest
version: 1.3.14
ng-e2e-helpers:
specifier: link:../ng-eventually-js/packages/ng-e2e-helpers
version: link:../ng-eventually-js/packages/ng-e2e-helpers
playwright:
specifier: 1.61.1
version: 1.61.1
typescript:
specifier: ^5.6.0
version: 5.9.3
packages:
'@ng-org/alien-deepsignals@0.1.2-alpha.13':
resolution: {integrity: sha512-yOEh/J+9IrCo8WDXTdZLxRcO9IHAczrUA4pjSD4BaZzmWhnCFE/amy/0PsAdh/yePurBrtQj3tG/y+hReM15Ew==}
peerDependencies:
react: ^19.0.0 || ^18.0.0
svelte: ^5.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
svelte:
optional: true
vue:
optional: true
'@ng-org/orm@0.1.2-alpha.21':
resolution: {integrity: sha512-+Dtt9jEa+277wonExxqC1BHqVwvWhXeXM51zcGg2w+Mc6C1F74qaXMGYZT3CcWCQISGEMOWV3n2movP3yQ8xJA==}
peerDependencies:
react: ^19.0.0 || ^18.0.0
svelte: ^5.0.0 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
react:
optional: true
svelte:
optional: true
vue:
optional: true
'@ng-org/web@0.1.2-alpha.14':
resolution: {integrity: sha512-FPQ7kyngMhAXK/WJtZ6pkoEJwGcau6GaBH2nhjjYorMq7OG//I+zwghat90FsIeMrfYsps0+5Y0UrRqfxNdUXA==}
'@types/bun@1.3.14':
resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
'@types/node@26.2.0':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
alien-signals@2.0.8:
resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==}
async-proxy@0.4.1:
resolution: {integrity: sha512-4e+zNtoGL4+cnqib8v169CnKcRfAsAubp2EsjBhAA5jyW7jjI3t36rVvuqLwmhtliwf8JvSnxinE4ecQN+DK4w==}
bun-types@1.3.14:
resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
object-path-operator@3.0.0:
resolution: {integrity: sha512-Z7dlPUeXqRU/lLfGerP24dPC66n7ehyXaTM81k71EFlsaaEjOHkf4/uq1WGicfGfiO7snYShneE1YZZUkyRiLQ==}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
snapshots:
'@ng-org/alien-deepsignals@0.1.2-alpha.13':
dependencies:
alien-signals: 2.0.8
'@ng-org/orm@0.1.2-alpha.21':
dependencies:
'@ng-org/alien-deepsignals': 0.1.2-alpha.13
'@ng-org/web@0.1.2-alpha.14':
dependencies:
async-proxy: 0.4.1
'@types/bun@1.3.14':
dependencies:
bun-types: 1.3.14
'@types/node@26.2.0':
dependencies:
undici-types: 8.3.0
alien-signals@2.0.8: {}
async-proxy@0.4.1:
dependencies:
object-path-operator: 3.0.0
bun-types@1.3.14:
dependencies:
'@types/node': 26.2.0
fsevents@2.3.2:
optional: true
object-path-operator@3.0.0: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
typescript@5.9.3: {}
undici-types@8.3.0: {}
-167
View File
@@ -1,167 +0,0 @@
import type { NextGraphPort, Nuri, NuriLike, UnionSubject } from "./port";
import { asNuri } from "./nuri";
import { decodeReference } from "./deposit";
import { descriptorOf, entriesOf, readIndexDocument, valuesOf } from "./index-document";
import { ENTRY_VALUE } from "./vocabulary";
/**
* Applying the references an index has received. Runs as the index's OWNER — only
* the owner reads the inbox, and only the owner writes the document.
*
* ## The curator does not act on a claim, it resolves the reference and looks
*
* A deposit is a bare reference. For each one the curator opens the object
* itself — which it can, because indexing is limited to PUBLIC data for now:
*
* - the object is **present** → it is added to the index, under the value it
* holds for the index's field, unless it is already there;
* - **anything else** → nothing at all happens.
*
* So there is no ownership check, and none is needed. A deposit is an invitation
* to re-examine, not an instruction: the truth is what the object says, not what
* the depositor says. A stranger who deposits a reference achieves exactly what
* the owner would have — which is what lets a passer-by who notices a missing
* entry nudge the index into re-checking it.
*
* ## AN INDEX ONLY EVER GROWS
*
* Nothing here removes an entry. Not "removal is guarded", not "removal needs
* proof" — there is no removal, and `NextGraphPort` has no operation that could
* express one.
*
* That is what makes the failure story trivial. Since the only write is an
* addition, a read that comes back empty — whether the object is gone, or
* unreadable, or the broker simply did not answer — can only ever mean "not added
* this time". It cannot damage what is already there, and a later deposit adds
* it. Nothing has to tell an absence from a failure, so nothing can get that
* wrong. Compare the defect this avoids: a failed read resolving as an absence,
* removed from `ng-eventually-js` in 8c8ade7 and e32b6d0 — "le silence n'est plus
* possible que sur une absence VÉRIFIÉE".
*
* The cost is stated plainly, because someone will eventually need it: an entry
* cannot be taken out of an index, by anyone, including the index's owner.
*
* ## A failure still has to be seen
*
* Harmless is not the same as invisible. Every reference that could not be
* resolved comes back as an `unresolved` outcome AND is warned about, because a
* failure that looks exactly like a normal outcome teaches nobody anything.
*
* ## Deposits are not consumed
*
* Nothing here retires an applied deposit, so every run sees every deposit again.
* That is affordable only because a deposit carries no instruction: re-applying
* one re-resolves the reference and lands on the same result. Curation is
* convergent, and its outcome does not depend on the order references arrive in.
*/
export type CurationOutcome =
/** Present, and now in the index under this value. */
| { readonly result: "indexed"; readonly object: Nuri; readonly value: string }
/** Present, and already in the index. Nothing was written. */
| { readonly result: "unchanged"; readonly object: Nuri }
/** Resolved, and deliberately not added. */
| { readonly result: "skipped"; readonly object: Nuri; readonly reason: SkipReason }
/**
* The reference could not be resolved — gone, unreadable, or the read failed;
* this layer does not tell those apart. Nothing was written, and nothing that
* was already in the index was touched. A later deposit will add it.
*/
| { readonly result: "unresolved"; readonly object: Nuri; readonly reason: string }
/** A payload in the inbox that is not a reference at all. */
| { readonly result: "foreign"; readonly reason: string };
export type SkipReason =
/** Present, but carries nothing for the index's field. OPEN QUESTION — see README. */
| "no-field"
/** Present, but carries several values for the field: which one would the entry hold? */
| "several-values"
/** A reference to the index document itself. */
| "self-reference";
export interface CurationReport {
readonly index: Nuri;
/**
* One per deposit, in the order they were made. Check it for `unresolved`:
* those references were not added and are worth a retry or a look.
*/
readonly outcomes: readonly CurationOutcome[];
}
export async function curate(port: NextGraphPort, indexLike: NuriLike): Promise<CurationReport> {
const index = asNuri(indexLike);
const subjects = await readIndexDocument(port, index);
const descriptor = descriptorOf(subjects, index);
// What the index already holds, kept up to date as this run writes, so that the
// same reference deposited twice produces one entry.
const indexed = new Set<Nuri>(entriesOf(subjects, index).map((e) => e.object));
const deposits = await port.readDeposits(index);
const outcomes: CurationOutcome[] = [];
for (const deposit of deposits) {
const object = decodeReference(deposit.payload);
if (object === null) {
outcomes.push({ result: "foreign", reason: "payload is not a reference" });
continue;
}
if (object === index) {
outcomes.push({ result: "skipped", object, reason: "self-reference" });
continue;
}
if (indexed.has(object)) {
// Already there. Nothing is re-read and nothing is rewritten — an entry, once
// made, is never touched again.
outcomes.push({ result: "unchanged", object });
continue;
}
const resolution = await port.resolveObject(object);
if (resolution.state === "unresolved") {
console.warn(
`[ng-helpers/indexing] ${index}: reference not added — ${object}: ${resolution.reason}`,
);
outcomes.push({ result: "unresolved", object, reason: resolution.reason });
continue;
}
const values = fieldValues(resolution.subjects, descriptor.field);
if (values.length === 0) {
// NARROW BEHAVIOUR, and an open question: an object carrying nothing for the
// field is simply not added. There is no key to index it by, and inventing
// one (a placeholder, the deposit time) would put something in the index that
// the object does not say.
outcomes.push({ result: "skipped", object, reason: "no-field" });
continue;
}
const value = values[0];
if (values.length > 1 || value === undefined) {
outcomes.push({ result: "skipped", object, reason: "several-values" });
continue;
}
await port.addLiteralProperty(index, object, ENTRY_VALUE, value);
indexed.add(object);
outcomes.push({ result: "indexed", object, value });
}
return { index, outcomes };
}
/**
* Every value the object holds for the field, across all the subjects it carries.
*
* Goes through `valuesOf`, which reads OWN properties only. The field is named by
* the index document, so `constructor` and `toString` are fields an index can
* genuinely declare — and reading one off a plain object literal hands back an
* inherited FUNCTION where a list of values belongs.
*/
function fieldValues(subjects: readonly UnionSubject[], field: string): readonly string[] {
const values: string[] = [];
for (const subject of subjects) {
values.push(...valuesOf(subject, field));
}
return values;
}
-37
View File
@@ -1,37 +0,0 @@
import type { Nuri } from "./port";
import { isNuri } from "./nuri";
/**
* What a depositor puts in an index document's inbox: **a bare reference, and
* nothing else**.
*
* Not an instruction — not "add this row", not even "I published this". The
* curator resolves the reference and looks; what it finds is what decides. This
* is the shape NextGraph already uses upstream: a `SocialQueryRequest` carries
* `definition_commit_body_ref` — a REFERENCE — and the recipient's
* `inbox_processor` composes its own SPARQL. Since anyone may deposit into any
* index, a payload that carried an operation would be a licence to rewrite
* someone else's document.
*
* It carries no index reference either. The deposit is addressed to the index
* document's inbox, and upstream an inbox belongs to exactly one repo — the
* address IS the identification. The polyfill says the same of its own inbox:
* "Tagging deposits with their document would be an invention consumers would
* have to unlearn at migration."
*
* And it carries no indexed data. Indexing is limited to PUBLIC objects
* precisely so that the curator can open the object itself; a value copied into
* the deposit would let a depositor put something in the index that the object
* does not say.
*
* What is left is the NURI. That is the whole payload.
*/
export type IndexDeposit = Nuri;
/**
* Anyone may deposit anything into an index's inbox, so every payload is
* untrusted input. Returns `null` for everything that is not a reference.
*/
export function decodeReference(payload: unknown): Nuri | null {
return isNuri(payload) ? payload : null;
}
-210
View File
@@ -1,210 +0,0 @@
import type { NextGraphPort, Nuri, NuriLike, UnionSubject } from "./port";
import { asNuri, isNuri } from "./nuri";
import { ENTRY_VALUE, INDEX_FIELD } from "./vocabulary";
/**
* An index is an ORDINARY document. Nothing in NextGraph marks it as one; what
* makes it an index is that an application references its NURI in its own source,
* and that it holds the two things below.
*
* - **A descriptor**, on the document's own NURI as subject: the FIELD this index
* indexes by — a predicate, since the objects are RDF.
* - **One entry per indexed object**: subject = the object's NURI, carrying the
* value that object holds for the field.
*
* ## Why the field is declared IN the document
*
* The curator has to know which field to read off an object. The only other place
* to keep it is the application's source next to the NURI — and then two
* applications pointing at the same index could curate it on two different fields
* and corrupt each other's entries. Declared in the document, the index answers
* for itself and its creator's choice is the only one.
*
* The cost is one extra subject in `readUnion([indexNuri])`, told apart from the
* entries by being the index's own NURI. `read` drops it; a reader going straight
* to `readUnion` sees it.
*/
export interface IndexDescriptor {
/** The predicate an indexed object must carry. */
readonly field: string;
}
/** One row of an index, as an application reads it. */
export interface IndexEntry {
/** The indexed object — hand this straight back to `readUnion`. */
readonly object: Nuri;
/** What that object holds for the index's field. */
readonly value: string;
}
export async function writeDescriptor(
port: NextGraphPort,
index: Nuri,
descriptor: IndexDescriptor,
): Promise<void> {
await port.addLiteralProperty(index, index, INDEX_FIELD, descriptor.field);
}
/**
* The values a subject carries for a predicate.
*
* Reads OWN properties only, because `props` arrives as a plain object literal
* (`readUnion` builds `const props: Record<string, string[]> = {}`), so it
* inherits from `Object.prototype`. An index whose field is `constructor`,
* `toString` or `valueOf` would otherwise find a FUNCTION where a list of values
* belongs — verified: spreading it throws `TypeError: Spread syntax requires
* ...iterable[Symbol.iterator] to be a function`, which aborted curation for
* every remaining deposit and could not be undone, since the field cannot change.
*
* The non-string filter is the same caution: this data crosses a process boundary,
* and a value that is not a string has no business being compared with `<`.
*
* The two guards are deliberately redundant — either alone stops the crash, as
* mutation testing confirms (removing just one fails nothing; removing both fails
* a test). They are kept because they answer different questions: `hasOwn` asks
* whether the subject really carries this predicate, `Array.isArray` whether what
* came back has the shape the type promises.
*/
export function valuesOf(
subject: UnionSubject | undefined,
predicate: string,
): readonly string[] {
const props = subject?.props;
if (props === undefined || !Object.hasOwn(props, predicate)) return [];
const values: unknown = props[predicate];
if (!Array.isArray(values)) return [];
return values.filter((value): value is string => typeof value === "string");
}
/**
* The smallest of several values, or `undefined` when there are none.
*
* The one tie-break rule this package has, used everywhere a document may carry
* more values than expected. It is deterministic and order-independent, so every
* reader of the same document reaches the same answer — which is the property that
* matters, far more than WHICH value wins.
*/
function smallestOf(values: readonly string[]): string | undefined {
let chosen: string | undefined;
for (const value of values) {
if (chosen === undefined || value < chosen) chosen = value;
}
return chosen;
}
/**
* The value of an ENTRY: it exists as soon as its subject carries AT LEAST ONE.
*
* This is what makes "an index only ever grows" true, and requiring exactly one
* was a hole big enough to drive the whole guarantee through. Two values for one
* object — which two curation runs racing each other produce, since each only ever
* ADDS — made the entry read as ABSENT. The index could therefore SHRINK through
* nothing but additions: the invariant defeated by the very operation meant to
* uphold it, with no delete anywhere in sight.
*/
export function entryValue(subject: UnionSubject): string | undefined {
return smallestOf(valuesOf(subject, ENTRY_VALUE));
}
/**
* Is this document an index at all? Answering needs only that it declares SOME
* field — not which one.
*
* Reading entries is deliberately separated from curating them, because the two
* need different things and conflating them cost every entry in the document. An
* entry already written is a fact; it does not become unreadable because the
* declaration above it turned ambiguous.
*/
export function assertIndexDocument(subjects: readonly UnionSubject[], index: Nuri): void {
const self = subjects.find((s) => s.subject === index);
if (valuesOf(self, INDEX_FIELD).length > 0) return;
throw new Error(
`${index} declares no index field (${INDEX_FIELD}) — either it is not an index, ` +
"or it could not be read: an unreadable document and an empty one are the same " +
"empty result here. Nothing was written. Retry before concluding it is malformed.",
);
}
/**
* The field to CURATE on — and here it must be unambiguous.
*
* Both earlier attempts at this were wrong, in opposite directions, and the
* second was worse than the first:
*
* - demanding exactly one and throwing out of the READ path meant a single
* add-only write of a second `INDEX_FIELD` made every entry in the document
* unreadable, permanently, since nothing here deletes;
* - "smallest wins" kept it readable but silently CORRUPTED it: entries already
* written are never re-read, so they keep their old field's values while new
* ones arrive under the new field, and `read()` returns a single list "ordered
* by value" whose values come from two different properties. A quiet wrong
* answer, which is worse than a loud failure.
*
* So: ambiguity refuses to CURATE, loudly, and leaves reading alone. Entries
* already in the index stay visible and correct; nothing new is added until a
* human looks. That the refusal is permanent is the honest price of having no
* delete — the message says so instead of pretending a retry will help.
*/
export function descriptorOf(subjects: readonly UnionSubject[], index: Nuri): IndexDescriptor {
const self = subjects.find((s) => s.subject === index);
const declared = valuesOf(self, INDEX_FIELD);
if (declared.length > 1) {
throw new Error(
`${index} declares ${declared.length} index fields (${declared.map((f) => JSON.stringify(f)).join(", ")}) ` +
"— refusing to curate rather than pick one, because entries already written under " +
"the other field are never re-read, and mixing them would leave one list ordered " +
"by two different properties. Reading the existing entries still works. This " +
"cannot be undone (nothing here deletes): curate into a fresh index.",
);
}
const field = declared[0];
if (field === undefined) {
throw new Error(
`${index} declares no index field (${INDEX_FIELD}) — either it is not an index, ` +
"or it could not be read: an unreadable document and an empty one are the same " +
"empty result here. Nothing was written. Retry before concluding it is malformed.",
);
}
return { field };
}
/**
* The entries, ordered by value. Ties are broken on the object NURI so that two
* readers of the same index always see the same order.
*
* Values are compared AS STRINGS — this layer does not know what the field means.
* An index whose field holds ISO-8601 dates therefore comes out in chronological
* order, which is the whole reason to index by a date.
*
* A subject that is not a NURI is SKIPPED, never thrown on. An index document is
* an ordinary document and its owner may put anything in it; a single stray
* triple must not be able to make every real entry unreadable at once. Reading is
* per-entry tolerant for the same reason the write path has no delete — what a
* reader could already see has to keep being visible.
*/
export function entriesOf(subjects: readonly UnionSubject[], index: Nuri): IndexEntry[] {
const entries: IndexEntry[] = [];
for (const subject of subjects) {
if (subject.subject === index) continue;
if (!isNuri(subject.subject)) continue;
const value = entryValue(subject);
if (value === undefined) continue;
entries.push({ object: asNuri(subject.subject), value });
}
entries.sort((a, b) => (a.value === b.value ? cmp(a.object, b.object) : cmp(a.value, b.value)));
return entries;
}
function cmp(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0;
}
export async function readIndexDocument(
port: NextGraphPort,
index: NuriLike,
): Promise<readonly UnionSubject[]> {
return port.readDocument(asNuri(index));
}
+24 -28
View File
@@ -1,39 +1,35 @@
/** /**
* `@ng-helpers/indexing` — an index built on top of NextGraph. * `@ng-helpers/indexing` — an index built on top of NextGraph.
* *
* NextGraph has no indexing concept and will not grow one, so nothing here * NextGraph has no indexing concept and will not grow one, so nothing here belongs
* belongs in `@ng-eventually/polyfill`: that package's inbox stays generic and * in `@ng-eventually/polyfill`: that package's inbox stays generic and carries
* carries opaque deposits, and knows nothing of what a deposit means. * opaque deposits.
* *
* An index is an ORDINARY document in its creator's public store. What makes it * An index is an ORDINARY document in its creator's public store. What makes it an
* an index is that an application references its NURI in its own source. Anyone * index is that it declares a field, and that an application references its NURI in
* may hand it a reference by depositing into its inbox; its owner resolves those * its own source. Anyone may hand it a reference; that reference becomes an entry
* references itself and adds what it finds. * because the layer below processes the index's inbox.
* *
* An index only ever grows — see `curator.ts`. * ## What this package is, and is not
*
* A helper that hides that an index is implemented by an inbox — two acts, and the
* addresses stay out of sight. It does not make an entry of what an index receives,
* and it does not read: an index is an ordinary document, so whoever holds its NURI
* queries it with the very call any other document takes — a SPARQL `SELECT`
* anchored on it — using the two IRIs below to recognise what it finds. A helper of ours would only teach a shape that has to be unlearned.
*
* ## Everything published is below, and this file wires nothing
*
* One function, the two acts on the handle it produces, and the two IRIs an index is
* written with.
*/ */
// Wiring // The one function, built on the real polyfill where the real polyfill is known
export { indexing } from "./indexing"; export { indexing } from "./polyfill-adapter";
export type { Indexing } from "./indexing"; export type { Indexing } from "./indexing";
export { polyfillPort } from "./polyfill-adapter";
export type { PolyfillPortOptions } from "./polyfill-adapter";
export type {
NextGraphPort,
IncomingDeposit,
ObjectResolution,
Nuri,
NuriLike,
UnionSubject,
} from "./port";
// What travels from a depositor to a curator: a bare reference // Addressing, as this layer's two acts speak it
export { decodeReference } from "./deposit"; export type { Nuri, NuriLike } from "./port";
export type { IndexDeposit } from "./deposit";
// What an index holds, and what curating it reports // The IRIs an index document is written with, for whoever queries one
export type { IndexDescriptor, IndexEntry } from "./index-document";
export type { CurationOutcome, CurationReport, SkipReason } from "./curator";
// The IRIs written into an index document, for a reader going straight to `readUnion`
export { ENTRY_VALUE, INDEX_FIELD } from "./vocabulary"; export { ENTRY_VALUE, INDEX_FIELD } from "./vocabulary";
+35 -56
View File
@@ -1,13 +1,6 @@
import type { NextGraphPort, Nuri, NuriLike } from "./port"; import type { NextGraphPort, Nuri, NuriLike } from "./port";
import { asNuri } from "./nuri"; import { asNuri } from "./nuri";
import { curate, type CurationReport } from "./curator"; import { INDEX_FIELD } from "./vocabulary";
import {
assertIndexDocument,
entriesOf,
readIndexDocument,
writeDescriptor,
type IndexEntry,
} from "./index-document";
/** /**
* Everything this package does, bound to one identity. * Everything this package does, bound to one identity.
@@ -15,11 +8,15 @@ import {
* A port carries an identity — the polyfill's session is one user's and no call * A port carries an identity — the polyfill's session is one user's and no call
* takes an identifier — so an `Indexing` is one person's handle. Two people mean * takes an identifier — so an `Indexing` is one person's handle. Two people mean
* two handles, which is also what keeps a multi-actor test honest. * two handles, which is also what keeps a multi-actor test honest.
*
* TWO acts, and no third. What becomes of what is added is not one of them, and
* neither is reading: an index is an ordinary document, so its contents come back
* through a SPARQL `SELECT` anchored on it, recognised by the two published IRIs.
*/ */
export interface Indexing { export interface Indexing {
/** /**
* Creates an index, in THIS identity's public store; the creator owns it. Any * Creates an index, in THIS identity's public store; the creator owns it. Any
* user may create one. * user may create one. Produces the index's NURI, and an inbox on it, open.
* *
* `field` is the predicate an indexed object must carry, declared once, here. * `field` is the predicate an indexed object must carry, declared once, here.
* An index "by a date" is just an index whose field is a date predicate — there * An index "by a date" is just an index whose field is a date predicate — there
@@ -30,54 +27,49 @@ export interface Indexing {
* reference is the only thing that makes this ordinary document an index, and * reference is the only thing that makes this ordinary document an index, and
* the only way anyone reaches it. * the only way anyone reaches it.
*/ */
createIndex(field: string): Promise<Nuri>; create(field: string): Promise<Nuri>;
/** /**
* Hands an index a reference to an object. Open to ANYONE — it is a deposit in * Hands an index a reference to an object. Open to ANYONE — it is a deposit in
* the index document's inbox, not a write. * the index document's inbox, not a write. Produces nothing: an application names
* a document or a person, never an inbox, and there is no receipt to hold on to.
* *
* The reference is the whole message: it claims nothing and instructs nothing, * ADDED NOW, VISIBLE LATER — the verb is the one every index carries (`addDocument`,
* it just invites the index's owner to look. Call it when the object is * `addObjects`), and those systems are asynchronous too. The reference is the whole
* created, and again whenever anyone notices the index may not have it yet — * message: it claims nothing and instructs nothing, it just invites the index's
* including a third party. Nothing lands in the index until its owner curates. * owner to look. Call it when the object is created, and again whenever anyone
* notices the index may not have it yet — including a third party.
*/ */
refer(index: NuriLike, object: NuriLike): Promise<void>; add(index: NuriLike, object: NuriLike): Promise<void>;
/**
* Resolves the references this index has received and adds what it can. Only
* the index's OWNER gets anything: nobody else reads its inbox, and nobody else
* may write it.
*
* Check the returned outcomes for `unresolved` — those references were not
* added.
*/
curate(index: NuriLike): Promise<CurationReport>;
/**
* The index's entries, ordered by value.
*
* Sugar only. Reading an index needs NOTHING new from NextGraph: an application
* that knows the NURI can call `readUnion([indexNuri])` and get these entries as
* subjects — one per indexed object, keyed by its NURI — plus the index's own
* subject declaring its field, which this function drops.
*/
read(index: NuriLike): Promise<IndexEntry[]>;
} }
export function indexing(port: NextGraphPort): Indexing { /**
* This identity's handle, built on a port.
*
* INTERNAL. What an application gets is `indexing(sessionId)` in `index.ts`, which
* builds the port for it — the port is the seam that keeps the two acts testable
* against an in-memory NextGraph, not a concept a caller has to learn.
*
* Synchronous, and it does nothing but bind the port. Only the two acts below talk
* to anything.
*/
export function indexingOn(port: NextGraphPort): Indexing {
return { return {
async createIndex(field: string): Promise<Nuri> { async create(field: string): Promise<Nuri> {
// Refused at the door, because a field cannot be corrected afterwards: // Refused at the door, because a field cannot be corrected afterwards:
// nothing here deletes, so an index created on a useless field is useless // nothing here deletes, so an index created on a useless field is useless
// for good. An empty string is the sharp case — it would match no object. // for good. An empty string is the sharp case — it would match no object.
if (field.length === 0 || field.trim().length === 0) { if (field.length === 0 || field.trim().length === 0) {
throw new Error( throw new Error(
"createIndex: the field must be the predicate an indexed object carries, " + "create: the field must be the predicate an indexed object carries, " +
"and it cannot be changed later — this package never removes anything.", "and it cannot be changed later — this package never removes anything.",
); );
} }
const index = await port.createPublicDocument(); const index = await port.createPublicDocument();
await writeDescriptor(port, index, { field }); // The declaration, on the document's own subject: the one triple this package
// writes, and what makes an ordinary document an index. Whoever reads the
// document recognises it by `INDEX_FIELD`.
await port.addLiteralProperty(index, index, INDEX_FIELD, field);
// An index nobody can deposit into is not an index. Only its owner can open // An index nobody can deposit into is not an index. Only its owner can open
// its inbox, and this is the one moment the owner is here — so it is opened // its inbox, and this is the one moment the owner is here — so it is opened
// at creation rather than left for a later call to remember. // at creation rather than left for a later call to remember.
@@ -85,23 +77,10 @@ export function indexing(port: NextGraphPort): Indexing {
return index; return index;
}, },
async refer(index: NuriLike, object: NuriLike): Promise<void> { async add(index: NuriLike, object: NuriLike): Promise<void> {
// The payload IS the reference. Nothing wraps it, nothing annotates it. // The payload IS the reference. Nothing wraps it, nothing annotates it — and
// it carries no index either, because the inbox it lands in identifies one.
await port.depositTo(asNuri(index), asNuri(object)); await port.depositTo(asNuri(index), asNuri(object));
}, },
curate(index: NuriLike): Promise<CurationReport> {
return curate(port, index);
},
async read(index: NuriLike): Promise<IndexEntry[]> {
const nuri = asNuri(index);
const subjects = await readIndexDocument(port, nuri);
// Only "is this an index?", deliberately NOT "which field does it curate
// on?". Reading entries never needed the field, and making it need one is
// what let an ambiguous declaration make every entry unreadable for good.
assertIndexDocument(subjects, nuri);
return entriesOf(subjects, nuri);
},
}; };
} }
+44 -37
View File
@@ -1,24 +1,23 @@
import { docs, inbox, readUnion, storeRegistry } from "@ng-eventually/polyfill"; import { docs, inbox, storeRegistry } from "@ng-eventually/polyfill";
import type { import type { NextGraphPort, NuriLike } from "./port";
IncomingDeposit, import { indexingOn, type Indexing } from "./indexing";
NextGraphPort,
NuriLike,
ObjectResolution,
UnionSubject,
} from "./port";
import { asNuri } from "./nuri"; import { asNuri } from "./nuri";
import { buildInsertTriple } from "./sparql"; import { buildInsertTriple } from "./sparql";
import { resolutionFromFailure, resolutionFromRead } from "./resolution";
/** /**
* The ONLY place in this package that touches `@ng-eventually/polyfill` at * The ONLY place in this package that touches `@ng-eventually/polyfill` at
* runtime. Everything else is written against `NextGraphPort`, so the indexing * runtime. Everything else is written against `NextGraphPort`, so the two acts
* rules never learn a polyfill call — and the polyfill never learns anything * never learn a polyfill call — and the polyfill never learns anything about
* about indexing. * indexing.
* *
* Only entries listed in `contract_polyfill-surface` are used here; nothing is * Only entries listed in `contract_polyfill-surface` are used here; nothing is
* reached for inside that package. Because this file compiles against the real * reached for inside that package. Because this file compiles against the real
* module, a change to the published surface breaks THIS file and nothing else. * module, a change to the published surface breaks THIS file and nothing else.
*
* NOT PUBLISHED. `indexing(sessionId)` builds the port itself, so an application
* never names one: the port is the seam that keeps the two acts testable without a
* broker, and every call it exposes is either one of those acts or something the
* polyfill already publishes to whoever wants it.
*/ */
export interface PolyfillPortOptions { export interface PolyfillPortOptions {
/** /**
@@ -39,25 +38,12 @@ export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
async createPublicDocument() { async createPublicDocument() {
// An index lives in its creator's public store, so that any reader can open // An index lives in its creator's public store, so that any reader can open
// it from the reference alone. // it from the reference alone.
return storeRegistry.createEntityDoc("public"); //
}, // A store is named by its REFERENCE and not by a word: `publicStore()` hands
// back the one this identity has, and `createEntityDoc` takes it. Omitting the
async resolveObject(doc: NuriLike): Promise<ObjectResolution> { // argument would place the document in the PRIVATE store, where no reader could
// The decision itself lives in `resolution.ts`, where it is unit-tested: // open it -- so this call names the store every time, and never by omission.
// here it would be reachable only through a live broker, and an untested return storeRegistry.createEntityDoc(await storeRegistry.publicStore());
// "empty means unresolved" quietly becomes "empty means the object has no
// field" — a failure filed as a fact about the object.
let subjects: readonly UnionSubject[];
try {
subjects = await readUnion([asNuri(doc)]);
} catch (error) {
return resolutionFromFailure(error);
}
return resolutionFromRead(subjects);
},
async readDocument(doc: NuriLike): Promise<readonly UnionSubject[]> {
return readUnion([asNuri(doc)]);
}, },
async addLiteralProperty( async addLiteralProperty(
@@ -68,7 +54,7 @@ export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
): Promise<void> { ): Promise<void> {
const graph = asNuri(doc); const graph = asNuri(doc);
// An INSERT and nothing else. There is deliberately no delete anywhere in // An INSERT and nothing else. There is deliberately no delete anywhere in
// this package, so no failure here can leave an index short of an entry. // this package, so no failure here can leave an index short of anything.
// //
// The document is named ONCE, as the anchor: `sparqlUpdate(sid, update, anchor)` // The document is named ONCE, as the anchor: `sparqlUpdate(sid, update, anchor)`
// scopes the write to that repo's default graph, so the statement carries no // scopes the write to that repo's default graph, so the statement carries no
@@ -79,7 +65,9 @@ export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
async openInbox(doc: NuriLike): Promise<void> { async openInbox(doc: NuriLike): Promise<void> {
// Without this nobody can deposit: `postToDocument` resolves the document's // Without this nobody can deposit: `postToDocument` resolves the document's
// address and throws when its owner never opened one. // address and throws when its owner never opened one. It is also the one call
// that hands an inbox address out, and this layer deliberately drops it — a
// document is what everything above names, exactly as upstream intends.
await storeRegistry.openDocumentInbox(asNuri(doc)); await storeRegistry.openDocumentInbox(asNuri(doc));
}, },
@@ -88,9 +76,28 @@ export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
// anyone — which is what lets a stranger contribute to someone else's index. // anyone — which is what lets a stranger contribute to someone else's index.
await inbox.postToDocument(asNuri(doc), { payload }); await inbox.postToDocument(asNuri(doc), { payload });
}, },
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
return inbox.readForDocument(asNuri(doc));
},
}; };
} }
/**
* This identity's handle, on the real polyfill — the package's one published
* function, re-exported by `index.ts`.
*
* It lives HERE, beside the port it builds, because this is the only file that knows
* `@ng-eventually/polyfill` exists at runtime; `index.ts` says what is published and
* wires nothing.
*
* `sessionId` is what the polyfill's own `init(…)` hands its callback — upstream's
* type, relayed and never converted. A session IS one identity, so the handle is one
* person's: two users mean two handles, which is also what keeps a multi-actor test
* honest.
*
* It reaches nothing on its own — build it once and keep it, or build one wherever
* you need it; only the two acts on it talk to anything.
*/
export function indexing(sessionId: string | number): Indexing {
// The port stays inside. It is the seam that keeps the two acts testable without
// a broker, and an application has no use for it: every call it exposes is either
// an act of the published surface or something the polyfill already publishes.
return indexingOn(polyfillPort({ sessionId }));
}
+16 -61
View File
@@ -1,45 +1,23 @@
/** /**
* What this layer needs from NextGraph, and nothing more. * What this layer needs from NextGraph, and nothing more.
* *
* FOUR operations, because this package does exactly two things: it creates an
* index document that declares its field and can receive deposits, and it deposits
* a reference into one. Nothing here reads a document, an inbox or a store —
* making an index's deposits into its entries belongs to the layer below, and an
* operation kept "in case" would be an invitation to do that work here again.
*
* Every member below is backed by published entries of `@ng-eventually/polyfill` * Every member below is backed by published entries of `@ng-eventually/polyfill`
* (see that package's `contract_polyfill-surface`). The mapping lives in * (see that package's `contract_polyfill-surface`). The mapping lives in
* `polyfill-adapter.ts`; the rest of this package never imports the polyfill at * `polyfill-adapter.ts`; the rest of this package never imports the polyfill at
* runtime, so the indexing rules stay testable against an in-memory port. * runtime, so the two acts stay testable against an in-memory port.
* *
* The types are imported *from* the polyfill (type-only, erased at build) so they * The types are imported *from* the polyfill (type-only, erased at build) so they
* are literally the published ones rather than a copy that could drift. * are literally the published ones rather than a copy that could drift.
*/ */
import type { Nuri, NuriLike, PrincipalId, UnionSubject } from "@ng-eventually/polyfill"; import type { Nuri, NuriLike } from "@ng-eventually/polyfill";
export type { Nuri, NuriLike, PrincipalId, UnionSubject }; export type { Nuri, NuriLike };
/** One deposit read out of a document's inbox. Mirrors the published `Deposit`. */
export interface IncomingDeposit {
/** The depositor, when identified; `null` for an anonymous deposit. */
readonly from: PrincipalId | null;
/** Opaque to the polyfill — this layer decodes it (see `deposit.ts`). */
readonly payload: unknown;
/** Deposit timestamp (ms epoch). */
readonly ts: number;
}
/**
* The answer to "what is at this reference?" — TWO states, deliberately.
*
* There is no `absent`. An index never removes anything, so nothing this layer
* does could ever hinge on telling "the object is gone" from "I could not read
* it". Both mean the same thing here: *not added this time*. Collapsing them is
* safe only because of that, and it is the point — the alternative would be a
* label a broker failure could wear, and an index emptied one reference at a time
* by a network problem.
*
* `unresolved` is still a failure, and the curator reports every one of them.
*/
export type ObjectResolution =
/** The read answered, and there is something there. */
| { readonly state: "present"; readonly subjects: readonly UnionSubject[] }
/** Nothing usable came back — gone, unreadable, or the read failed. Not distinguished. */
| { readonly state: "unresolved"; readonly reason: string };
/** /**
* A port is bound to ONE identity: the polyfill's session is one user's, and no * A port is bound to ONE identity: the polyfill's session is one user's, and no
@@ -48,37 +26,21 @@ export type ObjectResolution =
export interface NextGraphPort { export interface NextGraphPort {
/** /**
* A new document in this identity's PUBLIC store, owned by this identity. * A new document in this identity's PUBLIC store, owned by this identity.
* Backs onto `storeRegistry.createEntityDoc("public")`. * Backs onto `storeRegistry.createEntityDoc(await storeRegistry.publicStore())`
* -- a store is named by its reference, and omitting it would mean the private one.
*/ */
createPublicDocument(): Promise<Nuri>; createPublicDocument(): Promise<Nuri>;
/** Resolves a reference deposited into an index: the two-state answer above. */
resolveObject(doc: NuriLike): Promise<ObjectResolution>;
/**
* Every subject an index document holds. Backs onto `readUnion([doc])`.
*
* CAUTION — an empty result is NOT proof the document is empty, and this
* interface does not pretend otherwise. `readUnion` swallows a failing document
* into `[]` (its `readDoc` is `try {…} catch { return [] }`) and drops documents
* whose cap this user does not hold, so "could not read" and "holds nothing"
* arrive here indistinguishable. It may also reject outright.
*
* So a caller must never read `[]` as "a valid index that happens to be empty".
* Nothing here does: `descriptorOf` refuses a document that declares no field —
* which is exactly what an unreadable one looks like — and that refusal aborts
* curation before a single write.
*/
readDocument(doc: NuriLike): Promise<readonly UnionSubject[]>;
/** /**
* Adds ONE value for `predicate` on `subject`, inside `doc`. Only the * Adds ONE value for `predicate` on `subject`, inside `doc`. Only the
* document's OWNER may — holding a read key never grants a write. * document's OWNER may — holding a read key never grants a write.
* *
* There is no counterpart that removes, and no way to pass "no value": this * There is no counterpart that removes, and no way to pass "no value": this
* package must not be able to take anything out of an index, and making that * package must not be able to take anything out of an index, and making that
* structural beats leaving it to whoever edits the curator next. Backs onto * structural beats leaving it to whoever edits this next. Backs onto
* `docs.sparqlUpdate` with an `INSERT DATA`. * `docs.sparqlUpdate` with an `INSERT DATA`.
*
* One caller and one use: writing the field an index declares, at creation.
*/ */
addLiteralProperty( addLiteralProperty(
doc: NuriLike, doc: NuriLike,
@@ -93,7 +55,8 @@ export interface NextGraphPort {
* document has no address. Backs onto `storeRegistry.openDocumentInbox(doc)`. * document has no address. Backs onto `storeRegistry.openDocumentInbox(doc)`.
* *
* Returns nothing on purpose — an application names a document or a person, * Returns nothing on purpose — an application names a document or a person,
* never an inbox. * never an inbox, and keeping that address out of sight is the whole of what
* this package is for.
*/ */
openInbox(doc: NuriLike): Promise<void>; openInbox(doc: NuriLike): Promise<void>;
@@ -106,12 +69,4 @@ export interface NextGraphPort {
* vanishes silently is worse than a refusal. * vanishes silently is worse than a refusal.
*/ */
depositTo(doc: NuriLike, payload: unknown): Promise<void>; depositTo(doc: NuriLike, payload: unknown): Promise<void>;
/**
* The deposits made to this document's inbox, oldest first.
*
* THROWS for anyone but the document's owner — you may deposit into anyone's
* inbox, you may only READ your own. Backs onto `inbox.readForDocument(doc)`.
*/
readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]>;
} }
-36
View File
@@ -1,36 +0,0 @@
import type { ObjectResolution, UnionSubject } from "./port";
/**
* Why an empty read is `unresolved` and never `present`.
*
* `readUnion` cannot tell an absent object from one it failed to read, and does
* not claim to: its per-document read is wrapped in `try {…} catch { return [] }`
* (`readDoc`, in `@ng-eventually/polyfill`'s `src/surface/read-model.ts`), and
* documents whose cap this user does not hold are dropped from the batch before
* it reads at all. An empty result means absent OR unreadable OR failed, with
* nothing to separate them.
*
* That is not a gap this package has to close, because nothing it does depends on
* the answer: an index only ever grows, so every reading leads to the same act —
* do not add, and say so.
*
* The rule lives here, apart from the I/O, for one reason: in the adapter it was
* unreachable by any test, and dropping it there left the suite green while a
* FAILED read got reported as `skipped: "no-field"` — a failure filed as a
* property of the object. That is exactly the misclassification this layer exists
* to avoid, so the rule is now a pure function with its own tests.
*/
export const EMPTY_READ =
"the read came back empty — the object is absent, unreadable, or the read failed; " +
"the polyfill does not distinguish them, and this layer does not need it to";
/** Turns what a read returned into the two-state answer the curator acts on. */
export function resolutionFromRead(subjects: readonly UnionSubject[]): ObjectResolution {
if (subjects.length === 0) return { state: "unresolved", reason: EMPTY_READ };
return { state: "present", subjects };
}
/** Turns a read that threw into the same two-state answer. Never `present`. */
export function resolutionFromFailure(error: unknown): ObjectResolution {
return { state: "unresolved", reason: String(error) };
}
+3 -2
View File
@@ -1,5 +1,6 @@
/** /**
* The SPARQL this layer writes — and it writes only one kind of statement. * The SPARQL this layer writes — one statement, and one use for it: the field an
* index declares, written on the index document's own subject at creation.
* *
* There is NO delete builder, and there must not be one: an index never removes * There is NO delete builder, and there must not be one: an index never removes
* an entry, and the surest way to keep that true is for this package to be unable * an entry, and the surest way to keep that true is for this package to be unable
@@ -61,7 +62,7 @@ export function escapeIri(value: string): string {
/** /**
* One triple, for the ANCHORED default graph. The only statement this package * One triple, for the ANCHORED default graph. The only statement this package
* ever writes. * ever writes, and it writes it once per index.
* *
* No `GRAPH <…>` wrapper, and the document is not a parameter at all: it is named * No `GRAPH <…>` wrapper, and the document is not a parameter at all: it is named
* once, as `docs.sparqlUpdate`'s anchor, which already scopes the write to that * once, as `docs.sparqlUpdate`'s anchor, which already scopes the write to that
+15 -7
View File
@@ -1,17 +1,25 @@
/** /**
* The IRIs this layer writes into an index document. * The IRIs an index document is written with — published because a reader needs
* them to make sense of one.
* *
* `urn:ng-eventually:` is RESERVED by the polyfill — triples whose subject falls * `urn:ng-eventually:` is RESERVED by the polyfill — it writes its own registers
* under it are dropped on read. This layer stays out of it entirely and uses its * under subjects carrying that prefix, in the same graph, and a reader ignores them.
* own `urn:ng-helpers:` namespace, for PREDICATES only. Subjects in an index * This layer stays out of it entirely and uses its own `urn:ng-helpers:` namespace,
* document are always `did:ng:` NURIs, so nothing here is ever filtered away. * for PREDICATES only. Subjects this layer writes are always `did:ng:` NURIs, so
* nothing of it can be mistaken for the polyfill's.
*/ */
/** /**
* On the index document's own subject: the predicate an indexed object must * On the index document's own subject: the predicate an indexed object must
* carry, declared once by whoever creates the index. * carry, declared once by whoever creates the index. `create` writes it.
*/ */
export const INDEX_FIELD = "urn:ng-helpers:index:field"; export const INDEX_FIELD = "urn:ng-helpers:index:field";
/** On an entry (subject = the indexed object's NURI): the value of that field. */ /**
* On an entry (subject = the indexed object's NURI): the value of that field.
*
* This package never writes one — an entry is made by the layer that processes the
* index's inbox. It is published because reading an index means recognising this
* predicate on the subjects that come back.
*/
export const ENTRY_VALUE = "urn:ng-helpers:index:value"; export const ENTRY_VALUE = "urn:ng-helpers:index:value";
+78 -116
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { indexing } from "../src/indexing"; import { indexingOn } from "../src/indexing";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary"; import { INDEX_FIELD } from "../src/vocabulary";
import type { NextGraphPort, Nuri } from "../src/port"; import type { NextGraphPort, Nuri } from "../src/port";
import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill"; import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill";
@@ -9,22 +9,18 @@ import { DESTRUCTIVE, blankLiterals, installFakePolyfill } from "./fake-polyfill
* *
* ## What was wrong with the gate this replaces * ## What was wrong with the gate this replaces
* *
* It mocked the polyfill to constants and drove exactly TWO of the adapter's seven * It mocked the polyfill to constants and drove only the methods `create` happens to
* methods — `addLiteralProperty` and (through `createIndex`) `createPublicDocument` * use. Everything else was inert: a method could be gutted — write nothing, return
* and `openInbox`. Everything else was inert. Six methods could be gutted — write * `[]` — with the suite still green, and the gate's own promise ("every query it
* nothing, return `[]` — with the suite still green, and the gate's own promise * emits is read back") held only for the queries those methods emitted. A
* ("every query it emits is read back") held only for the queries those two methods * destructive statement planted in a method nobody drove was never recorded.
* emitted. A destructive statement planted in `readDeposits` was never recorded,
* because `readDeposits` was never called. That matters most exactly there:
* retiring an applied deposit — the README's own open question — lands in
* `readDeposits`.
* *
* ## What holds now * ## What holds now
* *
* The adapter runs on `fake-polyfill.ts`, an in-memory polyfill that ANSWERS rather * The adapter runs on `fake-polyfill.ts`, an in-memory polyfill that ANSWERS rather
* than returning constants, so the tests below are ordinary behavioural tests that * than returning constants, so the tests below are ordinary behavioural tests that
* happen to run through the real wiring. Gut any of the seven and something here * happen to run through the real wiring. Gut any of the four and something here goes
* goes red, because each one is now load-bearing for an outcome that is asserted. * red, because each one is load-bearing for an outcome that is asserted.
* *
* Three properties are checked on top of behaviour, and each closes a hole the * Three properties are checked on top of behaviour, and each closes a hole the
* review walked through: * review walked through:
@@ -82,100 +78,39 @@ async function publish(port: NextGraphPort, field: string, value: string): Promi
return object; return object;
} }
// --- the whole loop, through the real adapter ----------------------------- // --- both acts, through the real adapter ----------------------------------
test("the real adapter carries the whole loop: create, publish, refer, curate, read", async () => { test("the real adapter carries both acts: create, publish, add", async () => {
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
const article = await as("bob", async (bob) => { const article = await as("bob", async (bob) => {
const object = await publish(bob, FIELD, "2026-07-08"); const object = await publish(bob, FIELD, "2026-07-08");
await indexing(bob).refer(index, object); await indexingOn(bob).add(index, object);
return object; return object;
}); });
const report = await as("alice", (alice) => indexing(alice).curate(index)); // The index declares its field, and Bob's bare reference is waiting in its inbox.
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]); // That is the whole of what these two acts do; making an entry of that reference
// is the business of the layer below, and nothing here can do it or ask for it.
const entries = await as("alice", (alice) => indexing(alice).read(index)); expect(world.contentsOf(index)).toEqual([
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]); { subject: index, predicate: INDEX_FIELD, values: [FIELD] },
]);
expect(world.depositsIn(index)).toEqual([{ from: "bob", payload: article, ts: 1 }]);
}); });
test("two indexes are two documents, each owned by whoever created it", async () => { test("two indexes are two documents, each owned by whoever created it", async () => {
const [first, second] = await as("alice", async (alice) => [ const [first, second] = await as("alice", async (alice) => [
await indexing(alice).createIndex(FIELD), await indexingOn(alice).create(FIELD),
await indexing(alice).createIndex(FIELD), await indexingOn(alice).create(FIELD),
]); ]);
expect(first).not.toBe(second); expect(first).not.toBe(second);
// A stranger holding the NURI still cannot write it — reaching is not owning. // A stranger holding the NURI still cannot write it — reaching is not owning.
await expect( await expect(
as("bob", (bob) => bob.addLiteralProperty(first, first, ENTRY_VALUE, "2026-01-01")), as("bob", (bob) => bob.addLiteralProperty(first, first, INDEX_FIELD, "urn:forged")),
).rejects.toThrow(/only a document's owner writes to it/); ).rejects.toThrow(/only a document's owner writes to it/);
}); });
// --- the two answers a resolve may give, and why they must stay apart -----
test("a reference that could not be READ comes back unresolved", async () => {
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
const article = await as("bob", async (bob) => {
const object = await publish(bob, FIELD, "2026-07-08");
await indexing(bob).refer(index, object);
return object;
});
// `readDoc` catches and yields `[]`, so a failed read arrives looking exactly
// like an object that holds nothing. Telling them apart is not possible; filing
// the failure as a FACT about the object is what must not happen.
world.breakReadsOf(article, "broker unreachable");
try {
const report = await as("alice", (alice) => indexing(alice).curate(index));
expect(report.outcomes).toEqual([
{ result: "unresolved", object: article, reason: expect.stringContaining("absent") },
]);
} finally {
world.healReadsOf(article);
}
});
test("an object that really carries nothing for the field is SKIPPED — a different answer", async () => {
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
const unrelated = await as("bob", async (bob) => {
const object = await publish(bob, "http://schema.org/name", "Anemone");
await indexing(bob).refer(index, object);
return object;
});
// Paired with the test above ON PURPOSE. `resolveObject` is the only thing
// keeping these two apart: replace `resolutionFromRead(subjects)` with
// `{ state: "present", subjects }` and the unreadable object above is reported
// here's answer instead — a broker failure filed as a property of the object.
const report = await as("alice", (alice) => indexing(alice).curate(index));
expect(report.outcomes).toEqual([
{ result: "skipped", object: unrelated, reason: "no-field" },
]);
});
test("an EMPTY read is unresolved, straight off the adapter method", async () => {
// The same rule as the pair above, asserted directly rather than through a
// curation report, so the wiring of `resolutionFromRead` is held by two
// independent tests and not by one.
const blank = await as("alice", (alice) => alice.createPublicDocument());
const resolution = await as("alice", (alice) => alice.resolveObject(blank));
expect(resolution.state).toBe("unresolved");
expect(resolution.state === "unresolved" && resolution.reason).toContain("absent, unreadable");
});
test("a read that REJECTS outright is unresolved too, and names the failure", async () => {
world.breakReadUnion("session lost");
try {
const resolution = await as("alice", (alice) => alice.resolveObject("did:ng:o:whatever"));
expect(resolution.state).toBe("unresolved");
expect(resolution.state === "unresolved" && resolution.reason).toContain("session lost");
} finally {
world.healReadUnion();
}
});
// --- the inbox, from both sides ------------------------------------------- // --- the inbox, from both sides -------------------------------------------
test("a document whose owner never opened an inbox REFUSES the deposit", async () => { test("a document whose owner never opened an inbox REFUSES the deposit", async () => {
@@ -186,34 +121,48 @@ test("a document whose owner never opened an inbox REFUSES the deposit", async (
); );
}); });
test("anyone may deposit into an index, only its owner may read what was deposited", async () => { test("anyone may hand a reference to an index they do not own", async () => {
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
await as("bob", (bob) => indexing(bob).refer(index, "did:ng:o:some-object")); await as("bob", async (bob) => indexingOn(bob).add(index, "did:ng:o:some-object"));
const own = await as("alice", (alice) => alice.readDeposits(index)); // Bob needed no permission and got no write, and the deposit carries who made it.
expect(own.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]); const waiting = world.depositsIn(index);
expect(own[0]?.from).toBe("bob"); expect(waiting?.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]);
expect(waiting?.[0]?.from).toBe("bob");
await expect(as("bob", (bob) => bob.readDeposits(index))).rejects.toThrow( expect(world.contentsOf(index)).toEqual([
/may only READ your own/, { subject: index, predicate: INDEX_FIELD, values: [FIELD] },
); ]);
}); });
// --- what the adapter actually wrote -------------------------------------- // --- what the adapter actually wrote --------------------------------------
test("readDocument returns what was written, and refuses a document that is no index", async () => { test("creating an index writes its field declaration, and nothing else", async () => {
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
const subjects = await as("alice", (alice) => alice.readDocument(index)); expect(world.contentsOf(index)).toEqual([
expect(subjects).toEqual([{ subject: index, graph: index, props: { [INDEX_FIELD]: [FIELD] } }]); { subject: index, predicate: INDEX_FIELD, values: [FIELD] },
]);
// …and an ordinary public document is left exactly as it was made.
const ordinary = await as("alice", (alice) => alice.createPublicDocument()); const ordinary = await as("alice", (alice) => alice.createPublicDocument());
await expect(as("alice", (alice) => indexing(alice).read(ordinary))).rejects.toThrow( expect(world.contentsOf(ordinary)).toEqual([]);
/declares no index field/, });
);
test("an index is recorded in its creator's PUBLIC store, named by that store's reference", async () => {
const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
// WHERE it landed, read off the fake rather than inferred from the call: an index a
// stranger cannot open is not an index, and the private store is one keystroke away
// (omitting the argument places it there).
expect(world.storeOf(index)).toBe(world.storesOf("alice").public);
// And HOW the store was named: the reference the accessor handed back, not a word.
// A store is told apart from every other reference only by being one of the three,
// so the accessor is the only thing that can produce it.
const placed = world.calls.filter((call) => call.entry === "storeRegistry.createEntityDoc").at(-1);
expect(placed?.args).toEqual([world.storesOf("alice").public]);
}); });
test("the write is the polyfill's canonical anchored form: the document named once, as the anchor", async () => { test("the write is the polyfill's canonical anchored form: the document named once, as the anchor", async () => {
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD)); const index = await as("alice", async (alice) => indexingOn(alice).create(FIELD));
const writes = world.calls.filter((call) => call.entry === "docs.sparqlUpdate"); const writes = world.calls.filter((call) => call.entry === "docs.sparqlUpdate");
const last = writes.at(-1); const last = writes.at(-1);
expect(last?.args[1]).toBe(`INSERT DATA { <${index}> <${INDEX_FIELD}> "${FIELD}" }`); expect(last?.args[1]).toBe(`INSERT DATA { <${index}> <${INDEX_FIELD}> "${FIELD}" }`);
@@ -224,17 +173,15 @@ test("the write is the polyfill's canonical anchored form: the document named on
test("a hostile value lands as ONE inert literal, not as a second statement", async () => { test("a hostile value lands as ONE inert literal, not as a second statement", async () => {
const HOSTILE = '" } ; DROP GRAPH <did:ng:o:doc-1> ; INSERT DATA { <a> <b> "c'; const HOSTILE = '" } ; DROP GRAPH <did:ng:o:doc-1> ; INSERT DATA { <a> <b> "c';
const doc = await as("alice", async (alice) => { // A field is caller-supplied and goes straight into the statement, so it is the
const object = await alice.createPublicDocument(); // value this package really does have to survive.
await alice.addLiteralProperty(object, object, ENTRY_VALUE, HOSTILE); const index = await as("alice", async (alice) => indexingOn(alice).create(HOSTILE));
return object;
});
// Semantic, not a quote count: the value round-trips through a parser that would // Semantic, not a quote count: the value round-trips through a parser that would
// have refused the query outright had the literal closed early — and had it // have refused the query outright had the literal closed early — and had it
// closed early and still parsed, the extra subject would show up here. // closed early and still parsed, the extra subject would show up here.
expect(world.contentsOf(doc)).toEqual([ expect(world.contentsOf(index)).toEqual([
{ subject: doc, predicate: ENTRY_VALUE, values: [HOSTILE] }, { subject: index, predicate: INDEX_FIELD, values: [HOSTILE] },
]); ]);
}); });
@@ -255,9 +202,24 @@ test("nothing the adapter sent the polyfill, in any method, carries a destructiv
} }
}); });
test("the port the entry point builds has NO operation that reads or removes", async () => {
// Building it must not touch the broker: `indexing(sessionId)` builds one for every
// handle, and only the calls on it talk to anything. This check lived on the
// published surface while `polyfillPort` was published; the port is internal now,
// and the properties it guards are not.
const { polyfillPort } = await import("../src/polyfill-adapter");
const port = polyfillPort({ sessionId: "session:alice" });
expect(typeof port.createPublicDocument).toBe("function");
expect(typeof port.addLiteralProperty).toBe("function");
const forbidden = Object.keys(port).filter((name) =>
/delete|remove|clear|drop|read|list|watch/i.test(name),
);
expect(forbidden).toEqual([]);
});
test("LAST — every method the adapter exposes was driven above", async () => { test("LAST — every method the adapter exposes was driven above", async () => {
// Read off the adapter itself, so there is no list to remember to extend: add an // Read off the adapter itself, so there is no list to remember to extend: add a
// eighth method and this fails until something above actually calls it. That is // fifth method and this fails until something above actually calls it. That is
// the whole answer to "the gate proves the methods it drives" — it now names them // the whole answer to "the gate proves the methods it drives" — it now names them
// from the code rather than from a test's memory. // from the code rather than from a test's memory.
const { polyfillPort } = await import("../src/polyfill-adapter"); const { polyfillPort } = await import("../src/polyfill-adapter");
+39 -119
View File
@@ -1,11 +1,4 @@
import type { import type { NextGraphPort, Nuri, NuriLike } from "../src/port";
IncomingDeposit,
NextGraphPort,
Nuri,
NuriLike,
ObjectResolution,
UnionSubject,
} from "../src/port";
import { asNuri } from "../src/nuri"; import { asNuri } from "../src/nuri";
/** /**
@@ -18,18 +11,29 @@ import { asNuri } from "../src/nuri";
* *
* - only a document's owner writes to it, and a read key never grants a write * - only a document's owner writes to it, and a read key never grants a write
* (`contract_polyfill-surface`, "Guarantees"); * (`contract_polyfill-surface`, "Guarantees");
* - anyone may DEPOSIT into a document's inbox, but reading one THROWS for anyone * - anyone may DEPOSIT into a document's inbox (`inbox.postToDocument`);
* but its owner (`inbox.read`'s `assertOwnInbox`: "you may DEPOSIT into anyone's * - depositing into a document whose owner never opened an inbox THROWS, rather
* inbox; you may only READ your own"); * than silently going nowhere;
* - depositing into a document whose owner never opened an inbox THROWS
* (`inbox.postToDocument`), rather than silently going nowhere;
* - opening an inbox on a document is refused to anyone but its owner * - opening an inbox on a document is refused to anyone but its owner
* (`openDocumentInbox`: doing so publishes the document's address); * (`openDocumentInbox`: doing so publishes the document's address);
* - a document in a public store is readable by whoever knows its NURI; * - a document nobody created cannot be named: reaching for one REJECTS.
* - a document that cannot be read REJECTS, and a rejection means "unknown", *
* never "absent". * ## Reading is done from OUTSIDE the port, on purpose
*
* This package neither reads a document nor reads an inbox — what an index receives
* is made into entries by the layer below. So `contentsOf` and `depositsIn` are
* inspections of this double, not operations of the port: a test asks what the two
* acts LEFT BEHIND, and cannot accidentally hand the package back a way to read.
*/ */
/** One deposit this double is holding, as its inbox holds it. */
export interface StoredDeposit {
/** The depositor, as the polyfill defaults it to the current user. */
readonly from: string;
readonly payload: unknown;
readonly ts: number;
}
type Properties = Map<string, string[]>; type Properties = Map<string, string[]>;
interface StoredDocument { interface StoredDocument {
@@ -37,13 +41,11 @@ interface StoredDocument {
readonly owner: string; readonly owner: string;
readonly subjects: Map<string, Properties>; readonly subjects: Map<string, Properties>;
/** `undefined` until the owner opens one — the state `postToDocument` refuses. */ /** `undefined` until the owner opens one — the state `postToDocument` refuses. */
deposits: IncomingDeposit[] | undefined; deposits: StoredDeposit[] | undefined;
} }
export class FakeNextGraph { export class FakeNextGraph {
readonly #documents = new Map<string, StoredDocument>(); readonly #documents = new Map<string, StoredDocument>();
/** Documents the broker currently cannot answer about. See `breakReadsOf`. */
readonly #unreachable = new Map<string, string>();
#documentCount = 0; #documentCount = 0;
#clock = 0; #clock = 0;
@@ -55,14 +57,6 @@ export class FakeNextGraph {
return network.#createDocument(user); return network.#createDocument(user);
}, },
async resolveObject(doc: NuriLike): Promise<ObjectResolution> {
return network.#resolve(asNuri(doc));
},
async readDocument(doc: NuriLike): Promise<readonly UnionSubject[]> {
return network.#read(asNuri(doc));
},
async addLiteralProperty( async addLiteralProperty(
doc: NuriLike, doc: NuriLike,
subject: string, subject: string,
@@ -79,46 +73,27 @@ export class FakeNextGraph {
async depositTo(doc: NuriLike, payload: unknown): Promise<void> { async depositTo(doc: NuriLike, payload: unknown): Promise<void> {
network.#deposit(user, asNuri(doc), payload); network.#deposit(user, asNuri(doc), payload);
}, },
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
return network.#readDeposits(user, asNuri(doc));
},
}; };
} }
/** /** Every triple a document holds, read from outside the port. */
* Makes the broker unable to answer about a document — a transient failure, the contentsOf(doc: NuriLike): { subject: string; predicate: string; values: string[] }[] {
* state a curation run must survive without damaging the index. Reads of it
* REJECT, which is what the real surface does when it could not find out.
*/
breakReadsOf(doc: NuriLike, reason: string): void {
this.#unreachable.set(asNuri(doc), reason);
}
/** The broker can answer about this document again. */
healReadsOf(doc: NuriLike): void {
this.#unreachable.delete(asNuri(doc));
}
/** A NURI shaped like any other, that no document was ever created for. */
neverCreatedNuri(): Nuri {
return "did:ng:o:doc-never-created" as Nuri;
}
/**
* The document's owner REPLACING a value in their own document, through their
* own application — NOT through this package's port, which deliberately cannot
* delete anything.
*
* This is a real capability and it has to be modelled: an indexed object is an
* ordinary document whose owner keeps editing it, and an index tested only
* against frozen objects would be tested against a world that does not exist.
*/
ownerReplacesValue(doc: NuriLike, subject: string, predicate: string, value: string): void {
const stored = this.#require(asNuri(doc)); const stored = this.#require(asNuri(doc));
const properties = stored.subjects.get(subject); const out: { subject: string; predicate: string; values: string[] }[] = [];
if (properties === undefined) throw new Error(`${String(doc)} has no subject ${subject}`); for (const [subject, properties] of stored.subjects) {
properties.set(predicate, [value]); for (const [predicate, values] of properties) out.push({ subject, predicate, values });
}
return out;
}
/**
* What is waiting in a document's inbox, oldest first — or `null` when its owner
* never opened one, which is a state and not a failure.
*/
depositsIn(doc: NuriLike): readonly StoredDeposit[] | null {
const stored = this.#require(asNuri(doc));
if (stored.deposits === undefined) return null;
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
} }
#createDocument(owner: string): Nuri { #createDocument(owner: string): Nuri {
@@ -129,53 +104,11 @@ export class FakeNextGraph {
} }
#require(doc: Nuri): StoredDocument { #require(doc: Nuri): StoredDocument {
const broken = this.#unreachable.get(doc);
// "A rejection means 'unknown', never 'absent'."
if (broken !== undefined) throw new Error(`cannot reach ${doc}: ${broken}`);
const stored = this.#documents.get(doc); const stored = this.#documents.get(doc);
if (stored === undefined) throw new Error(`cannot open ${doc}`); if (stored === undefined) throw new Error(`cannot open ${doc}`);
return stored; return stored;
} }
#read(doc: Nuri): UnionSubject[] {
const stored = this.#require(doc);
const out: UnionSubject[] = [];
for (const [subject, properties] of stored.subjects) {
// Built EXACTLY as `readUnion` builds it — a plain object literal filled by
// `(props[p] ??= []).push(o)` (`read-model.ts`). Neither detail is cosmetic:
// the literal inherits from `Object.prototype`, and `??=` does NOT assign
// over an inherited truthy member, so a predicate named `constructor` or
// `toString` leaves `.push` undefined and the read THROWS. Assigning plainly
// here made this double more forgiving than the real thing, and a test
// written against it asserted an outcome production can never produce.
const props: Record<string, string[]> = {};
for (const [predicate, values] of properties) {
for (const value of values) {
(props[predicate] ??= []).push(value);
}
}
out.push({ subject, graph: doc, props });
}
return out;
}
/**
* Two states, like the port: something is there, or nothing usable came back.
* A document that cannot be reached and a document that holds nothing both come
* back `unresolved` — this layer never has to tell them apart, and the reason
* string is the only difference.
*/
#resolve(doc: Nuri): ObjectResolution {
let subjects: UnionSubject[];
try {
subjects = this.#read(doc);
} catch (error) {
return { state: "unresolved", reason: String(error) };
}
if (subjects.length === 0) return { state: "unresolved", reason: `${doc} holds nothing` };
return { state: "present", subjects };
}
#add(user: string, doc: Nuri, subject: string, predicate: string, value: string): void { #add(user: string, doc: Nuri, subject: string, predicate: string, value: string): void {
const stored = this.#require(doc); const stored = this.#require(doc);
if (stored.owner !== user) { if (stored.owner !== user) {
@@ -217,24 +150,11 @@ export class FakeNextGraph {
this.#clock += 1; this.#clock += 1;
stored.deposits.push({ from: user, payload, ts: this.#clock }); stored.deposits.push({ from: user, payload, ts: this.#clock });
} }
#readDeposits(user: string, doc: Nuri): readonly IncomingDeposit[] {
const stored = this.#require(doc);
// No inbox → there is no address to read, which is a state and not an error.
if (stored.deposits === undefined) return [];
if (stored.owner !== user) {
throw new Error(
`${user} may not read the inbox of ${doc}: you may DEPOSIT into anyone's ` +
"inbox, you may only READ your own",
);
}
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
}
} }
/** /**
* A public document holding one business entity with a value for `field` — what * A public document holding one business entity with a value for `field` — what
* an application creates and then refers to an index. * an application creates and then hands to an index.
* *
* Built through the very same port an application has: nothing here reaches * Built through the very same port an application has: nothing here reaches
* behind the surface to plant a state a real caller could not produce. * behind the surface to plant a state a real caller could not produce.
+158 -108
View File
@@ -1,5 +1,5 @@
import { mock } from "bun:test"; import { mock } from "bun:test";
import type { Nuri, UnionSubject } from "../src/port"; import type { Nuri } from "../src/port";
/** /**
* `@ng-eventually/polyfill` itself, in memory — NOT a mock returning constants. * `@ng-eventually/polyfill` itself, in memory — NOT a mock returning constants.
@@ -9,15 +9,12 @@ import type { Nuri, UnionSubject } from "../src/port";
* one stands BELOW the adapter, so the real adapter runs on top of it and every * one stands BELOW the adapter, so the real adapter runs on top of it and every
* behavioural test becomes a test of the wiring too. * behavioural test becomes a test of the wiring too.
* *
* ## Why a real implementation and not a recorder returning `[]` * ## Why a real implementation and not a recorder returning constants
* *
* The previous gate mocked `readUnion` to a constant `[]`. That single constant * A mock that always answers the same thing tests one state of the world, and a
* was an escape hatch: a destructive statement guarded by * statement guarded by what a read returned would never run under it. This one
* `if ((await readUnion([graph])).length > 0)` never ran, so it was never * answers what was actually written, so `contentsOf` and `depositsIn` report what
* recorded, and the gate passed. A mock that always answers the same thing tests * the adapter really left behind rather than what a test arranged.
* one state of the world. This answers what was actually written, so a curation
* run that adds an entry to a document already holding a descriptor DOES take the
* non-empty branch.
* *
* ## Why the SPARQL is EXECUTED and not pattern-matched * ## Why the SPARQL is EXECUTED and not pattern-matched
* *
@@ -30,28 +27,39 @@ import type { Nuri, UnionSubject } from "../src/port";
* (`"DEL" + "ETE …"`) is one string by the time it arrives, so the split buys * (`"DEL" + "ETE …"`) is one string by the time it arrives, so the split buys
* nothing. * nothing.
* *
* ## Every rule below is one the polyfill actually enforces * ## Every rule below is one `contract_polyfill-surface` states
* *
* - only a document's owner writes to it (`docs.sparqlUpdate`'s `assertMayWrite`); * - a store is named by its REFERENCE and by nothing else: `privateStore()`,
* - anyone may DEPOSIT into a document's inbox, but reading one throws for anyone * `protectedStore()` and `publicStore()` hand one out, `createEntityDoc(store?)`
* but its owner (`inbox.read`'s `assertOwnInbox`); * takes one, and every entry "accepts `NuriLike` and validates at the door" -- so
* - depositing into a document whose owner never opened an inbox THROWS * a word where a reference belongs is refused rather than understood;
* (`inbox.postToDocument`), rather than going nowhere; * - omitting the store places the document in the PRIVATE store;
* - a document with no inbox READS as `[]` — a state, not an error * - "you read a document whose key you hold: you created it, it was shared with you,
* (`depositsForDocument`); * or it sits in a public store, which serves its read key to whoever asks" -- so a
* stranger reaches a document in its owner's public store, and no other;
* - only a document's owner writes to it, and holding a read key never grants a write;
* - anyone may DEPOSIT into a document's inbox (`inbox.postToDocument`);
* - depositing into a document whose owner never opened an inbox THROWS, rather
* than going nowhere;
* - opening an inbox is refused to anyone but the document's owner * - opening an inbox is refused to anyone but the document's owner
* (`openDocumentInbox`); * (`openDocumentInbox`: doing so publishes the document's address);
* - `readUnion` swallows a failing document into `[]` (`readDoc`'s * - a document with no inbox has no address to read — a state, not an error.
* `try {…} catch { return [] }`), and may also reject outright; *
* - `readUnion` builds each subject's props as a plain object literal filled by * ## The three withdrawn placement entries are modelled by their ABSENCE
* `(props[p] ??= []).push(o)` — inherited members and all. *
* `listMyEntityDocs`, `resolveScopeGraph` and `resolveWriteGraph` left the engagement.
* Nothing here answers for them, so reaching for one throws by name (see
* {@link namespace}) -- which is the only thing a fake can honestly say about a call
* that no longer exists. The same holds for `useShapeQuery`: this package reads
* nothing, and the end-to-end application is where that read is exercised.
* *
* ## What is NOT modelled, and what happens then * ## What is NOT modelled, and what happens then
* *
* Anything the adapter reaches for on `docs`, `inbox` or `storeRegistry` that is * Anything the adapter reaches for on `docs`, `inbox` or `storeRegistry` that is
* absent here THROWS by name (see {@link namespace}) instead of returning * absent here THROWS by name (see {@link namespace}) instead of returning
* `undefined` — so routing a query through `docs.sparqlQuery` is a red test with a * `undefined` — so routing a query through `docs.sparqlQuery` is a red test with a
* message that says so. A brand-new TOP-LEVEL import is the one case that degrades: * message that says so. This package reads nothing at all, so `readUnion` is not
* modelled either: reaching for it is the same red. A brand-new TOP-LEVEL import is the one case that degrades:
* Bun's `mock.module` materialises the module namespace from own keys, so a Proxy * Bun's `mock.module` materialises the module namespace from own keys, so a Proxy
* there is lost and an unmodelled top-level export arrives as `undefined`. Still * there is lost and an unmodelled top-level export arrives as `undefined`. Still
* red (`undefined is not a function`), just with a duller message. * red (`undefined is not a function`), just with a duller message.
@@ -249,6 +257,8 @@ export interface RecordedCall {
interface StoredDocument { interface StoredDocument {
readonly nuri: Nuri; readonly nuri: Nuri;
readonly owner: string; readonly owner: string;
/** The store it was recorded in -- a REFERENCE, which is the only name a store has. */
readonly store: Nuri;
readonly subjects: Map<string, Map<string, string[]>>; readonly subjects: Map<string, Map<string, string[]>>;
/** `undefined` until the owner opens one — the state `postToDocument` refuses. */ /** `undefined` until the owner opens one — the state `postToDocument` refuses. */
deposits: Deposit[] | undefined; deposits: Deposit[] | undefined;
@@ -261,18 +271,23 @@ export interface FakePolyfill {
signIn(user: string): void; signIn(user: string): void;
/** The session id for the signed-in identity, as `init`'s callback hands it over. */ /** The session id for the signed-in identity, as `init`'s callback hands it over. */
sessionId(): string; sessionId(): string;
/** /** Every triple in a document, read from outside the adapter. */
* The broker can no longer answer about this document. `readDoc` catches and
* yields `[]`, so it arrives indistinguishable from an empty document — which is
* exactly the confusion `resolution.ts` exists to refuse.
*/
breakReadsOf(doc: string, reason: string): void;
healReadsOf(doc: string): void;
/** `readUnion` REJECTS outright — its session-level failure, not a per-document one. */
breakReadUnion(reason: string): void;
healReadUnion(): void;
/** Every subject in a document, read from outside the adapter. */
contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[]; contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[];
/**
* The store a document was recorded in, as the reference the accessors hand out.
* An INSPECTION: placement is not something this package can read back, and the
* point of holding it is that a document placed in the wrong store is visible here
* rather than only in a broker's refusal.
*/
storeOf(doc: string): Nuri;
/** The three stores of an identity, as `privateStore()` and its two siblings answer. */
storesOf(user: string): { private: Nuri; protected: Nuri; public: Nuri };
/**
* What is waiting in a document's inbox, oldest first — or `null` when its owner
* never opened one. An INSPECTION, not an entry of the polyfill: this package
* never reads an inbox, so nothing it does may depend on being able to.
*/
depositsIn(doc: string): readonly Deposit[] | null;
} }
/** /**
@@ -282,9 +297,8 @@ export interface FakePolyfill {
*/ */
export function installFakePolyfill(): FakePolyfill { export function installFakePolyfill(): FakePolyfill {
const documents = new Map<string, StoredDocument>(); const documents = new Map<string, StoredDocument>();
const unreachable = new Map<string, string>(); const stores = new Map<string, { private: Nuri; protected: Nuri; public: Nuri }>();
const calls: RecordedCall[] = []; const calls: RecordedCall[] = [];
let unionFailure: string | undefined;
let currentUser = "nobody"; let currentUser = "nobody";
let documentCount = 0; let documentCount = 0;
let clock = 0; let clock = 0;
@@ -295,28 +309,6 @@ export function installFakePolyfill(): FakePolyfill {
return stored; return stored;
} }
function readOne(doc: string): UnionSubject[] {
const broken = unreachable.get(doc);
if (broken !== undefined) throw new Error(`cannot reach ${doc}: ${broken}`);
const stored = require(doc);
const out: UnionSubject[] = [];
for (const [subject, properties] of stored.subjects) {
// Built EXACTLY as `readUnion` builds it — a plain object literal filled by
// `(props[p] ??= []).push(o)`. Neither detail is cosmetic: the literal
// inherits from `Object.prototype`, and `??=` does NOT assign over an
// inherited truthy member, so a predicate named `constructor` or `toString`
// leaves `.push` undefined and the read THROWS.
const props: Record<string, string[]> = {};
for (const [predicate, values] of properties) {
for (const value of values) {
(props[predicate] ??= []).push(value);
}
}
out.push({ subject, graph: doc as Nuri, props });
}
return out;
}
const docsImpl = { const docsImpl = {
async sparqlUpdate(sessionId: unknown, query: unknown, anchor?: unknown): Promise<unknown> { async sparqlUpdate(sessionId: unknown, query: unknown, anchor?: unknown): Promise<unknown> {
if (typeof query !== "string") throw new Error("[fake-polyfill] the query must be a string"); if (typeof query !== "string") throw new Error("[fake-polyfill] the query must be a string");
@@ -356,7 +348,7 @@ export function installFakePolyfill(): FakePolyfill {
async sparqlQuery(_sessionId: unknown, query: unknown): Promise<never> { async sparqlQuery(_sessionId: unknown, query: unknown): Promise<never> {
// Reached only if this package starts querying, which it does not: it reads // Reached only if this package starts querying, which it does not: it reads
// through `readUnion`. The message splits the two reasons someone lands here, // nothing at all. The message splits the two reasons someone lands here,
// because one of them is an attempt to write through the read door. // because one of them is an attempt to write through the read door.
if (typeof query === "string" && DESTRUCTIVE.test(blankLiterals(query))) { if (typeof query === "string" && DESTRUCTIVE.test(blankLiterals(query))) {
throw new Error( throw new Error(
@@ -366,8 +358,8 @@ export function installFakePolyfill(): FakePolyfill {
); );
} }
throw new Error( throw new Error(
"[fake-polyfill] docs.sparqlQuery is not modelled — this package reads through " + "[fake-polyfill] docs.sparqlQuery is not modelled — this package does not read. " +
"`readUnion`. Model it READ-ONLY here before using it.", "Model it READ-ONLY here before using it.",
); );
}, },
}; };
@@ -375,6 +367,16 @@ export function installFakePolyfill(): FakePolyfill {
const inboxImpl = { const inboxImpl = {
async postToDocument(doc: unknown, options: unknown): Promise<void> { async postToDocument(doc: unknown, options: unknown): Promise<void> {
const stored = require(String(doc)); const stored = require(String(doc));
if (!mayRead(currentUser, stored)) {
// Reaching a document's owner goes THROUGH the document, so a depositor that
// cannot open it has no way in. This is what makes the public store a
// load-bearing choice of `create` rather than a decoration: place an index
// anywhere else and no stranger can hand it anything.
throw new Error(
`[ng-eventually] inbox.postToDocument: you cannot read the document ` +
`${JSON.stringify(doc)}, so you cannot reach its inbox`,
);
}
if (stored.deposits === undefined) { if (stored.deposits === undefined) {
throw new Error( throw new Error(
`[ng-eventually] inbox.postToDocument: this document has no inbox — either its ` + `[ng-eventually] inbox.postToDocument: this document has no inbox — either its ` +
@@ -388,28 +390,91 @@ export function installFakePolyfill(): FakePolyfill {
stored.deposits.push({ from, payload: opts.payload ?? null, ts: clock }); stored.deposits.push({ from, payload: opts.payload ?? null, ts: clock });
}, },
async readForDocument(doc: unknown): Promise<Deposit[]> {
const stored = require(String(doc));
// No inbox → there is no address to read, which is a state and not an error.
if (stored.deposits === undefined) return [];
if (stored.owner !== currentUser) {
throw new Error(
`${currentUser} may not read the inbox of ${String(doc)}: you may DEPOSIT into ` +
"anyone's inbox, you may only READ your own",
);
}
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
},
}; };
/**
* The three stores an identity has, minted on first ask and stable afterwards.
*
* There is no identity parameter on any of the accessors -- a session is one user's
* -- so these are keyed by whoever is signed in, exactly as the engagement has it.
* A store is a REFERENCE: it carries no key, and it is told from every other
* reference only by being one of the three.
*/
function storesOf(user: string): { private: Nuri; protected: Nuri; public: Nuri } {
const known = stores.get(user);
if (known !== undefined) return known;
const minted = {
private: `did:ng:o:store-private-${user}` as Nuri,
protected: `did:ng:o:store-protected-${user}` as Nuri,
public: `did:ng:o:store-public-${user}` as Nuri,
};
stores.set(user, minted);
return minted;
}
/**
* Whether this identity holds a key that opens the document.
*
* Its creator does; so does anyone at all when it sits in a PUBLIC store, which
* serves its read key to whoever asks. Nothing else here shares a key, because
* nothing here calls `inbox.share`.
*/
function mayRead(user: string, stored: StoredDocument): boolean {
return stored.owner === user || stored.store === storesOf(stored.owner).public;
}
const storeRegistryImpl = { const storeRegistryImpl = {
async createEntityDoc(scope: unknown): Promise<Nuri> { async privateStore(): Promise<Nuri> {
if (scope !== "public" && scope !== "mine") { return storesOf(currentUser).private;
throw new Error(`[fake-polyfill] unknown scope ${JSON.stringify(scope)}`); },
async protectedStore(): Promise<Nuri> {
return storesOf(currentUser).protected;
},
async publicStore(): Promise<Nuri> {
return storesOf(currentUser).public;
},
async createEntityDoc(store?: unknown): Promise<Nuri> {
const mine = storesOf(currentUser);
// Omitted means the PRIVATE store, as `doc_create` does when no store is given.
// It is a real placement and not a default to shrug at: a document that lands
// there is one no stranger can open.
let placement: Nuri;
if (store === undefined) {
placement = mine.private;
} else {
// Validated AT THE DOOR, which is what makes a word where a reference belongs
// a refusal rather than a silent placement. This is the exact shape that was
// green here for a whole version while the broker rejected it.
if (typeof store !== "string" || !store.startsWith("did:ng:")) {
throw new Error(
"[fake-polyfill] storeRegistry.createEntityDoc: not a NextGraph reference " +
"-- a store is named by the reference privateStore(), protectedStore() " +
`or publicStore() hands back, got ${JSON.stringify(store)}`,
);
}
const named = [mine.private, mine.protected, mine.public].find((s) => s === store);
if (named === undefined) {
// "A rejection means unknown, never absent": a store this identity does not
// have is a document that could not be recorded, so the call throws.
throw new Error(
`[fake-polyfill] storeRegistry.createEntityDoc: ${store} is not one of this ` +
`identity's stores, so the document could not be recorded in it`,
);
}
placement = named;
} }
documentCount += 1; documentCount += 1;
const nuri = `did:ng:o:doc-${documentCount}` as Nuri; const nuri = `did:ng:o:doc-${documentCount}` as Nuri;
documents.set(nuri, { nuri, owner: currentUser, subjects: new Map(), deposits: undefined }); documents.set(nuri, {
nuri,
owner: currentUser,
store: placement,
subjects: new Map(),
deposits: undefined,
});
return nuri; return nuri;
}, },
@@ -422,26 +487,14 @@ export function installFakePolyfill(): FakePolyfill {
); );
} }
stored.deposits ??= []; stored.deposits ??= [];
// Idempotent within a page: asking again for a document that already has one
// resolves that same address rather than opening a second inbox. The address
// goes no further — this package drops it, which is the point of `openInbox`
// returning nothing.
return `${stored.nuri}:inbox`; return `${stored.nuri}:inbox`;
}, },
}; };
async function readUnionImpl(docsLike: unknown): Promise<UnionSubject[]> {
if (unionFailure !== undefined) throw new Error(unionFailure);
const list = Array.isArray(docsLike) ? docsLike : [];
const out: UnionSubject[] = [];
for (const doc of [...new Set(list.filter(Boolean))]) {
try {
out.push(...readOne(String(doc)));
} catch (error) {
// `readDoc` is `try {…} catch { return [] }`: a failing document is skipped
// and never aborts the batch, so failure and emptiness arrive identical.
console.error("[fake-polyfill] read failed for", doc, String(error));
}
}
return out;
}
function userOfSession(sessionId: unknown): string { function userOfSession(sessionId: unknown): string {
const id = String(sessionId); const id = String(sessionId);
const user = id.startsWith("session:") ? id.slice("session:".length) : undefined; const user = id.startsWith("session:") ? id.slice("session:".length) : undefined;
@@ -456,8 +509,8 @@ export function installFakePolyfill(): FakePolyfill {
/** /**
* A namespace whose every member is recorded, and whose UNKNOWN members throw by * A namespace whose every member is recorded, and whose UNKNOWN members throw by
* name rather than arriving as `undefined`. `Object.hasOwn` and not `impl[name]`, * name rather than arriving as `undefined`. `Object.hasOwn` and not `impl[name]`,
* because a plain object literal inherits `toString` and friends — the same trap * because a plain object literal inherits `toString` and friends, and an
* `valuesOf` guards against in `src/index-document.ts`. * inherited member is not a modelled entry.
*/ */
function namespace(name: string, impl: Record<string, (...args: never[]) => unknown>): unknown { function namespace(name: string, impl: Record<string, (...args: never[]) => unknown>): unknown {
return new Proxy(impl, { return new Proxy(impl, {
@@ -483,31 +536,28 @@ export function installFakePolyfill(): FakePolyfill {
docs: namespace("docs", docsImpl), docs: namespace("docs", docsImpl),
inbox: namespace("inbox", inboxImpl), inbox: namespace("inbox", inboxImpl),
storeRegistry: namespace("storeRegistry", storeRegistryImpl), storeRegistry: namespace("storeRegistry", storeRegistryImpl),
async readUnion(docsLike: unknown) {
record("readUnion", [docsLike]);
return readUnionImpl(docsLike);
},
})); }));
return { return {
calls, calls,
signIn(user: string) { signIn(user: string) {
// This fake has ONE signed-in identity at a time, as a page does: a polyfill
// session IS one identity, and no call takes an identifier.
currentUser = user; currentUser = user;
}, },
sessionId() { sessionId() {
return `session:${currentUser}`; return `session:${currentUser}`;
}, },
breakReadsOf(doc: string, reason: string) { depositsIn(doc: string) {
unreachable.set(doc, reason); const stored = require(doc);
if (stored.deposits === undefined) return null;
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
}, },
healReadsOf(doc: string) { storeOf(doc: string) {
unreachable.delete(doc); return require(doc).store;
}, },
breakReadUnion(reason: string) { storesOf(user: string) {
unionFailure = reason; return storesOf(user);
},
healReadUnion() {
unionFailure = undefined;
}, },
contentsOf(doc: string) { contentsOf(doc: string) {
const stored = require(doc); const stored = require(doc);
+87 -279
View File
@@ -1,10 +1,15 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { indexing, type Indexing } from "../src/indexing"; import { indexingOn, type Indexing } from "../src/indexing";
import type { Nuri } from "../src/port"; import type { Nuri } from "../src/port";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary"; import { INDEX_FIELD } from "../src/vocabulary";
import { FakeNextGraph, publishObject } from "./fake-nextgraph"; import { FakeNextGraph, publishObject } from "./fake-nextgraph";
/** /**
* The two acts, and nothing else — this package creates an index and hands one a
* reference. What becomes of that reference is the business of the layer that
* processes the index's inbox, and there is nothing here that could do it or ask
* for it.
*
* Each actor gets their own handle, and they share no variable carrying business * Each actor gets their own handle, and they share no variable carrying business
* data. The ONE value that crosses between them is the index's NURI — and that * data. The ONE value that crosses between them is the index's NURI — and that
* crossing is the mechanism this design names: an application references the * crossing is the mechanism this design names: an application references the
@@ -24,321 +29,124 @@ function world(): {
network: FakeNextGraph; network: FakeNextGraph;
alice: Indexing; alice: Indexing;
bob: Indexing; bob: Indexing;
carol: Indexing; ports: { alice: Port; bob: Port };
ports: { alice: Port; bob: Port; carol: Port };
} { } {
const network = new FakeNextGraph(); const network = new FakeNextGraph();
const ports = { const ports = { alice: network.portFor("alice"), bob: network.portFor("bob") };
alice: network.portFor("alice"), // A handle is nothing but a port bound to one identity: building one reaches
bob: network.portFor("bob"), // nothing, which is why there is nothing to await here.
carol: network.portFor("carol"), return { network, alice: indexingOn(ports.alice), bob: indexingOn(ports.bob), ports };
};
return {
network,
alice: indexing(ports.alice),
bob: indexing(ports.bob),
carol: indexing(ports.carol),
ports,
};
} }
// --- creating an index ---------------------------------------------------- // --- creating an index ----------------------------------------------------
test("any user creates an index in their public store, and it declares its field", async () => { test("any user creates an index in their public store, and it declares its field", async () => {
const { alice, ports } = world(); const { alice, network } = world();
const index = await alice.createIndex(PUBLISHED_AT); const index = await alice.create(PUBLISHED_AT);
// An ordinary document: what makes it an index is the field it declares, which // An ordinary document: what makes it an index is the field it declares, on the
// a reader going straight to `readUnion` sees on the index's own subject. // index's own subject, which is where a reader of the document finds it.
const subjects = await ports.alice.readDocument(index); expect(network.contentsOf(index)).toEqual([
const self = subjects.find((s) => s.subject === index); { subject: index, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
expect(self?.props[INDEX_FIELD]).toEqual([PUBLISHED_AT]); ]);
expect(await alice.read(index)).toEqual([]);
}); });
test("reading a document that declares no index field is refused, not answered empty", async () => { test("a new index is ready to receive: its inbox is open the moment create returns", async () => {
const { alice, ports } = world(); const { alice, bob, network } = world();
const ordinary = await ports.alice.createPublicDocument(); const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
await expect(alice.read(ordinary)).rejects.toThrow(/declares no index field/);
// Nothing to open, register or remember — a stranger deposits straight away.
await bob.add(index, "did:ng:o:doc-9");
expect(network.depositsIn(index)).toHaveLength(1);
}); });
// --- the whole loop, across three people ---------------------------------- test("a field that could never match an object is refused at creation", async () => {
const { alice } = world();
// It cannot be corrected later — nothing here deletes — so it is refused now.
await expect(alice.create("")).rejects.toThrow(/cannot be changed later/);
await expect(alice.create(" ")).rejects.toThrow(/cannot be changed later/);
});
test("a stranger refers an object, the owner curates, and anyone reads the result", async () => { test("two indexes are two documents, each declaring its own field", async () => {
const { alice, bob, carol, ports } = world(); const { alice, network } = world();
const byDate = await alice.create(PUBLISHED_AT);
const byName = await alice.create(NAME);
expect(byDate).not.toBe(byName);
expect(network.contentsOf(byDate)).toEqual([
{ subject: byDate, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
]);
expect(network.contentsOf(byName)).toEqual([
{ subject: byName, predicate: INDEX_FIELD, values: [NAME] },
]);
});
// --- handing an index a reference -----------------------------------------
test("a stranger hands an index a reference, and it waits in the index's inbox", async () => {
const { alice, bob, ports, network } = world();
// Alice creates the index and its NURI goes into the application's source. // Alice creates the index and its NURI goes into the application's source.
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT)); const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
// Bob, who owns nothing of Alice's, creates his own public object and hands the // Bob, who owns nothing of Alice's, creates his own public object and hands the
// index a reference to it. He needs no permission and gets no write. // index a reference to it. He needs no permission and gets no write.
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04"); const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article); await bob.add(index, article);
// Nothing is in the index until its owner acts. // THE WHOLE PAYLOAD is the reference: no wrapper, no claim, no copy of the value,
expect(await carol.read(indexNuri)).toEqual([]); // and no index either — the inbox it landed in is what identifies one. This is the
// one thing this package hands the layer that will make an entry of it.
expect(network.depositsIn(index)).toEqual([
{ from: "bob", payload: article, ts: expect.any(Number) },
]);
const report = await alice.curate(indexNuri); // And it stayed a deposit: nothing wrote it into the index document.
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-03-04" }]); expect(network.contentsOf(index)).toEqual([
{ subject: index, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
// Carol knows only the NURI from the application's source, and gets the entry. ]);
const entries = await carol.read(indexNuri);
expect(entries).toEqual([{ object: article, value: "2026-03-04" }]);
// The entry is a usable reference: Carol opens the object straight from it,
// holding nothing but what she read out of the index.
const first = entries[0];
expect(first).toBeDefined();
const opened = await ports.carol.readDocument(first!.object);
expect(opened[0]?.props[PUBLISHED_AT]).toEqual(["2026-03-04"]);
}); });
test("an entry is a subject keyed by the object's NURI, so reading needs nothing new", async () => { test("the same reference handed over twice is two deposits, and nothing is lost", async () => {
const { alice, bob, ports } = world(); const { alice, bob, ports, network } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT)); const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04"); const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await alice.curate(indexNuri);
// What `readUnion([indexNuri])` hands an application that never loaded this await bob.add(index, article);
// package: the index's own subject, plus one subject per indexed object. await bob.add(index, article);
const subjects = await ports.bob.readDocument(indexNuri);
const entry = subjects.find((s) => s.subject === article);
expect(entry?.props[ENTRY_VALUE]).toEqual(["2026-03-04"]);
expect(subjects.map((s) => s.subject).sort()).toEqual([article, indexNuri].sort());
});
// --- only the owner curates ---------------------------------------------- // Nothing here de-duplicates: a deposit is an invitation to look, so repeating one
// is legitimate and cheap, and whoever processes the inbox is the one that settles.
test("nobody but the index's owner can curate it: the inbox is refused to others", async () => { expect(network.depositsIn(index)?.map((d) => d.payload)).toEqual([article, article]);
const { alice, bob, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await expect(bob.curate(indexNuri)).rejects.toThrow(/may only READ your own/);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("nobody but the owner writes an index, whatever they know about it", async () => {
const { alice, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
await expect(
ports.bob.addLiteralProperty(indexNuri, "did:ng:o:forged", ENTRY_VALUE, "2999-01-01"),
).rejects.toThrow(/only a document's owner writes to it/);
await expect(ports.bob.openInbox(indexNuri)).rejects.toThrow(/may not open an inbox/);
}); });
test("an index whose owner never opened an inbox refuses a deposit rather than losing it", async () => { test("an index whose owner never opened an inbox refuses a deposit rather than losing it", async () => {
const { bob, ports } = world(); const { bob, ports } = world();
// A public document that was never made into an index: no inbox was opened. // A public document that was never made into an index: no inbox was opened.
const notAnIndex = hardcodedInAppSource(await ports.alice.createPublicDocument()); const notAnIndex = hardcodedInAppSource(await ports.alice.createPublicDocument());
await expect(bob.refer(notAnIndex, "did:ng:o:doc-9")).rejects.toThrow(/has no inbox/); await expect(bob.add(notAnIndex, "did:ng:o:doc-9")).rejects.toThrow(/has no inbox/);
}); });
// --- adding is idempotent ------------------------------------------------- test("a payload that is not a reference is refused here, not deposited for someone else to find", async () => {
test("the same reference deposited twice produces one entry", async () => {
const { alice, bob, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await bob.refer(indexNuri, article);
const report = await alice.curate(indexNuri);
expect(report.outcomes).toEqual([
{ result: "indexed", object: article, value: "2026-03-04" },
{ result: "unchanged", object: article },
]);
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-03-04" }]);
});
test("curating twice changes nothing the second time — deposits are not consumed", async () => {
const { alice, bob, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await alice.curate(indexNuri);
const before = await alice.read(indexNuri);
const second = await alice.curate(indexNuri);
expect(second.outcomes).toEqual([{ result: "unchanged", object: article }]);
expect(await alice.read(indexNuri)).toEqual(before);
});
// --- a read that cannot answer must never cost the index anything ---------
test("a reference the broker cannot resolve is reported, and adds nothing", async () => {
const { network, alice, bob, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const first = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
await bob.refer(indexNuri, first);
await alice.curate(indexNuri);
const second = await publishObject(ports.bob, PUBLISHED_AT, "2026-02-02");
await bob.refer(indexNuri, second);
network.breakReadsOf(second, "broker unreachable");
const report = await alice.curate(indexNuri);
const unresolved = report.outcomes.filter((o) => o.result === "unresolved");
expect(unresolved).toHaveLength(1);
expect(unresolved[0]).toMatchObject({ object: second });
// THE POINT: the entry that was already there is untouched.
expect(await alice.read(indexNuri)).toEqual([{ object: first, value: "2026-01-01" }]);
});
test("an already-indexed object survives its own reads failing, and is not even re-read", async () => {
const { network, alice, bob, carol, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
await bob.refer(indexNuri, article);
await alice.curate(indexNuri);
// A passer-by nudges the index about an entry she found IN IT. Carol obtains
// the reference the only way she could in a real application — by reading the
// index whose NURI her app hardcodes — rather than being handed it by the test.
const seen = await carol.read(indexNuri);
const noticed = seen[0];
expect(noticed).toBeDefined();
// …and only then does the object become unreachable.
network.breakReadsOf(article, "broker unreachable");
await carol.refer(indexNuri, noticed!.object);
const report = await alice.curate(indexNuri);
expect(report.outcomes.every((o) => o.result === "unchanged")).toBe(true);
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-01-01" }]);
});
test("a failed resolve is self-correcting: the next curation adds what it could not", async () => {
const { network, alice, bob, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-05-06");
await bob.refer(indexNuri, article);
network.breakReadsOf(article, "broker unreachable");
expect((await alice.curate(indexNuri)).outcomes[0]?.result).toBe("unresolved");
expect(await alice.read(indexNuri)).toEqual([]);
// The deposit is still there, so nothing has to be re-deposited.
network.healReadsOf(article);
expect((await alice.curate(indexNuri)).outcomes[0]).toEqual({
result: "indexed",
object: article,
value: "2026-05-06",
});
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-05-06" }]);
});
test("a reference to something that was never created is reported, not silently dropped", async () => {
const { network, alice, bob } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
await bob.refer(indexNuri, network.neverCreatedNuri());
const report = await alice.curate(indexNuri);
expect(report.outcomes).toHaveLength(1);
expect(report.outcomes[0]?.result).toBe("unresolved");
expect(await alice.read(indexNuri)).toEqual([]);
});
// --- an object that does not fit the index --------------------------------
test("an object carrying nothing for the index's field is not added", async () => {
const { alice, bob, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
// Exists, is public, is readable — but says nothing about the field this index
// is built on. OPEN QUESTION: this is the narrow behaviour, not a settled policy.
const object = await publishObject(ports.bob, NAME, "an object with no date");
await bob.refer(indexNuri, object);
const report = await alice.curate(indexNuri);
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "no-field" }]);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("an object carrying several values for the field is not added", async () => {
const { alice, bob, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
await ports.bob.addLiteralProperty(object, object, PUBLISHED_AT, "2026-09-09");
await bob.refer(indexNuri, object);
const report = await alice.curate(indexNuri);
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "several-values" }]);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("a payload that is not a reference is reported as foreign and changes nothing", async () => {
const { alice, bob, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
// Anyone may deposit anything into an inbox, so untrusted payloads do arrive.
await ports.bob.depositTo(indexNuri, { drop: "everything" });
const report = await alice.curate(indexNuri);
expect(report.outcomes).toEqual([
{ result: "indexed", object: article, value: "2026-03-04" },
{ result: "foreign", reason: "payload is not a reference" },
]);
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-03-04" }]);
});
test("an index referred to itself is skipped, so its declaration cannot become an entry", async () => {
const { alice, bob } = world(); const { alice, bob } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT)); const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
await bob.refer(indexNuri, indexNuri); // Both sides are checked, because both go on to name a document. An index's inbox
// takes anything anyone posts to it; what THIS package puts there is a NURI.
const report = await alice.curate(indexNuri); await expect(bob.add(index, "please index my article")).rejects.toThrow(/not a NURI/);
expect(report.outcomes).toEqual([ await expect(bob.add("http://example.org/index", "did:ng:o:doc-9")).rejects.toThrow(
{ result: "skipped", object: indexNuri, reason: "self-reference" }, /not a NURI/,
]); );
expect(await alice.read(indexNuri)).toEqual([]);
}); });
// --- indexing by a date is an instance of indexing by a field ------------- // --- only the owner owns it -----------------------------------------------
test("an index whose field is a date reads back in chronological order", async () => { test("nobody but the owner writes an index, whatever they know about it", async () => {
const { alice, bob, carol, ports } = world(); const { alice, ports } = world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT)); const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
const march = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04"); await expect(
const january = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-31"); ports.bob.addLiteralProperty(index, "did:ng:o:forged", INDEX_FIELD, "http://schema.org/aaa"),
const december = await publishObject(ports.bob, PUBLISHED_AT, "2025-12-25"); ).rejects.toThrow(/only a document's owner writes to it/);
await expect(ports.bob.openInbox(index)).rejects.toThrow(/may not open an inbox/);
// Referred out of order, on purpose.
await bob.refer(indexNuri, march);
await bob.refer(indexNuri, december);
await bob.refer(indexNuri, january);
await alice.curate(indexNuri);
expect((await carol.read(indexNuri)).map((e) => e.value)).toEqual([
"2025-12-25",
"2026-01-31",
"2026-03-04",
]);
});
test("two indexes over the same objects, on different fields, do not interfere", async () => {
const { alice, bob, ports } = world();
const byDate = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const byName = hardcodedInAppSource(await alice.createIndex(NAME));
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await ports.bob.addLiteralProperty(object, object, NAME, "Anemone");
await bob.refer(byDate, object);
await bob.refer(byName, object);
await alice.curate(byDate);
await alice.curate(byName);
expect(await alice.read(byDate)).toEqual([{ object, value: "2026-03-04" }]);
expect(await alice.read(byName)).toEqual([{ object, value: "Anemone" }]);
}); });
-271
View File
@@ -1,271 +0,0 @@
import { expect, mock, test } from "bun:test";
import { indexing } from "../src/indexing";
import { entriesOf, entryValue } from "../src/index-document";
import { resolutionFromFailure, resolutionFromRead } from "../src/resolution";
import type { Nuri, UnionSubject } from "../src/port";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
/**
* The invariant this package is built around — an index only ever grows — and the
* hole that was in it.
*
* `entriesOf` used to require EXACTLY ONE value per entry, so a subject carrying
* two read as absent. An index could therefore SHRINK through nothing but
* additions: no delete involved, the guarantee defeated by the one operation
* meant to uphold it. These tests pin the fix at both levels.
*/
const FIELD = "http://schema.org/datePublished";
function subject(iri: string, values: string[]): UnionSubject {
return { subject: iri, graph: "did:ng:o:index" as Nuri, props: { [ENTRY_VALUE]: values } };
}
test("an entry with several values still reads as one entry, deterministically", () => {
const s = subject("did:ng:o:a", ["2026-02-02", "2026-01-01"]);
expect(entryValue(s)).toBe("2026-01-01");
// Order of arrival must not change the answer: two readers must agree.
expect(entryValue(subject("did:ng:o:a", ["2026-01-01", "2026-02-02"]))).toBe("2026-01-01");
});
test("a subject with no value at all is not an entry", () => {
expect(entryValue({ subject: "did:ng:o:a", graph: "did:ng:o:i" as Nuri, props: {} })).toBeUndefined();
expect(entryValue(subject("did:ng:o:a", []))).toBeUndefined();
});
test("entriesOf keeps a multi-valued entry instead of dropping it", () => {
const index = "did:ng:o:index" as Nuri;
const entries = entriesOf([subject("did:ng:o:a", ["2026-02-02", "2026-01-01"])], index);
expect(entries).toEqual([{ object: "did:ng:o:a" as Nuri, value: "2026-01-01" }]);
});
test("one stray non-NURI subject cannot make every real entry unreadable", () => {
const index = "did:ng:o:index" as Nuri;
// An index document is an ordinary document; its owner may put anything in it.
// This used to THROW out of `entriesOf`, losing the whole index to one triple.
const entries = entriesOf(
[
subject("http://example.org/not-a-nuri", ["2026-02-02"]),
subject("did:ng:o:real", ["2026-01-01"]),
],
index,
);
expect(entries).toEqual([{ object: "did:ng:o:real" as Nuri, value: "2026-01-01" }]);
});
test("an entry whose value is the empty string is still an entry", () => {
const index = "did:ng:o:index" as Nuri;
expect(entriesOf([subject("did:ng:o:a", [""])], index)).toEqual([
{ object: "did:ng:o:a" as Nuri, value: "" },
]);
});
test("adding a second value to an entry cannot make it disappear", async () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const owner = indexing(ownerPort);
const index = await owner.createIndex(FIELD);
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
await indexing(network.portFor("bob")).refer(index, article);
await owner.curate(index);
// A pure ADD — the only write this package has. Before the fix this emptied
// `read()` while both triples sat in the document.
await ownerPort.addLiteralProperty(index, article, ENTRY_VALUE, "2026-02-02");
expect(await owner.read(index)).toEqual([{ object: article, value: "2026-01-01" }]);
});
test("a raced double-add settles, and does not make every later run re-add", async () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const owner = indexing(ownerPort);
const index = await owner.createIndex(FIELD);
const bobPort = network.portFor("bob");
const article = await publishObject(bobPort, FIELD, "2026-01-01");
await indexing(bobPort).refer(index, article);
await owner.curate(index);
// What two curation runs racing each other leave behind: the object's owner
// edited it between their reads, so each added its own value.
network.ownerReplacesValue(article, article, FIELD, "2026-02-02");
await ownerPort.addLiteralProperty(index, article, ENTRY_VALUE, "2026-02-02");
// The entry is still there, and the curator recognises it as already indexed —
// before the fix it was invisible, so every run added yet another value.
const report = await owner.curate(index);
expect(report.outcomes).toEqual([{ result: "unchanged", object: article }]);
expect(await owner.read(index)).toEqual([{ object: article, value: "2026-01-01" }]);
});
// --- the descriptor follows the SAME rule, for the same reason ------------
test("a second declared field stops curation LOUDLY and costs no entry", async () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const owner = indexing(ownerPort);
const bobPort = network.portFor("bob");
const index = await owner.createIndex(FIELD);
for (const date of ["2026-01-01", "2026-02-02", "2026-03-03"]) {
await indexing(bobPort).refer(index, await publishObject(bobPort, FIELD, date));
}
await owner.curate(index);
expect(await owner.read(index)).toHaveLength(3);
// One add-only write through the published surface — and the SMALLER string, the
// direction in which "smallest wins" would have switched the index onto it.
await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, "http://schema.org/aaa");
// Reading is untouched: an entry already written is a fact, and does not become
// unreadable because the declaration above it turned ambiguous.
expect(await owner.read(index)).toHaveLength(3);
// Curating refuses, and says why instead of quietly picking one.
await expect(owner.curate(index)).rejects.toThrow(/declares 2 index fields/);
});
test("a mixed-field index is never produced: curation refuses before adding anything", async () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const owner = indexing(ownerPort);
const bobPort = network.portFor("bob");
const NAME = "http://schema.org/name";
const index = await owner.createIndex(NAME);
const first = await publishObject(bobPort, NAME, "Anemone");
await indexing(bobPort).refer(index, first);
await owner.curate(index);
// "…/datePublished" < "…/name", so under "smallest wins" the new field took over
// while `first` kept its old value forever — one list ordered by two properties.
await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, FIELD);
const second = await publishObject(bobPort, FIELD, "2026-02-02");
await indexing(bobPort).refer(index, second);
await expect(owner.curate(index)).rejects.toThrow(/refusing to curate rather than pick one/);
expect(await owner.read(index)).toEqual([{ object: first, value: "Anemone" }]);
});
test("an index declaring no field at all is still refused", async () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const ordinary = await ownerPort.createPublicDocument();
await expect(indexing(ownerPort).read(ordinary)).rejects.toThrow(/declares no index field/);
});
test("a field that could never match an object is refused at creation", async () => {
const owner = indexing(new FakeNextGraph().portFor("alice"));
// It cannot be corrected later — nothing here deletes — so it is refused now.
await expect(owner.createIndex("")).rejects.toThrow(/cannot be changed later/);
await expect(owner.createIndex(" ")).rejects.toThrow(/cannot be changed later/);
});
// --- a field named like an Object.prototype member ------------------------
test("a field colliding with Object.prototype neither crashes nor is silently mis-read", async () => {
const network = new FakeNextGraph();
const owner = indexing(network.portFor("alice"));
const bobPort = network.portFor("bob");
for (const field of ["constructor", "toString", "valueOf", "hasOwnProperty"]) {
const index = await owner.createIndex(field);
// An object that CARRIES such a predicate cannot be read at all: `readUnion`
// fills `props` with `(props[p] ??= []).push(o)`, and `??=` does not assign
// over the inherited member, so `.push` is undefined and the read throws.
// Upstream's behaviour, mirrored by the double — so this comes back as a
// failure to resolve, NOT as an entry.
const carries = await publishObject(bobPort, field, "a value");
// An object that merely LACKS it must still resolve cleanly: reading the field
// off a plain object literal would otherwise hand back an inherited function.
const lacks = await publishObject(bobPort, "http://schema.org/name", "unrelated");
await indexing(bobPort).refer(index, lacks);
await indexing(bobPort).refer(index, carries);
const report = await owner.curate(index);
expect(report.outcomes[0]).toEqual({ result: "skipped", object: lacks, reason: "no-field" });
expect(report.outcomes[1]?.result).toBe("unresolved");
expect(await owner.read(index)).toEqual([]);
}
});
// --- the resolution rule, which used to be unreachable in the adapter -----
test("an empty read resolves as unresolved — never as an object with no field", () => {
const resolution = resolutionFromRead([]);
expect(resolution.state).toBe("unresolved");
// The distinction that matters: had this said `present`, the curator would have
// filed a FAILED read as `skipped: "no-field"` — a fact about the object.
expect(resolution.state === "unresolved" && resolution.reason).toContain("absent, unreadable");
});
test("a non-empty read resolves as present, carrying the subjects through", () => {
const subjects = [subject("did:ng:o:a", ["v"])];
expect(resolutionFromRead(subjects)).toEqual({ state: "present", subjects });
});
test("a read that threw resolves as unresolved, naming the error", () => {
const resolution = resolutionFromFailure(new Error("broker unreachable"));
expect(resolution).toEqual({ state: "unresolved", reason: "Error: broker unreachable" });
});
// --- a failure must SURFACE, not just be returned -------------------------
test("an unresolved reference is warned about, not only reported", async () => {
const network = new FakeNextGraph();
const owner = indexing(network.portFor("alice"));
const index = await owner.createIndex(FIELD);
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
await indexing(network.portFor("bob")).refer(index, article);
network.breakReadsOf(article, "broker unreachable");
const warn = mock((..._args: unknown[]) => {});
const original = console.warn;
console.warn = warn;
try {
await owner.curate(index);
} finally {
console.warn = original;
}
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]?.[0])).toContain("broker unreachable");
});
test("a normal run warns about nothing", async () => {
const network = new FakeNextGraph();
const owner = indexing(network.portFor("alice"));
const index = await owner.createIndex(FIELD);
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
await indexing(network.portFor("bob")).refer(index, article);
const warn = mock((..._args: unknown[]) => {});
const original = console.warn;
console.warn = warn;
let report;
try {
report = await owner.curate(index);
} finally {
console.warn = original;
}
// Assert the run actually DID something — otherwise this passes for a curation
// that indexed nothing at all, which would warn about nothing either.
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-01-01" }]);
expect(warn).not.toHaveBeenCalled();
});
// --- an unreadable index must not be diagnosed as a malformed one ---------
test("an index that could not be read is refused, and says so without blaming the document", async () => {
const network = new FakeNextGraph();
const owner = indexing(network.portFor("alice"));
const index = await owner.createIndex(FIELD);
network.breakReadsOf(index, "broker unreachable");
// The real `readUnion` turns a failed read into `[]`, so the failure arrives
// looking like a blank document. Whatever the shape, nothing may be written.
await expect(owner.curate(index)).rejects.toThrow();
await expect(owner.read(index)).rejects.toThrow();
network.healReadsOf(index);
expect(await owner.read(index)).toEqual([]);
});
+50 -36
View File
@@ -1,56 +1,70 @@
import { expect, test } from "bun:test"; import { expect, mock, test } from "bun:test";
// Deliberately the PACKAGE ENTRY POINT, not the modules behind it: this is the // Deliberately the PACKAGE ENTRY POINT, not the modules behind it: this is the
// surface an application gets, and it must be usable on its own. It pulls in // surface an application gets, and it must be usable on its own. It pulls in
// `polyfill-adapter.ts`, so this also proves the real `@ng-eventually/polyfill` // `polyfill-adapter.ts`, so this also proves the real `@ng-eventually/polyfill`
// still loads and still exports everything this package compiles against. // still loads and still exports everything this package compiles against.
import { import * as published from "../src/index";
indexing, import { ENTRY_VALUE, INDEX_FIELD, indexing } from "../src/index";
decodeReference, import { indexingOn } from "../src/indexing";
polyfillPort,
ENTRY_VALUE,
INDEX_FIELD,
type CurationReport,
type IndexEntry,
type NextGraphPort,
} from "../src/index";
import { FakeNextGraph, publishObject } from "./fake-nextgraph"; import { FakeNextGraph, publishObject } from "./fake-nextgraph";
const PUBLISHED_AT = "http://schema.org/datePublished"; const PUBLISHED_AT = "http://schema.org/datePublished";
test("the published surface carries the whole loop, end to end", async () => { test("the published surface carries both acts, end to end", async () => {
const network = new FakeNextGraph(); const network = new FakeNextGraph();
const ownerPort: NextGraphPort = network.portFor("alice"); const ownerPort = network.portFor("alice");
const strangerPort: NextGraphPort = network.portFor("bob"); const strangerPort = network.portFor("bob");
const owner = indexing(ownerPort); // `indexingOn` is what `indexing(sessionId)` calls once it has built the port for
const stranger = indexing(strangerPort); // you: the two acts below are the published ones, reached here over an in-memory
// NextGraph. `adapter.test.ts` drives these same acts through the real adapter.
const owner = indexingOn(ownerPort);
const stranger = indexingOn(strangerPort);
const index = await owner.createIndex(PUBLISHED_AT); const index = await owner.create(PUBLISHED_AT);
const article = await publishObject(strangerPort, PUBLISHED_AT, "2026-07-08"); const article = await publishObject(strangerPort, PUBLISHED_AT, "2026-07-08");
await stranger.refer(index, article); await stranger.add(index, article);
const report: CurationReport = await owner.curate(index); // What the two acts leave behind, and the whole of it: a document declaring its
expect(report.index).toBe(index); // field, and a bare reference waiting in its inbox. Making an entry of that
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]); // reference is the business of the layer below, and there is nothing on this
// surface that does it or asks for it.
const entries: IndexEntry[] = await owner.read(index); expect(network.contentsOf(index)).toEqual([
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]); { subject: index, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
]);
expect(network.depositsIn(index)?.map((deposit) => deposit.payload)).toEqual([article]);
}); });
test("the published surface exposes the deposit decoder and the two IRIs it writes", () => { test("the published surface offers TWO acts, and neither reads nor processes anything", () => {
expect(decodeReference("did:ng:o:doc-1")).toBe("did:ng:o:doc-1"); const handle = indexingOn(new FakeNextGraph().portFor("alice"));
expect(decodeReference({ object: "did:ng:o:doc-1" })).toBeNull(); // Read off the handle rather than from a list: an application gets these two acts
// and nothing else. Reading an index is not one of them, and neither is anything
// that would make its deposits into entries.
expect(Object.keys(handle).sort()).toEqual(["add", "create"]);
});
test("the package publishes ONE function and the two IRIs, and nothing else", () => {
// Read off the module rather than from a list of names to remember: publishing a
// helper again — a port builder, a deposit decoder, a read — fails here first.
expect(Object.keys(published).sort()).toEqual(["ENTRY_VALUE", "INDEX_FIELD", "indexing"]);
expect(INDEX_FIELD).toBe("urn:ng-helpers:index:field"); expect(INDEX_FIELD).toBe("urn:ng-helpers:index:field");
expect(ENTRY_VALUE).toBe("urn:ng-helpers:index:value"); expect(ENTRY_VALUE).toBe("urn:ng-helpers:index:value");
}); });
test("polyfillPort is published and builds a port without a live session", () => { test("indexing takes a session id and reaches nothing at all", () => {
// Constructing it must not touch the broker — an application wires it at // The REAL polyfill, unconfigured — which would fail every call that needs a
// startup, and only the calls on it talk to anything. // broker. Building the handle does not make one, so this passes without any
const port: NextGraphPort = polyfillPort({ sessionId: "session-under-test" }); // arrangement, and there is nothing on the log stream to report. It also proves
expect(typeof port.resolveObject).toBe("function"); // the entry point builds its own port: an application hands over the session id
expect(typeof port.addLiteralProperty).toBe("function"); // `init(…)` gave it, and never sees a port at all.
// The port has NO operation that could take an entry out of an index. const reported = mock((..._args: unknown[]) => {});
const removing = Object.keys(port).filter((name) => /delete|remove|clear|drop/i.test(name)); const original = console.error;
expect(removing).toEqual([]); console.error = reported;
try {
const handle = indexing("session-under-test");
expect(Object.keys(handle).sort()).toEqual(["add", "create"]);
} finally {
console.error = original;
}
expect(reported).not.toHaveBeenCalled();
}); });
+1 -14
View File
@@ -1,7 +1,6 @@
import { expect, test } from "bun:test"; import { expect, test } from "bun:test";
import { buildInsertTriple, escapeIri, escapeLiteral, isIriSafe } from "../src/sparql"; import { buildInsertTriple, escapeIri, escapeLiteral, isIriSafe } from "../src/sparql";
import { asNuri, isNuri } from "../src/nuri"; import { asNuri, isNuri } from "../src/nuri";
import { decodeReference } from "../src/deposit";
// --- what a reference is, and what it is not ------------------------------ // --- what a reference is, and what it is not ------------------------------
@@ -24,18 +23,6 @@ test("asNuri throws on a non-reference rather than passing it on", () => {
expect(() => asNuri("nope")).toThrow(/not a NURI/); expect(() => asNuri("nope")).toThrow(/not a NURI/);
}); });
test("a deposit is a bare reference — anything else decodes to null", () => {
expect(decodeReference("did:ng:o:doc-7")).toBe("did:ng:o:doc-7");
// The shapes a well-meaning caller might invent, all refused: the payload IS
// the reference, it is not wrapped and it carries nothing else.
expect(decodeReference({ object: "did:ng:o:doc-7" })).toBeNull();
expect(decodeReference({ assert: "published", object: "did:ng:o:doc-7" })).toBeNull();
expect(decodeReference(["did:ng:o:doc-7"])).toBeNull();
expect(decodeReference("please index did:ng:o:doc-7")).toBeNull();
expect(decodeReference(null)).toBeNull();
expect(decodeReference(7)).toBeNull();
});
// --- escaping ------------------------------------------------------------- // --- escaping -------------------------------------------------------------
test("escapeLiteral leaves no raw quote that could close a SPARQL literal", () => { test("escapeLiteral leaves no raw quote that could close a SPARQL literal", () => {
@@ -147,7 +134,7 @@ test("a tripwire: no destructive SPARQL keyword is written anywhere under src/",
// a keyword hidden behind the comment-stripper, `DELETE{` with no space, and a // a keyword hidden behind the comment-stripper, `DELETE{` with no space, and a
// literal split across concatenated lines. // literal split across concatenated lines.
// //
// What actually guards the invariant is `test/adapter-write-path.test.ts`, // What actually guards the invariant is `test/adapter.test.ts`,
// which RUNS the adapter and reads back every query it emits; all four evasions // which RUNS the adapter and reads back every query it emits; all four evasions
// fail there. This stays as a cheap tripwire that catches the obvious // fail there. This stays as a cheap tripwire that catches the obvious
// regression early and names the file — it is not the proof. // regression early and names the file — it is not the proof.