feat!: le paquet crée un index et lui ajoute une référence, rien de plus
This commit is contained in:
+91
-295
@@ -1,11 +1,15 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { indexing, type Indexing } from "../src/indexing";
|
||||
import { curate } from "../src/curator";
|
||||
import { indexingOn, type Indexing } from "../src/indexing";
|
||||
import type { Nuri } from "../src/port";
|
||||
import { ENTRY_VALUE, INDEX_FIELD } from "../src/vocabulary";
|
||||
import { INDEX_FIELD } from "../src/vocabulary";
|
||||
import { FakeNextGraph, publishObject } from "./fake-nextgraph";
|
||||
|
||||
/**
|
||||
* The two acts, and nothing else — this package creates an index and hands one a
|
||||
* reference. What becomes of that reference is the business of the layer that
|
||||
* processes the index's inbox, and there is nothing here that could do it or ask
|
||||
* for it.
|
||||
*
|
||||
* 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
|
||||
@@ -21,336 +25,128 @@ function hardcodedInAppSource(nuri: Nuri): Nuri {
|
||||
|
||||
type Port = ReturnType<FakeNextGraph["portFor"]>;
|
||||
|
||||
async function world(): Promise<{
|
||||
function world(): {
|
||||
network: FakeNextGraph;
|
||||
alice: Indexing;
|
||||
bob: Indexing;
|
||||
carol: Indexing;
|
||||
ports: { alice: Port; bob: Port; carol: Port };
|
||||
}> {
|
||||
ports: { alice: Port; bob: 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,
|
||||
};
|
||||
const ports = { alice: network.portFor("alice"), bob: network.portFor("bob") };
|
||||
// A handle is nothing but a port bound to one identity: building one reaches
|
||||
// nothing, which is why there is nothing to await here.
|
||||
return { network, alice: indexingOn(ports.alice), bob: indexingOn(ports.bob), 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 { alice, network } = world();
|
||||
|
||||
const index = await alice.createIndex(PUBLISHED_AT);
|
||||
const index = await alice.create(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([]);
|
||||
// An ordinary document: what makes it an index is the field it declares, on the
|
||||
// index's own subject, which is where a reader of the document finds it.
|
||||
expect(network.contentsOf(index)).toEqual([
|
||||
{ subject: index, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
|
||||
]);
|
||||
});
|
||||
|
||||
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/);
|
||||
test("a new index is ready to receive: its inbox is open the moment create returns", async () => {
|
||||
const { alice, bob, network } = world();
|
||||
const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
|
||||
|
||||
// Nothing to open, register or remember — a stranger deposits straight away.
|
||||
await bob.add(index, "did:ng:o:doc-9");
|
||||
expect(network.depositsIn(index)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- the whole loop, across three people ----------------------------------
|
||||
test("a field that could never match an object is refused at creation", async () => {
|
||||
const { alice } = world();
|
||||
// It cannot be corrected later — nothing here deletes — so it is refused now.
|
||||
await expect(alice.create("")).rejects.toThrow(/cannot be changed later/);
|
||||
await expect(alice.create(" ")).rejects.toThrow(/cannot be changed later/);
|
||||
});
|
||||
|
||||
test("a stranger refers an object, the owner curates, and anyone reads the result", async () => {
|
||||
const { alice, bob, carol, ports } = await world();
|
||||
test("two indexes are two documents, each declaring its own field", async () => {
|
||||
const { alice, network } = world();
|
||||
const byDate = await alice.create(PUBLISHED_AT);
|
||||
const byName = await alice.create(NAME);
|
||||
|
||||
expect(byDate).not.toBe(byName);
|
||||
expect(network.contentsOf(byDate)).toEqual([
|
||||
{ subject: byDate, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
|
||||
]);
|
||||
expect(network.contentsOf(byName)).toEqual([
|
||||
{ subject: byName, predicate: INDEX_FIELD, values: [NAME] },
|
||||
]);
|
||||
});
|
||||
|
||||
// --- handing an index a reference -----------------------------------------
|
||||
|
||||
test("a stranger hands an index a reference, and it waits in the index's inbox", async () => {
|
||||
const { alice, bob, ports, network } = world();
|
||||
|
||||
// Alice creates the index and its NURI goes into the application's source.
|
||||
const indexNuri = hardcodedInAppSource(await alice.createIndex(PUBLISHED_AT));
|
||||
const index = hardcodedInAppSource(await alice.create(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);
|
||||
await bob.add(index, article);
|
||||
|
||||
// Nothing is in the index until its owner acts.
|
||||
expect(await carol.read(indexNuri)).toEqual([]);
|
||||
// THE WHOLE PAYLOAD is the reference: no wrapper, no claim, no copy of the value,
|
||||
// and no index either — the inbox it landed in is what identifies one. This is the
|
||||
// one thing this package hands the layer that will make an entry of it.
|
||||
expect(network.depositsIn(index)).toEqual([
|
||||
{ from: "bob", payload: article, ts: expect.any(Number) },
|
||||
]);
|
||||
|
||||
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"]);
|
||||
// And it stayed a deposit: nothing wrote it into the index document.
|
||||
expect(network.contentsOf(index)).toEqual([
|
||||
{ subject: index, predicate: INDEX_FIELD, values: [PUBLISHED_AT] },
|
||||
]);
|
||||
});
|
||||
|
||||
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));
|
||||
test("the same reference handed over twice is two deposits, and nothing is lost", async () => {
|
||||
const { alice, bob, ports, network } = world();
|
||||
const index = hardcodedInAppSource(await alice.create(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());
|
||||
});
|
||||
await bob.add(index, article);
|
||||
await bob.add(index, article);
|
||||
|
||||
// --- 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/);
|
||||
// Nothing here de-duplicates: a deposit is an invitation to look, so repeating one
|
||||
// is legitimate and cheap, and whoever processes the inbox is the one that settles.
|
||||
expect(network.depositsIn(index)?.map((d) => d.payload)).toEqual([article, article]);
|
||||
});
|
||||
|
||||
test("an index whose owner never opened an inbox refuses a deposit rather than losing it", async () => {
|
||||
const { bob, ports } = await world();
|
||||
const { bob, ports } = 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/);
|
||||
await expect(bob.add(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("a payload that is not a reference is refused here, not deposited for someone else to find", async () => {
|
||||
const { alice, bob } = world();
|
||||
const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
|
||||
// Both sides are checked, because both go on to name a document. An index's inbox
|
||||
// takes anything anyone posts to it; what THIS package puts there is a NURI.
|
||||
await expect(bob.add(index, "please index my article")).rejects.toThrow(/not a NURI/);
|
||||
await expect(bob.add("http://example.org/index", "did:ng:o:doc-9")).rejects.toThrow(
|
||||
/not a NURI/,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
// --- only the owner owns it -----------------------------------------------
|
||||
|
||||
await curate(ports.alice, indexNuri);
|
||||
const before = await alice.read(indexNuri);
|
||||
test("nobody but the owner writes an index, whatever they know about it", async () => {
|
||||
const { alice, ports } = world();
|
||||
const index = hardcodedInAppSource(await alice.create(PUBLISHED_AT));
|
||||
|
||||
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" }]);
|
||||
await expect(
|
||||
ports.bob.addLiteralProperty(index, "did:ng:o:forged", INDEX_FIELD, "http://schema.org/aaa"),
|
||||
).rejects.toThrow(/only a document's owner writes to it/);
|
||||
await expect(ports.bob.openInbox(index)).rejects.toThrow(/may not open an inbox/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user