fix: un dépôt est délivré une fois et retiré, comme en amont

This commit is contained in:
Sylvain Duchesne
2026-08-26 14:11:56 +02:00
parent abfb619c76
commit f6a48a4507
24 changed files with 1486 additions and 176 deletions
@@ -137,6 +137,19 @@ function makeFakeNg(faults: Faults) {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (!anchor) return undefined;
// Retiring a DEPOSIT: every triple of one subject, whatever predicates it carries
// (`emulated-verifier/inbox-consume.removeDeposit`). Modelled rather than ignored — a
// fake that let this delete fall through would leave every deposit in place, and a
// suite asserting convergence would pass on a queue that never shrinks.
const delDeposit = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+\?\w+\s+\?\w+\s*\}/);
if (delDeposit) {
const subject = delDeposit[1]!;
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.s === subject) quads.splice(i, 1);
}
return undefined;
}
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses;
// everything else writes the anchored default graph.
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
@@ -204,6 +217,8 @@ function makeFakeNg(faults: Faults) {
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
// The deposit's own subject — the key a drain names to retire it.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
@@ -215,6 +230,7 @@ function makeFakeNg(faults: Faults) {
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
d: { value: r.d! },
payload: { value: r.payload! },
ts: { value: r.ts! },
};
@@ -325,7 +325,8 @@ describe("switching identity", () => {
fake._deliver(inTransit);
await converge();
// An inbox is not consumed by being ignored: connecting again drains what was left.
// An inbox is not consumed by being IGNORED — only by being applied. Alice never
// drained this one, so connecting again finds the deposit still queued.
await signIn("alice");
await converge();
@@ -348,15 +349,17 @@ describe("a deposit that cannot be applied", () => {
await converge();
});
expect(reported.filter((l) => /could not apply what is in this inbox/.test(l)).length)
expect(reported.filter((l) => /could not finish draining this inbox/.test(l)).length)
.toBeGreaterThan(0);
// Prefixed by the connected identity, like every other polyfill-layer line.
expect(reported.find((l) => /could not apply what is in this inbox/.test(l)))
expect(reported.find((l) => /could not finish draining this inbox/.test(l)))
.toContain("[alice][polyfill]");
expect(getCaps().capForHolder("alice", first.doc)).toBeUndefined();
// The broker recovers. The observation is still running — one unapplicable item denies
// nothing — and the deposit that failed was never consumed, so both land now.
// nothing — and this deposit was never consumed, which since 2026-08-26 is a claim worth
// being precise about: what failed here is the READ, before anything could be taken out
// of the queue. A deposit that had been popped and whose ARM then failed would be gone.
fake._failReadsOn.delete(aliceInbox);
fake._deliver(second.inTransit);
await converge();
@@ -103,6 +103,19 @@ function makeFakeNg() {
}
return undefined;
}
// Retiring a DEPOSIT: every triple of one subject, whatever predicates it carries
// (`emulated-verifier/inbox-consume.removeDeposit`). Modelled rather than ignored — a
// fake that let this delete fall through would leave every deposit in place, and a
// suite asserting convergence would pass on a queue that never shrinks.
const delDeposit = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+\?\w+\s+\?\w+\s*\}/);
if (delDeposit) {
const subject = delDeposit[1]!;
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.s === subject) quads.splice(i, 1);
}
return undefined;
}
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses.
// Without this arm the first `<…>` in the body is the GRAPH NAME, so the pointer was
// stored with the graph as its subject and its predicate as its object. The pointer
@@ -163,6 +176,8 @@ function makeFakeNg() {
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
// The deposit's own subject — the key a drain names to retire it.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
@@ -173,7 +188,7 @@ function makeFakeNg() {
bindings: [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = { payload: { value: r.payload! }, ts: { value: r.ts! } };
const row: Record<string, { value: string }> = { d: { value: r.d! }, payload: { value: r.payload! }, ts: { value: r.ts! } };
if (r.from !== undefined) row.from = { value: r.from };
return row;
}),
@@ -97,6 +97,19 @@ function makeFakeNg(faults: Faults) {
}
return undefined;
}
// Retiring a DEPOSIT: every triple of one subject, whatever predicates it carries
// (`emulated-verifier/inbox-consume.removeDeposit`). Modelled rather than ignored — a
// fake that let this delete fall through would leave every deposit in place, and a
// suite asserting convergence would pass on a queue that never shrinks.
const delDeposit = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+\?\w+\s+\?\w+\s*\}/);
if (delDeposit) {
const subject = delDeposit[1]!;
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.s === subject) quads.splice(i, 1);
}
return undefined;
}
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
const body = gm ? gm[2]! : query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
@@ -160,6 +173,8 @@ function makeFakeNg(faults: Faults) {
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
// The deposit's own subject — the key a drain names to retire it.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
@@ -171,6 +186,7 @@ function makeFakeNg(faults: Faults) {
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
d: { value: r.d! },
payload: { value: r.payload! },
ts: { value: r.ts! },
};
@@ -338,14 +354,15 @@ test("what the drain filed for the owner is DURABLE: he restores it with his inb
await share(aliceDoc, "bob");
await runScheduledInboxProcessingNow();
// EMPTY the inbox, as a consumed queue would be, and drop every in-memory cap — so what
// Bob recovers below can only have come from his own User branch, where the drain filed
// it. Without that, he would simply be draining the queue himself on connection, which
// is the behaviour this service exists to get ahead of.
// The inbox is ALREADY empty — the drain consumed the deposit as it applied it. Until
// 2026-08-26 this test had to empty the queue by hand to reach that state, which is
// exactly the shortcut that fabricates a state the system never produced; now it is
// asserted instead. It matters here because what Bob recovers below must come from his
// own User branch, where the drain filed it: with the deposit still in the queue he
// would simply be draining it himself on connection, which is the behaviour this
// service exists to get ahead of.
const bobInbox = await userInbox("bob", "protected");
for (let k = fake.quads.length - 1; k >= 0; k--) {
if (fake.quads[k]!.g === bobInbox) fake.quads.splice(k, 1);
}
expect(fake.quads.filter((q) => q.g === bobInbox && q.p === `${INBOX}:payload`)).toEqual([]);
resetCaps();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // re-arms the emulation: a cap exists again
@@ -436,8 +453,10 @@ test("a drain that fails says so in the package's log, and rejects into nobody",
// The wording is the SHARED reporter's since 2026-08-17 (`emulated-verifier/inbox-drain.ts`):
// the timer is no longer the only thing that drains an inbox — the continuous observation
// does too, through the same queue — so the line names the act and not the schedule.
const reported = errors.filter((line) => /could not apply what is in this inbox/.test(line));
// does too, through the same queue — so the line names the act and not the schedule. Since
// 2026-08-26 it also stops promising a retry it cannot make: a deposit already taken is
// consumed, and only what is STILL QUEUED waits for the next attempt.
const reported = errors.filter((line) => /could not finish draining this inbox/.test(line));
expect(reported.length).toBe(1);
// Prefixed by the CONNECTED identity, like every other polyfill-layer line — which is
// what makes a drain running under someone else's session legible in a live trace.
@@ -0,0 +1,461 @@
/**
* An inbox is a QUEUE, and this suite is about the act that had been missing from it: a
* deposit is delivered once and then it is gone.
*
* ── What each test would have looked like before ──────────────────────────
* Until 2026-08-26 this package never consumed anything. Every drain re-read everything
* ever deposited, which produced three symptoms at once: an inbox that grew without bound,
* a malformed deposit re-reported at every push and every connection for as long as the
* page lived, and an idempotence burden on every arm that existed only to survive the
* replay. Each test below fails against that code — the deposit is still there — and fails
* again if the removal is taken back out.
*
* ── Upstream is the specification, and so is its ORDER ────────────────────
* The broker POPS: `take_first_msg` → `MSGS.take_first_value` is `get_first_key_value`
* followed by `del`, so the message leaves the store BEFORE it is delivered; the verifier
* then applies it and a failure is logged and swallowed, never re-enqueued
* (`engine/verifier/src/verifier.rs`). Remove, apply, log, move on. So a deposit whose arm
* refused is gone exactly like one that succeeded, and that is the point of the second test
* below rather than an accident of it.
*
* ── Nobody is handed an inbox address ─────────────────────────────────────
* A depositor names a document and a person (`inbox.share`, `inbox.postToDocument`); the
* address is resolved inside. The addresses the TEST resolves are read off the wallet and
* used only to count what is left in a queue — never given to an actor.
*/
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
import { inbox as inboxSurface, storeRegistry } from "../src/index";
import { resolveAccount, userInbox } from "../src/shared-wallet/account-registry";
import { setCurrentUser } from "../src/shared-wallet/bootstrap";
import { openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import {
cancelScheduledInboxProcessing,
runScheduledInboxProcessingNow,
} from "../src/emulated-verifier/inbox-processor";
import { bootPage, forgetEverything, signIn, type FakeWallet, type Quad } from "./wallet-fake";
import type { Nuri } from "../src/model/types";
const INBOX = "urn:ng-eventually:inbox";
/** A kind in this package's OWN namespace that no arm answers — upstream's `NotImplemented`. */
const UNARMED_KIND = `${INBOX}:nonesuch`;
let quads: Quad[];
let fake: FakeWallet;
beforeEach(() => {
forgetEverything();
quads = [];
fake = bootPage(quads);
});
afterAll(() => {
cancelScheduledInboxProcessing();
forgetEverything();
});
/** What is STILL QUEUED in `inbox` — one entry per deposit that has not been consumed. */
function stillQueuedIn(inbox: Nuri): string[] {
return quads.filter((q) => q.g === inbox && q.p === `${INBOX}:payload`).map((q) => q.o);
}
/** How many deposits have been RETIRED — one `DELETE WHERE { <deposit> ?p ?o }` each. */
function removalCount(): number {
return fake.sparql_update.mock.calls.filter((c) =>
/DELETE\s+WHERE\s*\{\s*<[^>]+>\s+\?\w+\s+\?\w+\s*\}/.test(String(c[1]))
).length;
}
/** Capture what `console.error` is told while `body` runs — where a refusal goes. */
async function reportedDuring(body: () => Promise<void>): Promise<string[]> {
const lines: string[] = [];
const real = console.error;
console.error = ((...args: unknown[]) => {
lines.push(args.map((a) => String(a)).join(" "));
}) as typeof console.error;
try {
await body();
} finally {
console.error = real;
}
return lines;
}
/**
* Alice has been here once, so she is someone a deposit can NAME (`inbox.share` refuses a
* recipient nobody has ever been, and connecting does not provision — an identity acquires
* its account the first time it creates anything).
*/
async function aliceHasBeenHere(): Promise<void> {
await signIn("alice");
await storeRegistry.createEntityDoc("protected");
}
/** Bob shares a document of his with Alice, by name. Answers the document. */
async function bobSharesWithAlice(): Promise<Nuri> {
await signIn("bob");
const doc = await storeRegistry.createEntityDoc("protected");
await inboxSurface.share(doc, "alice");
return doc;
}
/** The Links durably filed on a user's User branch — the emulated `AddLink` records. */
async function documentsGivenTo(id: string): Promise<string[]> {
const store = (await resolveAccount(id))?.docPrivate;
if (!store) return [];
return quads
.filter((q) => q.g === store && q.p === "urn:ng-eventually:shim:link")
.map((q) => q.o.split(":r:")[0]!);
}
// --- a deposit that was applied is GONE -----------------------------------
test("a Link that has been applied is no longer in the queue", async () => {
await aliceHasBeenHere();
const doc = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
// Bob's deposit is waiting, and nothing of it has reached Alice yet.
expect(stillQueuedIn(aliceInbox).length).toBe(1);
expect(await documentsGivenTo("alice")).toEqual([]);
// Alice connects: her own session drains her own inbox.
await signIn("alice");
// Applied — and CONSUMED. The second assertion is the one that was missing: before
// 2026-08-26 the Link was filed and the deposit stayed, so this queue only ever grew.
expect(await documentsGivenTo("alice")).toEqual([doc]);
expect(stillQueuedIn(aliceInbox)).toEqual([]);
});
// --- a deposit whose ARM FAILED is gone too, and said so once --------------
describe("a deposit no arm can apply", () => {
/**
* Alice opens a document with an inbox on it; Bob deposits something that declares
* itself to this package and that no arm answers. He names her DOCUMENT — the address is
* resolved inside, and he never learns it.
*/
async function bobDepositsSomethingUnarmed(): Promise<Nuri> {
await signIn("alice");
// PUBLIC, so Bob can read it — the address rides on the document, so a depositor who
// cannot read it cannot reach its owner. That is the model, not a fixture detail.
const note = await storeRegistry.createEntityDoc("public");
await openDocumentInbox(note);
await signIn("bob");
await inboxSurface.postToDocument(note, { payload: { kind: UNARMED_KIND } });
return note;
}
test("is reported AND consumed — upstream logs the failure and the message is already gone", async () => {
const note = await bobDepositsSomethingUnarmed();
const noteInbox = await (async () => {
const rec = quads.find(
(q) => q.p === "urn:ng-eventually:shim:inboxCap" && q.o.startsWith(note + " "),
);
if (!rec) throw new Error("no AddInboxCap record was written for the note");
return rec.o.split(" ")[1] as Nuri;
})();
expect(stillQueuedIn(noteInbox).length).toBe(1);
const reported = await reportedDuring(async () => {
await signIn("alice");
});
// It said so — a message that could not be applied is never silent.
expect(reported.some((l) => /no arm applies deposits of kind/.test(l))).toBe(true);
// …and it is GONE. This is the whole change: upstream the broker had already removed
// the message before the arm ran, so `log_err!` is the END of it, not a promise to
// try again. The old wording ("it stays in it and the next attempt will try again")
// described a deposit that would be re-reported forever.
expect(stillQueuedIn(noteInbox)).toEqual([]);
expect(reported.some((l) => /will not be retried/.test(l))).toBe(true);
});
test("is not reported a SECOND time, because a second drain finds an empty queue", async () => {
const note = await bobDepositsSomethingUnarmed();
await signIn("alice");
// Alice connects again — a reload, a second tab, any of the things that drain. Before
// consumption this reported the same unapplicable deposit again, and would have gone
// on doing so at every connection for as long as it sat there.
const reportedAgain = await reportedDuring(async () => {
await signIn("alice");
});
expect(reportedAgain.filter((l) => /no arm applies deposits of kind/.test(l))).toEqual([]);
void note;
});
});
// --- a second drain of a drained inbox does nothing ------------------------
test("draining an inbox twice applies once and reports nothing the second time", async () => {
await aliceHasBeenHere();
const doc = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
await signIn("alice");
expect(await documentsGivenTo("alice")).toEqual([doc]);
const reported = await reportedDuring(async () => {
await signIn("alice");
});
// Nothing to do, nothing said, and nothing filed twice. Note WHY that last one is not
// just `addLink`'s de-duplication doing the work: the queue is empty, so no arm ran.
expect(reported).toEqual([]);
expect(stillQueuedIn(aliceInbox)).toEqual([]);
expect(await documentsGivenTo("alice")).toEqual([doc]);
});
// --- ONE primitive, exercised through BOTH triggers -----------------------
/**
* The two triggers must consume through the SAME act, so this asserts on that act rather
* than on each trigger — a fault injected at the ONE removal, and the same consequence
* demanded of both paths.
*
* The consequence is `inbox-consume`'s own contract: a removal that fails STOPS the apply,
* because applying a deposit that stayed in the queue is the replay this exists to end. A
* trigger carrying its own copy of the removal would not be bound by it.
*/
describe("both triggers consume through the one primitive", () => {
/** Make retiring a deposit fail — the single `DELETE WHERE { <deposit> ?p ?o }`. */
function breakTheRemoval(): void {
const real = fake.sparql_update;
fake.sparql_update = (async (...a: unknown[]) => {
if (/DELETE\s+WHERE\s*\{\s*<[^>]+>\s+\?\w+\s+\?\w+\s*\}/.test(String(a[1]))) {
throw new Error("the broker refused to retire this deposit");
}
return real(...a);
}) as typeof fake.sparql_update;
}
test("the OWNER's own drain applies nothing when the deposit cannot be retired", async () => {
await aliceHasBeenHere();
await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
breakTheRemoval();
await reportedDuring(async () => {
await signIn("alice");
});
// Nothing applied, and the deposit is still queued — so the next drain will try it
// again, which is the only safe answer when the pop did not happen.
expect(await documentsGivenTo("alice")).toEqual([]);
expect(stillQueuedIn(aliceInbox).length).toBe(1);
});
test("the DEFERRED stand-in applies nothing either, by the same rule", async () => {
await aliceHasBeenHere();
// Alice is NOT connected: Bob's session stands in for her absent one.
await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
breakTheRemoval();
await reportedDuring(async () => {
await runScheduledInboxProcessingNow();
});
expect(await documentsGivenTo("alice")).toEqual([]);
expect(stillQueuedIn(aliceInbox).length).toBe(1);
});
test("and with the removal working, the DEFERRED stand-in consumes exactly as the owner does", async () => {
await aliceHasBeenHere();
const doc = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
await runScheduledInboxProcessingNow();
// Same two facts the owner's own drain produces above — one act, two triggers.
expect(await documentsGivenTo("alice")).toEqual([doc]);
expect(stillQueuedIn(aliceInbox)).toEqual([]);
});
});
// --- a pop whose effect did not land must SAY so ---------------------------
test("an I/O failure while filing a Link STOPS the drain, so the rest of the queue survives", async () => {
await aliceHasBeenHere();
// Three deposits waiting for an absent Alice, so "what happened to the OTHERS" is a
// question this can answer at all.
await bobSharesWithAlice();
await bobSharesWithAlice();
await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
expect(stillQueuedIn(aliceInbox).length).toBe(3);
// The `AddLink` write fails — a broker that will not take the commit.
const real = fake.sparql_update;
fake.sparql_update = (async (...a: unknown[]) => {
if (/urn:ng-eventually:shim:link/.test(String(a[1]))) {
throw new Error("the broker refused the AddLink commit");
}
return real(...a);
}) as typeof fake.sparql_update;
const reported = await reportedDuring(async () => {
await runScheduledInboxProcessingNow();
});
// Reported ungated, at the granularity of the INBOX — a genuine I/O failure is not an
// unapplicable item, and the drain stops rather than working through the queue.
expect(reported.some((l) => /could not finish draining this inbox/.test(l))).toBe(true);
expect(await documentsGivenTo("alice")).toEqual([]);
// The load-bearing assertion. One deposit was popped before the write failed and is lost;
// the other TWO are untouched and wait for the next attempt. Between 2026-08-26's first
// and second corrections this read 0: `applyLink` caught the throw and returned `false`,
// so the loop went on popping — and most of what `addLink` does before its write is
// deposit-independent infrastructure that fails for the whole queue at once. Catching it
// per deposit therefore destroyed all three while dutifully reporting each one.
expect(stillQueuedIn(aliceInbox).length).toBe(2);
// And there is no WEDGE. Stopping the drain could have traded destruction for permanent
// blockage — a deposit that always throws would sit at the head of the queue for ever and
// no later one would be reached. It cannot, and the ORDER is why: the pop happens BEFORE
// the arm, so the deposit that failed is already gone and the next attempt starts at the
// one behind it. A failing broker costs one deposit per attempt, not the queue.
//
// The attempts are ALICE CONNECTING, not the timer: a deferred window is armed by a
// deposit and is not re-armed by a drain that failed, so the owner's own next connection
// is the recovery path — which is what the emulated stand-in has always documented.
await signIn("alice");
expect(stillQueuedIn(aliceInbox).length).toBe(1);
await signIn("alice");
expect(stillQueuedIn(aliceInbox).length).toBe(0);
});
// --- delivered ONCE means once, across both triggers ----------------------
test("a deposit the deferred drain consumed is not re-applied by the owner's own next pass", async () => {
await aliceHasBeenHere();
await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
// Alice READS her inbox without processing it — which is what `inbox.watch` does on every
// push, and it is the only step that records what this session decoded. She is set as the
// holder WITHOUT connecting, deliberately: connecting drains, and a drain would consume
// the deposit before the read could see it.
setCurrentUser("alice");
expect((await inboxSurface.read(aliceInbox)).length + stillQueuedIn(aliceInbox).length)
.toBeGreaterThan(0);
setCurrentUser("bob");
// The deferred stand-in then drains the same inbox through its own path, which never
// touches that record. Before 2026-08-26 the record was only ever WRITTEN when something
// was declared, so it survived the queue it described: Alice's own next pass worked from
// a list of deposits that were already gone, re-popping and re-applying every one.
await runScheduledInboxProcessingNow();
const removalsBefore = removalCount();
await signIn("alice");
expect(removalCount()).toBe(removalsBefore);
expect(stillQueuedIn(aliceInbox)).toEqual([]);
});
// --- the gap between deciding to deliver and delivering --------------------
test("an identity that moves DURING the pop does not get the deposit applied for it", async () => {
await aliceHasBeenHere();
// Alice opens an index and Bob deposits a bare reference into it. That arm is the one
// that reads AMBIENT state, so it is the one an identity switch can mis-file.
await signIn("alice");
const index = await storeRegistry.createEntityDoc("public");
await openDocumentInbox(index);
const object = await storeRegistry.createEntityDoc("public");
await signIn("bob");
await inboxSurface.postToDocument(index, { payload: object });
const indexInbox = (() => {
const rec = quads.find(
(q) => q.p === "urn:ng-eventually:shim:inboxCap" && q.o.startsWith(index + " "),
);
if (!rec) throw new Error("no AddInboxCap record was written for the index");
return rec.o.split(" ")[1] as Nuri;
})();
// Alice signs in, so the reference is CLAIMED — her own session may apply it. Then the
// identity moves in the one place nothing used to look: inside the await that removes the
// deposit. `claimsDeposit` had already said yes, and the arm below reads whoever holds the
// page now, so before the re-check this filed Alice's deposit against Bob's registers.
const real = fake.sparql_update;
fake.sparql_update = (async (...a: unknown[]) => {
const out = await real(...a);
if (/DELETE\s+WHERE\s*\{\s*<[^>]+>\s+\?\w+\s+\?\w+\s*\}/.test(String(a[1]))) {
setCurrentUser("bob");
}
return out;
}) as typeof fake.sparql_update;
const reported = await reportedDuring(async () => {
await signIn("alice");
});
// The deposit is gone — it was popped before the switch was visible — and that is said
// out loud rather than papered over. What must NOT have happened is the arm running for
// the wrong holder.
expect(stillQueuedIn(indexInbox)).toEqual([]);
expect(reported.some((l) => /consumed but not applied/.test(l))).toBe(true);
});
test("an arm that fails WHILE the page switches user is still reported, not swallowed", async () => {
await aliceHasBeenHere();
await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
// Both at once: the `AddLink` write fails AND the identity moves. `connect.connectedUser`
// drops an error from a drain whose holder has gone — an ownership refusal there is
// abandoning, not a failure — and that guard cannot tell the two apart. So for one day
// this exact pair destroyed a deposit and printed nothing at all.
const real = fake.sparql_update;
fake.sparql_update = (async (...a: unknown[]) => {
if (/urn:ng-eventually:shim:link/.test(String(a[1]))) {
setCurrentUser("bob");
throw new Error("the broker refused the AddLink commit");
}
return real(...a);
}) as typeof fake.sparql_update;
const reported = await reportedDuring(async () => {
await signIn("alice");
});
// The deposit is gone, and SOMETHING said so. Reported by the only code that knows a
// deposit was consumed, before the error is handed to anyone who might drop it.
expect(stillQueuedIn(aliceInbox)).toEqual([]);
expect(reported.some((l) => /was consumed and could not be applied/.test(l))).toBe(true);
});
test("a deposit that cannot be retired is refused, not applied again at every drain", async () => {
await aliceHasBeenHere();
const doc = await bobSharesWithAlice();
const aliceInbox = await userInbox("alice", "protected");
// A broker that answers the deposits query WITHOUT binding the deposit's subject. `?d` is
// bound by the query so this should not happen, but `depositsFrom` defaults it to "" and
// an empty subject would issue `DELETE WHERE { <> ?p ?o }`. The load-bearing half is that
// it must not be APPLIED either: a deposit applied but never retired is the replay this
// whole change exists to end, repeated at every drain for as long as the row sits there.
const realQuery = fake.sparql_query;
fake.sparql_query = (async (...a: unknown[]) => {
const res = await realQuery(...a);
if (a[3] === aliceInbox && String(a[1]).includes(`${INBOX}:payload`)) {
const r = res as { results?: { bindings?: Array<Record<string, unknown>> } };
for (const row of r.results?.bindings ?? []) delete row.d;
}
return res;
}) as typeof fake.sparql_query;
const reported = await reportedDuring(async () => {
await signIn("alice");
});
expect(reported.some((l) => /carries no subject/.test(l))).toBe(true);
expect(await documentsGivenTo("alice")).toEqual([]);
expect(stillQueuedIn(aliceInbox).length).toBe(1);
void doc;
});
+196 -13
View File
@@ -37,7 +37,7 @@ import {
runScheduledInboxProcessingNow,
} from "../src/emulated-verifier/inbox-processor";
import { stopObservingInboxes } from "../src/emulated-verifier/inbox-observer";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
import { ENTRY_VALUE, INDEX_FIELD } from "../src/emulated-verifier/index-deposit";
import { postToDocument } from "../src/surface/inbox";
@@ -84,6 +84,19 @@ function makeFakeNg() {
}
return undefined;
}
// Retiring a DEPOSIT: every triple of one subject, whatever predicates it carries
// (`emulated-verifier/inbox-consume.removeDeposit`). Modelled here rather than ignored
// — a fake that dropped this delete would leave every deposit in place and let this
// whole suite pass on a queue that never shrinks.
const delAll = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+\?\w+\s+\?\w+\s*\}/);
if (delAll) {
const s0 = delAll[1]!;
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === s0) quads.splice(i, 1);
}
return undefined;
}
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
@@ -140,6 +153,8 @@ function makeFakeNg() {
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
// The deposit's own subject — the key a drain names to retire it.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
@@ -151,6 +166,7 @@ function makeFakeNg() {
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
d: { value: r.d! },
payload: { value: r.payload! },
ts: { value: r.ts! },
};
@@ -200,7 +216,17 @@ function makeFakeNg() {
};
});
return { doc_create, sparql_update, sparql_query, quads };
// `doc_subscribe` is declared and left undefined: absent, `ensureRepoOpen` is the
// documented no-op of the unit-fake path. One test installs a subscription that never
// pushes `State`, to reach the `timed-out` repo state a cold start really produces.
const fakeNg: {
doc_create: typeof doc_create;
sparql_update: typeof sparql_update;
sparql_query: typeof sparql_query;
quads: Quad[];
doc_subscribe?: (...a: unknown[]) => () => void;
} = { doc_create, sparql_update, sparql_query, quads };
return fakeNg;
}
let fake: ReturnType<typeof makeFakeNg>;
@@ -314,18 +340,29 @@ test("a reference nobody can resolve is reported, and costs the index nothing",
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
// One resolvable reference and one that names nothing. The second must not take the
// first down with it, and must not leave the index short of an entry either.
await bobDepositsInto(index, object, 1);
await bobDepositsInto(index, "did:ng:o:nowhere", 2);
// A reference this holder cannot even attempt — no cap, so `readUnion` drops it before
// issuing a query — and a resolvable one BEHIND it, which is what makes the question
// answerable at all.
await bobDepositsInto(index, "did:ng:o:nowhere", 1);
await bobDepositsInto(index, object, 2);
const reported = await reportedDuring(aliceConnects);
// NOT called an absence, and this is the correction that cost the most to find. "No cap"
// reads as deterministic and is not: for a stranger's public document possession comes
// from a network fetch that swallows its errors and memoises the failure, so one outage
// presents exactly like a document that never existed. Classing it absent destroyed the
// deposit and every re-deposit after it. It stops the drain instead.
expect(reported.some((l) => /did:ng:o:nowhere/.test(l))).toBe(true);
expect(reported.some((l) => /the read came back empty/.test(l))).toBe(true);
// Costs nothing: the resolvable one is in, and nothing was written for the other.
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
expect(reported.some((l) => /was consumed and could not be applied/.test(l))).toBe(true);
expect(await entryValuesFor(index, "did:ng:o:nowhere")).toEqual([]);
// The price is LATENCY, not loss: the unreadable one was popped, so the next connection
// resumes past it and the resolvable one lands. Nothing wedges, and nothing is destroyed
// that a working broker would have delivered.
expect(await entryValuesFor(index, object)).toEqual([]);
await aliceConnects();
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
});
test("an object already in the index is passed over — the same reference twice makes ONE entry", async () => {
@@ -333,10 +370,11 @@ test("an object already in the index is passed over — the same reference twice
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
// Deposits are never consumed, so a curating pass sees every deposit on every run. That
// is affordable only because a reference already in the index is passed over: without
// that check the second deposit writes a second value for the same object, and an entry
// carrying two values is exactly what once made the index SHRINK through additions.
// TWO deposits, each consumed when it is applied — not one deposit replayed. The check
// that a reference already in the index is passed over is what makes this land on ONE
// entry: without it the second deposit writes a second value for the same object, and an
// entry carrying two values is exactly what once made the index SHRINK through additions.
// That is why the check outlived consumption: it never answered the replay.
await bobDepositsInto(index, object, 1);
await bobDepositsInto(index, object, 2);
@@ -447,3 +485,148 @@ test("a consumer payload is not a variant: no arm claims it and nothing is repor
expect(reported.filter((l) => /could not apply a deposit/.test(l))).toEqual([]);
});
// --- a consumed deposit is never discarded in silence ------------------------
test("a reference to an object carrying nothing for the field is REPORTED, not silently dropped", async () => {
inject();
await bobSignsInOnce();
const { index } = await aliceOpensAnIndex([WHEN]);
// A draft: it resolves, and it says nothing for the field the index declares. While
// deposits were re-read for ever this was a benign WAIT — the object might gain the field
// and a later drain would index it — so it was traced behind `debugAccessLog` and nothing
// more. Consumption ends the wait: this deposit is gone once it has been looked at, so the
// discard has to be audible without any flag turned on.
setCurrentUser("alice");
const draft = await createEntityDoc("alice", "public");
// It has to SAY something, or the read comes back empty and this is the `refused` path
// instead — a different outcome with a different report. What is under test is the
// object that resolves fine and simply has no value for the indexed field.
await write(draft, "urn:test:title", "a draft with no date yet");
setCurrentUser(null);
await bobDepositsInto(index, draft, 1);
const reported = await reportedDuring(aliceConnects);
expect(reported.some((l) => /was discarded/.test(l) && new RegExp(`carries no ${WHEN}`).test(l)))
.toBe(true);
expect(await entryValuesFor(index, draft)).toEqual([]);
});
// --- a broker that goes quiet must not walk the whole queue -----------------
test("a broker that cannot read the referenced objects costs ONE deposit, not the queue", async () => {
inject();
await bobSignsInOnce();
const { index } = await aliceOpensAnIndex([WHEN]);
// Three references waiting in the index's inbox.
setCurrentUser("alice");
const objects: Nuri[] = [];
for (let i = 0; i < 3; i++) {
const o = await createEntityDoc("alice", "public");
await write(o, WHEN, `2026-01-0${i + 1}`);
objects.push(o);
}
setCurrentUser(null);
for (let i = 0; i < 3; i++) await bobDepositsInto(index, objects[i]!, i + 1);
const queued = () =>
fake.quads.filter((q) => q.p === `${INBOX}:payload` && q.o.includes("did:ng:o:")).length;
expect(queued()).toBe(3);
// The broker goes quiet on the referenced objects. `read-model.readDoc` SWALLOWS a failed
// read and answers `[]` — so the arm is TOLD, through the `onUnreadable` reporter, rather
// than left to guess from an empty answer. Until 2026-08-26 this was classed as an
// ordinary refusal: the drain reported it and carried on, popping and destroying all three
// while printing "will not be retried" for each.
const realQuery = fake.sparql_query;
fake.sparql_query = (async (...a: unknown[]) => {
if (objects.includes(a[3] as Nuri)) throw new Error("the broker went quiet");
return realQuery(...a);
}) as typeof fake.sparql_query;
const reported = await reportedDuring(aliceConnects);
expect(reported.some((l) => /was consumed and could not be applied/.test(l))).toBe(true);
expect(queued()).toBe(2);
});
test("an EMPTY but readable object is absent — reported, and the queue behind it carries on", async () => {
inject();
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
// A document Alice really holds and that really reads — it just has no triples. This is
// the genuine clean-empty read, and until 2026-08-26 NOTHING exercised it: the only test
// of this branch pointed at `did:ng:o:nowhere`, which `readUnion`'s possession gate drops
// before any query is issued. So the branch that decides "absent vs unreadable" on a real
// read was uncovered, which is how two opposite misclassifications shipped through it.
setCurrentUser("alice");
const empty = await createEntityDoc("alice", "public");
setCurrentUser(null);
// The empty one FIRST, so "does it stall what is behind it" is a question with an answer.
await bobDepositsInto(index, empty, 1);
await bobDepositsInto(index, object, 2);
const reported = await reportedDuring(aliceConnects);
expect(reported.some((l) => /read cleanly from a synced repo and found nothing/.test(l)))
.toBe(true);
expect(await entryValuesFor(index, empty)).toEqual([]);
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
});
test("a rejection valued `null` is still a failure, not an absence", async () => {
inject();
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
await bobDepositsInto(index, object, 1);
// `throw null` is legal, and the first version of this classification tracked the error
// VALUE against a `null` sentinel — so a broker that rejected with `null` was recorded as
// "no failure happened" and its deposit was destroyed as an absence. Tracked by a boolean
// now, which is the only thing that cannot collide with a payload.
const realQuery = fake.sparql_query;
fake.sparql_query = (async (...a: unknown[]) => {
if (a[3] === object) throw null;
return realQuery(...a);
}) as typeof fake.sparql_query;
const reported = await reportedDuring(aliceConnects);
expect(reported.some((l) => /was consumed and could not be applied/.test(l))).toBe(true);
expect(reported.some((l) => /came back clean and empty/.test(l))).toBe(false);
expect(await entryValuesFor(index, object)).toEqual([]);
});
test("an empty read whose repo is NOT SYNCED is not an absence — it stops the drain", async () => {
inject();
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
setCurrentUser("alice");
const unsynced = await createEntityDoc("alice", "public");
setCurrentUser(null);
await bobDepositsInto(index, unsynced, 1);
await bobDepositsInto(index, object, 2);
// A broker that ACCEPTS the subscription and never pushes `State`. The bounded fallback
// fires, the repo is `timed-out` rather than `synced`, and the anchored read then answers
// 0 rows WITH NO ERROR — which is the shape a cold start takes, not an exotic one:
// `Verifier::load` repopulates `self.repos` from storage, so the repo is present and its
// commits simply have not arrived. Reading that as "the object is absent" consumed and
// destroyed the whole queue while reporting each deposit as absent. `open-repo` keeps
// `synced` and `timed-out` apart precisely so this arm can tell, and now it asks.
setOpenTimeoutForTests(20);
fake.doc_subscribe = () => () => {};
const reported = await reportedDuring(aliceConnects);
expect(reported.some((l) => /is not synced in this session/.test(l))).toBe(true);
expect(reported.some((l) => /came back clean and empty/.test(l))).toBe(false);
// Stopped rather than carried on: the deposit behind it is untouched.
expect(await entryValuesFor(index, object)).toEqual([]);
});
+17 -1
View File
@@ -94,6 +94,19 @@ function makeFakeNg() {
const anchor = a[2] as string | undefined;
if (/GRAPH\s*</.test(query)) return undefined; // explicit-GRAPH write → dropped
if (!anchor) return undefined;
// Retiring a DEPOSIT: every triple of one subject, whatever predicates it carries
// (`emulated-verifier/inbox-consume.removeDeposit`). Modelled rather than ignored — a
// fake that let this delete fall through would leave every deposit in place, and a
// suite asserting convergence would pass on a queue that never shrinks.
const delDeposit = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+\?\w+\s+\?\w+\s*\}/);
if (delDeposit) {
const subject = delDeposit[1]!;
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.s === subject) quads.splice(i, 1);
}
return undefined;
}
const g = anchor;
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
@@ -150,7 +163,9 @@ function makeFakeNg() {
continue;
}
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
// The deposit's own subject — the key a drain names to retire it.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
@@ -159,6 +174,7 @@ function makeFakeNg() {
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
d: { value: r.d! },
payload: { value: r.payload! },
ts: { value: r.ts! },
};
@@ -69,6 +69,19 @@ function makeFakeNg() {
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
// Retiring a DEPOSIT: every triple of one subject, whatever predicates it carries
// (`emulated-verifier/inbox-consume.removeDeposit`). Modelled rather than ignored — a
// fake that let this delete fall through would leave every deposit in place, and a
// suite asserting convergence would pass on a queue that never shrinks.
const delDeposit = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+\?\w+\s+\?\w+\s*\}/);
if (delDeposit) {
const subject = delDeposit[1]!;
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.s === subject) quads.splice(i, 1);
}
return undefined;
}
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
@@ -135,6 +148,8 @@ function makeFakeNg() {
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
// The deposit's own subject — the key a drain names to retire it.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
@@ -144,6 +159,7 @@ function makeFakeNg() {
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
d: { value: r.d! },
payload: { value: r.payload! },
ts: { value: r.ts! },
};
@@ -151,12 +151,21 @@ function makeWallet(quads: Quad[] = []) {
const anchor = a[2] as string | undefined;
const del = query.match(/DELETE\s+WHERE\s*\{([\s\S]*)\}/i);
if (del) {
const pattern = del[1]!.match(/<([^>]+)>\s+<([^>]+)>\s+\?/);
if (pattern && anchor !== undefined) {
if (del && anchor !== undefined) {
// `<s> <p> ?o` clears one register slot; `<s> ?p ?o` retires a whole DEPOSIT
// (`emulated-verifier/inbox-consume.removeDeposit`) — a deposit carries
// `a`/`payload`/`ts` and optionally `from`, so naming predicates would leave some.
const boundPredicate = del[1]!.match(/<([^>]+)>\s+<([^>]+)>\s+\?/);
const wholeSubject = del[1]!.match(/<([^>]+)>\s+\?\w+\s+\?\w+/);
const matches = boundPredicate
? (q: Quad) => q.s === boundPredicate[1] && q.p === boundPredicate[2]
: wholeSubject
? (q: Quad) => q.s === wholeSubject[1]
: null;
if (matches) {
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === pattern[1] && q.p === pattern[2]) quads.splice(i, 1);
if (q.g === anchor && matches(q)) quads.splice(i, 1);
}
}
return undefined;
@@ -222,6 +231,8 @@ function makeWallet(quads: Quad[] = []) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of inGraph) {
const rec = bySubject.get(q.s) ?? {};
// The deposit's own subject — the key a drain names to retire it.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
@@ -231,6 +242,7 @@ function makeWallet(quads: Quad[] = []) {
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
d: { value: r.d! },
payload: { value: r.payload! },
ts: { value: r.ts! },
};
+23 -4
View File
@@ -249,12 +249,27 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
const anchor = a[2] as string | undefined;
const del = query.match(/DELETE\s+WHERE\s*\{([\s\S]*)\}/i);
if (del) {
const pattern = del[1]!.match(/<([^>]+)>\s+<([^>]+)>\s+\?/);
if (pattern && anchor !== undefined) {
if (del && anchor !== undefined) {
// Two shapes, and the second one REMOVES A DEPOSIT — so the fake has to model it
// rather than answer "nothing matched" and let a consuming test pass on a queue that
// never shrank.
//
// `<s> <p> ?o` — one register slot, what the address/cap writers clear;
// `<s> ?p ?o` — every triple of one subject, which is how a deposit is retired
// (`emulated-verifier/inbox-consume.removeDeposit`): a deposit
// carries `a`/`payload`/`ts` and optionally `from`, so naming the
// predicates would leave some behind.
const boundPredicate = del[1]!.match(/<([^>]+)>\s+<([^>]+)>\s+\?/);
const wholeSubject = del[1]!.match(/<([^>]+)>\s+\?\w+\s+\?\w+/);
const matches = boundPredicate
? (q: Quad) => q.s === boundPredicate[1] && q.p === boundPredicate[2]
: wholeSubject
? (q: Quad) => q.s === wholeSubject[1]
: null;
if (matches) {
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === pattern[1] && q.p === pattern[2]) quads.splice(i, 1);
if (q.g === anchor && matches(q)) quads.splice(i, 1);
}
commit(anchor);
}
@@ -333,6 +348,9 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
const bySubject = new Map<string, Record<string, string>>();
for (const q of inGraph) {
const rec = bySubject.get(q.s) ?? {};
// `d` is the deposit's own subject, and it is bound because RETIRING a deposit
// names it. A fake that dropped it would hand back deposits nothing can consume.
rec.d = q.s;
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
@@ -342,6 +360,7 @@ export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWall
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
d: { value: r.d! },
payload: { value: r.payload! },
ts: { value: r.ts! },
};