fix: trois chemins vers une inbox en double, et la lecture qui manquait
Une application a rapporté quatre appels simultanés sur un même document enregistrant trois inboxes. Le contrat garantissait l'inverse. En cherchant, on en a trouvé DEUX autres, indépendantes, qui produisent le même dégât durable : le propriétaire surveille une inbox pendant que les dépôts arrivent dans une autre. La concurrence. openDocumentInbox ne partageait rien avec userInbox — module différent, registre propre, aucune coalescence. Reproduit pire que rapporté : quatre appels donnaient QUATRE inboxes. Une carte en vol par (détenteur, document), et le corps déplacé pour que l'invariant soit porté par la composition plutôt que par la position d'une vérification. La limite est nommée plutôt que cachée : deux onglets ne partagent aucune carte, chacun lit, chacun ne trouve rien, chacun frappe. Ce n'est pas réparable ici — une branche est en ajout seul, et ça ne se réconcilie pas après coup, le propriétaire lisant sa branche User quand un déposant lit l'adresse publiée du document. Le contrat porte donc une garantie positive ET une non-garantie. La page froide. readInboxCapPairs était le seul lecteur de store sans barrière, correct uniquement parce qu'une autre fonction s'exécutait avant lui à la connexion. Une dépendance d'ordre, pas une garantie portée par la lecture : sur une page froide il lisait le store privé non synchronisé, répondait « aucune inbox » et en frappait une seconde. Un seul appel, aucune concurrence. La barrière est désormais dans la lecture, et elle ne coûte rien aux chemins connectés, la connexion ayant déjà ouvert les trois stores. Et la lecture qui manquait. readSynced donnait la garantie, readForDocument l'adressage, pas leur intersection — si bien que matérialiser des dépôts obligeait une application à résoudre une adresse d'inbox elle-même, ce que le contrat lui interdit explicitement. inbox.readSyncedForDocument la lui épargne. Elle traverse deux dépôts, l'adresse vivant sur l'en-tête du document et les dépôts sur l'inbox — franchir la barrière sur la seule inbox ne réparait rien. Au passage, le compteur d'identifiants de la doublure était par page : une page rechargée refrappait le même identifiant PAR-DESSUS une inbox existante, aliasant deux dépôts en silence. Il est monotone.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* cold-read-for-document.test.ts — coming back to the messages left on my note.
|
||||
*
|
||||
* ── The gap this closes ───────────────────────────────────────────────────
|
||||
* The inbox surface offered a read with the SYNC GUARANTEE (`readSynced`) and a read
|
||||
* ADDRESSED BY DOCUMENT (`readForDocument`), and not their intersection. An application
|
||||
* materializing deposits needs both, so it had to resolve an inbox address itself — the
|
||||
* one gesture the contract says an application never performs.
|
||||
*
|
||||
* ── Why the document-addressed path needs the barrier TWICE ───────────────
|
||||
* Reading a document's messages crosses two repos: the DOCUMENT, whose Header branch
|
||||
* carries the address, and the INBOX, which carries the deposits. On a fresh session over
|
||||
* the same persistent wallet both are present and unsynced, and an anchored read of an
|
||||
* unsynced repo returns no rows — no error (`emulated-verifier/open-repo.ts`). So the
|
||||
* ADDRESS read comes back empty, `readForDocument` concludes "this document has no inbox",
|
||||
* and answers `[]` for a note whose inbox holds the message somebody left on it.
|
||||
*
|
||||
* That is the state this suite starts from, reached the way a real page reaches it: Alice
|
||||
* writes a note and opens it for messages, Bob leaves one, and Alice comes back on a new
|
||||
* page. Nothing is planted — a second page sees exactly what the first one WROTE, and the
|
||||
* broker fake only withholds what this page has not subscribed to yet
|
||||
* (`wallet-fake.ts`, `unsyncedUntilSubscribed`).
|
||||
*
|
||||
* The pair of tests is the point: on ONE state, the ungated read answers empty and the
|
||||
* gated one answers the message. A test that only showed `readSyncedForDocument` returning
|
||||
* deposits would pass just as well over a plain `read`.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, afterAll, beforeEach } from "bun:test";
|
||||
import { docs, inbox as inboxSurface, storeRegistry } from "../src/index";
|
||||
import { getSyncState } from "../src/emulated-verifier/open-repo";
|
||||
import { bootPage, forgetEverything, reloadPage, signIn, SESSION, type Quad } from "./wallet-fake";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
const TITLE = "urn:test:title";
|
||||
const MESSAGE = "j'apporte le café";
|
||||
|
||||
/** The broker's own cold start — see `wallet-fake.WalletOptions`. */
|
||||
const COLD = { unsyncedUntilSubscribed: true } as const;
|
||||
|
||||
/**
|
||||
* The first visit, in the application's own vocabulary: Alice writes a note and opens it
|
||||
* for messages; Bob leaves one on it. Both name the NOTE and nothing else.
|
||||
*
|
||||
* Returns the note, which is all an application ever holds.
|
||||
*/
|
||||
async function aNoteWithAMessageOnIt(quads: Quad[]): Promise<Nuri> {
|
||||
bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc("public");
|
||||
await docs.sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <${note}> <${TITLE}> "Courses" }`,
|
||||
note,
|
||||
"writeEntity",
|
||||
);
|
||||
await storeRegistry.openDocumentInbox(note);
|
||||
|
||||
await signIn("bob");
|
||||
await inboxSurface.postToDocument(note, { payload: { text: MESSAGE }, from: "bob", ts: 1 });
|
||||
return note;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inbox the note was opened on, read off the WALLET — the emulated `AddInboxCap`
|
||||
* record. The test asks the wallet because no application can ask the package: there is
|
||||
* deliberately no published call that hands out an address, which is the whole reason
|
||||
* `readSyncedForDocument` has to exist.
|
||||
*/
|
||||
function inboxOnTheNote(quads: Quad[], note: Nuri): Nuri {
|
||||
const record = quads.find(
|
||||
(q) => q.p === "urn:ng-eventually:shim:inboxCap" && q.o.startsWith(note + " "),
|
||||
);
|
||||
if (!record) throw new Error("no AddInboxCap record was written for the note");
|
||||
return record.o.split(" ")[1] as Nuri;
|
||||
}
|
||||
|
||||
/** Alice's note, found the way her application finds it: by listing her own store. */
|
||||
async function myNote(): Promise<Nuri> {
|
||||
const mine = await storeRegistry.listMyEntityDocs("public");
|
||||
const note = mine[0];
|
||||
if (!note) throw new Error("the note Alice wrote is not in her store");
|
||||
return note;
|
||||
}
|
||||
|
||||
/** Alice comes back on a NEW page, over the wallet the first one wrote. */
|
||||
async function aliceComesBack(quads: Quad[]): Promise<Nuri> {
|
||||
reloadPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
return myNote();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
forgetEverything();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
forgetEverything();
|
||||
});
|
||||
|
||||
describe("reading the messages left on my note, on a page that has just loaded", () => {
|
||||
test("the ungated document-addressed read answers EMPTY — the note's repo never synced", async () => {
|
||||
const quads: Quad[] = [];
|
||||
await aNoteWithAMessageOnIt(quads);
|
||||
const note = await aliceComesBack(quads);
|
||||
|
||||
// Not a failure anyone can see: the message is on the broker, Alice owns the inbox,
|
||||
// and the call returns a perfectly ordinary empty list.
|
||||
expect(await inboxSurface.readForDocument(note)).toEqual([]);
|
||||
// …because nothing ever brought the NOTE into view. The address lives on it.
|
||||
expect(getSyncState(note)).toBe("unknown");
|
||||
});
|
||||
|
||||
test("the synced document-addressed read answers the message, over that same state", async () => {
|
||||
const quads: Quad[] = [];
|
||||
await aNoteWithAMessageOnIt(quads);
|
||||
const note = await aliceComesBack(quads);
|
||||
|
||||
const mine = await inboxSurface.readSyncedForDocument(note);
|
||||
expect(mine.map((d) => (d.payload as { text: string }).text)).toEqual([MESSAGE]);
|
||||
expect(mine.map((d) => d.from)).toEqual(["bob"]);
|
||||
});
|
||||
|
||||
test("it crosses the sync barrier on BOTH repos the answer depends on", async () => {
|
||||
const quads: Quad[] = [];
|
||||
const written = await aNoteWithAMessageOnIt(quads);
|
||||
const inbox = inboxOnTheNote(quads, written);
|
||||
const note = await aliceComesBack(quads);
|
||||
|
||||
await inboxSurface.readSyncedForDocument(note);
|
||||
|
||||
// The guarantee itself, not the payload: past the first `State` on each, presence is
|
||||
// guaranteed and absence definitive — so an empty answer would MEAN empty. The note's
|
||||
// barrier is the one this call adds (nothing else on the page opens a note); the
|
||||
// inbox's is `readSynced`'s, and connecting may have crossed it already.
|
||||
expect(getSyncState(note)).toBe("synced");
|
||||
expect(getSyncState(inbox)).toBe("synced");
|
||||
});
|
||||
|
||||
test("a document nobody opened an inbox on answers empty, not an error", async () => {
|
||||
const quads: Quad[] = [];
|
||||
bootPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
const bare = await storeRegistry.createEntityDoc("public");
|
||||
|
||||
reloadPage(quads, COLD);
|
||||
await signIn("alice");
|
||||
expect(await inboxSurface.readSyncedForDocument(bare)).toEqual([]);
|
||||
});
|
||||
|
||||
test("it is still a read of MY inbox — the owner's guard is not bypassed", async () => {
|
||||
const quads: Quad[] = [];
|
||||
await aNoteWithAMessageOnIt(quads);
|
||||
const note = await aliceComesBack(quads);
|
||||
|
||||
// Bob can find where to deposit for Alice's public note, and that is all: reading it
|
||||
// would collect the caps addressed to her. A second door onto the same read must not
|
||||
// be a way around the guard the first one carries.
|
||||
await signIn("bob");
|
||||
await expect(inboxSurface.readSyncedForDocument(note)).rejects.toThrow(
|
||||
/does not belong to the connected wallet/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user