6b2b8ab42a
Trois constats, un seul defaut sous trois visages : un etat enregistre comme vrai qui ne l'est pas, sans moyen de se corriger. Deux des trois vivaient dans `open-repo.ts`. Cinq tours sont alles dans l'appelant ; c'est le teneur de registre qui mentait. CE QUE LE REGISTRE VEUT DIRE, ecrit dans l'en-tete. `held` est le SEUL registre d'ouverture : cette session detient un abonnement vivant a ce document. Il y avait a cote un `opened` voulant dire « on a appele `doc_subscribe` une fois et on a cesse d'attendre », et ce sens plus lache mentait de trois facons : il survivait a un abonnement REFUSE par le courtier (donc la reprise que `subscribe.ts` offre etait bloquee a jamais), il survivait a une reinitialisation (donc une nouvelle session court-circuitait sur l'enregistrement de la precedente), et il laissait un document annoncer une barriere jamais franchie. `opened` est supprime. Les trois consequences, desormais vraies par construction : - un abonnement qui a ECHOUE n'enregistre rien - `onSetupFailed`, le canal que `subscribe.ts` offre exactement pour cela, est enfin branche - donc l'appelant suivant s'abonne a nouveau ; - une reinitialisation efface l'enregistrement ET DECLASSE l'etat de synchro : une barriere franchie dans la session qui vient de disparaitre n'en est pas une que celle-ci a franchie ; - `"unknown"` ne veut plus dire qu'une chose : aucune barriere n'etait attendue ici (jamais demande, ou un `ng` sans `doc_subscribe`). Jamais « on a oublie ». Cote appelant, un seul changement, et c'est le troisieme constat : quand un document nomme n'a pas repondu, `publish` PUBLIE « la question reste ouverte » au lieu de sortir en laissant en place l'instantane precedent. Sortir laissait un refus DEJA LEVE a l'affichage : l'application lisait « vous ne detenez pas la capacite de ce document » a propos d'un document qu'elle detient desormais. Deux tests portaient l'ancien sens et sont corriges avec leur raison : celui du `ng` sans `doc_subscribe` (le compteur d'ids du faux courtier redemarrait a chaque test, donc deux tests aliasaient leurs depots - la lecon de `wallet-fake` a l'echelle du fichier), et celui de la lecture froide, ou « timed-out » est desormais ce que dit « cette page ne l'a pas confirme ».
181 lines
8.2 KiB
TypeScript
181 lines
8.2 KiB
TypeScript
/**
|
|
* 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 { listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
|
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(await storeRegistry.publicStore());
|
|
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 by listing her own store.
|
|
*
|
|
* That listing is the LIBRARY's own path now, not an application's: `listMyEntityDocs`
|
|
* stopped being published in `1.0.0-dev.5` (no counterpart at any level upstream), so
|
|
* this reaches it where it lives. What is under test is unchanged - the cold store still
|
|
* has to answer, and it still has to refile the caps while it does. */
|
|
async function myNote(): Promise<Nuri> {
|
|
const mine = await listMyEntityDocs("alice", "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 on THIS page ever brought the NOTE into view. The address lives
|
|
// on it.
|
|
//
|
|
// `"timed-out"`, not `"unknown"`, since 2026-09-24: a registry reset now DOWNGRADES the
|
|
// documents it takes subscriptions away from instead of forgetting their state, so
|
|
// `"unknown"` means only "no barrier was ever expected here" and can be read as
|
|
// authoritative. Note this particular value is a harness artefact and not what a real
|
|
// reload produces: `reloadPage` resets the registry inside one process, so the note is
|
|
// still in it from the first page, where a browser reload would start with an empty
|
|
// module and answer `"unknown"`. Either way it is the safe reading - neither value lets
|
|
// a zero-row read be called a definitive absence here.
|
|
expect(getSyncState(note)).toBe("timed-out");
|
|
});
|
|
|
|
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(await storeRegistry.publicStore());
|
|
|
|
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,
|
|
);
|
|
});
|
|
});
|