fix: un dépôt est délivré une fois et retiré, comme en amont
This commit is contained in:
@@ -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;
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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! },
|
||||
};
|
||||
|
||||
@@ -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! },
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user