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:
@@ -79,6 +79,7 @@ export const inbox: {
|
||||
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
|
||||
readForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||
readSyncedForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
|
||||
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
|
||||
@@ -114,6 +115,8 @@ Only a document's owner writes to it. Holding its read key never grants a write.
|
||||
|
||||
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
|
||||
|
||||
**You never resolve an inbox address, on either side.** You deposit by naming a document (`inbox.postToDocument`), and you read what was left on your own by naming it too — `inbox.readForDocument(doc)` at any time, or `inbox.readSyncedForDocument(doc)` on a page that has just loaded. The second is the one to call when an empty answer has to MEAN empty: a session that has just loaded has synced neither the document nor its inbox, and an unsynced read of either comes back empty with no error — so the ungated form can answer `[]` for a document whose inbox holds messages. `readSyncedForDocument` waits for both before answering. `inbox.readSynced` is the same guarantee on an inbox you already hold the address of, which no application does: it takes an address, so nothing here hands you one.
|
||||
|
||||
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
|
||||
|
||||
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, or a queue could not be drained, it throws instead of returning. So a resolved call means everything shared with you is readable — and a rejected one must not be rendered past, since the interface would show an empty account rather than an empty screen.
|
||||
@@ -124,6 +127,10 @@ It resolves **only once that work has actually completed**: if what was shared w
|
||||
|
||||
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
|
||||
|
||||
**`storeRegistry.openDocumentInbox(doc)` is idempotent, including when calls overlap.** Asks for the same document that are in flight together are answered by one call, and every one of them gets the same inbox — you do not have to serialise them yourself, and firing one per component as they mount is a supported way to use it. This holds **within one page**; two pages doing it in the same moment is a non-guarantee below, and it is the only part of this you have to think about.
|
||||
|
||||
**A reactive read says "nothing" and "I could not find out" differently.** `watchShape` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds no document of that shape — the distinction the surface exists for. Until 2026-08-17 a scope whose listing did not answer published that very snapshot, so an interface showed "you have created nothing" for "the store did not answer"; it now publishes `isError` carrying the error. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
|
||||
|
||||
The same rule reaches what a call hands BACK, not only what it looked up first: **`listMyEntityDocs` returns a listing whose documents you can open, or it throws.** It reads which documents are in the store and what opens each, and it throws if either did not answer — including when the documents came back and their keys did not. Nothing about a keyless listing is visible to you: it is the same `Nuri[]`, and the difference would only appear at the next read, empty, long after the cause. An empty array therefore means this account created nothing.
|
||||
|
||||
## Non-guarantees
|
||||
@@ -140,6 +147,8 @@ The same rule reaches what a call hands BACK, not only what it looked up first:
|
||||
|
||||
**No cross-broker reference.** A returned reference resolves for users of the same broker.
|
||||
|
||||
**`openDocumentInbox` does not coalesce across PAGES.** Two tabs — or two sessions of the same person — that open the same document's inbox in the same moment can each create one, and the document is left with two: its owner drains one while deposits arrive in the other. Nothing raises, nothing reports it, and neither page can detect it afterwards. It is not an oversight to be patched later: a branch MERGES records rather than refusing the second, so there is no "create only if absent" to build the guarantee on, and the address a depositor reads is a separate record from the one the owner resolves — so the two cannot even be made to agree on which of the pair won. Open a document's inbox from one place: the page that creates the document, or one call the rest of the interface waits on.
|
||||
|
||||
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
|
||||
|
||||
## Change policy
|
||||
|
||||
Reference in New Issue
Block a user