fix: un State tardif EST la barriere, donc il est enregistre quand il arrive

En relisant ce que je venais d'ecrire dans le contrat - « si le document se
synchronise plus tard et apporte des donnees, la lecture repond d'elle-meme » -
j'ai constate que c'etait faux. `onState` sortait aussitot des lors que le repli
borne avait deja termine l'ouverture, donc un `State` tardif etait jete :
`"timed-out"` restait indefiniment sur un depot desormais synchronise, et le
lecteur qui consulte cet etat ne pouvait jamais l'apprendre.

Le repli veut dire « cette ouverture a cesse d'attendre », jamais « la barriere ne
sera pas franchie » : l'abonnement n'est pas ferme, donc le `State` arrive encore.
`onState` enregistre desormais `"synced"` avant le controle. `"timed-out"` ->
`"synced"` est la seule transition ajoutee, et elle va de moins d'autorite vers
plus ; les deux lecteurs de cette valeur la veulent ainsi.

(dd) l'epingle : elle tombe si l'enregistrement repasse sous le controle.
This commit is contained in:
Sylvain Duchesne
2026-09-24 11:09:02 +02:00
parent 4af0be433d
commit 2e5bc100b9
2 changed files with 42 additions and 1 deletions
@@ -307,9 +307,17 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
}); });
// Resolve on the first `State` (the sync BARRIER), marking the nuri "synced". // Resolve on the first `State` (the sync BARRIER), marking the nuri "synced".
const onState = (): void => { const onState = (): void => {
// The first `State` IS the barrier, WHENEVER it arrives. Recorded before the
// already-settled check on purpose: the bounded fallback below only means "this
// open stopped waiting", never "the barrier will not be crossed" - the
// subscription is still held, so a `State` can still land afterwards. It used to
// be dropped, which left `timed-out` standing for ever on a repo that had since
// synced, and a reader consulting that state (`surface/use-shape-query.ts`) could
// never learn better. `timed-out` -> `synced` is the only transition this adds,
// and it is the one that goes from less authority to more.
syncState.set(nuri, "synced");
if (settled) return; if (settled) return;
settled = true; settled = true;
syncState.set(nuri, "synced");
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)"); logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
resolve(); resolve();
}; };
@@ -185,6 +185,12 @@ function makeFake(options: { pushState?: boolean; subscribe?: boolean } = {}) {
// is withheld in `inject` rather than here, so the two suites that count subscribes // is withheld in `inject` rather than here, so the two suites that count subscribes
// keep a mock to count. // keep a mock to count.
_noSubscribe: options.subscribe === false, _noSubscribe: options.subscribe === false,
/** The broker catching up: push the first `State` to a subscription already open on
* `nuri`, late. A subscription is never dropped by the bounded fallback, so this is
* what a slow repo really does. */
_pushStateNow(nuri: string): void {
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
},
}; };
} }
@@ -1135,6 +1141,33 @@ describe("useShapeQuery", () => {
unsub(); unsub();
}); });
it("(dd) a `State` that lands AFTER the fallback answers the read, with no call from the app", async () => {
const ng = makeFake({ pushState: false });
const orm = makeOrm();
inject(ng, orm);
setOpenTimeoutForTests(30);
setCurrentUser("uma");
const mine = await createEntityDoc("uma", "protected");
const obs = useShapeQuery(EventShape, { graphs: [mine] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 40 && getSyncState(mine) !== "timed-out"; i++) await tick();
expect(getSyncState(mine)).toBe("timed-out");
expect(obs.getSnapshot().isSuccess).toBe(false); // no authority yet, so no answer
// The broker catches up: the subscription was never dropped, so the first `State`
// still lands - late, but it IS the barrier - and it brings the document's data, which
// is what makes the ORM republish (`orm-fake.seed` pushes once delivery has happened,
// as an engine patch reaching the signal object does). Nothing here calls `refetch`.
ng._pushStateNow(mine);
seedEvent(orm, mine, `${mine}:q:one`, "Late");
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(getSyncState(mine)).toBe("synced");
expect(titles(obs.getSnapshot().data)).toEqual(["Late"]);
unsub();
});
// -- teardown ------------------------------------------------------------ // -- teardown ------------------------------------------------------------
it("(k) the last unsubscribe closes the shape query", async () => { it("(k) the last unsubscribe closes the shape query", async () => {