feat!: curer n'est plus un appel, c'est ce que fait le traitement de l'inbox

This commit is contained in:
Sylvain Duchesne
2026-08-20 11:04:31 +02:00
parent 2ce2113157
commit e2ed970cbd
21 changed files with 956 additions and 273 deletions
+40 -17
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import { indexing } from "../src/indexing";
import { curate } from "../src/curator";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
import type { NextGraphPort, Nuri } from "../src/port";
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 -----------------------------
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 object = await publish(bob, FIELD, "2026-07-08");
await indexing(bob).refer(index, object);
await (await indexing(bob)).refer(index, 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" }]);
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" }]);
});
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 () => {
const [first, second] = await as("alice", async (alice) => [
await indexing(alice).createIndex(FIELD),
await indexing(alice).createIndex(FIELD),
await (await indexing(alice)).createIndex(FIELD),
await (await indexing(alice)).createIndex(FIELD),
]);
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 -----
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 object = await publish(bob, FIELD, "2026-07-08");
await indexing(bob).refer(index, object);
await (await indexing(bob)).refer(index, 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.
world.breakReadsOf(article, "broker unreachable");
try {
const report = await as("alice", (alice) => indexing(alice).curate(index));
const report = await as("alice", (alice) => curate(alice, index));
expect(report.outcomes).toEqual([
{ 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 () => {
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 object = await publish(bob, "http://schema.org/name", "Anemone");
await indexing(bob).refer(index, object);
await (await indexing(bob)).refer(index, 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
// `{ state: "present", subjects }` and the unreadable object above is reported
// here's answer instead — a broker failure filed as a property of the object.
const report = await as("alice", (alice) => indexing(alice).curate(index));
const report = await as("alice", (alice) => curate(alice, index));
expect(report.outcomes).toEqual([
{ 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 () => {
const index = await as("alice", (alice) => indexing(alice).createIndex(FIELD));
await as("bob", (bob) => indexing(bob).refer(index, "did:ng:o:some-object"));
const index = await as("alice", async (alice) => (await indexing(alice)).createIndex(FIELD));
await as("bob", async (bob) => (await indexing(bob)).refer(index, "did:ng:o:some-object"));
const own = await as("alice", (alice) => alice.readDeposits(index));
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 --------------------------------------
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));
expect(subjects).toEqual([{ subject: index, graph: index, props: { [INDEX_FIELD]: [FIELD] } }]);
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/,
);
});
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 last = writes.at(-1);
expect(last?.args[1]).toBe(`INSERT DATA { <${index}> <${INDEX_FIELD}> "${FIELD}" }`);
+97 -1
View File
@@ -27,11 +27,29 @@ import { asNuri } from "../src/nuri";
* (`openDocumentInbox`: doing so publishes the document's address);
* - 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",
* 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[]>;
/** One session watching one document's inbox. */
interface Watch {
readonly doc: Nuri;
readonly user: string;
readonly onDeposits: () => Promise<void>;
}
interface StoredDocument {
readonly nuri: Nuri;
readonly owner: string;
@@ -44,6 +62,12 @@ export class FakeNextGraph {
readonly #documents = new Map<string, StoredDocument>();
/** Documents the broker currently cannot answer about. See `breakReadsOf`. */
readonly #unreachable = 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;
#clock = 0;
@@ -83,6 +107,14 @@ export class FakeNextGraph {
async readDeposits(doc: NuriLike): Promise<readonly IncomingDeposit[]> {
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 +132,40 @@ export class FakeNextGraph {
this.#unreachable.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. */
neverCreatedNuri(): Nuri {
return "did:ng:o:doc-never-created" as Nuri;
@@ -216,6 +282,36 @@ export class FakeNextGraph {
}
this.#clock += 1;
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",
);
}
// 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[] {
+77 -2
View File
@@ -40,7 +40,11 @@ import type { Nuri, UnionSubject } from "../src/port";
* - a document with no inbox READS as `[]` — a state, not an error
* (`depositsForDocument`);
* - 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
* `try {…} catch { return [] }`), and may also reject outright;
* - `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. */
breakReadUnion(reason: string): 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. */
contentsOf(doc: string): { subject: string; predicate: string; values: string[] }[];
}
@@ -284,6 +295,10 @@ export function installFakePolyfill(): FakePolyfill {
const documents = new Map<string, StoredDocument>();
const unreachable = new Map<string, string>();
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 currentUser = "nobody";
let documentCount = 0;
@@ -386,6 +401,32 @@ export function installFakePolyfill(): FakePolyfill {
const from = Object.hasOwn(opts, "from") ? (opts.from ?? null) : currentUser;
clock += 1;
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[]> {
@@ -422,7 +463,23 @@ export function installFakePolyfill(): FakePolyfill {
);
}
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 {
calls,
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;
},
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() {
return `session:${currentUser}`;
},
+227
View File
@@ -0,0 +1,227 @@
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 three that must NOT happen: a stranger
* connecting curates nothing, a document that is no index is left alone, and a
* session that could not look for its indexes is still a working handle.
*/
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);
}
// --- 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");
});
// --- 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
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import { indexing, type Indexing } from "../src/indexing";
import { curate } from "../src/curator";
import type { Nuri } from "../src/port";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
@@ -20,32 +21,43 @@ function hardcodedInAppSource(nuri: Nuri): Nuri {
type Port = ReturnType<FakeNextGraph["portFor"]>;
function world(): {
async function world(): Promise<{
network: FakeNextGraph;
alice: Indexing;
bob: Indexing;
carol: Indexing;
ports: { alice: Port; bob: Port; carol: Port };
} {
}> {
const network = new FakeNextGraph();
const ports = {
alice: network.portFor("alice"),
bob: network.portFor("bob"),
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 {
network,
alice: indexing(ports.alice),
bob: indexing(ports.bob),
carol: indexing(ports.carol),
alice: await indexing(ports.alice),
bob: await indexing(ports.bob),
carol: await indexing(ports.carol),
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 ----------------------------------------------------
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);
@@ -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 () => {
const { alice, ports } = world();
const { alice, ports } = await world();
const ordinary = await ports.alice.createPublicDocument();
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 ----------------------------------
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.
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.
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" }]);
// 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 () => {
const { alice, bob, ports } = world();
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await alice.curate(indexNuri);
await curate(ports.alice, indexNuri);
// What `readUnion([indexNuri])` hands an application that never loaded this
// 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 ----------------------------------------------
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 article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await expect(bob.curate(indexNuri)).rejects.toThrow(/may only READ your own/);
await expect(curate(ports.bob, indexNuri)).rejects.toThrow(/may only READ your own/);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("nobody but the owner writes an index, whatever they know about it", async () => {
const { alice, ports } = world();
const { alice, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
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 () => {
const { bob, ports } = world();
const { bob, ports } = await world();
// A public document that was never made into an index: no inbox was opened.
const notAnIndex = hardcodedInAppSource(await ports.alice.createPublicDocument());
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 -------------------------------------------------
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 article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await bob.refer(indexNuri, article);
const report = await alice.curate(indexNuri);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([
{ result: "indexed", object: article, value: "2026-03-04" },
{ 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 () => {
const { alice, bob, ports } = world();
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
await alice.curate(indexNuri);
await curate(ports.alice, 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(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 ---------
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 first = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
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");
await bob.refer(indexNuri, second);
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");
expect(unresolved).toHaveLength(1);
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 () => {
const { network, alice, bob, carol, ports } = world();
const { network, alice, bob, carol, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
await bob.refer(indexNuri, article);
await alice.curate(indexNuri);
await curate(ports.alice, indexNuri);
// A passer-by nudges the index about an entry she found IN IT. Carol obtains
// the reference the only way she could in a real application — by reading the
@@ -212,24 +224,24 @@ test("an already-indexed object survives its own reads failing, and is not even
network.breakReadsOf(article, "broker unreachable");
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(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-01-01" }]);
});
test("a failed resolve is self-correcting: the next curation adds what it could not", async () => {
const { network, alice, bob, ports } = world();
const { network, alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-05-06");
await bob.refer(indexNuri, article);
network.breakReadsOf(article, "broker unreachable");
expect((await alice.curate(indexNuri)).outcomes[0]?.result).toBe("unresolved");
expect((await curate(ports.alice, indexNuri)).outcomes[0]?.result).toBe("unresolved");
expect(await alice.read(indexNuri)).toEqual([]);
// The deposit is still there, so nothing has to be re-deposited.
network.healReadsOf(article);
expect((await alice.curate(indexNuri)).outcomes[0]).toEqual({
expect((await curate(ports.alice, indexNuri)).outcomes[0]).toEqual({
result: "indexed",
object: article,
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 () => {
const { network, alice, bob } = world();
const { network, alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
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[0]?.result).toBe("unresolved");
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 --------------------------------
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));
// Exists, is public, is readable — but says nothing about the field this index
// is built on. OPEN QUESTION: this is the narrow behaviour, not a settled policy.
const object = await publishObject(ports.bob, NAME, "an object with no date");
await bob.refer(indexNuri, object);
const report = await alice.curate(indexNuri);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "no-field" }]);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("an object carrying several values for the field is not added", async () => {
const { alice, bob, ports } = world();
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-01");
await ports.bob.addLiteralProperty(object, object, PUBLISHED_AT, "2026-09-09");
await bob.refer(indexNuri, object);
const report = await alice.curate(indexNuri);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([{ result: "skipped", object, reason: "several-values" }]);
expect(await alice.read(indexNuri)).toEqual([]);
});
test("a payload that is not a reference is reported as foreign and changes nothing", async () => {
const { alice, bob, ports } = world();
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
await bob.refer(indexNuri, article);
// Anyone may deposit anything into an inbox, so untrusted payloads do arrive.
await ports.bob.depositTo(indexNuri, { drop: "everything" });
const report = await alice.curate(indexNuri);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([
{ result: "indexed", object: article, value: "2026-03-04" },
{ 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 () => {
const { alice, bob } = world();
const { alice, bob, ports } = await world();
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
await bob.refer(indexNuri, indexNuri);
const report = await alice.curate(indexNuri);
const report = await curate(ports.alice, indexNuri);
expect(report.outcomes).toEqual([
{ 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 -------------
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 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, december);
await bob.refer(indexNuri, january);
await alice.curate(indexNuri);
await curate(ports.alice, indexNuri);
expect((await carol.read(indexNuri)).map((e) => e.value)).toEqual([
"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 () => {
const { alice, bob, ports } = world();
const { alice, bob, ports } = await world();
const byDate = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
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(byName, object);
await alice.curate(byDate);
await alice.curate(byName);
await curate(ports.alice, byDate);
await curate(ports.alice, byName);
expect(await alice.read(byDate)).toEqual([{ object, value: "2026-03-04" }]);
expect(await alice.read(byName)).toEqual([{ object, value: "Anemone" }]);
+35 -30
View File
@@ -1,5 +1,6 @@
import { expect, mock, test } from "bun:test";
import { indexing } from "../src/indexing";
import { curate } from "../src/curator";
import { entriesOf, entryValue } from "../src/index-document";
import { resolutionFromFailure, resolutionFromRead } from "../src/resolution";
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 () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const owner = indexing(ownerPort);
const owner = await indexing(ownerPort);
const index = await owner.createIndex(FIELD);
const article = await publishObject(network.portFor("bob"), FIELD, "2026-01-01");
await indexing(network.portFor("bob")).refer(index, article);
await owner.curate(index);
await (await indexing(network.portFor("bob"))).refer(index, article);
await curate(ownerPort, index);
// A pure ADD — the only write this package has. Before the fix this emptied
// `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 () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const owner = indexing(ownerPort);
const owner = await indexing(ownerPort);
const index = await owner.createIndex(FIELD);
const bobPort = network.portFor("bob");
const article = await publishObject(bobPort, FIELD, "2026-01-01");
await indexing(bobPort).refer(index, article);
await owner.curate(index);
await (await indexing(bobPort)).refer(index, article);
await curate(ownerPort, index);
// What two curation runs racing each other leave behind: the object's owner
// edited it between their reads, so each added its own value.
@@ -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 —
// 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(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 () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const owner = indexing(ownerPort);
const owner = await indexing(ownerPort);
const bobPort = network.portFor("bob");
const index = await owner.createIndex(FIELD);
for (const date of ["2026-01-01", "2026-02-02", "2026-03-03"]) {
await indexing(bobPort).refer(index, await publishObject(bobPort, FIELD, date));
await (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);
// 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.
expect(await owner.read(index)).toHaveLength(3);
// Curating refuses, and says why instead of quietly picking one.
await expect(owner.curate(index)).rejects.toThrow(/declares 2 index fields/);
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 () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const owner = indexing(ownerPort);
const owner = await indexing(ownerPort);
const bobPort = network.portFor("bob");
const NAME = "http://schema.org/name";
const index = await owner.createIndex(NAME);
const first = await publishObject(bobPort, NAME, "Anemone");
await indexing(bobPort).refer(index, first);
await owner.curate(index);
await (await indexing(bobPort)).refer(index, first);
await curate(ownerPort, index);
// "…/datePublished" < "…/name", so under "smallest wins" the new field took over
// while `first` kept its old value forever — one list ordered by two properties.
await ownerPort.addLiteralProperty(index, index, INDEX_FIELD, FIELD);
const second = await publishObject(bobPort, FIELD, "2026-02-02");
await indexing(bobPort).refer(index, second);
await (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" }]);
});
@@ -150,11 +151,11 @@ test("an index declaring no field at all is still refused", async () => {
const network = new FakeNextGraph();
const ownerPort = network.portFor("alice");
const ordinary = await ownerPort.createPublicDocument();
await expect(indexing(ownerPort).read(ordinary)).rejects.toThrow(/declares no index field/);
await expect((await indexing(ownerPort)).read(ordinary)).rejects.toThrow(/declares no index field/);
});
test("a field that could never match an object is refused at creation", async () => {
const owner = indexing(new FakeNextGraph().portFor("alice"));
const owner = await indexing(new FakeNextGraph().portFor("alice"));
// It cannot be corrected later — nothing here deletes — so it is refused now.
await expect(owner.createIndex("")).rejects.toThrow(/cannot be changed later/);
await expect(owner.createIndex(" ")).rejects.toThrow(/cannot be changed later/);
@@ -164,7 +165,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 () => {
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");
for (const field of ["constructor", "toString", "valueOf", "hasOwnProperty"]) {
@@ -178,10 +180,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
// off a plain object literal would otherwise hand back an inherited function.
const lacks = await publishObject(bobPort, "http://schema.org/name", "unrelated");
await indexing(bobPort).refer(index, lacks);
await indexing(bobPort).refer(index, carries);
await (await indexing(bobPort)).refer(index, lacks);
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[1]?.result).toBe("unresolved");
expect(await owner.read(index)).toEqual([]);
@@ -212,17 +214,18 @@ test("a read that threw resolves as unresolved, naming the error", () => {
test("an unresolved reference is warned about, not only reported", async () => {
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 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");
const warn = mock((..._args: unknown[]) => {});
const original = console.warn;
console.warn = warn;
try {
await owner.curate(index);
await curate(ownerPort, index);
} finally {
console.warn = original;
}
@@ -233,17 +236,18 @@ test("an unresolved reference is warned about, not only reported", async () => {
test("a normal run warns about nothing", async () => {
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 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 original = console.warn;
console.warn = warn;
let report;
try {
report = await owner.curate(index);
report = await curate(ownerPort, index);
} finally {
console.warn = original;
}
@@ -257,13 +261,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 () => {
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);
network.breakReadsOf(index, "broker unreachable");
// The real `readUnion` turns a failed read into `[]`, so the failure arrives
// looking like a blank document. Whatever the shape, nothing may be written.
await expect(owner.curate(index)).rejects.toThrow();
await expect(curate(ownerPort, index)).rejects.toThrow();
await expect(owner.read(index)).rejects.toThrow();
network.healReadsOf(index);
+12 -6
View File
@@ -9,7 +9,6 @@ import {
polyfillPort,
ENTRY_VALUE,
INDEX_FIELD,
type CurationReport,
type IndexEntry,
type NextGraphPort,
} 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 strangerPort: NextGraphPort = network.portFor("bob");
const owner = indexing(ownerPort);
const stranger = indexing(strangerPort);
const owner = await indexing(ownerPort);
const stranger = await indexing(strangerPort);
const index = await owner.createIndex(PUBLISHED_AT);
const article = await publishObject(strangerPort, PUBLISHED_AT, "2026-07-08");
await stranger.refer(index, article);
const report: CurationReport = await owner.curate(index);
expect(report.index).toBe(index);
expect(report.outcomes).toEqual([{ result: "indexed", object: article, value: "2026-07-08" }]);
// NOBODY CURATES — there is nothing on this surface to call. Alice is connected,
// so her session is told a deposit landed and processes that inbox itself.
await network.deliverNotifications();
const entries: IndexEntry[] = await owner.read(index);
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", () => {
expect(decodeReference("did:ng:o:doc-1")).toBe("did:ng:o:doc-1");
expect(decodeReference({ object: "did:ng:o:doc-1" })).toBeNull();