From 2e5bc100b90e53704a187a2b3e781fb246f6f798 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Thu, 24 Sep 2026 11:09:02 +0200 Subject: [PATCH] fix: un `State` tardif EST la barriere, donc il est enregistre quand il arrive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/emulated-verifier/open-repo.ts | 10 +++++- .../polyfill/test/use-shape-query.test.ts | 33 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/polyfill/src/emulated-verifier/open-repo.ts b/packages/polyfill/src/emulated-verifier/open-repo.ts index 7d19b91..a443c2c 100644 --- a/packages/polyfill/src/emulated-verifier/open-repo.ts +++ b/packages/polyfill/src/emulated-verifier/open-repo.ts @@ -307,9 +307,17 @@ export async function openRepoUnguarded(nuri: Nuri): Promise { }); // Resolve on the first `State` (the sync BARRIER), marking the nuri "synced". 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; settled = true; - syncState.set(nuri, "synced"); logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)"); resolve(); }; diff --git a/packages/polyfill/test/use-shape-query.test.ts b/packages/polyfill/test/use-shape-query.test.ts index e28752a..db42d71 100644 --- a/packages/polyfill/test/use-shape-query.test.ts +++ b/packages/polyfill/test/use-shape-query.test.ts @@ -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 // keep a mock to count. _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(); }); + 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 ------------------------------------------------------------ it("(k) the last unsubscribe closes the shape query", async () => {