docs: dire l'effet et non le canal, et n'exiger le NURI en dur que d'un index global

This commit is contained in:
Sylvain Duchesne
2026-08-20 14:47:55 +02:00
parent e2ed970cbd
commit ebaae15baf
8 changed files with 354 additions and 56 deletions
+113 -3
View File
@@ -15,9 +15,15 @@ import { FakeNextGraph, publishObject } from "./fake-nextgraph";
*
* 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.
* that a previous session created. Plus the two that must NOT happen: a stranger
* connecting curates nothing, and a document that is no index is left alone.
*
* And crossing all of it, the three ways connecting can FAIL — it cannot look for
* its indexes, it cannot go through one, it cannot watch one. Each has its own test
* below, because each is a failure wearing the shape of an absence: the session
* carries on, the handle works, and an index quietly holds less than it should. The
* engagement is that none of them denies anything and none of them loses a deposit,
* which is only worth anything if it is exercised rather than asserted.
*/
const PUBLISHED_AT = "http://schema.org/datePublished";
@@ -38,6 +44,29 @@ async function aliceCreatesAnIndexAndLeaves(network: FakeNextGraph): Promise<Nur
return hardcodedInAppSource(index);
}
/**
* Runs `body` with this package's log stream captured, and reports HOW MANY
* failures it put there alongside whatever the body produced.
*
* The count, never the text: what a log line reads is for a human and nothing
* promises it, so a test that pinned the words would pin the one thing that is
* free to change. What is worth pinning is that a failure was reported AT ALL —
* harmless is not the same as invisible, and the whole risk here is a failure
* passing for an absence.
*/
async function capturingReports<T>(
body: () => Promise<T>,
): Promise<{ result: T; reports: number }> {
const reported = mock((..._args: unknown[]) => {});
const original = console.error;
console.error = reported;
try {
return { result: await body(), reports: reported.mock.calls.length };
} finally {
console.error = original;
}
}
// --- 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 () => {
@@ -198,6 +227,87 @@ test("a session that could not look for its indexes is still a working handle",
expect(String(reported.mock.calls[0]?.[0])).toContain("public store could not be listed");
});
test("an index whose catch-up failed is still a working handle, and loses no deposit", async () => {
const network = new FakeNextGraph();
const stalled = await aliceCreatesAnIndexAndLeaves(network);
const healthy = 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(stalled, article);
await bob.refer(healthy, article);
// The broker answers about the document and not about its inbox. Two repos
// upstream, read with two capabilities, so this is a partial failure and not a
// contrived one — and it is what makes the catch-up fail on THIS index alone.
network.breakInboxReadsOf(stalled, "broker unreachable");
const { result: alice, reports } = await capturingReports(() =>
indexing(network.portFor("alice")),
);
// Obtaining the handle RESOLVED — reaching this line at all is the assertion.
// Reading the index it could not go through still works…
expect(await alice.read(stalled)).toEqual([]);
// …and so does depositing into it: neither ever depended on that work.
await alice.refer(stalled, article);
// The session is not poisoned either: the other index was caught up normally.
expect(await alice.read(healthy)).toEqual([{ object: article, value: "2026-03-04" }]);
expect(reports).toBe(1);
// And nothing was lost. The deposits never left the inbox, so the first
// connection that can read it puts them in — which is the whole reason a failed
// run is allowed to be this quiet.
network.healInboxReadsOf(stalled);
network.disconnect("alice");
const back = await indexing(network.portFor("alice"));
expect(await back.read(stalled)).toEqual([{ object: article, value: "2026-03-04" }]);
});
test("an index that could not be watched is still caught up, and the rest still notices", async () => {
const network = new FakeNextGraph();
const unwatched = await aliceCreatesAnIndexAndLeaves(network);
const watched = await aliceCreatesAnIndexAndLeaves(network);
const bobPort = network.portFor("bob");
const bob = await indexing(bobPort);
const waiting = await publishObject(bobPort, PUBLISHED_AT, "2026-01-01");
await bob.refer(unwatched, waiting);
// The subscription is refused; reading that same inbox still works. A watch is
// held open where a read is one question and one answer, so one can be turned
// down while the other is served.
network.breakWatchingOf(unwatched, "the broker refused the subscription");
const { result: alice, reports } = await capturingReports(() =>
indexing(network.portFor("alice")),
);
// The watch failed and the catch-up ran ANYWAY — the backlog is in. That is the
// order the code goes to some trouble to hold: failing to watch must not cost
// the deposits that were already waiting.
expect(await alice.read(unwatched)).toEqual([{ object: waiting, value: "2026-01-01" }]);
expect(reports).toBe(1);
// What the failure costs, exactly and no more: a deposit made from now on is not
// NOTICED on that index…
const late = await publishObject(bobPort, PUBLISHED_AT, "2026-02-02");
await bob.refer(unwatched, late);
await bob.refer(watched, late);
await network.deliverNotifications();
expect((await alice.read(unwatched)).map((e) => e.value)).toEqual(["2026-01-01"]);
// …while every other index of the very same session goes on noticing its own.
expect(await alice.read(watched)).toEqual([{ object: late, value: "2026-02-02" }]);
// "Until the next connection" is the whole of the damage, and the next
// connection is where it ends.
network.healWatchingOf(unwatched);
network.disconnect("alice");
const back = await indexing(network.portFor("alice"));
expect((await back.read(unwatched)).map((e) => e.value)).toEqual(["2026-01-01", "2026-02-02"]);
});
// --- the primitive that keeps a burst from piling up ----------------------
test("coalescing never runs twice at once, and grants exactly one more run", async () => {