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
+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" }]);