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:
@@ -62,6 +62,10 @@ export class FakeNextGraph {
|
||||
readonly #documents = new Map<string, StoredDocument>();
|
||||
/** Documents the broker currently cannot answer about. See `breakReadsOf`. */
|
||||
readonly #unreachable = new Map<string, string>();
|
||||
/** Inboxes the broker currently cannot READ. See `breakInboxReadsOf`. */
|
||||
readonly #inboxUnreadable = new Map<string, string>();
|
||||
/** Inboxes the broker currently refuses to WATCH. See `breakWatchingOf`. */
|
||||
readonly #inboxUnwatchable = 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`. */
|
||||
@@ -132,6 +136,43 @@ export class FakeNextGraph {
|
||||
this.#unreachable.delete(asNuri(doc));
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker serves the DOCUMENT but not its INBOX.
|
||||
*
|
||||
* Not a contrivance: upstream an inbox is a repo of its own, reached through an
|
||||
* address `openDocumentInbox` resolves and read with that repo's capability,
|
||||
* while the document itself is read by `readUnion`. Two repos, two reads — so
|
||||
* one answering while the other does not is what a partial failure looks like,
|
||||
* and it is the state that makes a catch-up fail on one index and no other.
|
||||
*/
|
||||
breakInboxReadsOf(doc: NuriLike, reason: string): void {
|
||||
this.#inboxUnreadable.set(asNuri(doc), reason);
|
||||
}
|
||||
|
||||
/** The inbox can be read again. */
|
||||
healInboxReadsOf(doc: NuriLike): void {
|
||||
this.#inboxUnreadable.delete(asNuri(doc));
|
||||
}
|
||||
|
||||
/**
|
||||
* The broker refuses to keep this session posted about that inbox, while
|
||||
* everything else about it still works.
|
||||
*
|
||||
* Watching is a live subscription, set up and held open for as long as the
|
||||
* session lasts; reading an inbox is one question and one answer. A subscription
|
||||
* can be refused where a read succeeds, which is the state that leaves an index
|
||||
* caught up but unwatched — deposits into it going unnoticed until the next
|
||||
* connection, exactly as the failure this models says.
|
||||
*/
|
||||
breakWatchingOf(doc: NuriLike, reason: string): void {
|
||||
this.#inboxUnwatchable.set(asNuri(doc), reason);
|
||||
}
|
||||
|
||||
/** The inbox can be watched again. */
|
||||
healWatchingOf(doc: NuriLike): void {
|
||||
this.#inboxUnwatchable.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
|
||||
@@ -297,6 +338,12 @@ export class FakeNextGraph {
|
||||
"is reading it, and you may only READ your own",
|
||||
);
|
||||
}
|
||||
const unwatchable = this.#inboxUnwatchable.get(doc);
|
||||
// Refused AFTER the owner check: resolving the address is an owner-only act, so
|
||||
// a stranger is turned away before any subscription is ever attempted.
|
||||
if (unwatchable !== undefined) {
|
||||
throw new Error(`cannot watch the inbox of ${doc}: ${unwatchable}`);
|
||||
}
|
||||
// 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 ??= [];
|
||||
@@ -324,6 +371,10 @@ export class FakeNextGraph {
|
||||
"inbox, you may only READ your own",
|
||||
);
|
||||
}
|
||||
const unreadable = this.#inboxUnreadable.get(doc);
|
||||
if (unreadable !== undefined) {
|
||||
throw new Error(`cannot read the inbox of ${doc}: ${unreadable}`);
|
||||
}
|
||||
return [...stored.deposits].sort((a, b) => a.ts - b.ts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -154,6 +154,29 @@ test("an index declaring no field at all is still refused", async () => {
|
||||
await expect((await indexing(ownerPort)).read(ordinary)).rejects.toThrow(/declares no index field/);
|
||||
});
|
||||
|
||||
test("curating a document that declares no field refuses, and writes nothing", async () => {
|
||||
const network = new FakeNextGraph();
|
||||
const ownerPort = network.portFor("alice");
|
||||
const bobPort = network.portFor("bob");
|
||||
|
||||
// A document of Alice's with an inbox open and a reference waiting in it, and no
|
||||
// field declared. This is also the shape an INDEX arrives in when it could not be
|
||||
// read — the real `readUnion` turns a failed read into `[]` — so the two are one
|
||||
// case here, and the refusal has to hold for both.
|
||||
const noField = await ownerPort.createPublicDocument();
|
||||
await ownerPort.openInbox(noField);
|
||||
const article = await publishObject(bobPort, FIELD, "2026-01-01");
|
||||
await (await indexing(bobPort)).refer(noField, article);
|
||||
|
||||
await expect(curate(ownerPort, noField)).rejects.toThrow(/declares no index field/);
|
||||
|
||||
// It refused instead of curating on a field it does not have, and it refused
|
||||
// BEFORE writing: the document is still empty, so no entry was invented for it,
|
||||
// and the deposit is still in the inbox for a run that knows what to do with it.
|
||||
expect(await ownerPort.readDocument(noField)).toEqual([]);
|
||||
expect(await ownerPort.readDeposits(noField)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a field that could never match an object is refused at creation", async () => {
|
||||
const owner = await indexing(new FakeNextGraph().portFor("alice"));
|
||||
// It cannot be corrected later — nothing here deletes — so it is refused now.
|
||||
|
||||
Reference in New Issue
Block a user