feat: un dépôt est aiguillé sur son type, comme en amont, et l'index a son bras

This commit is contained in:
Sylvain Duchesne
2026-08-21 19:28:48 +02:00
parent 1b4fcb567e
commit 3dfc231005
5 changed files with 1043 additions and 76 deletions
@@ -0,0 +1,449 @@
/**
* Applying an inbox deposit is a DISPATCH on what the deposit declares itself to be, and
* this suite is about the two things a `match` owes: every arm does real work, and the
* arm that does not exist SAYS SO.
*
* ── What each test would have looked like before ──────────────────────────
* Before 2026-08-21 `processInbox` iterated the caps a read had observed. A deposit of any
* other shape was read, counted, logged — and produced nothing: no effect, and no reported
* failure. So a reference deposit vanished, a malformed Link vanished, and a kind nobody
* had written an arm for vanished, all three indistinguishable from an empty inbox. Every
* test below fails against that code, and fails again if the arm it exercises is removed.
*
* ── Nobody is handed an inbox address ─────────────────────────────────────
* Each depositor names the DOCUMENT (`inbox.postToDocument`), which is all an application
* has; the address is resolved inside. The one address the TEST resolves for itself is
* used only to assert what landed, never given to an actor.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { configure } from "../src/index";
import {
configureStoreRegistry,
resetCaps,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import {
createEntityDoc,
resetRegistryCache,
resolveWriteGraph,
} from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import { connectedUser } from "../src/emulated-verifier/connect";
import {
cancelScheduledInboxProcessing,
runScheduledInboxProcessingNow,
} from "../src/emulated-verifier/inbox-processor";
import { stopObservingInboxes } from "../src/emulated-verifier/inbox-observer";
import { resetOpenedRepos } 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";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
const SESSION: RegistrySession = { sessionId: "sid-dispatch", privateStoreId: "PRIV-DISPATCH" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** The field the index documents below declare they index by. */
const WHEN = "urn:test:when";
interface Quad { g: string; s: string; p: string; o: string }
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored per-doc read. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (!anchor) return undefined;
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
if (del) {
const [s0, p0] = [del[1]!, del[2]!];
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
}
return undefined;
}
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g: anchor, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
const byPred = (pred: string, v: string) => ({
results: {
bindings: quads
.filter((q) => q.g === anchor && q.p === pred)
.map((q) => ({ [v]: { value: q.o } })),
},
});
if (query.includes(`<${SHIM}:shimDoc>`)) return byPred(`${SHIM}:shimDoc`, "shimDoc");
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
})),
},
};
}
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(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);
}
return {
results: {
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! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
}),
},
};
}
if (query.includes(`<${SHIM}:inboxCap>`)) return byPred(`${SHIM}:inboxCap`, "c");
if (query.includes(`<${SHIM}:inboxAddress>`)) return byPred(`${SHIM}:inboxAddress`, "a");
if (query.includes(`<${SHIM}:readCap>`)) return byPred(`${SHIM}:readCap`, "c");
if (query.includes(`<${SHIM}:link>`)) return byPred(`${SHIM}:link`, "c");
if (query.includes(`${SHIM}:isInbox`)) return byPred(`${SHIM}:isInbox`, "i");
if (query.includes(`<${SHIM}:inboxOwner>`)) {
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:inboxOwner>/);
const subj = sm ? sm[1]! : null;
return {
results: {
bindings: quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxOwner` && q.s === subj)
.map((q) => ({ u: { value: q.o } })),
},
};
}
if (query.includes(`${SHIM}:docInbox`)) {
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
const pred = pm ? pm[1]! : "";
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
const subj = sm ? sm[1]! : null;
return {
results: {
bindings: quads
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
.map((q) => ({ d: { value: q.o } })),
},
};
}
if (query.includes(`<${SHIM}:exposedReadCap>`)) return byPred(`${SHIM}:exposedReadCap`, "c");
if (query.includes(`<${SHIM}:contains>`)) return byPred(`${SHIM}:contains`, "e");
return {
results: {
bindings: quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
},
};
});
return { doc_create, sparql_update, sparql_query, quads };
}
let fake: ReturnType<typeof makeFakeNg>;
function inject() {
fake = makeFakeNg();
configure({ ng: fake as never, useShape: (() => {}) as never });
configureStoreRegistry({ getSession: async () => SESSION });
resetRegistryCache();
resetOpenedRepos();
resetPublicStoreFetches();
resetCaps();
setCurrentUser(null);
}
afterEach(() => {
cancelScheduledInboxProcessing();
stopObservingInboxes();
resetConfig();
resetStoreRegistry();
resetCaps();
resetRegistryCache();
resetOpenedRepos();
resetPublicStoreFetches();
setCurrentUser(null);
});
/** Write one triple into `doc`, as the consumer's own write path would. */
async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** Every value the index holds for `object` — one per entry written. */
async function entryValuesFor(index: Nuri, object: Nuri): Promise<string[]> {
const subjects = await readUnion([index]);
return subjects.filter((s) => s.subject === object).flatMap((s) => s.props[ENTRY_VALUE] ?? []);
}
/** What the package reported, for the span of `run`. Restores `console.error` whatever
* happens — a suite that leaks a stub takes the next file down with it. */
async function reportedDuring(run: () => 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 run();
} finally {
console.error = real;
}
return lines;
}
/**
* Alice's index: a public document declaring the field it indexes by, with an inbox open
* on it so that anyone can hand it a reference. Returns the index and one PUBLIC object
* carrying a value for that field.
*/
async function aliceOpensAnIndex(fields: string[]): Promise<{ index: Nuri; object: Nuri }> {
setCurrentUser("alice");
const index = await createEntityDoc("alice", "public");
for (const field of fields) await write(index, INDEX_FIELD, field);
await openDocumentInbox(index);
const object = await createEntityDoc("alice", "public");
await write(object, WHEN, "2026-01-01");
setCurrentUser(null);
return { index, object };
}
/** Bob has been in the page once, so he is someone the wallet knows. */
async function bobSignsInOnce(): Promise<void> {
setCurrentUser("bob");
await resolveWriteGraph("bob", "protected");
setCurrentUser(null);
}
/** Bob hands `payload` to the owner of `doc`, naming the document and nothing else. */
async function bobDepositsInto(doc: Nuri, payload: unknown, ts: number): Promise<void> {
setCurrentUser("bob");
await postToDocument(doc, { payload, ts });
setCurrentUser(null);
}
/** Alice connects — which restores her registers and drains every inbox she holds. */
async function aliceConnects(): Promise<void> {
setCurrentUser("alice");
await connectedUser();
}
// --- the reference arm -------------------------------------------------------
test("an entry appears in the index from a bare reference deposited by a stranger", async () => {
inject();
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
// The whole payload is the reference: no operation, no claim, and no copy of the value.
// What lands in the index is what the OBJECT says, which is why a stranger depositing
// achieves exactly what the owner would have.
await bobDepositsInto(index, object, 1);
expect(await entryValuesFor(index, object)).toEqual([]);
await aliceConnects();
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
});
test("a reference nobody can resolve is reported, and costs the index nothing", async () => {
inject();
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);
const reported = await reportedDuring(aliceConnects);
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(await entryValuesFor(index, "did:ng:o:nowhere")).toEqual([]);
});
test("an object already in the index is passed over — the same reference twice makes ONE entry", async () => {
inject();
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.
await bobDepositsInto(index, object, 1);
await bobDepositsInto(index, object, 2);
await aliceConnects();
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
});
test("an index declaring NO field refuses, loudly, rather than writing something wrong", async () => {
inject();
await bobSignsInOnce();
// A document with an inbox and no declaration: either it is not an index, or it could
// not be read — and here those are the same empty result.
const { index, object } = await aliceOpensAnIndex([]);
await bobDepositsInto(index, object, 1);
const reported = await reportedDuring(aliceConnects);
expect(reported.some((l) => /declares no index field/.test(l))).toBe(true);
expect(await entryValuesFor(index, object)).toEqual([]);
});
test("an index declaring SEVERAL fields refuses rather than picking one", async () => {
inject();
await bobSignsInOnce();
// Entries already written are never re-read, so curating under a second field would
// leave one list ordered by two different properties — a quiet wrong answer.
const { index, object } = await aliceOpensAnIndex([WHEN, "urn:test:other"]);
await bobDepositsInto(index, object, 1);
const reported = await reportedDuring(aliceConnects);
expect(reported.some((l) => /declares 2 index fields/.test(l))).toBe(true);
expect(await entryValuesFor(index, object)).toEqual([]);
});
test("a reference deposit is left for its owner's own session, not applied from a stranger's", async () => {
inject();
await bobSignsInOnce();
const { index, object } = await aliceOpensAnIndex([WHEN]);
await bobDepositsInto(index, object, 1);
// The deferred stand-in for an absent owner runs under BOB's session. It files the
// verifier's own registers for an absent owner and stops there: writing application data
// into Alice's documents from Bob's page is a different act, and it is not taken.
setCurrentUser("bob");
const reported = await reportedDuring(async () => {
await runScheduledInboxProcessingNow();
});
setCurrentUser(null);
expect(await entryValuesFor(index, object)).toEqual([]);
// Waiting is not failing, and it must not be reported as one: the arm did not try and
// fall short, it is not this session's to run. Running it anyway would report a refusal
// here every twenty seconds — Bob's User branch pairs no document with Alice's inbox.
expect(reported.filter((l) => /could not apply a deposit/.test(l))).toEqual([]);
// Nothing was consumed, so Alice's own next connection applies it — the real path.
await aliceConnects();
expect(await entryValuesFor(index, object)).toEqual(["2026-01-01"]);
});
// --- the cap arm -------------------------------------------------------------
test("a deposit declaring itself a Link and carrying no readable cap is reported, not passed over", async () => {
inject();
await bobSignsInOnce();
const { index } = await aliceOpensAnIndex([WHEN]);
// A bare reference where a ReadCap belongs — the one type confusion that would invert
// the model if it were filed, and the shape a version skew produces. Upstream an arm
// opens by validating its envelope and a malformed one is a hard failure, not a skip.
await bobDepositsInto(index, { kind: "urn:ng-eventually:inbox:link", cap: "did:ng:o:bare" }, 1);
const reported = await reportedDuring(aliceConnects);
expect(reported.some((l) => /declares itself a Link and carries no readable cap/.test(l))).toBe(true);
});
// --- the arm that does not exist ---------------------------------------------
test("a kind no arm answers says so — the dispatch's `NotImplemented`", async () => {
inject();
await bobSignsInOnce();
const { index } = await aliceOpensAnIndex([WHEN]);
// A deposit declaring itself in THIS package's reserved namespace, for a variant nothing
// here applies. Upstream that is `_ => Err(NotImplemented)`; here it used to be silence.
await bobDepositsInto(index, { kind: "urn:ng-eventually:inbox:not-a-thing" }, 1);
const reported = await reportedDuring(aliceConnects);
expect(
reported.some((l) => /no arm applies deposits of kind "urn:ng-eventually:inbox:not-a-thing"/.test(l)),
).toBe(true);
});
test("a consumer payload is not a variant: no arm claims it and nothing is reported", async () => {
inject();
await bobSignsInOnce();
const { index } = await aliceOpensAnIndex([WHEN]);
// `kind` is an ordinary word an application may use for its own messages. Keying the
// dispatch on the mere presence of the field would have turned every one of them into an
// unapplied message reported at each drain.
await bobDepositsInto(index, { kind: "join", who: "bob" }, 1);
const reported = await reportedDuring(aliceConnects);
expect(reported.filter((l) => /could not apply a deposit/.test(l))).toEqual([]);
});