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:
@@ -47,6 +47,18 @@ export interface Quad {
|
||||
o: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The repo ids this fake broker has ever handed out — MONOTONIC, and deliberately not a
|
||||
* counter inside {@link makeWallet}.
|
||||
*
|
||||
* A per-wallet counter restarted at each {@link bootPage}, so a reloaded page re-issued the
|
||||
* NURIs the previous one had minted: a document created after a reload came back as
|
||||
* `did:ng:o:doc6` when `did:ng:o:doc6` was already somebody else's inbox, and the two
|
||||
* aliased into one repo with no error anywhere. A broker never mints a repo id twice — an
|
||||
* id is a public key — so neither does this.
|
||||
*/
|
||||
let minted = 0;
|
||||
|
||||
/** Reverse of the lib's `escapeLiteral`: one left-to-right pass over `\x`. */
|
||||
function unescapeLiteral(s: string): string {
|
||||
let out = "";
|
||||
@@ -110,21 +122,70 @@ export interface FakeWallet {
|
||||
doc_create: ReturnType<typeof mock>;
|
||||
sparql_update: ReturnType<typeof mock>;
|
||||
sparql_query: ReturnType<typeof mock>;
|
||||
/** Present only under {@link WalletOptions.unsyncedUntilSubscribed}. */
|
||||
doc_subscribe?: ReturnType<typeof mock>;
|
||||
_quads: Quad[];
|
||||
}
|
||||
|
||||
export interface WalletOptions {
|
||||
/**
|
||||
* Model the broker's cold start: a repo this PAGE has not subscribed to answers an
|
||||
* anchored read with **nothing**, and `doc_subscribe` is what brings its commits into
|
||||
* view (pushing the first `State` — the sync barrier `ensureRepoOpen` awaits).
|
||||
*
|
||||
* ── Why this is the real system's state, not a convenient one ─────────────
|
||||
* On a fresh session over the same persistent wallet, `Verifier::load` repopulates
|
||||
* `self.repos` from user storage, so the repo is PRESENT but unsynced and the anchored
|
||||
* query legitimately matches nothing — no error, no rows (the mechanism written out in
|
||||
* `emulated-verifier/open-repo.ts`, corrected there on 2026-08-03). Two consequences the
|
||||
* fake keeps faithfully:
|
||||
*
|
||||
* - a repo CREATED on this page is synced by construction (`doc_create` opens it, and
|
||||
* there is no remote history to fetch), which is why the defect is invisible to the
|
||||
* session that wrote the data;
|
||||
* - a WRITE does not sync anything. Appending a commit to a repo whose remote commits
|
||||
* have not arrived leaves them just as absent, so `sparql_update` never marks a repo
|
||||
* synced — only `doc_subscribe` does.
|
||||
*
|
||||
* OFF by default: the two reload suites that predate this run without a `doc_subscribe`
|
||||
* at all, where `ensureRepoOpen` is the documented no-op of the unit-fake path.
|
||||
*/
|
||||
unsyncedUntilSubscribed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A quad-store fake `ng` over `quads` — the durable half. The library holds nothing across
|
||||
* a {@link reloadPage}; this does.
|
||||
*
|
||||
* No `doc_subscribe`: `ensureRepoOpen` is then the documented no-op of the unit-fake path
|
||||
* (`emulated-verifier/open-repo.ts`), so an anchored read resolves directly. A limit of the
|
||||
* fake broker, not a library state.
|
||||
* By default no `doc_subscribe`: `ensureRepoOpen` is then the documented no-op of the
|
||||
* unit-fake path (`emulated-verifier/open-repo.ts`), so an anchored read resolves directly.
|
||||
* A limit of the fake broker, not a library state — and the one
|
||||
* {@link WalletOptions.unsyncedUntilSubscribed} lifts, for the suites that are about the
|
||||
* sync barrier itself.
|
||||
*/
|
||||
export function makeWallet(quads: Quad[]): FakeWallet {
|
||||
let docCounter = 0;
|
||||
export function makeWallet(quads: Quad[], options: WalletOptions = {}): FakeWallet {
|
||||
/** The repos whose commits this PAGE can see — created here, or subscribed to. */
|
||||
const synced = new Set<string>();
|
||||
const cold = options.unsyncedUntilSubscribed === true;
|
||||
|
||||
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||
const doc_create = mock(async () => {
|
||||
const nuri = `did:ng:o:doc${++minted}`;
|
||||
// Created here: nothing remote to wait for. This is why the session that wrote the
|
||||
// data never sees the cold-start defect, and the next one does.
|
||||
synced.add(nuri);
|
||||
return nuri;
|
||||
});
|
||||
|
||||
const doc_subscribe = mock(async (...a: unknown[]) => {
|
||||
const nuri = a[0] as string;
|
||||
const onChange = a[2] as (r: unknown) => void;
|
||||
synced.add(nuri);
|
||||
// `TabInfo` first, then the initial `State` — the platform's own order, so a waiter
|
||||
// that resolved on "the first push of any kind" would return BEFORE the barrier.
|
||||
setTimeout(() => onChange({ V0: { TabInfo: {} } }), 0);
|
||||
setTimeout(() => onChange({ V0: { State: {} } }), 0);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
@@ -157,6 +218,11 @@ export function makeWallet(quads: Quad[]): FakeWallet {
|
||||
const anchor = a[3] as string | undefined;
|
||||
const wrapped = query.match(/GRAPH\s+<([^>]+)>/);
|
||||
const g = wrapped ? wrapped[1]! : anchor;
|
||||
// The repo the verifier resolves the read against — the anchor when there is one,
|
||||
// otherwise the graph named in the query.
|
||||
const target = anchor ?? g;
|
||||
// COLD: present but unsynced. No error, no rows — which is exactly why it is dangerous.
|
||||
if (cold && target !== undefined && !synced.has(target)) return { results: { bindings: [] } };
|
||||
const inGraph = quads.filter((q) => q.g === g);
|
||||
|
||||
// The whole-document read (`read-model.readDoc`).
|
||||
@@ -233,12 +299,14 @@ export function makeWallet(quads: Quad[]): FakeWallet {
|
||||
return { results: { bindings: [] } };
|
||||
});
|
||||
|
||||
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
||||
return cold
|
||||
? { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads }
|
||||
: { doc_create, sparql_update, sparql_query, _quads: quads };
|
||||
}
|
||||
|
||||
/** Wire the library onto `quads` — what a page load does. */
|
||||
export function bootPage(quads: Quad[]): FakeWallet {
|
||||
const ng = makeWallet(quads);
|
||||
export function bootPage(quads: Quad[], options: WalletOptions = {}): FakeWallet {
|
||||
const ng = makeWallet(quads, options);
|
||||
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
return ng;
|
||||
@@ -262,9 +330,9 @@ export function forgetEverything(): void {
|
||||
}
|
||||
|
||||
/** A page RELOAD: the library forgets, the wallet does not. */
|
||||
export function reloadPage(quads: Quad[]): FakeWallet {
|
||||
export function reloadPage(quads: Quad[], options: WalletOptions = {}): FakeWallet {
|
||||
forgetEverything();
|
||||
return bootPage(quads);
|
||||
return bootPage(quads, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user