357 lines
16 KiB
TypeScript
357 lines
16 KiB
TypeScript
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";
|
|
|
|
/**
|
|
* Each actor gets their own handle, and they share no variable carrying business
|
|
* data. The ONE value that crosses between them is the index's NURI — and that
|
|
* crossing is the mechanism this design names: an application references the
|
|
* index's NURI in its own source. `hardcodedInAppSource` marks every such
|
|
* crossing, so anything else moving between actors would stand out.
|
|
*/
|
|
const PUBLISHED_AT = "http://schema.org/datePublished";
|
|
const NAME = "http://schema.org/name";
|
|
|
|
function hardcodedInAppSource(nuri: Nuri): Nuri {
|
|
return nuri;
|
|
}
|
|
|
|
type Port = ReturnType<FakeNextGraph["portFor"]>;
|
|
|
|
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: 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 } = await world();
|
|
|
|
const index = await alice.createIndex(PUBLISHED_AT);
|
|
|
|
// An ordinary document: what makes it an index is the field it declares, which
|
|
// a reader going straight to `readUnion` sees on the index's own subject.
|
|
const subjects = await ports.alice.readDocument(index);
|
|
const self = subjects.find((s) => s.subject === index);
|
|
expect(self?.props[INDEX_FIELD]).toEqual([PUBLISHED_AT]);
|
|
|
|
expect(await alice.read(index)).toEqual([]);
|
|
});
|
|
|
|
test("reading a document that declares no index field is refused, not answered empty", async () => {
|
|
const { alice, ports } = await world();
|
|
const ordinary = await ports.alice.createPublicDocument();
|
|
await expect(alice.read(ordinary)).rejects.toThrow(/declares no index field/);
|
|
});
|
|
|
|
// --- 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 } = await world();
|
|
|
|
// Alice creates the index and its NURI goes into the application's source.
|
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
|
|
|
// Bob, who owns nothing of Alice's, creates his own public object and hands the
|
|
// index a reference to it. He needs no permission and gets no write.
|
|
const article = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
|
await bob.refer(indexNuri, article);
|
|
|
|
// Nothing is in the index until its owner acts.
|
|
expect(await carol.read(indexNuri)).toEqual([]);
|
|
|
|
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.
|
|
const entries = await carol.read(indexNuri);
|
|
expect(entries).toEqual([{ object: article, value: "2026-03-04" }]);
|
|
|
|
// The entry is a usable reference: Carol opens the object straight from it,
|
|
// holding nothing but what she read out of the index.
|
|
const first = entries[0];
|
|
expect(first).toBeDefined();
|
|
const opened = await ports.carol.readDocument(first!.object);
|
|
expect(opened[0]?.props[PUBLISHED_AT]).toEqual(["2026-03-04"]);
|
|
});
|
|
|
|
test("an entry is a subject keyed by the object's NURI, so reading needs nothing new", async () => {
|
|
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 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.
|
|
const subjects = await ports.bob.readDocument(indexNuri);
|
|
const entry = subjects.find((s) => s.subject === article);
|
|
expect(entry?.props[ENTRY_VALUE]).toEqual(["2026-03-04"]);
|
|
expect(subjects.map((s) => s.subject).sort()).toEqual([article, indexNuri].sort());
|
|
});
|
|
|
|
// --- only the owner curates ----------------------------------------------
|
|
|
|
test("nobody but the index's owner can curate it: the inbox is refused to others", async () => {
|
|
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(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 } = await world();
|
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
|
|
|
await expect(
|
|
ports.bob.addLiteralProperty(indexNuri, "did:ng:o:forged", ENTRY_VALUE, "2999-01-01"),
|
|
).rejects.toThrow(/only a document's owner writes to it/);
|
|
await expect(ports.bob.openInbox(indexNuri)).rejects.toThrow(/may not open an inbox/);
|
|
});
|
|
|
|
test("an index whose owner never opened an inbox refuses a deposit rather than losing it", async () => {
|
|
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/);
|
|
});
|
|
|
|
// --- adding is idempotent -------------------------------------------------
|
|
|
|
test("the same reference deposited twice produces one entry", async () => {
|
|
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 curate(ports.alice, indexNuri);
|
|
expect(report.outcomes).toEqual([
|
|
{ result: "indexed", object: article, value: "2026-03-04" },
|
|
{ result: "unchanged", object: article },
|
|
]);
|
|
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-03-04" }]);
|
|
});
|
|
|
|
test("curating twice changes nothing the second time — deposits are not consumed", async () => {
|
|
const { alice, bob, ports } = 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 curate(ports.alice, indexNuri);
|
|
const before = await alice.read(indexNuri);
|
|
|
|
const second = await curate(ports.alice, indexNuri);
|
|
expect(second.outcomes).toEqual([{ result: "unchanged", object: article }]);
|
|
expect(await alice.read(indexNuri)).toEqual(before);
|
|
});
|
|
|
|
// --- a read that cannot answer must never cost the index anything ---------
|
|
|
|
test("a reference the broker cannot resolve is reported, and adds nothing", async () => {
|
|
const { network, alice, bob, ports } = 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 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 curate(ports.alice, indexNuri);
|
|
const unresolved = report.outcomes.filter((o) => o.result === "unresolved");
|
|
expect(unresolved).toHaveLength(1);
|
|
expect(unresolved[0]).toMatchObject({ object: second });
|
|
|
|
// THE POINT: the entry that was already there is untouched.
|
|
expect(await alice.read(indexNuri)).toEqual([{ object: first, value: "2026-01-01" }]);
|
|
});
|
|
|
|
test("an already-indexed object survives its own reads failing, and is not even re-read", async () => {
|
|
const { network, alice, bob, carol, ports } = 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 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
|
|
// index whose NURI her app hardcodes — rather than being handed it by the test.
|
|
const seen = await carol.read(indexNuri);
|
|
const noticed = seen[0];
|
|
expect(noticed).toBeDefined();
|
|
|
|
// …and only then does the object become unreachable.
|
|
network.breakReadsOf(article, "broker unreachable");
|
|
await carol.refer(indexNuri, noticed!.object);
|
|
|
|
const report = await 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 } = 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 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 curate(ports.alice, indexNuri)).outcomes[0]).toEqual({
|
|
result: "indexed",
|
|
object: article,
|
|
value: "2026-05-06",
|
|
});
|
|
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-05-06" }]);
|
|
});
|
|
|
|
test("a reference to something that was never created is reported, not silently dropped", async () => {
|
|
const { network, alice, bob, ports } = await world();
|
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
|
await bob.refer(indexNuri, network.neverCreatedNuri());
|
|
|
|
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([]);
|
|
});
|
|
|
|
// --- 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 } = 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 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 } = 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 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 } = 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 curate(ports.alice, indexNuri);
|
|
expect(report.outcomes).toEqual([
|
|
{ result: "indexed", object: article, value: "2026-03-04" },
|
|
{ result: "foreign", reason: "payload is not a reference" },
|
|
]);
|
|
expect(await alice.read(indexNuri)).toEqual([{ object: article, value: "2026-03-04" }]);
|
|
});
|
|
|
|
test("an index referred to itself is skipped, so its declaration cannot become an entry", async () => {
|
|
const { alice, bob, ports } = await world();
|
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
|
await bob.refer(indexNuri, indexNuri);
|
|
|
|
const report = await curate(ports.alice, indexNuri);
|
|
expect(report.outcomes).toEqual([
|
|
{ result: "skipped", object: indexNuri, reason: "self-reference" },
|
|
]);
|
|
expect(await alice.read(indexNuri)).toEqual([]);
|
|
});
|
|
|
|
// --- 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 } = await world();
|
|
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
|
|
|
const march = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
|
const january = await publishObject(ports.bob, PUBLISHED_AT, "2026-01-31");
|
|
const december = await publishObject(ports.bob, PUBLISHED_AT, "2025-12-25");
|
|
|
|
// Referred out of order, on purpose.
|
|
await bob.refer(indexNuri, march);
|
|
await bob.refer(indexNuri, december);
|
|
await bob.refer(indexNuri, january);
|
|
await curate(ports.alice, indexNuri);
|
|
|
|
expect((await carol.read(indexNuri)).map((e) => e.value)).toEqual([
|
|
"2025-12-25",
|
|
"2026-01-31",
|
|
"2026-03-04",
|
|
]);
|
|
});
|
|
|
|
test("two indexes over the same objects, on different fields, do not interfere", async () => {
|
|
const { alice, bob, ports } = await world();
|
|
const byDate = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
|
const byName = hardcodedInAppSource(await alice.createIndex(NAME));
|
|
|
|
const object = await publishObject(ports.bob, PUBLISHED_AT, "2026-03-04");
|
|
await ports.bob.addLiteralProperty(object, object, NAME, "Anemone");
|
|
|
|
await bob.refer(byDate, object);
|
|
await bob.refer(byName, object);
|
|
await 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" }]);
|
|
});
|