Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebaae15baf | |||
| e2ed970cbd |
@@ -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: The API @ng-helpers/indexing exposes to an application — creating an index, depositing references into it, reading it back; what a reference becomes is not a call: it becomes an entry once the index's creator is connected
|
||||||
---
|
---
|
||||||
|
|
||||||
# contract_indexing-layer — `@ng-helpers/indexing`
|
# contract_indexing-layer — `@ng-helpers/indexing`
|
||||||
@@ -9,71 +9,58 @@ 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 creating an index, depositing a reference to an object into one (open to anyone), and reading the entries back in order. What becomes of them is covered too, but never as a call — see `## Guarantees`.
|
||||||
|
|
||||||
It does not cover NextGraph itself — documents, identity, sharing, 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 NextGraph itself — documents, identity, sharing, transport — all of which reach it through a port you supply. It does not cover search, filtering, pagination, or querying by anything but the index's field. It **never removes anything**, anywhere — an engagement, not a missing feature.
|
||||||
|
|
||||||
### Deployment requirements
|
### 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 build the port from it — `polyfillPort({ sessionId })`, where `sessionId` is what `@ng-eventually/polyfill`'s own `init(…)` hands its callback;
|
||||||
- reach a broker, since every operation here is a document read, a document write, or an inbox deposit;
|
- **await `indexing(port)` once at startup and keep what it produces.** That handle is one identity's and is also that identity's connection: awaiting it is what makes entries appear, dropping it is what stops them;
|
||||||
- **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: every operation here reaches NextGraph, and nothing 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.
|
- **supply `@ng-eventually/polyfill` itself.** This package declares it a *peer*: the application names it among its own dependencies, and that copy must be the one its own code calls — that package requires exactly one instance of itself, for reasons its own contract states;
|
||||||
|
- **connect as an index's creator if that index is ever to fill.** Unconditional, whoever holds its reference: entries are made while the creator is connected and by nothing else, so an index whose creator never returns stays as it was, however many references it is handed.
|
||||||
|
|
||||||
One handle is one identity: the port carries a session and no call takes an identifier. Two users mean two handles.
|
One handle is one identity: the port carries a session, no call takes an identifier, and two users mean two handles.
|
||||||
|
|
||||||
**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. Nothing marks a document as an index, so that reference is the only way anyone reaches it, and losing it loses the index. **Hardcoding it in the source is what a single GLOBAL index needs, and only that case**: one index serving the whole application has nothing else to be discovered by. Anything narrower is discovered, not hardcoded.
|
||||||
|
|
||||||
|
**Obtaining it.** Not published to npm or any other host, and not built output: the entry point is TypeScript source, so whatever builds the application compiles it, and `@ng-eventually/polyfill` arrives the same way. What this contract fixes is the version you pin and what you must provide alongside it.
|
||||||
|
|
||||||
## Surface
|
## Surface
|
||||||
|
|
||||||
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures:
|
Full typed shape: the package's `types` entry, `@ng-helpers/indexing`. The load-bearing signatures:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// ── wiring: one handle, one identity ─────────────────────────────────────────
|
// ── wiring: one handle, one identity, and that identity's connection ─────────
|
||||||
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort;
|
export function polyfillPort(options: PolyfillPortOptions): NextGraphPort;
|
||||||
export interface PolyfillPortOptions { readonly sessionId: string | number }
|
export interface PolyfillPortOptions { readonly sessionId: string | number }
|
||||||
export function indexing(port: NextGraphPort): Indexing;
|
/** This identity's handle, and its connection: see `## Guarantees`. */
|
||||||
|
export function indexing(port: NextGraphPort): Promise<Indexing>;
|
||||||
|
|
||||||
// ── addressing (re-exported so you import them from here) ────────────────────
|
// ── addressing (re-exported so you import them from here) ────────────────────
|
||||||
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 };
|
export type { PrincipalId, UnionSubject, NextGraphPort, IncomingDeposit, ObjectResolution };
|
||||||
|
|
||||||
// ── everything this package does ─────────────────────────────────────────────
|
// ── the three acts an application performs ───────────────────────────────────
|
||||||
export interface Indexing {
|
export interface Indexing {
|
||||||
/** Creates an index in THIS identity's public store and opens its inbox. Any user may.
|
/** A new index in THIS identity's public store, and its NURI. Any user may. `field`
|
||||||
* `field` is the predicate an indexed object must carry, declared once and for good;
|
* is the predicate an indexed object must carry; an empty or blank one throws. */
|
||||||
* an empty or blank one throws. Returns the NURI to hardcode. */
|
|
||||||
createIndex(field: string): Promise<Nuri>;
|
createIndex(field: string): Promise<Nuri>;
|
||||||
/** Deposits a bare reference into the index's inbox. Open to ANYONE. Nothing lands in
|
/** Deposits a bare reference to an object into the index. Open to ANYONE. Produces
|
||||||
* the index until its owner curates. Throws if the index has no inbox. */
|
* nothing. Throws when the document cannot take one, rather than losing it. */
|
||||||
refer(index: NuriLike, object: NuriLike): Promise<void>;
|
refer(index: NuriLike, object: NuriLike): Promise<void>;
|
||||||
/** OWNER only — resolves the references received and adds what it can. */
|
/** The entries, ordered by value. Refuses a document that declares no index field
|
||||||
curate(index: NuriLike): Promise<CurationReport>;
|
* rather than producing an empty list. Sugar over `readUnion([index])`. */
|
||||||
/** The entries, ordered by value. Sugar over `readUnion([index])`. */
|
|
||||||
read(index: NuriLike): Promise<IndexEntry[]>;
|
read(index: NuriLike): Promise<IndexEntry[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── what an index holds ──────────────────────────────────────────────────────
|
// ── what an index holds, and what travels from a depositor to an index ───────
|
||||||
export interface IndexEntry { readonly object: Nuri; readonly value: string }
|
export interface IndexEntry { readonly object: Nuri; readonly value: string }
|
||||||
export interface IndexDescriptor { readonly field: 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 type IndexDeposit = Nuri; // the reference IS the whole payload
|
||||||
export function decodeReference(payload: unknown): Nuri | null; // untrusted input
|
export function decodeReference(payload: unknown): Nuri | null; // untrusted input
|
||||||
|
|
||||||
@@ -86,70 +73,68 @@ export const ENTRY_VALUE: string; // on an entry: that object's value for the
|
|||||||
|
|
||||||
**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 deposited into an index becomes an entry once the index's creator is connected.** Awaiting `indexing(port)` is that connection: across every index that identity owns, what was deposited while it was away becomes an entry then, and what arrives from that moment on becomes one as it lands. There is nothing to call, schedule or configure, and no way to aim it at one index — the creator's session is the only thing that ever adds an entry, and it serves all of them at once.
|
||||||
|
|
||||||
**`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, inside the document, and cannot be changed.** `createIndex` refuses an empty or blank one at the door: nothing here deletes, so an index created on a useless field is useless for good. Declaring it in the document, not in an application's source, stops two applications indexing one on two fields.
|
||||||
|
|
||||||
**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 `createIndex` produces it.** It can be deposited into straight away, and the session that created it is already the one making its entries — nothing to open, register or reconnect.
|
||||||
|
|
||||||
**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.
|
**Depositing is open to anyone; writing is the creator's alone.** `refer` is a deposit, not a write, so a stranger contributes to an index they could not write. What is deposited is a **bare reference**: no operation, no claim, no copy of the value. What the object itself says, when its entry is made, is what goes in.
|
||||||
|
|
||||||
**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.
|
**An index ONLY EVER GROWS.** No call removes an entry, for anyone including the creator, and none is planned: this package cannot express a removal at all, and it was deliberately never built rather than left for later. Do not design around a future delete — the answer to "this entry must go" is a fresh index.
|
||||||
|
|
||||||
**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.
|
**The same references produce the same index, whatever order they arrived in.** One reference deposited a hundred times leaves one entry; an object already indexed is passed over rather than read again; a burst settles exactly where those references would one at a time. The cost: no deposit is ever retired, so the work behind an index is linear in its whole history.
|
||||||
|
|
||||||
**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.
|
**None of this can deny you anything.** A session that could not find its indexes, catch one up, or stay posted about one still hands you a working handle: reading an index and depositing into one never depended on that work. Every such failure is on this package's log stream, as is every reference that could not be resolved — harmless is not the same as invisible. Nothing is lost either way: a reference already deposited is still waiting, and the next connection makes its entry.
|
||||||
|
|
||||||
**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.
|
**Reading is per-entry tolerant.** `read` returns entries ordered by value, ties broken on the object NURI, so two readers always see the same order. Values are compared **as strings**, so an index whose field holds ISO-8601 dates comes out in chronological order. A subject that is not a NURI is passed over rather than thrown on, and only own properties are read: one stray triple cannot make every real entry unreadable.
|
||||||
|
|
||||||
**`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 entry carrying several values keeps the smallest, deterministically** — which two sessions racing each other can produce, and which keeps the entry visible with every reader agreeing on it.
|
||||||
|
|
||||||
**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.
|
**The document's own declaration is read strictly for writing and leniently for reading.** `read` refuses a document that declares no field at all rather than answering "an empty index": an unreadable document and an empty one arrive as the same empty result, so an empty answer would be a failure wearing the shape of a fact — retry before concluding it is malformed. An index declaring SEVERAL fields stops gaining entries, loudly and permanently, and stays readable: picking one would order a single list by two properties, since entries already made are never revisited. That cannot be undone — start a fresh index.
|
||||||
|
|
||||||
**Reading needs nothing from this package.** An application that knows the NURI can call the polyfill's `readUnion([index])` and get 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.
|
**Reading needs nothing from this package.** An application that knows the NURI can call the polyfill's `readUnion([index])` and get one subject per indexed object, plus the index's own subject declaring its field, which `read` drops. `INDEX_FIELD` and `ENTRY_VALUE` are published for that reader.
|
||||||
|
|
||||||
**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.
|
**Everything deposited is untrusted.** Anyone may deposit anything; `decodeReference` returns `null` for whatever is not a reference, and such a payload is passed over rather than stopping the rest.
|
||||||
|
|
||||||
|
**A BET, named as one — this layer's, not yours.** Nothing here is an application's to do or handle; it is what this layer stands on. That a document can receive deposits at all is aligned with NextGraph — a repository takes an inbox capability, at most one, on a real commit upstream. **What a deposit carries, and what receiving one does, are ours**: upstream's own set of deposit kinds is closed, carries no payload this could travel in, and offers no hook to extend it, so an index deposit has a shape NextGraph has not defined. When upstream defines it, this layer moves with it, and a `major` is how you hear about that.
|
||||||
|
|
||||||
## Non-guarantees
|
## 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.** No report, no outcome list, no callback: an application that cannot ask for it has nowhere to receive the result. A reference that could not be resolved is warned about on the log stream; an object carrying nothing for the field, one carrying several, a self-reference and a payload that is no reference are not reported at all. Reading the index is how you find out.
|
||||||
|
|
||||||
**No 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 timing.** Nothing says how long a reference takes to become an entry or lets you wait, and while its creator stays away it waits for as long as that lasts. There is no depth to inspect and no ordering between a deposit and a read.
|
||||||
|
|
||||||
**No private data.** Indexing is limited to objects the curator can open itself. An object the index's owner cannot read is simply `unresolved`.
|
**No refresh.** An already-indexed object is never read again, so one whose value changes later keeps its original indefinitely.
|
||||||
|
|
||||||
**`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 private data.** Only objects the index's creator can open are indexed; one it cannot read is not added.
|
||||||
|
|
||||||
**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.
|
**A handle is one identity for its whole life, and nothing releases what it holds.** An application that changes identity within one page must build a new handle and drop the old one, which goes on listening under a session that holds nothing.
|
||||||
|
|
||||||
**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.
|
**Connecting reads this identity's whole public store** — a store read plus one read per document, every time a handle is built, because nothing marks one as an index. An index whose read did not answer then is not found, silently, and its entries are made at the next connection.
|
||||||
|
|
||||||
**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 narrow behaviours are open questions, not promises.** An object carrying nothing for the field is not added; one carrying several values is not added; a raced entry keeps the smallest value. Each is implemented in its narrowest form rather than generalised, and each may change.
|
||||||
|
|
||||||
**The report grows with the inbox.** Since deposits are never retired, `CurationReport.outcomes` has one entry per deposit ever made, not per change.
|
**No stable error text.** What a throw or a log line reads is for a human. Do not parse or branch on it.
|
||||||
|
|
||||||
**No cross-broker reach.** A NURI resolves for users of the same broker.
|
**No cross-broker reach.** A NURI resolves for users of one broker.
|
||||||
|
|
||||||
**No depositor authentication or rate limit.** Anyone may deposit any number of payloads into any index's inbox.
|
**No depositor authentication and no rate limit.** Anyone may deposit any number of payloads into any index.
|
||||||
|
|
||||||
## 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 itself converging on a NextGraph that does not ship yet, several of its behaviours are open questions above, and one part of it is a bet. Settling any of those narrows this surface, so the major number moves often — that frequency is the honest signal about this package, not an apology.
|
||||||
|
|
||||||
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** (`2.0.1`); work on a branch carries a **pre-release** of the version it heads for (`2.1.0-dev.3`), which sorts below it by construction, and between two pre-releases of one version nothing is promised. Nothing you pinned is ever withdrawn: a pre-release keeps resolving once the full version appears alongside it. The tag is bare — `v2.0.1` — because this repository publishes exactly one engagement; should a second ever ship here, tags take the package name (`indexing/v…`).
|
||||||
|
|
||||||
**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.
|
**`2.0.1` is prose, and one requirement corrected.** This surface was described throughout as an *inbox* you deposit into and a thing that gets *curated*; neither is yours to know — you deposit a reference and you read entries, and how that travels is this layer's business and free to change under you — so the same facts are stated as effects now. And "hardcode the index's NURI in your source" was stated unconditionally, fused with the creator-must-connect requirement; it is neither. A reference to an index is held however you hold any other, and hardcoding is what a single GLOBAL index needs. Creator-must-connect is unchanged and stays unconditional. **Nothing to migrate** — no symbol moved, no call behaves differently, and the requirement that changed asks less than before. A **patch** under the rule above, worth spelling out because the wording moved so much: no exported symbol changed, nothing under `## Guarantees` promises anything it did not promise in `2.0.0` — only how it is said — and the requirement that did change is a deployment one, asking less.
|
||||||
|
|
||||||
`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.
|
**`2.0.0` removed `Indexing.curate(index)` — and `CurationReport`, `CurationOutcome`, `SkipReason` with it — and made `indexing(port)` a promise.** Removals, hence the major: that call asked an application to decide who owns an index and when its entries are made, neither of which is an application's decision. **Migrating from `1.x`**: delete every call to `curate`, `await` the `indexing(port)` you already make, and where you read a `CurationReport` read the index instead — the log carries what did not resolve. `1.0.1` and `1.0.0` keep resolving and neither is forced to upgrade.
|
||||||
|
|
||||||
**`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.
|
Cut on `main`, so `2.0.1` is what you pin, and your `usage_` leaf anchors `against:` on that exact string — `against: @ng-helpers/indexing@2.0.1`.
|
||||||
|
|
||||||
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.
|
There is no changelog file and no deprecation window: **the sections above are the release note.** Diff this leaf between two pulls, `## Guarantees` and `## Non-guarantees` before `## Surface`, because that is where a narrowing shows up first.
|
||||||
|
|
||||||
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,30 +1,30 @@
|
|||||||
---
|
---
|
||||||
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 nine 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
|
||||||
against: "@ng-eventually/polyfill@1.0.0-dev.1"
|
against: "@ng-eventually/polyfill@1.0.0-dev.1"
|
||||||
---
|
---
|
||||||
|
|
||||||
# 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, contributions reach it through its inbox, and its owner's session curates it by going through that inbox. 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 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.
|
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.
|
||||||
|
|
||||||
## Consumed surface
|
## Consumed surface
|
||||||
|
|
||||||
**Placement** — `storeRegistry.createEntityDoc("public")`, for the index document itself; `storeRegistry.openDocumentInbox(doc)`, called once at creation.
|
**Placement** — `storeRegistry.createEntityDoc("public")`, for the index document itself; `storeRegistry.listMyEntityDocs("public")`, to find again, at each connection, the indexes this identity owns; `storeRegistry.openDocumentInbox(doc)`, at creation to open one and afterwards to resolve its address.
|
||||||
|
|
||||||
**Reading** — `readUnion([doc])` and the type `UnionSubject`. Used both for the index document and for resolving a deposited reference.
|
**Reading** — `readUnion([doc])` and the type `UnionSubject`. Used both for the index document and for resolving a deposited reference.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**Inbox** — `inbox.postToDocument(doc, { payload })` for depositing, `inbox.readForDocument(doc)` for the owner going through it, `inbox.watch(address, onDeposits)` so that a deposit made while the owner is connected is applied as it lands, and the shape of `Deposit` (`from` / `payload` / `ts`), which our `IncomingDeposit` mirrors.
|
||||||
|
|
||||||
**Types** — `Nuri`, `NuriLike`, `PrincipalId`, `UnionSubject`.
|
**Types** — `Nuri`, `NuriLike`, `PrincipalId`, `UnionSubject`.
|
||||||
|
|
||||||
**Bootstrap, in the end-to-end application only** — `configure`, this package's `init` (for the `sessionId` its callback delivers), and `ensureIdentity`.
|
**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.
|
Everything else on the engagement is offered and NOT consumed: `watchShape`, `useShape`, `subscribeDoc`/`subscribeDocs`, `docs.docCreate`, `docs.sparqlQuery`, `storeRegistry.resolveScopeGraph`/`resolveWriteGraph`, `inbox.share`/`post`/`read`/`readSynced`/`readSyncedForDocument`/`processInbox`, `ng`, `initNg`. It is safely evolvable as far as this layer is concerned.
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
@@ -34,7 +34,11 @@ Everything else on the engagement is offered and NOT consumed: `watchShape`, `us
|
|||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
**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.
|
**The inbox is opened at creation, from one place — and resolved, never opened, afterwards.** The engagement disclaims coalescing `openDocumentInbox` across pages, so the only call that can CREATE one is in `createIndex`, under its owner, at the moment the document is created. Every later call is on a document whose inbox already exists, where the same entry resolves the address instead; the engagement guarantees that idempotence within a page, which is the level our watching lives at.
|
||||||
|
|
||||||
|
**One session watches its own indexes, and nothing stops it.** `inbox.watch`'s unsubscribe is deliberately dropped: our watching lasts exactly as long as the identity is connected, which is what the engagement does with the inboxes it watches on its own account. It also means the inbox address never leaves the one adapter function that resolves it — everything above names a document.
|
||||||
|
|
||||||
|
**Our watching relies on subscriptions coexisting on one document.** `inbox.watch` opens a `subscribeDoc` on the inbox document, and the engagement already watches every inbox this identity may read. Before subscriptions coexisted, one of those two would have silenced the other with nothing raised anywhere. Verified in the resolved copy: the fan-out holds a set of listeners.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@@ -48,4 +52,8 @@ Everything else on the engagement is offered and NOT consumed: `watchShape`, `us
|
|||||||
|
|
||||||
**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 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.
|
||||||
|
|
||||||
|
**Watching a document's inbox needs an address, and the only call that hands one out is the one that creates one.** The engagement is explicit that an application never resolves an inbox address, and it publishes `inbox.watch(address, …)` with no `watchForDocument(doc, …)` beside `readForDocument`. So the one thing an owner cannot avoid — being told about deposits on its own document — is also the one place this layer must hold an address, obtained from `openDocumentInbox`, whose other job is to create. A `watchForDocument(doc, onDeposits)` would close that gap and keep the "never resolve an address" rule whole.
|
||||||
|
|
||||||
|
**The version declared and the version the guarantees describe disagree.** The resolved copy's `package.json` says `1.0.0-dev.1`, while the engagement dates the continuous inbox observation and the coexisting subscriptions to `1.0.0-dev.2`. The code has them, so nothing is broken; but a pin cannot express what we actually depend on, and `against:` above names the string we resolve rather than the one the guarantees belong to.
|
||||||
|
|
||||||
**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.** `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.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ That boundary is held by one file. `src/polyfill-adapter.ts` is the only place t
|
|||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
|
**Curating is not an act an application performs.** There is no `curate` to call, and there never will be: it would ask an application to decide who owns an index and when curation runs, and neither is its decision. Curation is what happens when the index's inbox is processed — **at its creator's next connection, and on each deposit while the creator is connected**. `await indexing(port)` IS that connection: it goes through the inbox of every index that identity owns, and leaves each one watched. `src/observation.ts` is where that lives.
|
||||||
|
|
||||||
**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 calls `readUnion([indexNuri])` and gets the entries as subjects. `Indexing.read` is sugar over exactly that, dropping the index's own declaration subject.
|
||||||
|
|
||||||
## An index only ever grows
|
## An index only ever grows
|
||||||
@@ -47,20 +49,22 @@ The lesson is worth keeping: **"nothing removes" is a claim about the write path
|
|||||||
|
|
||||||
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.
|
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.
|
**A failed resolve is still a failure, and still surfaces.** Harmless is not the same as invisible. Nobody asked for the run, so there is nobody to hand a report to: every reference that could not be resolved is warned about on this package's log stream, and so is a run, a watch or a store read that could not happen at all. None of them denies the application anything — reading an index and depositing into one never depended on that work, and the deposits stay in their inbox for the next connection.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
Deliberately not settled. Each is implemented in its narrowest form and reported rather than generalised.
|
Deliberately not settled. Each is implemented in its narrowest form and reported rather than generalised.
|
||||||
|
|
||||||
- **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 nothing for the index's field.** Narrow behaviour: it is not added, and nothing says so. 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 object that carries several values for the field.** Not added either. 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.
|
- **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.
|
- **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.
|
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.
|
- **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.
|
- **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.
|
||||||
|
- **How an owner finds its own indexes.** Nothing marks a document as an index, so connecting reads that identity's whole public store and looks at each document. It is the only question the surface can ask, and it does not scale with a large store. A marker on the document, or a listing narrowed by shape, would both settle it — and both are decisions about what an index *is*.
|
||||||
|
- **Nothing stops a handle.** `inbox.watch`'s unsubscribe is dropped, so a session watches its own indexes until the page goes. An application that changes identity in one page has to drop the old handle and know that it goes on watching.
|
||||||
- **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.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@@ -72,6 +76,8 @@ Deliberately not settled. Each is implemented in its narrowest form and reported
|
|||||||
| `src/deposit.ts` | The deposit's shape: a bare reference |
|
| `src/deposit.ts` | The deposit's shape: a bare reference |
|
||||||
| `src/index-document.ts` | An index's declaration and its entries |
|
| `src/index-document.ts` | An index's declaration and its entries |
|
||||||
| `src/curator.ts` | Resolving references and adding what is there |
|
| `src/curator.ts` | Resolving references and adding what is there |
|
||||||
|
| `src/observation.ts` | When that happens: this identity's indexes, caught up and watched |
|
||||||
|
| `src/coalescing.ts` | Runs a job, never twice at once, and grants exactly one more run |
|
||||||
| `src/indexing.ts` | The public surface, bound to one identity |
|
| `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 |
|
||||||
| `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 |
|
||||||
@@ -82,14 +88,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.
|
||||||
|
|||||||
+10
-3
@@ -14,7 +14,7 @@
|
|||||||
* ends up typed as a reference.
|
* ends up typed as a reference.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CurationReport, IndexEntry, UnionSubject } from "../src/index";
|
import type { IndexEntry, UnionSubject } from "../src/index";
|
||||||
|
|
||||||
/** 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 {
|
||||||
@@ -60,8 +60,15 @@ export interface IndexingBridge {
|
|||||||
referConfigured(object: string): Promise<void>;
|
referConfigured(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>;
|
referTo(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>;
|
* Connect again — obtain a fresh `Indexing` handle, which is what a page load does.
|
||||||
|
*
|
||||||
|
* There is no curating act to drive: an index is curated at its creator's next
|
||||||
|
* connection and on each deposit while the creator is connected. This is the first
|
||||||
|
* of the two, driven deliberately so a journey has a point at which the catching
|
||||||
|
* up is over; the second needs nothing from anyone.
|
||||||
|
*/
|
||||||
|
reconnect(): Promise<void>;
|
||||||
/** The index's entries, ordered by value. */
|
/** The index's entries, ordered by value. */
|
||||||
read(index: string): Promise<IndexEntry[]>;
|
read(index: string): Promise<IndexEntry[]>;
|
||||||
|
|
||||||
|
|||||||
+9
-11
@@ -3,7 +3,7 @@
|
|||||||
* `@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,
|
||||||
@@ -35,12 +35,7 @@ import {
|
|||||||
import { ng as realNg, init as realInit } from "@ng-org/web";
|
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||||
|
|
||||||
import { indexing, polyfillPort } from "../src/index";
|
import { indexing, polyfillPort } from "../src/index";
|
||||||
import type {
|
import type { IndexEntry, Indexing, NextGraphPort } from "../src/index";
|
||||||
CurationReport,
|
|
||||||
IndexEntry,
|
|
||||||
Indexing,
|
|
||||||
NextGraphPort,
|
|
||||||
} from "../src/index";
|
|
||||||
import type { BrokenInboxOutcome, IndexingBridge } from "./bridge";
|
import type { BrokenInboxOutcome, IndexingBridge } from "./bridge";
|
||||||
|
|
||||||
// ── bootstrap: the one polyfill-era call, then the SDK-shaped ones ──────────
|
// ── bootstrap: the one polyfill-era call, then the SDK-shaped ones ──────────
|
||||||
@@ -91,7 +86,10 @@ async function boot(): Promise<void> {
|
|||||||
state.who = await ensureIdentity();
|
state.who = await ensureIdentity();
|
||||||
const session = await sessionReady;
|
const session = await sessionReady;
|
||||||
port = polyfillPort({ sessionId: session.session_id });
|
port = polyfillPort({ sessionId: session.session_id });
|
||||||
api = indexing(port);
|
// One await, and curation is part of it: obtaining the handle processes the inboxes
|
||||||
|
// of the indexes this identity owns and leaves them watched. This application never
|
||||||
|
// curates anything, and has nothing to call if it wanted to.
|
||||||
|
api = await indexing(port);
|
||||||
state.status = "ready";
|
state.status = "ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +173,8 @@ const bridge: IndexingBridge = {
|
|||||||
await ready().refer(index, object);
|
await ready().refer(index, object);
|
||||||
},
|
},
|
||||||
|
|
||||||
async curate(index: string): Promise<CurationReport> {
|
async reconnect(): Promise<void> {
|
||||||
return ready().curate(index);
|
api = await indexing(readyPort());
|
||||||
},
|
},
|
||||||
|
|
||||||
async read(index: string): Promise<IndexEntry[]> {
|
async read(index: string): Promise<IndexEntry[]> {
|
||||||
@@ -199,7 +197,7 @@ const bridge: IndexingBridge = {
|
|||||||
// Everything real except the inbox step. The failure is injected at the exact moment
|
// 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
|
// the question is about: after the document exists and carries its descriptor, before
|
||||||
// anyone can deposit into it.
|
// anyone can deposit into it.
|
||||||
const broken = indexing({
|
const broken = await indexing({
|
||||||
...p,
|
...p,
|
||||||
openInbox: async (): Promise<void> => {
|
openInbox: async (): Promise<void> => {
|
||||||
throw new Error("[e2e] injected: the inbox could not be opened");
|
throw new Error("[e2e] injected: the inbox could not be opened");
|
||||||
|
|||||||
+38
-49
@@ -126,12 +126,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 Alice's next connection curates it",
|
||||||
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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -145,8 +144,7 @@ const { check, journey, finish } = declareSuite({
|
|||||||
{
|
{
|
||||||
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",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -365,7 +363,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 Alice's next connection curates it",
|
||||||
needs: [
|
needs: [
|
||||||
aliceIsUp,
|
aliceIsUp,
|
||||||
bobIsUp,
|
bobIsUp,
|
||||||
@@ -379,30 +377,11 @@ async function main(): Promise<void> {
|
|||||||
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), bobsObject!),
|
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), bobsObject!),
|
||||||
);
|
);
|
||||||
|
|
||||||
const report = await step("Alice curating", BRIDGE_MS, () =>
|
// NOBODY CURATES: there is nothing on the surface to call. Alice's page connects
|
||||||
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
|
// again — what a page load does — and her session processes the inboxes of the
|
||||||
);
|
// indexes she owns, this one among them.
|
||||||
// The deposit is proven ARRIVED, by the only person who can see it. That the post
|
await step("Alice connecting again", BRIDGE_MS, () =>
|
||||||
// did not throw is a weaker claim entirely — it says the call returned, not that
|
alice!.frame.evaluate(() => window.__indexing.reconnect()),
|
||||||
// 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.
|
|
||||||
check(
|
|
||||||
"a stranger's deposit into the index's inbox is accepted",
|
|
||||||
report.outcomes.length === 1,
|
|
||||||
`from=${BOB} outcomes=${report.outcomes.length}`,
|
|
||||||
);
|
|
||||||
const forBob = report.outcomes.find(
|
|
||||||
(o) => "object" in o && o.object === bobsObject,
|
|
||||||
);
|
|
||||||
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
|
// THE WRITE FORM, answered. An entry is a triple whose subject is another
|
||||||
@@ -412,11 +391,27 @@ async function main(): Promise<void> {
|
|||||||
alice!.frame.evaluate((d) => window.__indexing.readRaw(d), index!),
|
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 the curation 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 ?? {})}`,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,16 +441,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 connecting a second time", BRIDGE_MS, () =>
|
||||||
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
|
alice!.frame.evaluate(() => window.__indexing.reconnect()),
|
||||||
);
|
);
|
||||||
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)}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -478,22 +473,16 @@ async function main(): Promise<void> {
|
|||||||
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), other),
|
bob!.frame.evaluate((o) => window.__indexing.referConfigured(o), other),
|
||||||
);
|
);
|
||||||
|
|
||||||
const report = await step("Alice curating the unrelated reference", BRIDGE_MS, () =>
|
await step("Alice connecting after the unrelated reference", BRIDGE_MS, () =>
|
||||||
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
|
alice!.frame.evaluate(() => window.__indexing.reconnect()),
|
||||||
);
|
|
||||||
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)}`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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)}`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -593,8 +582,8 @@ 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.referConfigured(o), object),
|
||||||
);
|
);
|
||||||
await step("Alice curating the hostile reference", BRIDGE_MS, () =>
|
await step("Alice connecting after the hostile reference", BRIDGE_MS, () =>
|
||||||
alice!.frame.evaluate((i) => window.__indexing.curate(i), index!),
|
alice!.frame.evaluate(() => window.__indexing.reconnect()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Read the index document RAW: it must still declare its own field. An injected
|
// Read the index document RAW: it must still declare its own field. An injected
|
||||||
|
|||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@ng-helpers/indexing",
|
"name": "@ng-helpers/indexing",
|
||||||
"version": "1.0.1",
|
"version": "2.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 layer 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 once the index's creator is connected.",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
"@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/web": "0.1.2-alpha.13",
|
||||||
"@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"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+110
@@ -0,0 +1,110 @@
|
|||||||
|
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/web':
|
||||||
|
specifier: 0.1.2-alpha.13
|
||||||
|
version: 0.1.2-alpha.13
|
||||||
|
'@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/web@0.1.2-alpha.13':
|
||||||
|
resolution: {integrity: sha512-/xO0c+3NTphnws5Do2LDqgZWmAf+aNnYdChJKdU0dnp1U1iVSgi/y3yb8AYryf0v9sooj0aYJxt08B6DpirFMQ==}
|
||||||
|
|
||||||
|
'@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==}
|
||||||
|
|
||||||
|
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/web@0.1.2-alpha.13':
|
||||||
|
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
|
||||||
|
|
||||||
|
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: {}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Runs a job, and never runs it twice at once: an ask that arrives while it is
|
||||||
|
* running earns exactly ONE more run afterwards, however many arrive.
|
||||||
|
*
|
||||||
|
* A burst of deposits produces a burst of notifications, and each one means the same
|
||||||
|
* thing — "look at this inbox again". Curation reads the whole inbox every time, so
|
||||||
|
* a run started after the last deposit landed already covers every deposit before
|
||||||
|
* it: running once per notification would re-read the same inbox N times to reach
|
||||||
|
* the same place. One more run is enough, and one more run is also NECESSARY — a
|
||||||
|
* deposit that lands while a run is in flight may have arrived after that run read
|
||||||
|
* the inbox, and dropping the ask would leave it unprocessed until the next
|
||||||
|
* connection.
|
||||||
|
*
|
||||||
|
* `run` must not reject: this returns the caller's own promise and nothing here
|
||||||
|
* turns a rejection into a report. Its one caller wraps a failing run in its own
|
||||||
|
* reporting before handing it over.
|
||||||
|
*/
|
||||||
|
export function coalescing(run: () => Promise<void>): () => Promise<void> {
|
||||||
|
let inFlight: Promise<void> | null = null;
|
||||||
|
let asked = false;
|
||||||
|
|
||||||
|
async function drain(): Promise<void> {
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
asked = false;
|
||||||
|
await run();
|
||||||
|
} while (asked);
|
||||||
|
} finally {
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return function ask(): Promise<void> {
|
||||||
|
if (inFlight !== null) {
|
||||||
|
asked = true;
|
||||||
|
return inFlight;
|
||||||
|
}
|
||||||
|
inFlight = drain();
|
||||||
|
return inFlight;
|
||||||
|
};
|
||||||
|
}
|
||||||
+15
-2
@@ -117,8 +117,7 @@ export function entryValue(subject: UnionSubject): string | undefined {
|
|||||||
* declaration above it turned ambiguous.
|
* declaration above it turned ambiguous.
|
||||||
*/
|
*/
|
||||||
export function assertIndexDocument(subjects: readonly UnionSubject[], index: Nuri): void {
|
export function assertIndexDocument(subjects: readonly UnionSubject[], index: Nuri): void {
|
||||||
const self = subjects.find((s) => s.subject === index);
|
if (declaresIndexField(subjects, index)) return;
|
||||||
if (valuesOf(self, INDEX_FIELD).length > 0) return;
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${index} declares no index field (${INDEX_FIELD}) — either it is not an index, ` +
|
`${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 " +
|
"or it could not be read: an unreadable document and an empty one are the same " +
|
||||||
@@ -126,6 +125,20 @@ export function assertIndexDocument(subjects: readonly UnionSubject[], index: Nu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same question as `assertIndexDocument`, asked without an opinion on the
|
||||||
|
* answer: is this document an index?
|
||||||
|
*
|
||||||
|
* Separate because the two callers want opposite things from a "no". A reader
|
||||||
|
* naming a document it believes to be an index wants the refusal. A session
|
||||||
|
* looking through its own public store for the indexes it owns wants a plain
|
||||||
|
* `false`: most of what it looks at is not an index and never claimed to be.
|
||||||
|
*/
|
||||||
|
export function declaresIndexField(subjects: readonly UnionSubject[], doc: Nuri): boolean {
|
||||||
|
const self = subjects.find((s) => s.subject === doc);
|
||||||
|
return valuesOf(self, INDEX_FIELD).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The field to CURATE on — and here it must be unambiguous.
|
* The field to CURATE on — and here it must be unambiguous.
|
||||||
*
|
*
|
||||||
|
|||||||
+4
-4
@@ -7,8 +7,9 @@
|
|||||||
*
|
*
|
||||||
* 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 index is that an application references its NURI in its own source. Anyone
|
* an index is that an application references its NURI in its own source. Anyone
|
||||||
* may hand it a reference by depositing into its inbox; its owner resolves those
|
* may hand it a reference by depositing into its inbox; its creator's session
|
||||||
* references itself and adds what it finds.
|
* resolves those references and adds what it finds — see `observation.ts` for what
|
||||||
|
* makes that happen, and `curator.ts` for what it does.
|
||||||
*
|
*
|
||||||
* An index only ever grows — see `curator.ts`.
|
* An index only ever grows — see `curator.ts`.
|
||||||
*/
|
*/
|
||||||
@@ -31,9 +32,8 @@ export type {
|
|||||||
export { decodeReference } from "./deposit";
|
export { decodeReference } from "./deposit";
|
||||||
export type { IndexDeposit } from "./deposit";
|
export type { IndexDeposit } from "./deposit";
|
||||||
|
|
||||||
// What an index holds, and what curating it reports
|
// What an index holds
|
||||||
export type { IndexDescriptor, IndexEntry } from "./index-document";
|
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`
|
// 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";
|
||||||
|
|||||||
+34
-23
@@ -1,6 +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 { observeOwnIndexes } from "./observation";
|
||||||
import {
|
import {
|
||||||
assertIndexDocument,
|
assertIndexDocument,
|
||||||
entriesOf,
|
entriesOf,
|
||||||
@@ -19,42 +19,39 @@ import {
|
|||||||
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
|
||||||
* is no separate kind of index, and the entries of such an index come out in
|
* is no separate kind of index, and the entries of such an index come out in
|
||||||
* chronological order because ISO-8601 sorts as a string.
|
* chronological order because ISO-8601 sorts as a string.
|
||||||
*
|
*
|
||||||
* The returned NURI is what an application hardcodes in its own source: that
|
* WHAT BECOMES OF IT is its creator's business, in both directions at once. The
|
||||||
* reference is the only thing that makes this ordinary document an index, and
|
* returned NURI is what an application hardcodes in its own source: that
|
||||||
* the only way anyone reaches it.
|
* reference is the only thing that makes this ordinary document an index, and the
|
||||||
|
* only way anyone reaches it. And this session is what curates it — from now on
|
||||||
|
* while it lasts, and again at its creator's next connection. An index has no
|
||||||
|
* life of its own: it lives through the application that names it and the
|
||||||
|
* creator who connects.
|
||||||
*/
|
*/
|
||||||
createIndex(field: string): Promise<Nuri>;
|
createIndex(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,
|
* The reference is the whole message: it claims nothing and instructs nothing,
|
||||||
* it just invites the index's owner to look. Call it when the object is
|
* it just invites the index's owner to look. Call it when the object is
|
||||||
* created, and again whenever anyone notices the index may not have it yet —
|
* created, and again whenever anyone notices the index may not have it yet —
|
||||||
* including a third party. Nothing lands in the index until its owner curates.
|
* including a third party. What the deposit becomes is up to the index's creator:
|
||||||
|
* it enters the index the moment their session processes that inbox.
|
||||||
*/
|
*/
|
||||||
refer(index: NuriLike, object: NuriLike): Promise<void>;
|
refer(index: NuriLike, object: NuriLike): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the references this index has received and adds what it can. Only
|
* The index's entries, ordered by value. Refuses a document that declares no
|
||||||
* the index's OWNER gets anything: nobody else reads its inbox, and nobody else
|
* index field rather than producing an empty list.
|
||||||
* 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
|
* 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
|
* that knows the NURI can call `readUnion([indexNuri])` and get these entries as
|
||||||
@@ -64,7 +61,22 @@ export interface Indexing {
|
|||||||
read(index: NuriLike): Promise<IndexEntry[]>;
|
read(index: NuriLike): Promise<IndexEntry[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function indexing(port: NextGraphPort): Indexing {
|
/**
|
||||||
|
* This identity's handle, and its connection.
|
||||||
|
*
|
||||||
|
* Awaiting it processes the inbox of every index this identity owns — the deposits
|
||||||
|
* that piled up while it was away — and leaves those inboxes watched, so a deposit
|
||||||
|
* made from now on is curated as it lands. That is the whole of when curation
|
||||||
|
* happens; there is nothing to call, schedule or configure, and no way to aim it at
|
||||||
|
* one index.
|
||||||
|
*
|
||||||
|
* It never rejects for that work: an identity that could not be caught up still
|
||||||
|
* gets its handle, because reading an index and depositing into one do not depend
|
||||||
|
* on it. What went wrong is reported on this package's log stream.
|
||||||
|
*/
|
||||||
|
export async function indexing(port: NextGraphPort): Promise<Indexing> {
|
||||||
|
const observation = await observeOwnIndexes(port);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async createIndex(field: string): Promise<Nuri> {
|
async createIndex(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:
|
||||||
@@ -82,6 +94,9 @@ export function indexing(port: NextGraphPort): Indexing {
|
|||||||
// 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.
|
||||||
await port.openInbox(index);
|
await port.openInbox(index);
|
||||||
|
// The store was searched before this document existed, so this session would
|
||||||
|
// otherwise ignore its own new index until the next connection.
|
||||||
|
await observation.include(index);
|
||||||
return index;
|
return index;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -90,10 +105,6 @@ export function indexing(port: NextGraphPort): Indexing {
|
|||||||
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[]> {
|
async read(index: NuriLike): Promise<IndexEntry[]> {
|
||||||
const nuri = asNuri(index);
|
const nuri = asNuri(index);
|
||||||
const subjects = await readIndexDocument(port, nuri);
|
const subjects = await readIndexDocument(port, nuri);
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { NextGraphPort, Nuri, NuriLike } from "./port";
|
||||||
|
import { asNuri } from "./nuri";
|
||||||
|
import { coalescing } from "./coalescing";
|
||||||
|
import { curate } from "./curator";
|
||||||
|
import { declaresIndexField, readIndexDocument } from "./index-document";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curating an index is what happens when its inbox is processed. Nothing calls it.
|
||||||
|
*
|
||||||
|
* ## Why there is no `curate(index)` to call
|
||||||
|
*
|
||||||
|
* An application that could call it would have to answer two questions it has no
|
||||||
|
* business answering — who owns the index, and when curation runs. Both answers are
|
||||||
|
* already fixed by the design: the owner is the only one who CAN (nobody else reads
|
||||||
|
* the inbox, nobody else writes the document), and "when" is "whenever a deposit
|
||||||
|
* arrives, or has been waiting". A published call would only let an application get
|
||||||
|
* those wrong.
|
||||||
|
*
|
||||||
|
* So the trigger is the identity's own session: obtaining an `Indexing` handle is a
|
||||||
|
* connection, and a connection processes what is waiting for it and keeps
|
||||||
|
* processing what arrives. That is the same shape the polyfill gives its own
|
||||||
|
* inboxes — watched for as long as the identity is connected, backlog applied at
|
||||||
|
* connection — and this layer holds itself to it.
|
||||||
|
*
|
||||||
|
* ## Only the owner, and the layer does not have to check
|
||||||
|
*
|
||||||
|
* Reading an index's inbox is refused to anyone else, so a session curates exactly
|
||||||
|
* the indexes it owns and could not do otherwise if it tried. Which is why the
|
||||||
|
* search below is a search of THIS identity's own public store.
|
||||||
|
*
|
||||||
|
* ## Nothing here denies anything
|
||||||
|
*
|
||||||
|
* A session that could not look for its indexes, could not watch one, or could not
|
||||||
|
* process one reports it and carries on: reading an index and depositing into one
|
||||||
|
* need none of this, and an index only ever grows, so a run that did not happen
|
||||||
|
* costs a deposit nothing — it is still in the inbox, and the next notification or
|
||||||
|
* the next connection applies it. The polyfill states the same rule for its own
|
||||||
|
* inboxes: failing to apply one denies nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IndexObservation {
|
||||||
|
/**
|
||||||
|
* Brings one more index under observation — its inbox is processed now, and again
|
||||||
|
* on every deposit. For an index this session has just created: it did not exist
|
||||||
|
* when the store was searched, and its creator is right here.
|
||||||
|
*
|
||||||
|
* Asking twice for the same index changes nothing.
|
||||||
|
*/
|
||||||
|
include(index: NuriLike): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the inboxes of every index this identity owns, and keeps processing
|
||||||
|
* them. Resolves once the search is done and what it found has been caught up.
|
||||||
|
*/
|
||||||
|
export async function observeOwnIndexes(port: NextGraphPort): Promise<IndexObservation> {
|
||||||
|
const observed = new Set<Nuri>();
|
||||||
|
|
||||||
|
async function include(indexLike: NuriLike): Promise<void> {
|
||||||
|
const index = asNuri(indexLike);
|
||||||
|
if (observed.has(index)) return;
|
||||||
|
observed.add(index);
|
||||||
|
|
||||||
|
const processInbox = coalescing(async () => {
|
||||||
|
try {
|
||||||
|
await curate(port, index);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[ng-helpers/indexing] ${index}: its inbox could not be processed — ${String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// WATCH FIRST, then process. The other order has a gap: a deposit landing
|
||||||
|
// between the read and the watch is seen by neither, and waits for the next
|
||||||
|
// connection. This order overlaps instead of gapping — a deposit landing in
|
||||||
|
// between is processed twice — which costs nothing, because curation resolves
|
||||||
|
// the reference again and lands on the same result.
|
||||||
|
try {
|
||||||
|
await port.watchDeposits(index, processInbox);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[ng-helpers/indexing] ${index}: deposits into it will not be noticed until the next ` +
|
||||||
|
`connection — its inbox could not be watched: ${String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await processInbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
const observation: IndexObservation = { include };
|
||||||
|
|
||||||
|
let mine: readonly Nuri[];
|
||||||
|
try {
|
||||||
|
mine = await port.listPublicDocuments();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"[ng-helpers/indexing] this identity's indexes were not found, so none of them is being " +
|
||||||
|
`curated in this session — its public store could not be listed: ${String(error)}`,
|
||||||
|
);
|
||||||
|
return observation;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
mine.map(async (doc) => {
|
||||||
|
if (await isAnIndex(port, doc)) await include(doc);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return observation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this document declare an index field? A document that could not be READ
|
||||||
|
* answers `false`, silently and on purpose.
|
||||||
|
*
|
||||||
|
* There is nothing to report: upstream hands a failed read back as an empty one, so
|
||||||
|
* "not an index" and "could not tell" are the same answer here, and warning about
|
||||||
|
* every ordinary document that did not answer would bury the failures that mean
|
||||||
|
* something. The cost is bounded by the invariant this package is built on — an
|
||||||
|
* index only ever grows, so an index missed at this connection is curated at the
|
||||||
|
* next one, with its deposits still in its inbox.
|
||||||
|
*/
|
||||||
|
async function isAnIndex(port: NextGraphPort, doc: Nuri): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return declaresIndexField(await readIndexDocument(port, doc), doc);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { docs, inbox, readUnion, storeRegistry } from "@ng-eventually/polyfill";
|
|||||||
import type {
|
import type {
|
||||||
IncomingDeposit,
|
IncomingDeposit,
|
||||||
NextGraphPort,
|
NextGraphPort,
|
||||||
|
Nuri,
|
||||||
NuriLike,
|
NuriLike,
|
||||||
ObjectResolution,
|
ObjectResolution,
|
||||||
UnionSubject,
|
UnionSubject,
|
||||||
@@ -42,6 +43,12 @@ export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
|
|||||||
return storeRegistry.createEntityDoc("public");
|
return storeRegistry.createEntityDoc("public");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listPublicDocuments(): Promise<readonly Nuri[]> {
|
||||||
|
// The same store `createPublicDocument` writes into, listed back: an index is
|
||||||
|
// an ordinary document there, and there is no narrower question to ask.
|
||||||
|
return storeRegistry.listMyEntityDocs("public");
|
||||||
|
},
|
||||||
|
|
||||||
async resolveObject(doc: NuriLike): Promise<ObjectResolution> {
|
async resolveObject(doc: NuriLike): Promise<ObjectResolution> {
|
||||||
// The decision itself lives in `resolution.ts`, where it is unit-tested:
|
// The decision itself lives in `resolution.ts`, where it is unit-tested:
|
||||||
// here it would be reachable only through a live broker, and an untested
|
// here it would be reachable only through a live broker, and an untested
|
||||||
@@ -92,5 +99,22 @@ export function polyfillPort(options: PolyfillPortOptions): NextGraphPort {
|
|||||||
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
|
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
|
||||||
return inbox.readForDocument(asNuri(doc));
|
return inbox.readForDocument(asNuri(doc));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async watchDeposits(doc: NuriLike, onDeposits: () => Promise<void>): Promise<void> {
|
||||||
|
// The one call that hands out an inbox address, and the reason this is the only
|
||||||
|
// line in the package that holds one. It is idempotent within a page — the
|
||||||
|
// engagement says so, and says that firing one per component is supported — so
|
||||||
|
// asking again for a document whose inbox `createIndex` already opened resolves
|
||||||
|
// that inbox rather than adding a second.
|
||||||
|
const address = await storeRegistry.openDocumentInbox(asNuri(doc));
|
||||||
|
// `inbox.watch` hands back an unsubscribe, and this layer deliberately drops it:
|
||||||
|
// a session watches its own indexes for its whole life, exactly as the polyfill
|
||||||
|
// watches the inboxes it holds for as long as the identity stays connected.
|
||||||
|
//
|
||||||
|
// The callback is declared `void` upstream, so what it returns is ignored and the
|
||||||
|
// processing runs on its own. That is why it must never reject: nothing over
|
||||||
|
// there would catch it. It reports its own failures instead.
|
||||||
|
inbox.watch(address, onDeposits);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -52,6 +52,18 @@ export interface NextGraphPort {
|
|||||||
*/
|
*/
|
||||||
createPublicDocument(): Promise<Nuri>;
|
createPublicDocument(): Promise<Nuri>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every document this identity has in its PUBLIC store. Backs onto
|
||||||
|
* `storeRegistry.listMyEntityDocs("public")`.
|
||||||
|
*
|
||||||
|
* It is how an owner finds its own indexes again after a page load, and it has to
|
||||||
|
* be a full listing because NOTHING marks a document as an index — the index's own
|
||||||
|
* declaration, read from the document, is the only tell. Upstream throws rather
|
||||||
|
* than hand back a listing whose documents could not be opened, so an answer here
|
||||||
|
* is a listing, never a shrug.
|
||||||
|
*/
|
||||||
|
listPublicDocuments(): Promise<readonly Nuri[]>;
|
||||||
|
|
||||||
/** Resolves a reference deposited into an index: the two-state answer above. */
|
/** Resolves a reference deposited into an index: the two-state answer above. */
|
||||||
resolveObject(doc: NuriLike): Promise<ObjectResolution>;
|
resolveObject(doc: NuriLike): Promise<ObjectResolution>;
|
||||||
|
|
||||||
@@ -114,4 +126,23 @@ export interface NextGraphPort {
|
|||||||
* inbox, you may only READ your own. Backs onto `inbox.readForDocument(doc)`.
|
* inbox, you may only READ your own. Backs onto `inbox.readForDocument(doc)`.
|
||||||
*/
|
*/
|
||||||
readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]>;
|
readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls back for as long as this session lives, every time something is deposited
|
||||||
|
* into this document's inbox. OWNER only, for the same reason `readDeposits` is:
|
||||||
|
* being told what landed in an inbox is reading it.
|
||||||
|
*
|
||||||
|
* Backs onto `inbox.watch(address, onDeposits)`, whose address comes from
|
||||||
|
* `storeRegistry.openDocumentInbox(doc)` — the one call that hands one out, and
|
||||||
|
* which is idempotent within a page. The address never leaves this layer: a
|
||||||
|
* document is what everything above names, exactly as upstream intends.
|
||||||
|
*
|
||||||
|
* `onDeposits` takes nothing: what arrived is not read from the callback but from
|
||||||
|
* the inbox itself, which is re-read whole. That is not an omission — a deposit is
|
||||||
|
* never an instruction, so the only thing a notification can say is "look again".
|
||||||
|
*
|
||||||
|
* Nothing stops it. This session watches its own indexes for as long as it lasts,
|
||||||
|
* which is what the polyfill does with the inboxes it watches on its own account.
|
||||||
|
*/
|
||||||
|
watchDeposits(doc: NuriLike, onDeposits: () => Promise<void>): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-17
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import { indexing } from "../src/indexing";
|
import { indexing } from "../src/indexing";
|
||||||
|
import { curate } from "../src/curator";
|
||||||
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
|
import { ENTRY_VALUE, 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";
|
||||||
@@ -85,25 +86,47 @@ async function publish(port: NextGraphPort, field: string, value: string): Promi
|
|||||||
// --- the whole loop, through the real adapter -----------------------------
|
// --- the whole loop, through the real adapter -----------------------------
|
||||||
|
|
||||||
test("the real adapter carries the whole loop: create, publish, refer, curate, read", async () => {
|
test("the real adapter carries the whole loop: create, publish, refer, curate, read", async () => {
|
||||||
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(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 (await indexing(bob)).refer(index, object);
|
||||||
return object;
|
return object;
|
||||||
});
|
});
|
||||||
|
|
||||||
const report = await as("alice", (alice) => indexing(alice).curate(index));
|
const report = await as("alice", (alice) => curate(alice, index));
|
||||||
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]);
|
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]);
|
||||||
|
|
||||||
const entries = await as("alice", (alice) => indexing(alice).read(index));
|
const entries = await as("alice", async (alice) => (await indexing(alice)).read(index));
|
||||||
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]);
|
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a deposit made while the owner is connected is curated as it lands, with nobody asking", async () => {
|
||||||
|
const seen = await as("alice", async (alice) => {
|
||||||
|
const api = await indexing(alice);
|
||||||
|
const index = await api.createIndex(FIELD);
|
||||||
|
// An owner may deposit into her own index: `refer` is open to anyone, and here it
|
||||||
|
// keeps both sides on one session, which is all this fake models at a time.
|
||||||
|
const object = await publish(alice, FIELD, "2026-09-09");
|
||||||
|
await api.refer(index, object);
|
||||||
|
|
||||||
|
// NOTHING CALLS CURATION. The session is told a deposit landed on an inbox it
|
||||||
|
// watches, and processes that inbox itself — through the real adapter, so the
|
||||||
|
// address resolution and `inbox.watch` are the ones an application would get.
|
||||||
|
await world.deliverNotifications();
|
||||||
|
|
||||||
|
return { object, subjects: await alice.readDocument(index) };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seen.subjects.find((s) => s.subject === seen.object)?.props[ENTRY_VALUE]).toEqual([
|
||||||
|
"2026-09-09",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
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 (await indexing(alice)).createIndex(FIELD),
|
||||||
await indexing(alice).createIndex(FIELD),
|
await (await indexing(alice)).createIndex(FIELD),
|
||||||
]);
|
]);
|
||||||
expect(first).not.toBe(second);
|
expect(first).not.toBe(second);
|
||||||
|
|
||||||
@@ -116,10 +139,10 @@ test("two indexes are two documents, each owned by whoever created it", async ()
|
|||||||
// --- the two answers a resolve may give, and why they must stay apart -----
|
// --- 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 () => {
|
test("a reference that could not be READ comes back unresolved", async () => {
|
||||||
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(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 (await indexing(bob)).refer(index, object);
|
||||||
return object;
|
return object;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +151,7 @@ test("a reference that could not be READ comes back unresolved", async () => {
|
|||||||
// the failure as a FACT about the object is what must not happen.
|
// the failure as a FACT about the object is what must not happen.
|
||||||
world.breakReadsOf(article, "broker unreachable");
|
world.breakReadsOf(article, "broker unreachable");
|
||||||
try {
|
try {
|
||||||
const report = await as("alice", (alice) => indexing(alice).curate(index));
|
const report = await as("alice", (alice) => curate(alice, index));
|
||||||
expect(report.outcomes).toEqual([
|
expect(report.outcomes).toEqual([
|
||||||
{ result: "unresolved", object: article, reason: expect.stringContaining("absent") },
|
{ result: "unresolved", object: article, reason: expect.stringContaining("absent") },
|
||||||
]);
|
]);
|
||||||
@@ -138,10 +161,10 @@ test("a reference that could not be READ comes back unresolved", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("an object that really carries nothing for the field is SKIPPED — a different answer", async () => {
|
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 index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
|
||||||
const unrelated = await as("bob", async (bob) => {
|
const unrelated = await as("bob", async (bob) => {
|
||||||
const object = await publish(bob, "http://schema.org/name", "Anemone");
|
const object = await publish(bob, "http://schema.org/name", "Anemone");
|
||||||
await indexing(bob).refer(index, object);
|
await (await indexing(bob)).refer(index, object);
|
||||||
return object;
|
return object;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +172,7 @@ test("an object that really carries nothing for the field is SKIPPED — a diffe
|
|||||||
// keeping these two apart: replace `resolutionFromRead(subjects)` with
|
// keeping these two apart: replace `resolutionFromRead(subjects)` with
|
||||||
// `{ state: "present", subjects }` and the unreadable object above is reported
|
// `{ state: "present", subjects }` and the unreadable object above is reported
|
||||||
// here's answer instead — a broker failure filed as a property of the object.
|
// here's answer instead — a broker failure filed as a property of the object.
|
||||||
const report = await as("alice", (alice) => indexing(alice).curate(index));
|
const report = await as("alice", (alice) => curate(alice, index));
|
||||||
expect(report.outcomes).toEqual([
|
expect(report.outcomes).toEqual([
|
||||||
{ result: "skipped", object: unrelated, reason: "no-field" },
|
{ result: "skipped", object: unrelated, reason: "no-field" },
|
||||||
]);
|
]);
|
||||||
@@ -187,8 +210,8 @@ 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 deposit into an index, only its owner may read what was deposited", async () => {
|
||||||
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
|
||||||
await as("bob", (bob) => indexing(bob).refer(index, "did:ng:o:some-object"));
|
await as("bob", async (bob) => (await indexing(bob)).refer(index, "did:ng:o:some-object"));
|
||||||
|
|
||||||
const own = await as("alice", (alice) => alice.readDeposits(index));
|
const own = await as("alice", (alice) => alice.readDeposits(index));
|
||||||
expect(own.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]);
|
expect(own.map((deposit) => deposit.payload)).toEqual(["did:ng:o:some-object"]);
|
||||||
@@ -202,18 +225,18 @@ test("anyone may deposit into an index, only its owner may read what was deposit
|
|||||||
// --- 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("readDocument returns what was written, and refuses a document that is no index", async () => {
|
||||||
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
|
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
|
||||||
const subjects = await as("alice", (alice) => alice.readDocument(index));
|
const subjects = await as("alice", (alice) => alice.readDocument(index));
|
||||||
expect(subjects).toEqual([{ subject: index, graph: index, props: { [INDEX_FIELD]: [FIELD] } }]);
|
expect(subjects).toEqual([{ subject: index, graph: index, props: { [INDEX_FIELD]: [FIELD] } }]);
|
||||||
|
|
||||||
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(
|
await expect(as("alice", async (alice) => (await indexing(alice)).read(ordinary))).rejects.toThrow(
|
||||||
/declares no index field/,
|
/declares no index field/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
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) => (await indexing(alice)).createIndex(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}" }`);
|
||||||
|
|||||||
+148
-1
@@ -27,11 +27,29 @@ import { asNuri } from "../src/nuri";
|
|||||||
* (`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 in a public store is readable by whoever knows its NURI;
|
||||||
* - a document that cannot be read REJECTS, and a rejection means "unknown",
|
* - a document that cannot be read REJECTS, and a rejection means "unknown",
|
||||||
* never "absent".
|
* never "absent";
|
||||||
|
* - being TOLD what landed in an inbox is reading it, so watching one is refused to
|
||||||
|
* anyone but the document's owner, exactly as opening one is.
|
||||||
|
*
|
||||||
|
* ## Telling a watcher crosses the network, so it is a step of its own
|
||||||
|
*
|
||||||
|
* A deposit is stored the moment it is made — that is the durable fact, and it is
|
||||||
|
* what the owner's next connection finds. Notifying a session that is watching goes
|
||||||
|
* over the wire, and this fake holds those notifications until a test calls
|
||||||
|
* {@link FakeNextGraph.deliverNotifications}. A test that never calls it is a test
|
||||||
|
* in which the owner has not been told yet: a real state, and precisely the one the
|
||||||
|
* catch-up at connection exists for.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Properties = Map<string, string[]>;
|
type Properties = Map<string, string[]>;
|
||||||
|
|
||||||
|
/** One session watching one document's inbox. */
|
||||||
|
interface Watch {
|
||||||
|
readonly doc: Nuri;
|
||||||
|
readonly user: string;
|
||||||
|
readonly onDeposits: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
interface StoredDocument {
|
interface StoredDocument {
|
||||||
readonly nuri: Nuri;
|
readonly nuri: Nuri;
|
||||||
readonly owner: string;
|
readonly owner: string;
|
||||||
@@ -44,6 +62,16 @@ export class FakeNextGraph {
|
|||||||
readonly #documents = new Map<string, StoredDocument>();
|
readonly #documents = new Map<string, StoredDocument>();
|
||||||
/** Documents the broker currently cannot answer about. See `breakReadsOf`. */
|
/** Documents the broker currently cannot answer about. See `breakReadsOf`. */
|
||||||
readonly #unreachable = new Map<string, string>();
|
readonly #unreachable = new Map<string, string>();
|
||||||
|
/** Inboxes the broker currently cannot READ. See `breakInboxReadsOf`. */
|
||||||
|
readonly #inboxUnreadable = new Map<string, string>();
|
||||||
|
/** Inboxes the broker currently refuses to WATCH. See `breakWatchingOf`. */
|
||||||
|
readonly #inboxUnwatchable = new Map<string, string>();
|
||||||
|
/** Every live watch, across every identity — a session watching its own inbox. */
|
||||||
|
#watches: Watch[] = [];
|
||||||
|
/** Notifications the broker has not handed over yet. See `deliverNotifications`. */
|
||||||
|
#undelivered: Watch[] = [];
|
||||||
|
/** Why a store listing cannot answer, when a test has made it fail. */
|
||||||
|
#listingFailure: string | undefined;
|
||||||
#documentCount = 0;
|
#documentCount = 0;
|
||||||
#clock = 0;
|
#clock = 0;
|
||||||
|
|
||||||
@@ -83,6 +111,14 @@ export class FakeNextGraph {
|
|||||||
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
|
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
|
||||||
return network.#readDeposits(user, asNuri(doc));
|
return network.#readDeposits(user, asNuri(doc));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async watchDeposits(doc: NuriLike, onDeposits: () => Promise<void>): Promise<void> {
|
||||||
|
network.#watchDeposits(user, asNuri(doc), onDeposits);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPublicDocuments(): Promise<readonly Nuri[]> {
|
||||||
|
return network.#listDocuments(user);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +136,77 @@ export class FakeNextGraph {
|
|||||||
this.#unreachable.delete(asNuri(doc));
|
this.#unreachable.delete(asNuri(doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The broker serves the DOCUMENT but not its INBOX.
|
||||||
|
*
|
||||||
|
* Not a contrivance: upstream an inbox is a repo of its own, reached through an
|
||||||
|
* address `openDocumentInbox` resolves and read with that repo's capability,
|
||||||
|
* while the document itself is read by `readUnion`. Two repos, two reads — so
|
||||||
|
* one answering while the other does not is what a partial failure looks like,
|
||||||
|
* and it is the state that makes a catch-up fail on one index and no other.
|
||||||
|
*/
|
||||||
|
breakInboxReadsOf(doc: NuriLike, reason: string): void {
|
||||||
|
this.#inboxUnreadable.set(asNuri(doc), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inbox can be read again. */
|
||||||
|
healInboxReadsOf(doc: NuriLike): void {
|
||||||
|
this.#inboxUnreadable.delete(asNuri(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The broker refuses to keep this session posted about that inbox, while
|
||||||
|
* everything else about it still works.
|
||||||
|
*
|
||||||
|
* Watching is a live subscription, set up and held open for as long as the
|
||||||
|
* session lasts; reading an inbox is one question and one answer. A subscription
|
||||||
|
* can be refused where a read succeeds, which is the state that leaves an index
|
||||||
|
* caught up but unwatched — deposits into it going unnoticed until the next
|
||||||
|
* connection, exactly as the failure this models says.
|
||||||
|
*/
|
||||||
|
breakWatchingOf(doc: NuriLike, reason: string): void {
|
||||||
|
this.#inboxUnwatchable.set(asNuri(doc), reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inbox can be watched again. */
|
||||||
|
healWatchingOf(doc: NuriLike): void {
|
||||||
|
this.#inboxUnwatchable.delete(asNuri(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands over every inbox notification the broker was holding, and waits for the
|
||||||
|
* sessions watching to finish with them — including notifications those very runs
|
||||||
|
* provoke, so this returns with nothing left in flight.
|
||||||
|
*/
|
||||||
|
async deliverNotifications(): Promise<void> {
|
||||||
|
while (this.#undelivered.length > 0) {
|
||||||
|
const batch = this.#undelivered;
|
||||||
|
this.#undelivered = [];
|
||||||
|
for (const watch of batch) await watch.onDeposits();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This identity's page is gone: every watch its sessions had opened stops, and
|
||||||
|
* anything the broker was about to tell them is dropped. The polyfill's watching
|
||||||
|
* lasts exactly as long as an identity stays connected, and so does this.
|
||||||
|
*
|
||||||
|
* Nothing durable is lost — the deposits are in their inboxes, which is what makes
|
||||||
|
* the catch-up at the next connection enough on its own.
|
||||||
|
*/
|
||||||
|
disconnect(user: string): void {
|
||||||
|
this.#undelivered = this.#undelivered.filter((watch) => watch.user !== user);
|
||||||
|
this.#watches = this.#watches.filter((watch) => watch.user !== user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store can no longer say which documents an identity has. Upstream throws
|
||||||
|
* rather than answer a listing it could not establish, so this does too.
|
||||||
|
*/
|
||||||
|
breakListing(reason: string): void {
|
||||||
|
this.#listingFailure = reason;
|
||||||
|
}
|
||||||
|
|
||||||
/** A NURI shaped like any other, that no document was ever created for. */
|
/** A NURI shaped like any other, that no document was ever created for. */
|
||||||
neverCreatedNuri(): Nuri {
|
neverCreatedNuri(): Nuri {
|
||||||
return "did:ng:o:doc-never-created" as Nuri;
|
return "did:ng:o:doc-never-created" as Nuri;
|
||||||
@@ -216,6 +323,42 @@ 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 });
|
||||||
|
// Stored first, told afterwards: the deposit is a fact even if nobody is ever
|
||||||
|
// told, which is what makes the catch-up at connection sufficient on its own.
|
||||||
|
for (const watch of this.#watches) {
|
||||||
|
if (watch.doc === doc) this.#undelivered.push(watch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#watchDeposits(user: string, doc: Nuri, onDeposits: () => Promise<void>): void {
|
||||||
|
const stored = this.#require(doc);
|
||||||
|
if (stored.owner !== user) {
|
||||||
|
throw new Error(
|
||||||
|
`${user} may not watch the inbox of ${doc}: being told what landed in an inbox ` +
|
||||||
|
"is reading it, and you may only READ your own",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const unwatchable = this.#inboxUnwatchable.get(doc);
|
||||||
|
// Refused AFTER the owner check: resolving the address is an owner-only act, so
|
||||||
|
// a stranger is turned away before any subscription is ever attempted.
|
||||||
|
if (unwatchable !== undefined) {
|
||||||
|
throw new Error(`cannot watch the inbox of ${doc}: ${unwatchable}`);
|
||||||
|
}
|
||||||
|
// Watching resolves the inbox address, and the call that resolves one opens it
|
||||||
|
// when there is none — the same idempotent call `openInbox` makes.
|
||||||
|
stored.deposits ??= [];
|
||||||
|
this.#watches.push({ doc, user, onDeposits });
|
||||||
|
}
|
||||||
|
|
||||||
|
#listDocuments(user: string): readonly Nuri[] {
|
||||||
|
if (this.#listingFailure !== undefined) {
|
||||||
|
throw new Error(`cannot list the public store: ${this.#listingFailure}`);
|
||||||
|
}
|
||||||
|
const mine: Nuri[] = [];
|
||||||
|
for (const stored of this.#documents.values()) {
|
||||||
|
if (stored.owner === user) mine.push(stored.nuri);
|
||||||
|
}
|
||||||
|
return mine;
|
||||||
}
|
}
|
||||||
|
|
||||||
#readDeposits(user: string, doc: Nuri): readonly IncomingDeposit[] {
|
#readDeposits(user: string, doc: Nuri): readonly IncomingDeposit[] {
|
||||||
@@ -228,6 +371,10 @@ export class FakeNextGraph {
|
|||||||
"inbox, you may only READ your own",
|
"inbox, you may only READ your own",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const unreadable = this.#inboxUnreadable.get(doc);
|
||||||
|
if (unreadable !== undefined) {
|
||||||
|
throw new Error(`cannot read the inbox of ${doc}: ${unreadable}`);
|
||||||
|
}
|
||||||
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
|
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-2
@@ -40,7 +40,11 @@ import type { Nuri, UnionSubject } from "../src/port";
|
|||||||
* - a document with no inbox READS as `[]` — a state, not an error
|
* - a document with no inbox READS as `[]` — a state, not an error
|
||||||
* (`depositsForDocument`);
|
* (`depositsForDocument`);
|
||||||
* - 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`), and so is watching one, since being told what landed in an
|
||||||
|
* inbox is reading it;
|
||||||
|
* - watching lasts exactly as long as the identity stays connected: signing in as
|
||||||
|
* somebody else stops every watch the previous identity had opened
|
||||||
|
* (`contract_polyfill-surface`, "Guarantees");
|
||||||
* - `readUnion` swallows a failing document into `[]` (`readDoc`'s
|
* - `readUnion` swallows a failing document into `[]` (`readDoc`'s
|
||||||
* `try {…} catch { return [] }`), and may also reject outright;
|
* `try {…} catch { return [] }`), and may also reject outright;
|
||||||
* - `readUnion` builds each subject's props as a plain object literal filled by
|
* - `readUnion` builds each subject's props as a plain object literal filled by
|
||||||
@@ -271,6 +275,13 @@ export interface FakePolyfill {
|
|||||||
/** `readUnion` REJECTS outright — its session-level failure, not a per-document one. */
|
/** `readUnion` REJECTS outright — its session-level failure, not a per-document one. */
|
||||||
breakReadUnion(reason: string): void;
|
breakReadUnion(reason: string): void;
|
||||||
healReadUnion(): void;
|
healReadUnion(): void;
|
||||||
|
/**
|
||||||
|
* Hands over every inbox notification the broker was holding, and waits for the
|
||||||
|
* watching session to finish with each — the callback is declared `void` upstream,
|
||||||
|
* so production never waits for it, and this does only because a test needs a point
|
||||||
|
* at which the work is over.
|
||||||
|
*/
|
||||||
|
deliverNotifications(): Promise<void>;
|
||||||
/** Every subject in a document, read from outside the adapter. */
|
/** 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[] }[];
|
||||||
}
|
}
|
||||||
@@ -284,6 +295,10 @@ export function installFakePolyfill(): FakePolyfill {
|
|||||||
const documents = new Map<string, StoredDocument>();
|
const documents = new Map<string, StoredDocument>();
|
||||||
const unreachable = new Map<string, string>();
|
const unreachable = new Map<string, string>();
|
||||||
const calls: RecordedCall[] = [];
|
const calls: RecordedCall[] = [];
|
||||||
|
/** address → the document whose inbox it is. Nothing else resolves one. */
|
||||||
|
const inboxAddresses = new Map<string, string>();
|
||||||
|
let watches: { readonly doc: string; readonly onDeposits: (d: Deposit[]) => unknown }[] = [];
|
||||||
|
let undelivered: { readonly doc: string; readonly onDeposits: (d: Deposit[]) => unknown }[] = [];
|
||||||
let unionFailure: string | undefined;
|
let unionFailure: string | undefined;
|
||||||
let currentUser = "nobody";
|
let currentUser = "nobody";
|
||||||
let documentCount = 0;
|
let documentCount = 0;
|
||||||
@@ -386,6 +401,32 @@ export function installFakePolyfill(): FakePolyfill {
|
|||||||
const from = Object.hasOwn(opts, "from") ? (opts.from ?? null) : currentUser;
|
const from = Object.hasOwn(opts, "from") ? (opts.from ?? null) : currentUser;
|
||||||
clock += 1;
|
clock += 1;
|
||||||
stored.deposits.push({ from, payload: opts.payload ?? null, ts: clock });
|
stored.deposits.push({ from, payload: opts.payload ?? null, ts: clock });
|
||||||
|
// Stored first, told afterwards — and told over the wire, which is why the
|
||||||
|
// notification waits for `deliverNotifications` rather than firing inline.
|
||||||
|
for (const watch of watches) if (watch.doc === stored.nuri) undelivered.push(watch);
|
||||||
|
},
|
||||||
|
|
||||||
|
watch(targetInbox: unknown, onDeposits: unknown): () => void {
|
||||||
|
const doc = inboxAddresses.get(String(targetInbox));
|
||||||
|
if (doc === undefined) {
|
||||||
|
// "`inbox.post` refuses a target that is not an inbox" — so does watching one,
|
||||||
|
// and nothing outside `openDocumentInbox` ever hands an address out.
|
||||||
|
throw new Error(`[fake-polyfill] not an inbox address: ${String(targetInbox)}`);
|
||||||
|
}
|
||||||
|
if (require(doc).owner !== currentUser) {
|
||||||
|
throw new Error(
|
||||||
|
`${currentUser} may not watch the inbox of ${doc}: you may DEPOSIT into ` +
|
||||||
|
"anyone's inbox, you may only READ your own",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof onDeposits !== "function") {
|
||||||
|
throw new Error("[fake-polyfill] inbox.watch takes a callback");
|
||||||
|
}
|
||||||
|
const watch = { doc, onDeposits: onDeposits as (d: Deposit[]) => unknown };
|
||||||
|
watches.push(watch);
|
||||||
|
return () => {
|
||||||
|
watches = watches.filter((w) => w !== watch);
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async readForDocument(doc: unknown): Promise<Deposit[]> {
|
async readForDocument(doc: unknown): Promise<Deposit[]> {
|
||||||
@@ -422,7 +463,23 @@ export function installFakePolyfill(): FakePolyfill {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
stored.deposits ??= [];
|
stored.deposits ??= [];
|
||||||
return `${stored.nuri}:inbox`;
|
// Idempotent within a page: asking again for a document that already has one
|
||||||
|
// resolves that same address rather than adding a second inbox.
|
||||||
|
const address = `${stored.nuri}:inbox`;
|
||||||
|
inboxAddresses.set(address, stored.nuri);
|
||||||
|
return address;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listMyEntityDocs(scope: unknown): Promise<Nuri[]> {
|
||||||
|
if (scope !== "public" && scope !== "mine") {
|
||||||
|
throw new Error(`[fake-polyfill] unknown scope ${JSON.stringify(scope)}`);
|
||||||
|
}
|
||||||
|
// "listMyEntityDocs returns a listing whose documents you can open, or it throws."
|
||||||
|
const mine: Nuri[] = [];
|
||||||
|
for (const stored of documents.values()) {
|
||||||
|
if (stored.owner === currentUser) mine.push(stored.nuri);
|
||||||
|
}
|
||||||
|
return mine;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -492,8 +549,26 @@ export function installFakePolyfill(): FakePolyfill {
|
|||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
signIn(user: string) {
|
signIn(user: string) {
|
||||||
|
// "It lasts exactly as long as that identity stays connected — changing identity
|
||||||
|
// or clearing it stops it." This fake has ONE signed-in identity at a time, as a
|
||||||
|
// page does, so a watch cannot outlive the identity that opened it. Signing in as
|
||||||
|
// the same identity again is not a change, and stops nothing.
|
||||||
|
if (user !== currentUser) {
|
||||||
|
watches = [];
|
||||||
|
undelivered = [];
|
||||||
|
}
|
||||||
currentUser = user;
|
currentUser = user;
|
||||||
},
|
},
|
||||||
|
async deliverNotifications() {
|
||||||
|
while (undelivered.length > 0) {
|
||||||
|
const batch = undelivered;
|
||||||
|
undelivered = [];
|
||||||
|
for (const watch of batch) {
|
||||||
|
const stored = documents.get(watch.doc);
|
||||||
|
await watch.onDeposits([...(stored?.deposits ?? [])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
sessionId() {
|
sessionId() {
|
||||||
return `session:${currentUser}`;
|
return `session:${currentUser}`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { expect, mock, test } from "bun:test";
|
||||||
|
import { indexing, type Indexing } from "../src/indexing";
|
||||||
|
import { coalescing } from "../src/coalescing";
|
||||||
|
import type { Nuri } from "../src/port";
|
||||||
|
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHEN an index is curated — the engagement `createIndex` makes about what becomes
|
||||||
|
* of what it created: the index is curated at its creator's next connection, and on
|
||||||
|
* each deposit while the creator is connected.
|
||||||
|
*
|
||||||
|
* Nothing below calls curation, because there is nothing to call. Every test here
|
||||||
|
* drives the two acts an application really has — connecting (obtaining a handle)
|
||||||
|
* and depositing — and asserts what the index holds afterwards.
|
||||||
|
*
|
||||||
|
* The case space is the creator's presence crossed with the deposit's timing:
|
||||||
|
* away when it was made, connected when it was made, and connected on an index
|
||||||
|
* that a previous session created. Plus the two that must NOT happen: a stranger
|
||||||
|
* connecting curates nothing, and a document that is no index is left alone.
|
||||||
|
*
|
||||||
|
* And crossing all of it, the three ways connecting can FAIL — it cannot look for
|
||||||
|
* its indexes, it cannot go through one, it cannot watch one. Each has its own test
|
||||||
|
* below, because each is a failure wearing the shape of an absence: the session
|
||||||
|
* carries on, the handle works, and an index quietly holds less than it should. The
|
||||||
|
* engagement is that none of them denies anything and none of them loses a deposit,
|
||||||
|
* which is only worth anything if it is exercised rather than asserted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PUBLISHED_AT = "http://schema.org/datePublished";
|
||||||
|
|
||||||
|
function hardcodedInAppSource(nuri: Nuri): Nuri {
|
||||||
|
return nuri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alice creates an index, and then her page closes. That is the state most of these
|
||||||
|
* tests start from: an index exists, its creator is away, and nothing is watching
|
||||||
|
* it — so a deposit made now can only be seen at her next connection.
|
||||||
|
*/
|
||||||
|
async function aliceCreatesAnIndexAndLeaves(network: FakeNextGraph): Promise<Nuri> {
|
||||||
|
const alice = await indexing(network.portFor("alice"));
|
||||||
|
const index = await alice.createIndex(PUBLISHED_AT);
|
||||||
|
network.disconnect("alice");
|
||||||
|
return hardcodedInAppSource(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `body` with this package's log stream captured, and reports HOW MANY
|
||||||
|
* failures it put there alongside whatever the body produced.
|
||||||
|
*
|
||||||
|
* The count, never the text: what a log line reads is for a human and nothing
|
||||||
|
* promises it, so a test that pinned the words would pin the one thing that is
|
||||||
|
* free to change. What is worth pinning is that a failure was reported AT ALL —
|
||||||
|
* harmless is not the same as invisible, and the whole risk here is a failure
|
||||||
|
* passing for an absence.
|
||||||
|
*/
|
||||||
|
async function capturingReports<T>(
|
||||||
|
body: () => Promise<T>,
|
||||||
|
): Promise<{ result: T; reports: number }> {
|
||||||
|
const reported = mock((..._args: unknown[]) => {});
|
||||||
|
const original = console.error;
|
||||||
|
console.error = reported;
|
||||||
|
try {
|
||||||
|
return { result: await body(), reports: reported.mock.calls.length };
|
||||||
|
} finally {
|
||||||
|
console.error = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the deposits that piled up while the creator was away ----------------
|
||||||
|
|
||||||
|
test("an index is curated at its creator's next connection, with nobody asking", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const index = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
|
||||||
|
// Bob deposits while Alice is away: her session is never told, and the deposit
|
||||||
|
// waits in the inbox where only she can see it.
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
|
||||||
|
await (await indexing(bobPort)).refer(index, article);
|
||||||
|
|
||||||
|
const carol = await indexing(network.portFor("carol"));
|
||||||
|
expect(await carol.read(index)).toEqual([]);
|
||||||
|
|
||||||
|
// Alice comes back. This is the whole of it: obtaining her handle IS the trigger.
|
||||||
|
const alice = await indexing(network.portFor("alice"));
|
||||||
|
|
||||||
|
expect(await alice.read(index)).toEqual([{ object: article, value: "2026-03-04" }]);
|
||||||
|
expect(await carol.read(index)).toEqual([{ object: article, value: "2026-03-04" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a whole backlog is caught up, across every index the creator owns", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const alicePort = network.portFor("alice");
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
|
||||||
|
const first = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
const second = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
// An ordinary public document of Alice's, which is no index at all.
|
||||||
|
await publishObject(alicePort, PUBLISHED_AT, "2026-01-01");
|
||||||
|
|
||||||
|
const bob = await indexing(bobPort);
|
||||||
|
const early = await publishObject(bobPort, PUBLISHED_AT, "2026-01-02");
|
||||||
|
const late = await publishObject(bobPort, PUBLISHED_AT, "2026-05-06");
|
||||||
|
await bob.refer(first, early);
|
||||||
|
await bob.refer(first, late);
|
||||||
|
await bob.refer(second, late);
|
||||||
|
|
||||||
|
const alice = await indexing(alicePort);
|
||||||
|
|
||||||
|
expect((await alice.read(first)).map((e) => e.value)).toEqual(["2026-01-02", "2026-05-06"]);
|
||||||
|
expect(await alice.read(second)).toEqual([{ object: late, value: "2026-05-06" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- the deposits that arrive while the creator is looking ----------------
|
||||||
|
|
||||||
|
test("a deposit made while the creator is connected is curated as it lands", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const alice = await indexing(network.portFor("alice"));
|
||||||
|
const index = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
|
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
|
||||||
|
await (await indexing(bobPort)).refer(index, article);
|
||||||
|
|
||||||
|
// The index was created in THIS session, so the store search never saw it: what
|
||||||
|
// brings it under observation is `createIndex` itself.
|
||||||
|
await network.deliverNotifications();
|
||||||
|
|
||||||
|
expect(await alice.read(index)).toEqual([{ object: article, value: "2026-03-04" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an index from a previous session is watched too, not merely caught up once", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const index = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
const bob = await indexing(bobPort);
|
||||||
|
|
||||||
|
// Alice comes back to an index she created before, with nothing waiting in it.
|
||||||
|
const alice = await indexing(network.portFor("alice"));
|
||||||
|
expect(await alice.read(index)).toEqual([]);
|
||||||
|
|
||||||
|
// …and only now does Bob deposit. Nothing but the watch can carry this one.
|
||||||
|
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-07-08");
|
||||||
|
await bob.refer(index, article);
|
||||||
|
await network.deliverNotifications();
|
||||||
|
|
||||||
|
expect(await alice.read(index)).toEqual([{ object: article, value: "2026-07-08" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a burst of deposits settles on the same index, whatever order they are told in", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const alice = await indexing(network.portFor("alice"));
|
||||||
|
const index = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
|
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
const bob = await indexing(bobPort);
|
||||||
|
for (const date of ["2026-03-04", "2026-01-31", "2025-12-25"]) {
|
||||||
|
await bob.refer(index, await publishObject(bobPort, PUBLISHED_AT, date));
|
||||||
|
}
|
||||||
|
|
||||||
|
await network.deliverNotifications();
|
||||||
|
|
||||||
|
expect((await alice.read(index)).map((e) => e.value)).toEqual([
|
||||||
|
"2025-12-25",
|
||||||
|
"2026-01-31",
|
||||||
|
"2026-03-04",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- what connecting must NOT do -----------------------------------------
|
||||||
|
|
||||||
|
test("connecting curates nothing for anyone but the creator", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const index = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
const bob = await indexing(bobPort);
|
||||||
|
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
|
||||||
|
await bob.refer(index, article);
|
||||||
|
|
||||||
|
// Bob connects again, and Carol connects: neither owns the index, so neither can
|
||||||
|
// read its inbox — and connecting must not try, nor fail, nor write anything.
|
||||||
|
const carol = await indexing(network.portFor("carol"));
|
||||||
|
await indexing(bobPort);
|
||||||
|
await network.deliverNotifications();
|
||||||
|
|
||||||
|
expect(await carol.read(index)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a public document that is no index is left alone — no inbox, no entry", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const alicePort = network.portFor("alice");
|
||||||
|
const ordinary = await alicePort.createPublicDocument();
|
||||||
|
|
||||||
|
await indexing(alicePort);
|
||||||
|
|
||||||
|
// Had connecting treated every public document as an index, it would have opened
|
||||||
|
// an inbox on this one — which is exactly what makes a deposit possible.
|
||||||
|
const bob = await indexing(network.portFor("bob"));
|
||||||
|
await expect(bob.refer(ordinary, "did:ng:o:doc-9")).rejects.toThrow(/has no inbox/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a session that could not look for its indexes is still a working handle", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const index = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
|
||||||
|
await (await indexing(bobPort)).refer(index, article);
|
||||||
|
|
||||||
|
network.breakListing("broker unreachable");
|
||||||
|
const reported = mock((..._args: unknown[]) => {});
|
||||||
|
const original = console.error;
|
||||||
|
console.error = reported;
|
||||||
|
let alice: Indexing;
|
||||||
|
try {
|
||||||
|
alice = await indexing(network.portFor("alice"));
|
||||||
|
} finally {
|
||||||
|
console.error = original;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reading an index and depositing into one need none of that work, so nothing is
|
||||||
|
// denied — but the failure is on the log, because a silent one teaches nobody.
|
||||||
|
expect(await alice.read(index)).toEqual([]);
|
||||||
|
await alice.refer(index, article);
|
||||||
|
expect(reported).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(reported.mock.calls[0]?.[0])).toContain("public store could not be listed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an index whose catch-up failed is still a working handle, and loses no deposit", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const stalled = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
const healthy = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
const bob = await indexing(bobPort);
|
||||||
|
const article = await publishObject(bobPort, PUBLISHED_AT, "2026-03-04");
|
||||||
|
await bob.refer(stalled, article);
|
||||||
|
await bob.refer(healthy, article);
|
||||||
|
|
||||||
|
// The broker answers about the document and not about its inbox. Two repos
|
||||||
|
// upstream, read with two capabilities, so this is a partial failure and not a
|
||||||
|
// contrived one — and it is what makes the catch-up fail on THIS index alone.
|
||||||
|
network.breakInboxReadsOf(stalled, "broker unreachable");
|
||||||
|
|
||||||
|
const { result: alice, reports } = await capturingReports(() =>
|
||||||
|
indexing(network.portFor("alice")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Obtaining the handle RESOLVED — reaching this line at all is the assertion.
|
||||||
|
// Reading the index it could not go through still works…
|
||||||
|
expect(await alice.read(stalled)).toEqual([]);
|
||||||
|
// …and so does depositing into it: neither ever depended on that work.
|
||||||
|
await alice.refer(stalled, article);
|
||||||
|
// The session is not poisoned either: the other index was caught up normally.
|
||||||
|
expect(await alice.read(healthy)).toEqual([{ object: article, value: "2026-03-04" }]);
|
||||||
|
expect(reports).toBe(1);
|
||||||
|
|
||||||
|
// And nothing was lost. The deposits never left the inbox, so the first
|
||||||
|
// connection that can read it puts them in — which is the whole reason a failed
|
||||||
|
// run is allowed to be this quiet.
|
||||||
|
network.healInboxReadsOf(stalled);
|
||||||
|
network.disconnect("alice");
|
||||||
|
const back = await indexing(network.portFor("alice"));
|
||||||
|
expect(await back.read(stalled)).toEqual([{ object: article, value: "2026-03-04" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an index that could not be watched is still caught up, and the rest still notices", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const unwatched = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
const watched = await aliceCreatesAnIndexAndLeaves(network);
|
||||||
|
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
const bob = await indexing(bobPort);
|
||||||
|
const waiting = await publishObject(bobPort, PUBLISHED_AT, "2026-01-01");
|
||||||
|
await bob.refer(unwatched, waiting);
|
||||||
|
|
||||||
|
// The subscription is refused; reading that same inbox still works. A watch is
|
||||||
|
// held open where a read is one question and one answer, so one can be turned
|
||||||
|
// down while the other is served.
|
||||||
|
network.breakWatchingOf(unwatched, "the broker refused the subscription");
|
||||||
|
|
||||||
|
const { result: alice, reports } = await capturingReports(() =>
|
||||||
|
indexing(network.portFor("alice")),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The watch failed and the catch-up ran ANYWAY — the backlog is in. That is the
|
||||||
|
// order the code goes to some trouble to hold: failing to watch must not cost
|
||||||
|
// the deposits that were already waiting.
|
||||||
|
expect(await alice.read(unwatched)).toEqual([{ object: waiting, value: "2026-01-01" }]);
|
||||||
|
expect(reports).toBe(1);
|
||||||
|
|
||||||
|
// What the failure costs, exactly and no more: a deposit made from now on is not
|
||||||
|
// NOTICED on that index…
|
||||||
|
const late = await publishObject(bobPort, PUBLISHED_AT, "2026-02-02");
|
||||||
|
await bob.refer(unwatched, late);
|
||||||
|
await bob.refer(watched, late);
|
||||||
|
await network.deliverNotifications();
|
||||||
|
expect((await alice.read(unwatched)).map((e) => e.value)).toEqual(["2026-01-01"]);
|
||||||
|
// …while every other index of the very same session goes on noticing its own.
|
||||||
|
expect(await alice.read(watched)).toEqual([{ object: late, value: "2026-02-02" }]);
|
||||||
|
|
||||||
|
// "Until the next connection" is the whole of the damage, and the next
|
||||||
|
// connection is where it ends.
|
||||||
|
network.healWatchingOf(unwatched);
|
||||||
|
network.disconnect("alice");
|
||||||
|
const back = await indexing(network.portFor("alice"));
|
||||||
|
expect((await back.read(unwatched)).map((e) => e.value)).toEqual(["2026-01-01", "2026-02-02"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- the primitive that keeps a burst from piling up ----------------------
|
||||||
|
|
||||||
|
test("coalescing never runs twice at once, and grants exactly one more run", async () => {
|
||||||
|
const trace: string[] = [];
|
||||||
|
const ask = coalescing(async () => {
|
||||||
|
trace.push("start");
|
||||||
|
// Yields, so the asks below really do arrive while a run is in flight — which
|
||||||
|
// is the only situation this primitive exists for.
|
||||||
|
await Promise.resolve();
|
||||||
|
trace.push("end");
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = ask();
|
||||||
|
const during = [ask(), ask(), ask()];
|
||||||
|
await Promise.all([first, ...during]);
|
||||||
|
|
||||||
|
// Three asks during one run earn ONE more run between them, not three — and not
|
||||||
|
// none, since a deposit that landed after the first run read the inbox would
|
||||||
|
// otherwise wait for the next connection.
|
||||||
|
expect(trace).toEqual(["start", "end", "start", "end"]);
|
||||||
|
// Runs never overlap: no "start" ever follows a "start".
|
||||||
|
expect(trace.join(" ")).not.toContain("start start");
|
||||||
|
|
||||||
|
// And an ask that arrives once everything is quiet is a run of its own.
|
||||||
|
await ask();
|
||||||
|
expect(trace.filter((step) => step === "start")).toHaveLength(3);
|
||||||
|
});
|
||||||
+56
-44
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import { indexing, type Indexing } from "../src/indexing";
|
import { indexing, type Indexing } from "../src/indexing";
|
||||||
|
import { curate } from "../src/curator";
|
||||||
import type { Nuri } from "../src/port";
|
import type { Nuri } from "../src/port";
|
||||||
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
|
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
|
||||||
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
|
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
|
||||||
@@ -20,32 +21,43 @@ function hardcodedInAppSource(nuri: Nuri): Nuri {
|
|||||||
|
|
||||||
type Port = ReturnType<FakeNextGraph["portFor"]>;
|
type Port = ReturnType<FakeNextGraph["portFor"]>;
|
||||||
|
|
||||||
function world(): {
|
async function world(): Promise<{
|
||||||
network: FakeNextGraph;
|
network: FakeNextGraph;
|
||||||
alice: Indexing;
|
alice: Indexing;
|
||||||
bob: Indexing;
|
bob: Indexing;
|
||||||
carol: Indexing;
|
carol: Indexing;
|
||||||
ports: { alice: Port; bob: Port; carol: Port };
|
ports: { alice: Port; bob: Port; carol: Port };
|
||||||
} {
|
}> {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const ports = {
|
const ports = {
|
||||||
alice: network.portFor("alice"),
|
alice: network.portFor("alice"),
|
||||||
bob: network.portFor("bob"),
|
bob: network.portFor("bob"),
|
||||||
carol: network.portFor("carol"),
|
carol: network.portFor("carol"),
|
||||||
};
|
};
|
||||||
|
// Three connections, none of which owns an index yet: there is nothing to catch up
|
||||||
|
// on and nothing to watch. What each of them does next is what these tests are about.
|
||||||
return {
|
return {
|
||||||
network,
|
network,
|
||||||
alice: indexing(ports.alice),
|
alice: await indexing(ports.alice),
|
||||||
bob: indexing(ports.bob),
|
bob: await indexing(ports.bob),
|
||||||
carol: indexing(ports.carol),
|
carol: await indexing(ports.carol),
|
||||||
ports,
|
ports,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These tests exercise the curation RULES, so they run the curator itself rather
|
||||||
|
* than wait for an inbox notification: what a run makes of a deposit is what is
|
||||||
|
* under test, not when the run happens. `inbox-processing.test.ts` covers the when.
|
||||||
|
*
|
||||||
|
* The deposits below therefore sit in their inbox, told to nobody, which is exactly
|
||||||
|
* the state an owner's next connection finds.
|
||||||
|
*/
|
||||||
|
|
||||||
// --- 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, ports } = await world();
|
||||||
|
|
||||||
const index = await alice.createIndex(PUBLISHED_AT);
|
const index = await alice.createIndex(PUBLISHED_AT);
|
||||||
|
|
||||||
@@ -59,7 +71,7 @@ test("any user creates an index in their public store, and it declares its field
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("reading a document that declares no index field is refused, not answered empty", async () => {
|
test("reading a document that declares no index field is refused, not answered empty", async () => {
|
||||||
const { alice, ports } = world();
|
const { alice, ports } = await world();
|
||||||
const ordinary = await ports.alice.createPublicDocument();
|
const ordinary = await ports.alice.createPublicDocument();
|
||||||
await expect(alice.read(ordinary)).rejects.toThrow(/declares no index field/);
|
await expect(alice.read(ordinary)).rejects.toThrow(/declares no index field/);
|
||||||
});
|
});
|
||||||
@@ -67,7 +79,7 @@ test("reading a document that declares no index field is refused, not answered e
|
|||||||
// --- the whole loop, across three people ----------------------------------
|
// --- the whole loop, across three people ----------------------------------
|
||||||
|
|
||||||
test("a stranger refers an object, the owner curates, and anyone reads the result", async () => {
|
test("a stranger refers an object, the owner curates, and anyone reads the result", async () => {
|
||||||
const { alice, bob, carol, ports } = world();
|
const { alice, bob, carol, ports } = await 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 indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
@@ -80,7 +92,7 @@ test("a stranger refers an object, the owner curates, and anyone reads the resul
|
|||||||
// Nothing is in the index until its owner acts.
|
// Nothing is in the index until its owner acts.
|
||||||
expect(await carol.read(indexNuri)).toEqual([]);
|
expect(await carol.read(indexNuri)).toEqual([]);
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-03-04" }]);
|
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-03-04" }]);
|
||||||
|
|
||||||
// Carol knows only the NURI from the application's source, and gets the entry.
|
// Carol knows only the NURI from the application's source, and gets the entry.
|
||||||
@@ -96,11 +108,11 @@ test("a stranger refers an object, the owner curates, and anyone reads the resul
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("an entry is a subject keyed by the object's NURI, so reading needs nothing new", async () => {
|
test("an entry is a subject keyed by the object's NURI, so reading needs nothing new", async () => {
|
||||||
const { alice, bob, ports } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(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 bob.refer(indexNuri, article);
|
||||||
await alice.curate(indexNuri);
|
await curate(ports.alice, indexNuri);
|
||||||
|
|
||||||
// What `readUnion([indexNuri])` hands an application that never loaded this
|
// What `readUnion([indexNuri])` hands an application that never loaded this
|
||||||
// package: the index's own subject, plus one subject per indexed object.
|
// package: the index's own subject, plus one subject per indexed object.
|
||||||
@@ -113,17 +125,17 @@ test("an entry is a subject keyed by the object's NURI, so reading needs nothing
|
|||||||
// --- only the owner curates ----------------------------------------------
|
// --- only the owner curates ----------------------------------------------
|
||||||
|
|
||||||
test("nobody but the index's owner can curate it: the inbox is refused to others", async () => {
|
test("nobody but the index's owner can curate it: the inbox is refused to others", async () => {
|
||||||
const { alice, bob, ports } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(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 bob.refer(indexNuri, article);
|
||||||
|
|
||||||
await expect(bob.curate(indexNuri)).rejects.toThrow(/may only READ your own/);
|
await expect(curate(ports.bob, indexNuri)).rejects.toThrow(/may only READ your own/);
|
||||||
expect(await alice.read(indexNuri)).toEqual([]);
|
expect(await alice.read(indexNuri)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nobody but the owner writes an index, whatever they know about it", async () => {
|
test("nobody but the owner writes an index, whatever they know about it", async () => {
|
||||||
const { alice, ports } = world();
|
const { alice, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -133,7 +145,7 @@ test("nobody but the owner writes an index, whatever they know about it", async
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 } = await 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.refer(notAnIndex, "did:ng:o:doc-9")).rejects.toThrow(/has no inbox/);
|
||||||
@@ -142,14 +154,14 @@ test("an index whose owner never opened an inbox refuses a deposit rather than l
|
|||||||
// --- adding is idempotent -------------------------------------------------
|
// --- adding is idempotent -------------------------------------------------
|
||||||
|
|
||||||
test("the same reference deposited twice produces one entry", async () => {
|
test("the same reference deposited twice produces one entry", async () => {
|
||||||
const { alice, bob, ports } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(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 bob.refer(indexNuri, article);
|
||||||
await bob.refer(indexNuri, article);
|
await bob.refer(indexNuri, article);
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
expect(report.outcomes).toEqual([
|
expect(report.outcomes).toEqual([
|
||||||
{ result: "indexed", object: article, value: "2026-03-04" },
|
{ result: "indexed", object: article, value: "2026-03-04" },
|
||||||
{ result: "unchanged", object: article },
|
{ result: "unchanged", object: article },
|
||||||
@@ -158,15 +170,15 @@ test("the same reference deposited twice produces one entry", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("curating twice changes nothing the second time — deposits are not consumed", async () => {
|
test("curating twice changes nothing the second time — deposits are not consumed", async () => {
|
||||||
const { alice, bob, ports } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(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 bob.refer(indexNuri, article);
|
||||||
|
|
||||||
await alice.curate(indexNuri);
|
await curate(ports.alice, indexNuri);
|
||||||
const before = await alice.read(indexNuri);
|
const before = await alice.read(indexNuri);
|
||||||
|
|
||||||
const second = await alice.curate(indexNuri);
|
const second = await curate(ports.alice, indexNuri);
|
||||||
expect(second.outcomes).toEqual([{ result: "unchanged", object: article }]);
|
expect(second.outcomes).toEqual([{ result: "unchanged", object: article }]);
|
||||||
expect(await alice.read(indexNuri)).toEqual(before);
|
expect(await alice.read(indexNuri)).toEqual(before);
|
||||||
});
|
});
|
||||||
@@ -174,18 +186,18 @@ test("curating twice changes nothing the second time — deposits are not consum
|
|||||||
// --- a read that cannot answer must never cost the index anything ---------
|
// --- a read that cannot answer must never cost the index anything ---------
|
||||||
|
|
||||||
test("a reference the broker cannot resolve is reported, and adds nothing", async () => {
|
test("a reference the broker cannot resolve is reported, and adds nothing", async () => {
|
||||||
const { network, alice, bob, ports } = world();
|
const { network, alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
|
|
||||||
const first = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
const first = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
||||||
await bob.refer(indexNuri, first);
|
await bob.refer(indexNuri, first);
|
||||||
await alice.curate(indexNuri);
|
await curate(ports.alice, indexNuri);
|
||||||
|
|
||||||
const second = await publishObject(ports.bob, PUBLISHED_AT, "2026-02-02");
|
const second = await publishObject(ports.bob, PUBLISHED_AT, "2026-02-02");
|
||||||
await bob.refer(indexNuri, second);
|
await bob.refer(indexNuri, second);
|
||||||
network.breakReadsOf(second, "broker unreachable");
|
network.breakReadsOf(second, "broker unreachable");
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
const unresolved = report.outcomes.filter((o) => o.result === "unresolved");
|
const unresolved = report.outcomes.filter((o) => o.result === "unresolved");
|
||||||
expect(unresolved).toHaveLength(1);
|
expect(unresolved).toHaveLength(1);
|
||||||
expect(unresolved[0]).toMatchObject({ object: second });
|
expect(unresolved[0]).toMatchObject({ object: second });
|
||||||
@@ -195,11 +207,11 @@ test("a reference the broker cannot resolve is reported, and adds nothing", asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("an already-indexed object survives its own reads failing, and is not even re-read", async () => {
|
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 { network, alice, bob, carol, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
||||||
await bob.refer(indexNuri, article);
|
await bob.refer(indexNuri, article);
|
||||||
await alice.curate(indexNuri);
|
await curate(ports.alice, indexNuri);
|
||||||
|
|
||||||
// A passer-by nudges the index about an entry she found IN IT. Carol obtains
|
// 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
|
// the reference the only way she could in a real application — by reading the
|
||||||
@@ -212,24 +224,24 @@ test("an already-indexed object survives its own reads failing, and is not even
|
|||||||
network.breakReadsOf(article, "broker unreachable");
|
network.breakReadsOf(article, "broker unreachable");
|
||||||
await carol.refer(indexNuri, noticed!.object);
|
await carol.refer(indexNuri, noticed!.object);
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
expect(report.outcomes.every((o) => o.result === "unchanged")).toBe(true);
|
expect(report.outcomes.every((o) => o.result === "unchanged")).toBe(true);
|
||||||
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-01-01" }]);
|
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 () => {
|
test("a failed resolve is self-correcting: the next curation adds what it could not", async () => {
|
||||||
const { network, alice, bob, ports } = world();
|
const { network, alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-05-06");
|
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-05-06");
|
||||||
await bob.refer(indexNuri, article);
|
await bob.refer(indexNuri, article);
|
||||||
|
|
||||||
network.breakReadsOf(article, "broker unreachable");
|
network.breakReadsOf(article, "broker unreachable");
|
||||||
expect((await alice.curate(indexNuri)).outcomes[0]?.result).toBe("unresolved");
|
expect((await curate(ports.alice, indexNuri)).outcomes[0]?.result).toBe("unresolved");
|
||||||
expect(await alice.read(indexNuri)).toEqual([]);
|
expect(await alice.read(indexNuri)).toEqual([]);
|
||||||
|
|
||||||
// The deposit is still there, so nothing has to be re-deposited.
|
// The deposit is still there, so nothing has to be re-deposited.
|
||||||
network.healReadsOf(article);
|
network.healReadsOf(article);
|
||||||
expect((await alice.curate(indexNuri)).outcomes[0]).toEqual({
|
expect((await curate(ports.alice, indexNuri)).outcomes[0]).toEqual({
|
||||||
result: "indexed",
|
result: "indexed",
|
||||||
object: article,
|
object: article,
|
||||||
value: "2026-05-06",
|
value: "2026-05-06",
|
||||||
@@ -238,11 +250,11 @@ test("a failed resolve is self-correcting: the next curation adds what it could
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a reference to something that was never created is reported, not silently dropped", async () => {
|
test("a reference to something that was never created is reported, not silently dropped", async () => {
|
||||||
const { network, alice, bob } = world();
|
const { network, alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
await bob.refer(indexNuri, network.neverCreatedNuri());
|
await bob.refer(indexNuri, network.neverCreatedNuri());
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
expect(report.outcomes).toHaveLength(1);
|
expect(report.outcomes).toHaveLength(1);
|
||||||
expect(report.outcomes[0]?.result).toBe("unresolved");
|
expect(report.outcomes[0]?.result).toBe("unresolved");
|
||||||
expect(await alice.read(indexNuri)).toEqual([]);
|
expect(await alice.read(indexNuri)).toEqual([]);
|
||||||
@@ -251,39 +263,39 @@ test("a reference to something that was never created is reported, not silently
|
|||||||
// --- an object that does not fit the index --------------------------------
|
// --- an object that does not fit the index --------------------------------
|
||||||
|
|
||||||
test("an object carrying nothing for the index's field is not added", async () => {
|
test("an object carrying nothing for the index's field is not added", async () => {
|
||||||
const { alice, bob, ports } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
// Exists, is public, is readable — but says nothing about the field this index
|
// 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.
|
// 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");
|
const object = await publishObject(ports.bob, NAME, "an object with no date");
|
||||||
await bob.refer(indexNuri, object);
|
await bob.refer(indexNuri, object);
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "no-field" }]);
|
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "no-field" }]);
|
||||||
expect(await alice.read(indexNuri)).toEqual([]);
|
expect(await alice.read(indexNuri)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("an object carrying several values for the field is not added", async () => {
|
test("an object carrying several values for the field is not added", async () => {
|
||||||
const { alice, bob, ports } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
|
||||||
await ports.bob.addLiteralProperty(object, object, PUBLISHED_AT, "2026-09-09");
|
await ports.bob.addLiteralProperty(object, object, PUBLISHED_AT, "2026-09-09");
|
||||||
await bob.refer(indexNuri, object);
|
await bob.refer(indexNuri, object);
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "several-values" }]);
|
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "several-values" }]);
|
||||||
expect(await alice.read(indexNuri)).toEqual([]);
|
expect(await alice.read(indexNuri)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a payload that is not a reference is reported as foreign and changes nothing", async () => {
|
test("a payload that is not a reference is reported as foreign and changes nothing", async () => {
|
||||||
const { alice, bob, ports } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(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 bob.refer(indexNuri, article);
|
||||||
// Anyone may deposit anything into an inbox, so untrusted payloads do arrive.
|
// Anyone may deposit anything into an inbox, so untrusted payloads do arrive.
|
||||||
await ports.bob.depositTo(indexNuri, { drop: "everything" });
|
await ports.bob.depositTo(indexNuri, { drop: "everything" });
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
expect(report.outcomes).toEqual([
|
expect(report.outcomes).toEqual([
|
||||||
{ result: "indexed", object: article, value: "2026-03-04" },
|
{ result: "indexed", object: article, value: "2026-03-04" },
|
||||||
{ result: "foreign", reason: "payload is not a reference" },
|
{ result: "foreign", reason: "payload is not a reference" },
|
||||||
@@ -292,11 +304,11 @@ test("a payload that is not a reference is reported as foreign and changes nothi
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("an index referred to itself is skipped, so its declaration cannot become an entry", async () => {
|
test("an index referred to itself is skipped, so its declaration cannot become an entry", async () => {
|
||||||
const { alice, bob } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
await bob.refer(indexNuri, indexNuri);
|
await bob.refer(indexNuri, indexNuri);
|
||||||
|
|
||||||
const report = await alice.curate(indexNuri);
|
const report = await curate(ports.alice, indexNuri);
|
||||||
expect(report.outcomes).toEqual([
|
expect(report.outcomes).toEqual([
|
||||||
{ result: "skipped", object: indexNuri, reason: "self-reference" },
|
{ result: "skipped", object: indexNuri, reason: "self-reference" },
|
||||||
]);
|
]);
|
||||||
@@ -306,7 +318,7 @@ test("an index referred to itself is skipped, so its declaration cannot become a
|
|||||||
// --- indexing by a date is an instance of indexing by a field -------------
|
// --- indexing by a date is an instance of indexing by a field -------------
|
||||||
|
|
||||||
test("an index whose field is a date reads back in chronological order", async () => {
|
test("an index whose field is a date reads back in chronological order", async () => {
|
||||||
const { alice, bob, carol, ports } = world();
|
const { alice, bob, carol, ports } = await world();
|
||||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
|
|
||||||
const march = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
const march = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
||||||
@@ -317,7 +329,7 @@ test("an index whose field is a date reads back in chronological order", async (
|
|||||||
await bob.refer(indexNuri, march);
|
await bob.refer(indexNuri, march);
|
||||||
await bob.refer(indexNuri, december);
|
await bob.refer(indexNuri, december);
|
||||||
await bob.refer(indexNuri, january);
|
await bob.refer(indexNuri, january);
|
||||||
await alice.curate(indexNuri);
|
await curate(ports.alice, indexNuri);
|
||||||
|
|
||||||
expect((await carol.read(indexNuri)).map((e) => e.value)).toEqual([
|
expect((await carol.read(indexNuri)).map((e) => e.value)).toEqual([
|
||||||
"2025-12-25",
|
"2025-12-25",
|
||||||
@@ -327,7 +339,7 @@ test("an index whose field is a date reads back in chronological order", async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("two indexes over the same objects, on different fields, do not interfere", async () => {
|
test("two indexes over the same objects, on different fields, do not interfere", async () => {
|
||||||
const { alice, bob, ports } = world();
|
const { alice, bob, ports } = await world();
|
||||||
const byDate = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
const byDate = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||||
const byName = hardcodedInAppSource(await alice.createIndex(NAME));
|
const byName = hardcodedInAppSource(await alice.createIndex(NAME));
|
||||||
|
|
||||||
@@ -336,8 +348,8 @@ test("two indexes over the same objects, on different fields, do not interfere",
|
|||||||
|
|
||||||
await bob.refer(byDate, object);
|
await bob.refer(byDate, object);
|
||||||
await bob.refer(byName, object);
|
await bob.refer(byName, object);
|
||||||
await alice.curate(byDate);
|
await curate(ports.alice, byDate);
|
||||||
await alice.curate(byName);
|
await curate(ports.alice, byName);
|
||||||
|
|
||||||
expect(await alice.read(byDate)).toEqual([{ object, value: "2026-03-04" }]);
|
expect(await alice.read(byDate)).toEqual([{ object, value: "2026-03-04" }]);
|
||||||
expect(await alice.read(byName)).toEqual([{ object, value: "Anemone" }]);
|
expect(await alice.read(byName)).toEqual([{ object, value: "Anemone" }]);
|
||||||
|
|||||||
+58
-30
@@ -1,5 +1,6 @@
|
|||||||
import { expect, mock, test } from "bun:test";
|
import { expect, mock, test } from "bun:test";
|
||||||
import { indexing } from "../src/indexing";
|
import { indexing } from "../src/indexing";
|
||||||
|
import { curate } from "../src/curator";
|
||||||
import { entriesOf, entryValue } from "../src/index-document";
|
import { entriesOf, entryValue } from "../src/index-document";
|
||||||
import { resolutionFromFailure, resolutionFromRead } from "../src/resolution";
|
import { resolutionFromFailure, resolutionFromRead } from "../src/resolution";
|
||||||
import type { Nuri, UnionSubject } from "../src/port";
|
import type { Nuri, UnionSubject } from "../src/port";
|
||||||
@@ -64,11 +65,11 @@ test("an entry whose value is the empty string is still an entry", () => {
|
|||||||
test("adding a second value to an entry cannot make it disappear", async () => {
|
test("adding a second value to an entry cannot make it disappear", async () => {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const ownerPort = network.portFor("alice");
|
const ownerPort = network.portFor("alice");
|
||||||
const owner = indexing(ownerPort);
|
const owner = await indexing(ownerPort);
|
||||||
const index = await owner.createIndex(FIELD);
|
const index = await owner.createIndex(FIELD);
|
||||||
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
||||||
await indexing(network.portFor("bob")).refer(index, article);
|
await (await indexing(network.portFor("bob"))).refer(index, article);
|
||||||
await owner.curate(index);
|
await curate(ownerPort, index);
|
||||||
|
|
||||||
// A pure ADD — the only write this package has. Before the fix this emptied
|
// A pure ADD — the only write this package has. Before the fix this emptied
|
||||||
// `read()` while both triples sat in the document.
|
// `read()` while both triples sat in the document.
|
||||||
@@ -80,12 +81,12 @@ test("adding a second value to an entry cannot make it disappear", async () => {
|
|||||||
test("a raced double-add settles, and does not make every later run re-add", async () => {
|
test("a raced double-add settles, and does not make every later run re-add", async () => {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const ownerPort = network.portFor("alice");
|
const ownerPort = network.portFor("alice");
|
||||||
const owner = indexing(ownerPort);
|
const owner = await indexing(ownerPort);
|
||||||
const index = await owner.createIndex(FIELD);
|
const index = await owner.createIndex(FIELD);
|
||||||
const bobPort = network.portFor("bob");
|
const bobPort = network.portFor("bob");
|
||||||
const article = await publishObject(bobPort, FIELD, "2026-01-01");
|
const article = await publishObject(bobPort, FIELD, "2026-01-01");
|
||||||
await indexing(bobPort).refer(index, article);
|
await (await indexing(bobPort)).refer(index, article);
|
||||||
await owner.curate(index);
|
await curate(ownerPort, index);
|
||||||
|
|
||||||
// What two curation runs racing each other leave behind: the object's owner
|
// What two curation runs racing each other leave behind: the object's owner
|
||||||
// edited it between their reads, so each added its own value.
|
// edited it between their reads, so each added its own value.
|
||||||
@@ -94,7 +95,7 @@ test("a raced double-add settles, and does not make every later run re-add", asy
|
|||||||
|
|
||||||
// The entry is still there, and the curator recognises it as already indexed —
|
// 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.
|
// before the fix it was invisible, so every run added yet another value.
|
||||||
const report = await owner.curate(index);
|
const report = await curate(ownerPort, index);
|
||||||
expect(report.outcomes).toEqual([{ result: "unchanged", object: article }]);
|
expect(report.outcomes).toEqual([{ result: "unchanged", object: article }]);
|
||||||
expect(await owner.read(index)).toEqual([{ object: article, value: "2026-01-01" }]);
|
expect(await owner.read(index)).toEqual([{ object: article, value: "2026-01-01" }]);
|
||||||
});
|
});
|
||||||
@@ -104,13 +105,13 @@ test("a raced double-add settles, and does not make every later run re-add", asy
|
|||||||
test("a second declared field stops curation LOUDLY and costs no entry", async () => {
|
test("a second declared field stops curation LOUDLY and costs no entry", async () => {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const ownerPort = network.portFor("alice");
|
const ownerPort = network.portFor("alice");
|
||||||
const owner = indexing(ownerPort);
|
const owner = await indexing(ownerPort);
|
||||||
const bobPort = network.portFor("bob");
|
const bobPort = network.portFor("bob");
|
||||||
const index = await owner.createIndex(FIELD);
|
const index = await owner.createIndex(FIELD);
|
||||||
for (const date of ["2026-01-01", "2026-02-02", "2026-03-03"]) {
|
for (const date of ["2026-01-01", "2026-02-02", "2026-03-03"]) {
|
||||||
await indexing(bobPort).refer(index, await publishObject(bobPort, FIELD, date));
|
await (await indexing(bobPort)).refer(index, await publishObject(bobPort, FIELD, date));
|
||||||
}
|
}
|
||||||
await owner.curate(index);
|
await curate(ownerPort, index);
|
||||||
expect(await owner.read(index)).toHaveLength(3);
|
expect(await owner.read(index)).toHaveLength(3);
|
||||||
|
|
||||||
// One add-only write through the published surface — and the SMALLER string, the
|
// One add-only write through the published surface — and the SMALLER string, the
|
||||||
@@ -121,28 +122,28 @@ test("a second declared field stops curation LOUDLY and costs no entry", async (
|
|||||||
// unreadable because the declaration above it turned ambiguous.
|
// unreadable because the declaration above it turned ambiguous.
|
||||||
expect(await owner.read(index)).toHaveLength(3);
|
expect(await owner.read(index)).toHaveLength(3);
|
||||||
// Curating refuses, and says why instead of quietly picking one.
|
// Curating refuses, and says why instead of quietly picking one.
|
||||||
await expect(owner.curate(index)).rejects.toThrow(/declares 2 index fields/);
|
await expect(curate(ownerPort, index)).rejects.toThrow(/declares 2 index fields/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a mixed-field index is never produced: curation refuses before adding anything", async () => {
|
test("a mixed-field index is never produced: curation refuses before adding anything", async () => {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const ownerPort = network.portFor("alice");
|
const ownerPort = network.portFor("alice");
|
||||||
const owner = indexing(ownerPort);
|
const owner = await indexing(ownerPort);
|
||||||
const bobPort = network.portFor("bob");
|
const bobPort = network.portFor("bob");
|
||||||
const NAME = "http://schema.org/name";
|
const NAME = "http://schema.org/name";
|
||||||
|
|
||||||
const index = await owner.createIndex(NAME);
|
const index = await owner.createIndex(NAME);
|
||||||
const first = await publishObject(bobPort, NAME, "Anemone");
|
const first = await publishObject(bobPort, NAME, "Anemone");
|
||||||
await indexing(bobPort).refer(index, first);
|
await (await indexing(bobPort)).refer(index, first);
|
||||||
await owner.curate(index);
|
await curate(ownerPort, index);
|
||||||
|
|
||||||
// "…/datePublished" < "…/name", so under "smallest wins" the new field took over
|
// "…/datePublished" < "…/name", so under "smallest wins" the new field took over
|
||||||
// while `first` kept its old value forever — one list ordered by two properties.
|
// while `first` kept its old value forever — one list ordered by two properties.
|
||||||
await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, FIELD);
|
await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, FIELD);
|
||||||
const second = await publishObject(bobPort, FIELD, "2026-02-02");
|
const second = await publishObject(bobPort, FIELD, "2026-02-02");
|
||||||
await indexing(bobPort).refer(index, second);
|
await (await indexing(bobPort)).refer(index, second);
|
||||||
|
|
||||||
await expect(owner.curate(index)).rejects.toThrow(/refusing to curate rather than pick one/);
|
await expect(curate(ownerPort, index)).rejects.toThrow(/refusing to curate rather than pick one/);
|
||||||
expect(await owner.read(index)).toEqual([{ object: first, value: "Anemone" }]);
|
expect(await owner.read(index)).toEqual([{ object: first, value: "Anemone" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,11 +151,34 @@ test("an index declaring no field at all is still refused", async () => {
|
|||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const ownerPort = network.portFor("alice");
|
const ownerPort = network.portFor("alice");
|
||||||
const ordinary = await ownerPort.createPublicDocument();
|
const ordinary = await ownerPort.createPublicDocument();
|
||||||
await expect(indexing(ownerPort).read(ordinary)).rejects.toThrow(/declares no index field/);
|
await expect((await indexing(ownerPort)).read(ordinary)).rejects.toThrow(/declares no index field/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("curating a document that declares no field refuses, and writes nothing", async () => {
|
||||||
|
const network = new FakeNextGraph();
|
||||||
|
const ownerPort = network.portFor("alice");
|
||||||
|
const bobPort = network.portFor("bob");
|
||||||
|
|
||||||
|
// A document of Alice's with an inbox open and a reference waiting in it, and no
|
||||||
|
// field declared. This is also the shape an INDEX arrives in when it could not be
|
||||||
|
// read — the real `readUnion` turns a failed read into `[]` — so the two are one
|
||||||
|
// case here, and the refusal has to hold for both.
|
||||||
|
const noField = await ownerPort.createPublicDocument();
|
||||||
|
await ownerPort.openInbox(noField);
|
||||||
|
const article = await publishObject(bobPort, FIELD, "2026-01-01");
|
||||||
|
await (await indexing(bobPort)).refer(noField, article);
|
||||||
|
|
||||||
|
await expect(curate(ownerPort, noField)).rejects.toThrow(/declares no index field/);
|
||||||
|
|
||||||
|
// It refused instead of curating on a field it does not have, and it refused
|
||||||
|
// BEFORE writing: the document is still empty, so no entry was invented for it,
|
||||||
|
// and the deposit is still in the inbox for a run that knows what to do with it.
|
||||||
|
expect(await ownerPort.readDocument(noField)).toEqual([]);
|
||||||
|
expect(await ownerPort.readDeposits(noField)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a field that could never match an object is refused at creation", async () => {
|
test("a field that could never match an object is refused at creation", async () => {
|
||||||
const owner = indexing(new FakeNextGraph().portFor("alice"));
|
const owner = await indexing(new FakeNextGraph().portFor("alice"));
|
||||||
// It cannot be corrected later — nothing here deletes — so it is refused now.
|
// 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/);
|
||||||
await expect(owner.createIndex(" ")).rejects.toThrow(/cannot be changed later/);
|
await expect(owner.createIndex(" ")).rejects.toThrow(/cannot be changed later/);
|
||||||
@@ -164,7 +188,8 @@ test("a field that could never match an object is refused at creation", async ()
|
|||||||
|
|
||||||
test("a field colliding with Object.prototype neither crashes nor is silently mis-read", async () => {
|
test("a field colliding with Object.prototype neither crashes nor is silently mis-read", async () => {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const owner = indexing(network.portFor("alice"));
|
const ownerPort = network.portFor("alice");
|
||||||
|
const owner = await indexing(ownerPort);
|
||||||
const bobPort = network.portFor("bob");
|
const bobPort = network.portFor("bob");
|
||||||
|
|
||||||
for (const field of ["constructor", "toString", "valueOf", "hasOwnProperty"]) {
|
for (const field of ["constructor", "toString", "valueOf", "hasOwnProperty"]) {
|
||||||
@@ -178,10 +203,10 @@ test("a field colliding with Object.prototype neither crashes nor is silently mi
|
|||||||
// An object that merely LACKS it must still resolve cleanly: reading the field
|
// 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.
|
// off a plain object literal would otherwise hand back an inherited function.
|
||||||
const lacks = await publishObject(bobPort, "http://schema.org/name", "unrelated");
|
const lacks = await publishObject(bobPort, "http://schema.org/name", "unrelated");
|
||||||
await indexing(bobPort).refer(index, lacks);
|
await (await indexing(bobPort)).refer(index, lacks);
|
||||||
await indexing(bobPort).refer(index, carries);
|
await (await indexing(bobPort)).refer(index, carries);
|
||||||
|
|
||||||
const report = await owner.curate(index);
|
const report = await curate(ownerPort, index);
|
||||||
expect(report.outcomes[0]).toEqual({ result: "skipped", object: lacks, reason: "no-field" });
|
expect(report.outcomes[0]).toEqual({ result: "skipped", object: lacks, reason: "no-field" });
|
||||||
expect(report.outcomes[1]?.result).toBe("unresolved");
|
expect(report.outcomes[1]?.result).toBe("unresolved");
|
||||||
expect(await owner.read(index)).toEqual([]);
|
expect(await owner.read(index)).toEqual([]);
|
||||||
@@ -212,17 +237,18 @@ test("a read that threw resolves as unresolved, naming the error", () => {
|
|||||||
|
|
||||||
test("an unresolved reference is warned about, not only reported", async () => {
|
test("an unresolved reference is warned about, not only reported", async () => {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const owner = indexing(network.portFor("alice"));
|
const ownerPort = network.portFor("alice");
|
||||||
|
const owner = await indexing(ownerPort);
|
||||||
const index = await owner.createIndex(FIELD);
|
const index = await owner.createIndex(FIELD);
|
||||||
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
||||||
await indexing(network.portFor("bob")).refer(index, article);
|
await (await indexing(network.portFor("bob"))).refer(index, article);
|
||||||
network.breakReadsOf(article, "broker unreachable");
|
network.breakReadsOf(article, "broker unreachable");
|
||||||
|
|
||||||
const warn = mock((..._args: unknown[]) => {});
|
const warn = mock((..._args: unknown[]) => {});
|
||||||
const original = console.warn;
|
const original = console.warn;
|
||||||
console.warn = warn;
|
console.warn = warn;
|
||||||
try {
|
try {
|
||||||
await owner.curate(index);
|
await curate(ownerPort, index);
|
||||||
} finally {
|
} finally {
|
||||||
console.warn = original;
|
console.warn = original;
|
||||||
}
|
}
|
||||||
@@ -233,17 +259,18 @@ test("an unresolved reference is warned about, not only reported", async () => {
|
|||||||
|
|
||||||
test("a normal run warns about nothing", async () => {
|
test("a normal run warns about nothing", async () => {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const owner = indexing(network.portFor("alice"));
|
const ownerPort = network.portFor("alice");
|
||||||
|
const owner = await indexing(ownerPort);
|
||||||
const index = await owner.createIndex(FIELD);
|
const index = await owner.createIndex(FIELD);
|
||||||
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
|
||||||
await indexing(network.portFor("bob")).refer(index, article);
|
await (await indexing(network.portFor("bob"))).refer(index, article);
|
||||||
|
|
||||||
const warn = mock((..._args: unknown[]) => {});
|
const warn = mock((..._args: unknown[]) => {});
|
||||||
const original = console.warn;
|
const original = console.warn;
|
||||||
console.warn = warn;
|
console.warn = warn;
|
||||||
let report;
|
let report;
|
||||||
try {
|
try {
|
||||||
report = await owner.curate(index);
|
report = await curate(ownerPort, index);
|
||||||
} finally {
|
} finally {
|
||||||
console.warn = original;
|
console.warn = original;
|
||||||
}
|
}
|
||||||
@@ -257,13 +284,14 @@ test("a normal run warns about nothing", async () => {
|
|||||||
|
|
||||||
test("an index that could not be read is refused, and says so without blaming the document", async () => {
|
test("an index that could not be read is refused, and says so without blaming the document", async () => {
|
||||||
const network = new FakeNextGraph();
|
const network = new FakeNextGraph();
|
||||||
const owner = indexing(network.portFor("alice"));
|
const ownerPort = network.portFor("alice");
|
||||||
|
const owner = await indexing(ownerPort);
|
||||||
const index = await owner.createIndex(FIELD);
|
const index = await owner.createIndex(FIELD);
|
||||||
network.breakReadsOf(index, "broker unreachable");
|
network.breakReadsOf(index, "broker unreachable");
|
||||||
|
|
||||||
// The real `readUnion` turns a failed read into `[]`, so the failure arrives
|
// The real `readUnion` turns a failed read into `[]`, so the failure arrives
|
||||||
// looking like a blank document. Whatever the shape, nothing may be written.
|
// looking like a blank document. Whatever the shape, nothing may be written.
|
||||||
await expect(owner.curate(index)).rejects.toThrow();
|
await expect(curate(ownerPort, index)).rejects.toThrow();
|
||||||
await expect(owner.read(index)).rejects.toThrow();
|
await expect(owner.read(index)).rejects.toThrow();
|
||||||
|
|
||||||
network.healReadsOf(index);
|
network.healReadsOf(index);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
polyfillPort,
|
polyfillPort,
|
||||||
ENTRY_VALUE,
|
ENTRY_VALUE,
|
||||||
INDEX_FIELD,
|
INDEX_FIELD,
|
||||||
type CurationReport,
|
|
||||||
type IndexEntry,
|
type IndexEntry,
|
||||||
type NextGraphPort,
|
type NextGraphPort,
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
@@ -22,21 +21,28 @@ test("the published surface carries the whole loop, end to end", async () => {
|
|||||||
const ownerPort: NextGraphPort = network.portFor("alice");
|
const ownerPort: NextGraphPort = network.portFor("alice");
|
||||||
const strangerPort: NextGraphPort = network.portFor("bob");
|
const strangerPort: NextGraphPort = network.portFor("bob");
|
||||||
|
|
||||||
const owner = indexing(ownerPort);
|
const owner = await indexing(ownerPort);
|
||||||
const stranger = indexing(strangerPort);
|
const stranger = await indexing(strangerPort);
|
||||||
|
|
||||||
const index = await owner.createIndex(PUBLISHED_AT);
|
const index = await owner.createIndex(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.refer(index, article);
|
||||||
|
|
||||||
const report: CurationReport = await owner.curate(index);
|
// NOBODY CURATES — there is nothing on this surface to call. Alice is connected,
|
||||||
expect(report.index).toBe(index);
|
// so her session is told a deposit landed and processes that inbox itself.
|
||||||
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]);
|
await network.deliverNotifications();
|
||||||
|
|
||||||
const entries: IndexEntry[] = await owner.read(index);
|
const entries: IndexEntry[] = await owner.read(index);
|
||||||
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]);
|
expect(entries).toEqual([{ object: article, value: "2026-07-08" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the published surface offers no way to run, aim or schedule curation", async () => {
|
||||||
|
const handle = await indexing(new FakeNextGraph().portFor("alice"));
|
||||||
|
// Read off the handle rather than from a list: an application gets these three
|
||||||
|
// acts and nothing else, and curation is not one of them.
|
||||||
|
expect(Object.keys(handle).sort()).toEqual(["createIndex", "read", "refer"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("the published surface exposes the deposit decoder and the two IRIs it writes", () => {
|
test("the published surface exposes the deposit decoder and the two IRIs it writes", () => {
|
||||||
expect(decodeReference("did:ng:o:doc-1")).toBe("did:ng:o:doc-1");
|
expect(decodeReference("did:ng:o:doc-1")).toBe("did:ng:o:doc-1");
|
||||||
expect(decodeReference({ object: "did:ng:o:doc-1" })).toBeNull();
|
expect(decodeReference({ object: "did:ng:o:doc-1" })).toBeNull();
|
||||||
|
|||||||
Reference in New Issue
Block a user