462 lines
20 KiB
TypeScript
462 lines
20 KiB
TypeScript
/**
|
|
* 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;
|
|
});
|