fix: ne pas re-provisionner un compte sur un read 0-lignes dû au lag de sync

Cause racine mesurée (broker réel, access-log) : à la première resolveAccount
d'une session, le record de compte tout juste persisté (ou d'une session
antérieure) peut lire 0 lignes à cause du LAG DE SYNC broker. ensureAccount
interprétait ce 0 comme « compte inexistant » et RE-PROVISIONNAIT un second jeu
de docs de scope (docPublic/docProtected forkés) → les lectures d'une session
tombaient sur un jeu, celles d'une autre (ou après drop de cache) sur l'autre
jeu vide → données « perdues » à la reconnexion.

Fix : `resolveAccountReliably` (store-registry.ts) — retry borné (ouverture du
repo d'ancre shim + backoff plafonné, défaut 8 tentatives / ≲8.5s) AVANT que
ensureAccount ne décide qu'un compte est neuf. Provisionne seulement si, après
le budget, la lecture rend toujours 0 (compte réellement neuf). Budget injecté
via StoreRegistryDeps.provisionRetry (polyfill.ts), ON en prod ; tests unitaires
à provisionRetry synchrone (attempts:1, fake sans lag). Idempotence de session
préservée par accountCache (hit court-circuite, déterministe).

Portée : corrige le déterminisme de provisioning. NE suffit PAS à réparer la
reconnexion (le read public à 0 same-session subsiste, cause distincte encore à
mesurer de façon déterministe — le lag broker rend les mesures non-reproductibles).
Complémentaire du commit open-repo précédent, pas redondant.

gate : tsc --noEmit propre ; bun test 91 pass ; auth @data (vide/distinctes) verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-08 12:56:22 +02:00
parent 1825d4d72f
commit ffa1f94206
8 changed files with 115 additions and 5 deletions
+76 -1
View File
@@ -35,6 +35,28 @@
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen } from "./open-repo";
// --- provisioning anti-fork retry budget (polyfill-era) --------------------
//
// The shim (account→docs trust root) lives in the shared wallet's PRIVATE store.
// On a fresh session over a persistent wallet, that store's repo may not have
// synced yet from the broker: a targeted account resolve then reads 0 rows even
// though the account WAS persisted in an earlier session. If ensureAccount took
// that 0 at face value it would RE-PROVISION a second set of scope documents —
// an account FORK: one session writes/reads one set, another (or the same after
// a cache drop) the other, EMPTY set → the user "loses" their data on reconnect.
//
// Guard: before deciding an account is genuinely new, re-read it with a BOUNDED
// backoff (open + await the anchor repo, then retry the targeted query). Only if
// EVERY attempt within the budget still reads 0 do we treat it as a first-time
// account and provision. A pre-existing account merely lagging by sync is thus
// found on a later attempt and REUSES its docs (no fork); a truly new account
// exhausts the budget cheaply and is provisioned exactly once. The budget is a
// hard ceiling — never an unbounded poll — and is INJECTED (StoreRegistryDeps.
// provisionRetry): production defaults to ~8 attempts / ≲8.5s; a synchronous
// in-memory store (unit fake `ng`) passes `attempts: 1` to skip it (a 0-row read
// is authoritative there — no lag to wait out). Disappears at the real
// multi-store migration (deterministic per-user store NURIs make this moot).
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import type { Nuri, Scope } from "./types";
@@ -257,6 +279,51 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
}
}
/**
* Resolve ONE account, retrying a BOUNDED number of times before giving up, to
* distinguish a genuinely-absent account from one whose shim record has simply
* not synced yet on a fresh session (the account-FORK guard — see the
* PROVISION_RETRY_* budget note at the top of this module).
*
* On the FIRST miss it opens/subscribes the shim anchor repo (the shared
* wallet's private store, where the shim lives) so the broker pushes its state,
* then re-reads with capped exponential backoff. Returns the record as soon as
* any attempt finds it (populating `accountCache` via resolveAccount), or `null`
* if the whole budget is exhausted with 0 rows — the only case in which the
* caller may treat the account as new and provision it.
*
* No-op fast path: a cache hit returns immediately with no retries. With the
* unit fake `ng` (no doc_subscribe, synchronous store) a present account is
* found on attempt 0 and an absent one exhausts the (short-sleep) budget without
* changing behaviour — the fork can only arise against a real, lagging broker.
*/
async function resolveAccountReliably(id: string): Promise<AccountRecord | null> {
// Cache hit → session-idempotent, no query, no fork risk.
const cached = accountCache.get(accountKey(id));
if (cached) return cached;
const budget = getStoreRegistryDeps().provisionRetry;
const attempts = Math.max(1, budget.attempts ?? 8);
const baseMs = budget.baseMs ?? 300;
const maxStepMs = budget.maxStepMs ?? 1500;
for (let attempt = 0; attempt < attempts; attempt++) {
const found = await resolveAccount(id);
if (found) return found;
if (attempt === attempts - 1) break; // budget exhausted → genuinely absent
// The shim anchor repo may not be synced on a fresh session; open it once so
// the broker pushes its state, then back off before the next targeted read.
try {
await ensureRepoOpen(await anchorNuri());
} catch {
/* tolerant: a failed open just leaves the next read to behave as before */
}
const step = Math.min(baseMs * 2 ** attempt, maxStepMs);
await new Promise((r) => setTimeout(r, step));
}
return null;
}
/** All known accounts (from the shim). */
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()];
@@ -278,7 +345,15 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
const key = accountKey(id);
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
const existing = await resolveAccount(id);
//
// ANTI-FORK (polyfill-era): resolve with a BOUNDED retry, NOT a single read.
// A single 0-row read on a fresh, still-syncing session would misfire as
// "new account" and RE-PROVISION a second set of scope docs (the fork — see
// the PROVISION_RETRY_* note at the top of this module). We only fall through
// to provisioning once the whole retry budget has confirmed 0 rows. A cache
// hit inside resolveAccountReliably keeps same-session resolves free and
// deterministic (the cached record wins over any re-provision).
const existing = await resolveAccountReliably(id);
if (existing) return existing;
const [docPublic, docProtected, docPrivate] = await Promise.all([