fix: à la reconnexion, on retrouve ce qu'on possède — pas seulement ce qu'on a reçu
Une application signalait deux symptômes. Ils sont indépendants, et ils ont une racine commune : se connecter ne rejouait qu'UN des registres durables du portefeuille. connectedUser lisait les AddLink de la branche User — ce qu'on vous a partagé — et rien d'autre. Les capacités des documents que vous avez FAITS vivent sur la branche Store, et un seul chemin les relisait : listMyEntityDocs. Une application qui recharge et va droit à sa note ne tenait donc rien pour elle. Constaté : capFor(note) vaut undefined juste après une connexion résolue, et devient défini dès que listMyEntityDocs passe. Le public survivait en lecture parce que fetchReadCap va rechercher la clé dans le store ; il échouait quand même à l'écriture, qui ne fait pas cette démarche. Deuxième défaut : myInboxes énumérait les inbox sans en remettre la clé au détenteur. Se connecter demandait donc un document qu'on n'avait pas de quoi lire — sur une inbox, que l'application n'a jamais nommée puisque rien ne le lui permet. Troisième défaut, et c'est lui qui rendait tout ça fatal : un drainage refusé faisait échouer toute la connexion. Une inbox n'étant pas consommée par un échec, elle refusait à chaque tentative suivante. D'où le « trois fois sur trois », et d'où un verrouillage plutôt qu'un partage manquant. C'est ma spécification qui l'a créé. En rendant les échecs visibles j'avais écrasé une distinction : ne pas ATTEINDRE la file est une panne, et refuser la session est juste ; ne pas pouvoir APPLIQUER un élément est une donnée, et ça ne doit priver personne de sa session. Le drainage par inbox est désormais isolé — signalé haut et fort, les autres files drainées, la session accordée — tandis qu'énumérer les files et restaurer rejettent toujours. Le parcours e2e ratait les deux : personne ne se reconnecte après avoir ouvert son document aux messages. Il s'arrêtait une reconnexion trop tôt.
This commit is contained in:
@@ -9,11 +9,20 @@
|
||||
* is *"the app works but the documents shared with me never appear"* — the worst kind,
|
||||
* because it looks like a permission decision and is a swallowed error.
|
||||
*
|
||||
* The rule this file pins, one line: **any failure must surface; only "there was nothing to
|
||||
* do" may resolve quietly.** Nothing to do means exactly two things — no identity is
|
||||
* connected, or the identity has no account yet (connecting must never PROVISION one, see
|
||||
* `connect.ts`) — plus abandoning when the identity changed under the run, which is not a
|
||||
* failure either: the next connection picks it up.
|
||||
* The rule this file pins, one line: **failing to ESTABLISH the session surfaces; failing
|
||||
* to apply one of its queues is reported and does not deny the session; only "there was
|
||||
* nothing to do" resolves quietly.** Nothing to do means exactly two things — no identity
|
||||
* is connected, or the identity has no account yet (connecting must never PROVISION one,
|
||||
* see `connect.ts`) — plus abandoning when the identity changed under the run, which is not
|
||||
* a failure either: the next connection picks it up.
|
||||
*
|
||||
* The queue clause was added 2026-08-16, and it is a correction rather than a softening.
|
||||
* Rejecting on an undrained inbox looked like the same rigour as the rest, and it was not:
|
||||
* a queue that cannot be applied is not consumed by failing, so it is still there at the
|
||||
* next connection and the one after — one unapplicable item denied a live application's
|
||||
* user their sign-in, permanently, three times out of three. Reporting it and carrying on
|
||||
* is what makes the failure recoverable instead of terminal; nothing about it is silent
|
||||
* (the branch below asserts the report, and that the OTHER queues still ran).
|
||||
*
|
||||
* ── Why every branch is here, not just the interesting ones ───────────────
|
||||
* A swallowed failure is invisible by construction, so a suite that covers "the happy path
|
||||
@@ -408,21 +417,76 @@ test("a document-inbox list that cannot be read fails the connection", async ()
|
||||
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
||||
});
|
||||
|
||||
test("an inbox that cannot be drained fails the connection, and the rest stay undrained", async () => {
|
||||
// Draining is what APPLIES a share. An inbox that could not be read may hold the cap this
|
||||
// very reconnection was for, so a silent skip loses it with no trace — and it took the
|
||||
// remaining inboxes down with it, silently too. It still stops at the first failure; the
|
||||
// difference is that stopping is now audible.
|
||||
/**
|
||||
* The one branch where a failure does NOT reject — and the four things that have to hold
|
||||
* at once for that to be honest. Rewritten 2026-08-16, when the previous contract
|
||||
* ("stop at the first failure, reject") was reported doing this to a live application:
|
||||
* one queue it could not apply denied its user the application, at every sign-in, because
|
||||
* an inbox that fails is not consumed and is still there next time. See `connect.ts`.
|
||||
*
|
||||
* A queue is not the session. Failing to REACH the queues still rejects — that case is
|
||||
* the test above, and it is untouched.
|
||||
*/
|
||||
test("an inbox that cannot be drained is reported, the rest are drained, and the session stands", async () => {
|
||||
const faults = noFaults();
|
||||
const ng = inject(faults);
|
||||
const note = await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
const publicInbox = await userInbox("bob", "public"); // the first of the two drained
|
||||
const protectedInbox = await userInbox("bob", "protected"); // where Alice's share landed
|
||||
faults.inboxRead = publicInbox;
|
||||
|
||||
const reported: string[] = [];
|
||||
const realError = console.error;
|
||||
console.error = (...args: unknown[]): void => void reported.push(args.map(String).join(" "));
|
||||
try {
|
||||
// 1. The person gets their session. This is what the previous contract denied them.
|
||||
await connectedUser();
|
||||
} finally {
|
||||
console.error = realError;
|
||||
}
|
||||
|
||||
// 2. The queue after the failing one was still drained — a failure that took the others
|
||||
// down with it lost shares that had nothing to do with it.
|
||||
expect(ng.inboxReads).toEqual([publicInbox, protectedInbox]);
|
||||
// 3. …so the share this reconnection was for actually arrived.
|
||||
expect(holds(note)).toBe(true);
|
||||
// 4. And it is NOT silence: the failure names the queue and carries the broker's error.
|
||||
// Ungated — this suite never turns the access log on.
|
||||
expect(reported.some((line) => line.includes("RepoNotFound"))).toBe(true);
|
||||
expect(reported.some((line) => line.includes("could not be drained"))).toBe(true);
|
||||
});
|
||||
|
||||
// The property the live report was actually about: not one refused sign-in, but EVERY one
|
||||
// of them. An inbox is not consumed by failing to be read, so a fault that persists is
|
||||
// still on the drain list at the next connection — which, under the previous contract, made
|
||||
// the first refusal permanent rather than transient. Signing in twice over the same
|
||||
// standing fault is the cheapest way to pin that it no longer is.
|
||||
test("a queue that keeps failing does not deny the session at the NEXT connection either", async () => {
|
||||
const faults = noFaults();
|
||||
const ng = inject(faults);
|
||||
await aliceSharesANoteWithBob();
|
||||
|
||||
adoptCurrentUser("bob");
|
||||
const publicInbox = await userInbox("bob", "public"); // the first of the two drained
|
||||
faults.inboxRead = publicInbox;
|
||||
const publicInbox = await userInbox("bob", "public");
|
||||
faults.inboxRead = publicInbox; // a standing fault: nothing about it heals
|
||||
|
||||
await expect(connectedUser()).rejects.toThrow(/RepoNotFound/);
|
||||
expect(ng.inboxReads).toEqual([publicInbox]); // the protected one was never reached
|
||||
const realError = console.error;
|
||||
console.error = (): void => undefined;
|
||||
try {
|
||||
await connectedUser();
|
||||
// A second connection, the way a reload produces one: the caches go, the wallet stays.
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
adoptCurrentUser("bob");
|
||||
await connectedUser();
|
||||
} finally {
|
||||
console.error = realError;
|
||||
}
|
||||
|
||||
// Both connections went all the way through the list rather than stopping at the fault.
|
||||
expect(ng.inboxReads.filter((r) => r === publicInbox).length).toBe(2);
|
||||
});
|
||||
|
||||
// --- abandoning on an identity switch: not a failure ------------------------
|
||||
|
||||
Reference in New Issue
Block a user