feat!: curer n'est plus un appel, c'est ce que fait le traitement de l'inbox
This commit is contained in:
+40
-17
@@ -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
@@ -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
@@ -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}`;
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user