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