feat!: une seule reponse a « ce document est-il ouvert dans cette session »

Trois reponses coexistaient et pouvaient se contredire : un ensemble de
souscriptions bootstrap et une carte d'etat de barriere dans `open-repo`, et les
auditeurs du fan-out dans `surface/subscribe`. Les deux premieres survivaient a
la disparition de la troisieme, parce que `resetDocSubscriptions()` - ce que fait
le `configure()` publie - largue ses auditeurs sans rien dire a personne.

Il n'en reste qu'une, l'etat de l'unique souscription reelle du document, exposee
par `docChannel(nuri)`. `open-repo` n'enregistre plus que l'auditeur qu'il a pose
- « est-ce que j'ecoute encore », rien de plus - et derive `getSyncState` a chaque
lecture, si bien qu'aucune reponse ne peut survivre au canal qu'elle decrit.
Disparaissent : `held`, `syncState`, `inFlight`, `settleInFlight` et le compteur
de generation, remplace par l'identite de l'enregistrement.

Les deux echecs prouves deviennent des tests qui echouent sans le changement :
un second `configure()` apres une synchro reussie (`"synced"` au lieu de
`"timed-out"`, zero re-souscription, et `isSuccess` cote application), et un
etablissement encore en cours quand la couche de souscription est demontee - cas
que le canal `onSetupFailed` ne peut pas rattraper, puisqu'il ne rapporte que la
tentative encore attendue.

La ligne qui ne casse pas : un document synchronise qui ne contient rien repond
toujours un succes a zero objet. `1.0.0-dev.10`, les deux non-garanties
nommees sont retirees et la garantie livree porte le test qui l'epingle.
This commit is contained in:
Sylvain Duchesne
2026-09-24 12:57:43 +02:00
parent 2da26c5c4d
commit de9b8a0d67
10 changed files with 574 additions and 191 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ng-eventually/polyfill",
"version": "1.0.0-dev.9",
"version": "1.0.0-dev.10",
"type": "module",
"description": "Polyfill of the NextGraph JS SDK over @ng-org/web + @ng-org/orm, with emulated capabilities and inbox. Drop-in; remove at migration.",
"main": "./src/index.ts",
@@ -45,26 +45,31 @@
* later establishes for change signals.
*
* ── Idempotence / perf (once per session, no polling) ─────────────────────
* The registry opens each repo at most ONCE per session: `opened` records completed
* opens (a hit skips everything), `inFlight` de-dupes concurrent opens of the same
* repo. A brand-new page / module instance starts with an empty registry; and when
* the injected session id CHANGES within the same page (an in-page re-login /
* `session_stop`+`session_start`, whose new verifier has an empty `self.repos`) the
* registry auto-resets (`syncSession`) so repos are re-opened against the new session
* rather than wrongly skipped as "already open". No polling: we wait on the first
* `State` push, with a bounded fallback timeout so a missing push can't hang.
* A repo is opened once per session, and what makes that safe is ASKING rather than
* remembering: {@link opens} records the bootstrap listener this module took out, and
* {@link docChannel} says whether the subscription behind it stands. A caller skips the work
* only when both hold - see the invariant below, and `openRepoUnguarded`, where the two
* halves are named. Concurrent callers join one wait ({@link BootstrapOpen.done}). A
* brand-new page / module instance starts empty; and when the injected session id CHANGES
* within the same page (an in-page re-login / `session_stop`+`session_start`, whose new
* verifier has an empty `self.repos`) the registry auto-resets (`syncSession`) so repos are
* re-opened against the new session rather than wrongly skipped as "already open". No
* polling: we wait on the first `State` push, with a bounded fallback timeout so a missing
* push can't hang.
*
* ── Sync state (per-nuri, lib-internal) ───────────────────────────────────
* Each nuri carries an explicit sync state, readable via {@link getSyncState}:
* `"syncing"` — subscribed, no `State` yet (barrier not reached);
* `"synced"` — first `State` received (barrier reached — the TRUTH signal);
* `"timed-out"` — the bounded fallback fired with NO `State` (open proceeded so
* the read is never blocked, but this is NOT `"synced"`: a future
* "ready" signal must not mistake a timeout for a real barrier);
* `"unknown"` — never requested (or the fake-ng no-op path: no `State` semantics).
* This distinction is the point — `"synced"` and `"timed-out"` are kept apart so a
* later reactive readiness layer can trust `"synced"` and treat `"timed-out"` as
* "opened best-effort, sync unconfirmed".
* ── Sync state (per-nuri, lib-internal, DERIVED) ──────────────────────────
* {@link getSyncState} computes a nuri's barrier state; nothing stores it:
* `"syncing"` - this module is inside its bounded wait, no `State` yet;
* `"synced"` - a `State` has arrived on the channel standing NOW (barrier reached,
* the TRUTH signal: a zero-row read over it MEANS zero);
* `"timed-out"` - opened here, and no barrier to show for it: the wait gave up, the
* subscribe was refused, or the channel was taken away. The read is never
* blocked, but this is NOT `"synced"` - a readiness signal must not
* mistake either for a real barrier;
* `"unknown"` - never opened here (or the fake-ng no-op path: no `State` semantics).
* `"synced"` and `"timed-out"` are kept apart for that reason, and `"unknown"` is the one
* value that is deliberately treated as authoritative, because where there are no `State`
* semantics there is no barrier to have missed.
*
* ── Migration ─────────────────────────────────────────────────────────────
* At the real multi-store migration this becomes "open the user's store repo by
@@ -75,69 +80,104 @@
import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store";
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { resubscribeDocs, subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
import { docChannel, resubscribeDocs, subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
import { accessLogPrefix, logStage, shortNuri } from "../shared-wallet/access-log";
import type { Nuri } from "../model/types";
/**
* The per-nuri bootstrap sync state (lib-internal). See the module header:
* - `"syncing"` subscribed, first `State` not yet received;
* - `"synced"` first `State` received — the real sync barrier (CONTRACT 3);
* - `"timed-out"` fallback fired without a `State` — open proceeded, sync UNconfirmed;
* `"unknown"` (from {@link getSyncState}) means "never requested / no `State` semantics".
* The per-nuri bootstrap sync state (lib-internal), DERIVED on every read by
* {@link getSyncState} - nothing stores it. See the module header:
* - `"syncing"` inside the bounded wait, first `State` not yet received;
* - `"synced"` a `State` arrived on the channel standing NOW - the real sync barrier
* (CONTRACT 3);
* - `"timed-out"` opened here with no barrier to show for it - the wait gave up, the
* subscribe was refused, or the channel was taken away. Sync UNconfirmed;
* `"unknown"` (from {@link getSyncState}) means "never opened here / no `State` semantics".
*/
// @provenance SyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — the first `State` push IS the barrier; `timed-out` names the bounded fallback and is deliberately not `synced`
export type SyncState = "syncing" | "synced" | "timed-out";
/*
* WHAT THIS REGISTRY MEANS, and it is the thing that went wrong five rounds running.
* THE INVARIANT, and it is the thing that went wrong six rounds running.
*
* `held` is the ONE record of openness: **this session holds a live bootstrap subscription
* to this document**. Nothing else records it, because two records of the same fact can
* disagree and this pair did. There was an `opened` set beside it meaning "we once called
* `doc_subscribe` and stopped waiting", and that looser meaning is what lied three ways:
* it survived a subscribe the broker REJECTED (so the retry `subscribe.ts` offers was
* blocked for ever), it survived a registry reset (so a new session short-circuited on the
* previous one's record), and it let a document report a barrier it had never crossed.
* **"Is this document open in this session" is answered in ONE place - the state of the
* document's one real subscription (`surface/subscribe.ts`, {@link docChannel}) - and this
* module records only the bootstrap listener it TOOK OUT, never whether that listener ever
* became a live channel or crossed a barrier.** So no answer here can outlive the channel
* it describes, because none is kept.
*
* The three consequences, each now true by construction:
* - a subscribe that FAILED records nothing, so the next caller subscribes again;
* - a reset clears the record AND downgrades the sync state, so nothing claims authority
* the new session has not earned;
* - `syncState` says `"unknown"` for one reason only: no barrier was ever expected here
* (never requested, or an `ng` with no `doc_subscribe` and so no `State` semantics).
* It never means "we forgot".
* What that replaces, and why each had to go rather than be repaired:
* - a `held` set of "live bootstrap subscriptions". It was written the moment
* `subscribeDocUnguarded` returned, which is BEFORE the `doc_subscribe` behind it has
* been placed - so it meant "we asked", the looser meaning an `opened` set had been
* deleted for one round earlier. And `resetDocSubscriptions` (which PUBLISHED
* `configure()` calls) drops every listener without a word, so it also survived the
* teardown of the very subscription it named.
* - a `syncState` map of barrier states. Same fate, one step worse: it kept saying
* `"synced"` for a document whose channel had gone, and `"synced"` is AUTHORITY - a
* zero-row read over it is a definitive absence. A read that had been correctly
* withheld came back a success, for a document this session had never subscribed.
*
* Both facts live in the fan-out already and are forgotten there at exactly the right
* moments, which is what makes asking better than remembering: a channel released, a
* session changed under it, an establish refused.
*
* {@link getSyncState}'s four values are therefore DERIVED, and `"unknown"` still means one
* thing only: no barrier was ever expected here (never requested, or an `ng` with no
* `doc_subscribe` and so no `State` semantics). It never means "we forgot".
*/
/** In-flight opens... see {@link inFlight} below; `held` is the record of openness. */
/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */
const inFlight = new Map<Nuri, Promise<void>>();
/**
* How to END an in-flight open from outside it - see {@link resetOpenedRepos}, the only
* caller. An open normally settles on the first `State` or on its own bounded fallback,
* and both of those live inside the subscription; a reset tears that subscription down,
* so without this the open is left with nothing that can ever finish it but a timer whose
* whole purpose has gone.
* The bootstrap open this module ASKED FOR - never a record of whether it succeeded.
*
* One per document, and its presence is the whole of what this module knows: *`open-repo`
* has opened this document in this session*. Everything else about it is a question for
* {@link docChannel}.
*/
const settleInFlight = new Map<Nuri, () => void>();
/** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */
const held = new Map<Nuri, Unsubscribe>();
/** Explicit per-nuri sync state — the barrier signal, distinct from `opened`.
* `synced` and `timed-out` are NOT merged (see module header). */
const syncState = new Map<Nuri, SyncState>();
/** The session id the current `opened`/`held` entries belong to. A change means a
interface BootstrapOpen {
/**
* The bounded wait this open exposes to its callers, resolved on the first `State`, on the
* fallback, on a refused subscribe, or by a reset. Concurrent callers join it rather than
* opening a second time - which is what an `inFlight` map used to do.
*/
done: Promise<void>;
/**
* Release the bootstrap listener this open took out. `null` once it has been released -
* by a refused subscribe, or by {@link resetOpenedRepos} - and that is ALL it says: it
* answers "am I still listening", never "is this document open". The second question needs
* {@link docChannel} as well, because a listener can be dropped from under this module
* (`resetDocSubscriptions`) and nothing here would be told.
*/
release: Unsubscribe | null;
/**
* How to END the wait from outside it - {@link resetOpenedRepos}, the only caller; `null`
* once the wait is over. An open normally settles on the first `State` or on its own
* bounded fallback, and both of those live inside the subscription; a reset tears that
* subscription down, so without this the open is left with nothing that can ever finish it
* but a timer whose whole purpose has gone.
*/
settle: (() => void) | null;
/** Inside the bounded wait: no `State` yet, and the fallback has not fired. */
waiting: boolean;
}
/** Every document this module has opened in this session. See {@link BootstrapOpen}. */
const opens = new Map<Nuri, BootstrapOpen>();
/**
* A document this module opened and no longer holds anything for - what a reset leaves
* behind. It is deliberately NOT a deletion: forgetting it entirely would make
* {@link getSyncState} answer `"unknown"`, which means "no barrier was ever expected here"
* and is therefore read as authoritative. A document whose subscription a reset took away is
* the opposite of that.
*
* It is also the generation guard. A reset REPLACES the record rather than mutating it, so
* an open still running against the previous one compares `opens.get(nuri) !== mine` and
* writes nothing - object identity doing what a counter used to.
*/
function disowned(): BootstrapOpen {
return { done: Promise.resolve(), release: null, settle: null, waiting: false };
}
/** The session id the current {@link opens} entries belong to. A change means a
* new verifier session (fresh `self.repos`) → the registry must be invalidated. */
let boundSessionId: string | number | null = null;
/**
* Which GENERATION of the registry an open belongs to, bumped by {@link resetOpenedRepos}.
*
* An open's continuation records the repo as opened, and it runs one microtask after the
* open settles - a window a reset can land in. Without this it wrote that record into the
* registry that had just replaced its own, and every later open of the same nuri
* short-circuited on it: the repo was never subscribed in the new session, and a read over
* it answered as though it had been. A reset must INVALIDATE what it abandons, not only
* end it.
*/
let registryEpoch = 0;
/**
* Max wait (ms) for the initial-state push before proceeding with the read anyway.
@@ -162,40 +202,30 @@ export function setOpenTimeoutForTests(ms: number): void {
* held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */
// @provenance resetOpenedRepos kind=invention level=none ref=none — per-session bookkeeping reset; upstream the verifier owns `self.repos` and nothing resets it from outside
export function resetOpenedRepos(): void {
for (const unsub of held.values()) {
for (const [nuri, open] of [...opens]) {
// Let the bootstrap listener go first: it is what held the repo open for the session
// that is ending, and a document nobody else follows is simply released.
try {
unsub();
open.release?.();
} catch {
/* ignore */
}
// END the open this reset abandons: a caller is already awaiting it, and the `State` it
// was waiting for cannot arrive now that the subscription has been torn down. It used to
// wait out the full fallback - eight seconds of a read going nowhere. Taken before the
// record is replaced, and called after, so the waiter's continuation finds the record
// already gone and writes nothing.
const settle = open.settle;
open.settle = null;
// DISOWNED rather than forgotten - see {@link disowned}. The barrier needs no downgrading
// of its own any more: it was never recorded here, and the fan-out forgets it on exactly
// these events.
opens.set(nuri, disowned());
// The wait's own end, not a shortcut past it: clearing `waiting` here instead would make
// this call a no-op and leave the caller hanging on a subscription that no longer exists -
// the eight-second read the abandoned-open fix exists to prevent.
settle?.();
}
// END the opens this reset abandons, BEFORE the maps go: a caller is already awaiting
// one, and the `State` it was waiting for cannot arrive now that the subscription above
// has been torn down. It used to wait out the full fallback - eight seconds of a read
// going nowhere - and then proceed with its sync state CLEARED, which reads as
// `"unknown"`; and `unknown` is the LAX answer (`emulated-verifier/index-deposit.ts`
// `noRowsMeansAbsent` accepts it), so a zero-row read taken right after a session change
// could be called a definitive absence. Marked `"timed-out"` instead, which is the exact
// truth - opened best-effort, sync unconfirmed - and the reading that refuses to call
// nothing an absence. The abandoned open's own fallback fires later and sets the same
// value, so the two cannot disagree.
const abandoned = [...settleInFlight.entries()];
// Every document this reset takes a subscription away from, in flight or long settled.
// Their sync state is DOWNGRADED rather than cleared: a barrier crossed in the session
// that just went away is not one this session has crossed, and clearing would leave them
// reading `"unknown"` - which means "no barrier was ever expected here" and is therefore
// treated as authoritative. That conflation is how a read that had been correctly
// withheld came back claiming a success, against a query nothing had re-subscribed.
const disowned = new Set<Nuri>([...held.keys(), ...abandoned.map(([nuri]) => nuri)]);
// Everything in flight now belongs to a registry that no longer exists, so its
// continuation must write nothing - see {@link registryEpoch}.
registryEpoch += 1;
inFlight.clear();
settleInFlight.clear();
held.clear();
syncState.clear();
for (const nuri of disowned) syncState.set(nuri, "timed-out");
for (const [, settle] of abandoned) settle();
boundSessionId = null;
OPEN_TIMEOUT_MS = 8000;
// Whatever ELSE was still subscribed to a document - an inbox observation, an
@@ -204,8 +234,8 @@ export function resetOpenedRepos(): void {
// ones above no longer closes those channels; they are re-opened against the new session,
// with their barrier forgotten. Without this a bootstrap open would join a surviving
// fan-out, be handed the PREVIOUS session's `State`, and call a repo the new session has
// never synced "synced". After the held teardown, so a document nobody else follows is
// simply released.
// never synced "synced". After the listeners above are released, so a document nobody else
// follows is simply released.
resubscribeDocs();
}
@@ -220,7 +250,22 @@ export function resetOpenedRepos(): void {
*/
// @provenance getSyncState kind=aligned level=1 ref=engine/net/src/app_protocol.rs:AppResponseV0 — reports whether the first `State` arrived; upstream this is not observable from JS
export function getSyncState(nuri: Nuri): SyncState | "unknown" {
return syncState.get(nuri) ?? "unknown";
const channel = docChannel(nuri);
// The first `State` on the channel standing NOW. Asked first and asked of the fan-out,
// because that is the one record of it: a barrier is forgotten when its channel is released
// or its session changes, so this cannot report one that has gone. Answered even for a
// document this module never opened - an application's own subscription crosses the same
// barrier, and `"synced"` is then simply true.
if (channel.barrierCrossed) return "synced";
const open = opens.get(nuri);
// Never opened here, or opened on the no-`doc_subscribe` path, which records nothing
// because there is no barrier to have missed.
if (open === undefined) return "unknown";
// Opened here, and no channel stands: the subscribe was refused, or a reset took it away,
// or `resetDocSubscriptions` dropped it from under us. Opened best-effort, sync UNconfirmed -
// never `"unknown"`, which is the reading that lets a zero-row read be called an absence.
if (channel.state === "none") return "timed-out";
return open.waiting ? "syncing" : "timed-out";
}
/**
@@ -294,69 +339,71 @@ export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise<vo
export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
// Drop the registry if the session changed (in-page re-login → fresh verifier).
await syncSession();
// IN FLIGHT first, so a concurrent caller joins the open and waits on the same barrier
// rather than stepping past it: `held` is set the moment the subscription is taken out,
// which is before the `State` that makes it authoritative.
const pending = inFlight.get(nuri);
if (pending) return pending;
if (held.has(nuri)) return;
const standing = opens.get(nuri);
// A caller arriving while the bounded wait is still running JOINS it, and waits on the same
// barrier rather than stepping past it.
if (standing?.waiting === true) return standing.done;
// A caller arriving when this module's own listener is still in place AND the channel behind
// it has ANSWERED has nothing to do. Both halves, and each catches a defect the other
// cannot: without the listener half, a document whose subscription a reset took away would
// never be re-held for the new session; without the channel half - which is the whole of
// this task - a document whose `doc_subscribe` has not answered, or whose fan-out was torn
// down by a `configure()`, reads as open for ever and nothing re-subscribes it.
if (standing !== undefined && standing.release !== null && docChannel(nuri).state === "open") return;
// No reactive primitive on the injected ng (fake-ng unit suite): nothing to open,
// no `State` to await → preserve the old immediate-resolve behaviour so `bun test`
// does not regress. No sync state is recorded (getSyncState → "unknown"): the fake
// path has no barrier semantics, and claiming "synced" here would be a lie.
// does not regress. No record is kept (getSyncState → "unknown"): the fake
// path has no barrier semantics, and claiming a barrier here would be a lie.
const ng = getConfig().ng as { doc_subscribe?: unknown };
if (typeof ng.doc_subscribe !== "function") {
// Nothing to open and nothing to record: there is no subscription to hold, so `held`
// must stay empty for this document or it would claim one. No sync state either -
// `"unknown"` is exactly right here, and it is the one place that MEANS it.
// Nothing to open and nothing to record: there is no subscription to hold, so this
// document must stay out of `opens` or it would claim one - and `"unknown"` is exactly
// right here, the one place that MEANS "no barrier was ever expected".
return;
}
const myEpoch = registryEpoch;
// Subscribed, first `State` not yet seen.
syncState.set(nuri, "syncing");
// Barrier clock: how long the subscribe→first-State (or fallback) round-trip
// took, surfaced on the BARRIER trace line below — the most important line in
// the whole low-level data-path trace: it distinguishes a genuine absence
// (`synced` → a 0-row read means it) from a not-yet-synced read (`timed-out`).
const barrierStartedAt = Date.now();
// Published BEFORE the subscribe, so a concurrent caller joins this wait instead of opening
// a second time - and carrying nothing but the wait, because at this instant nothing is
// true yet. What the previous registry put here was a claim of openness.
const mine: BootstrapOpen = { done: Promise.resolve(), release: null, settle: null, waiting: true };
opens.set(nuri, mine);
const p = (async () => {
mine.done = (async () => {
await new Promise<void>((resolve) => {
let settled = false;
// Handed to `resetOpenedRepos`, so an open this session abandons can be ended
// rather than left hanging on a subscription that no longer exists.
settleInFlight.set(nuri, () => {
if (settled) return;
settled = true;
resolve();
});
// 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;
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
// Stop waiting, once. Handed to `resetOpenedRepos` as well, so an open this session
// abandons can be ended rather than left hanging on a subscription that no longer
// exists.
const stopWaiting = (): void => {
if (!mine.waiting) return;
mine.waiting = false;
mine.settle = null;
resolve();
};
// Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT
// "synced". A genuinely-absent doc reads 0 rows anyway (same as before, never
// a hang); the distinct state keeps a future "ready" signal from lying.
mine.settle = stopWaiting;
// The first `State` IS the barrier, WHENEVER it arrives - and it is the FAN-OUT that
// records it, not this module, so a `State` landing long after this wait gave up still
// counts and still turns `getSyncState` to `"synced"`. That used to need a write placed
// carefully above the already-settled check; there is nothing to place now.
const onState = (): void => {
if (mine.waiting) {
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
}
stopWaiting();
};
// Bounded fallback: proceed WITHOUT a `State`. A genuinely-absent doc reads 0 rows
// anyway (same as before, never a hang); giving up the WAIT is not crossing the
// barrier, and `getSyncState` says so because the fan-out has no `State` to show.
const onTimeout = (): void => {
if (settled) return;
settled = true;
syncState.set(nuri, "timed-out");
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
resolve();
if (mine.waiting) {
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
}
stopWaiting();
};
// The bootstrap subscription is kept ALIVE for the session — holding it open
// is the whole point. We wait for the FIRST `State` event specifically (the
@@ -366,43 +413,53 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
// rule 2 above; `ensurePhysicalRepoOpen` is the machinery's door and is not
// subject to the boundary at all (see physical.ts).
// A failed establish is REPORTED, and `subscribe.ts` offers this channel precisely
// so that a caller can retry. Ignoring it is what made a refused subscribe look like
// an open repo: the fallback below fired, the open was recorded, and every later
// attempt short-circuited on a subscription that had never existed.
// so that a caller can retry. Let the listener go, so the next caller opens a NEW
// subscription rather than joining a fan-out that is not going to push. There is no
// barrier state to correct: none was ever written here, and the fan-out has no `State`
// to show for a call that failed.
//
// NOT a substitute for the channel question above, and that is the lesson of this
// task: `subscribe.ts` reports a failure only for the attempt it is still WAITING ON,
// so a rejection arriving after `resetDocSubscriptions` released the fan-out is logged
// and nothing else. When the record was a claim of openness, that claim then stood for
// the whole session and no retry could ever get past it.
const onSetupFailed = (error: unknown): void => {
if (myEpoch !== registryEpoch) return;
if (opens.get(nuri) !== mine) return;
console.error(accessLogPrefix() + " ensureRepoOpen: subscribe failed for " + shortNuri(nuri) + ":", error);
// Record NOTHING, so the next caller subscribes again - and say plainly that no
// barrier was crossed, rather than leaving a state that reads as authority.
const taken = held.get(nuri);
if (taken) {
held.delete(nuri);
try {
taken();
} catch {
/* ignore */
}
const listening = mine.release;
mine.release = null;
try {
listening?.();
} catch {
/* ignore */
}
syncState.set(nuri, "timed-out");
settleInFlight.get(nuri)?.();
stopWaiting();
};
const unsub = subscribeDocUnguarded(
// The bootstrap subscription is kept ALIVE for the session - holding it open is the
// whole point. It is a LISTENER on the document's one real subscription, which is why
// it says nothing about whether that subscription exists.
mine.release = subscribeDocUnguarded(
nuri,
(_r, type) => {
if (type === "State") onState();
},
onSetupFailed,
);
held.set(nuri, unsub);
// A record this open REPLACED still holds a listener, and its wait is over - so nothing
// will ever release it. Done after the new listener has joined, so the fan-out keeps its
// entry and its establish, and this does not turn into a second `doc_subscribe` on a
// branch that already has one (which upstream would EVICT the first).
if (standing !== undefined && standing.release !== null) {
const superseded = standing.release;
standing.release = null;
try {
superseded();
} catch {
/* ignore */
}
}
setTimeout(onTimeout, OPEN_TIMEOUT_MS);
});
// Only if this open still belongs to the registry it started in. After a reset these
// maps are somebody else's, and writing into them is how a new session came to
// short-circuit on the previous one's record.
if (myEpoch !== registryEpoch) return;
inFlight.delete(nuri);
settleInFlight.delete(nuri);
})();
inFlight.set(nuri, p);
return p;
return mine.done;
}
+73 -1
View File
@@ -63,6 +63,14 @@
* fire, and `inbox.watch`'s "fires once immediately" would silently stop being true.
* The real subscription is torn down when the LAST listener leaves.
*
* ── This module OWNS "is this document open in this session" ───────────────
* Because the subscription is the only thing that makes a document open, the fan-out is the
* only honest answer to whether one is - and {@link docChannel} is how the rest of the
* package asks. Nobody keeps a copy: `emulated-verifier/open-repo.ts` kept two (a set of
* live bootstrap subscriptions, and a per-document barrier state), and both outlived the
* channel they described the moment `resetDocSubscriptions` dropped it, which is how a
* document with no subscription at all came to report itself synced.
*
* ── What SHARING one subscription must not cost a caller ───────────────────
* Three things a per-caller `doc_subscribe` gave for free, and that the fan-out has to give
* back deliberately — each of them was lost when it was introduced on 2026-08-17:
@@ -214,6 +222,63 @@ interface DocFanOut {
*/
const fanOuts = new Map<Nuri, DocFanOut>();
/**
* What the one real subscription for a document IS, right now.
*
* - `"none"` nothing stands: no listener holds this document, or the establish that
* was standing FAILED and the next joiner will make its own;
* - `"opening"` an establish is in flight - the call has been made and has not answered,
* so there is no channel yet and there may never be one;
* - `"open"` the `doc_subscribe` call ANSWERED and this fan-out is still the one
* waiting on it: pushes arrive here.
*/
// @provenance DocChannelState kind=invention level=none ref=none — names the states of this module's own fan-out; upstream a caller holds its own subscription and has nothing to ask
export type DocChannelState = "none" | "opening" | "open";
/**
* THE answer to "is this document open in this session", and the only one.
*
* Read it, never copy it. `emulated-verifier/open-repo.ts` used to keep records of its own
* - a set of live bootstrap subscriptions and a per-document barrier state - and both
* survived a teardown of the fan-out they described: `resetDocSubscriptions` drops every
* listener without a word to anybody, so a document whose channel had gone was still
* recorded as open and as SYNCED, nothing re-subscribed it, and a zero-row read over it
* answered a success. Three records of one fact, and they disagreed. There is one now, it
* is this one, and it cannot outlive the channel it describes because it IS the channel.
*/
export interface DocChannel {
/** Whether a channel stands, and how far it got. */
readonly state: DocChannelState;
/**
* Has a `State` push arrived on the channel standing NOW?
*
* The first `State` is the sync barrier (CONTRACT 3): past it, presence is guaranteed and
* absence definitive, so a zero-row read MEANS zero. It is {@link DocFanOut.lastState}
* that says so - the one the fan-out replays to a late joiner - which is exactly the right
* record, because it is forgotten on the two events that invalidate a barrier: the
* channel being released, and the session changing under it ({@link resubscribeDocs}).
*/
readonly barrierCrossed: boolean;
}
/**
* The state of `nuri`'s one real subscription - see {@link DocChannel}.
*
* Internal. Exported for `emulated-verifier/open-repo.ts`, which asks it instead of
* remembering the answer, and for nobody else; neither entry point re-exports it.
*/
// @provenance docChannel kind=invention level=none ref=none — a question about this module's own fan-out, which exists because a branch upstream holds a single subscriber; there is nothing to align it with
export function docChannel(nuri: Nuri): DocChannel {
const entry = fanOuts.get(nuri);
if (!entry) return { state: "none", barrierCrossed: false };
// `establishing` says an attempt STANDS (it is left true once one has succeeded, see
// {@link DocFanOut.establishing}); `realUnsub` says that attempt's call has answered. The
// two together are the only thing that separates "asked" from "open" - and recording the
// first as if it were the second is the defect this accessor exists to make impossible.
const state: DocChannelState = !entry.establishing ? "none" : entry.realUnsub !== null ? "open" : "opening";
return { state, barrierCrossed: entry.lastState !== null };
}
/** Hand one push to one listener, isolating a throwing handler from the others. */
function deliver(nuri: Nuri, listener: DocListener, resp: DocChange, type: DocChangeType): void {
try {
@@ -420,7 +485,14 @@ async function establish(
if (typeof unsub === "function") unsub();
return;
}
entry.realUnsub = typeof unsub === "function" ? unsub : null;
// A function is what upstream resolves to, and it is the cancel. When the injected SDK
// hands back something else there is nothing to cancel WITH - but the call has still
// ANSWERED, and that is what {@link docChannel} reads off this field. Left `null`, the
// channel would read `"opening"` for the rest of the session and every `ensureRepoOpen`
// would take out a fresh bootstrap listener over a document that is perfectly open. A
// no-op costs exactly what `null` cost before: {@link releaseFanOut} returned early on
// `null` rather than cancelling, so nothing is lost by calling nothing instead.
entry.realUnsub = typeof unsub === "function" ? unsub : (): void => {};
} catch (error) {
reportSetupFailure(nuri, entry, error, attempt);
}
+106
View File
@@ -22,6 +22,7 @@
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, getSyncState, resetOpenedRepos, setOpenTimeoutForTests } from "../src/emulated-verifier/open-repo";
import { readDocumentSubjects } from "../src/surface/read-document";
import { docChannel, subscribeDoc } from "../src/surface/subscribe";
import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
@@ -261,6 +262,111 @@ describe("ensureRepoOpen", () => {
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
});
// -- ONE answer to "is this document open in this session" ---------------
//
// Both of these are the same defect: a record of openness kept HERE, which outlives the
// subscription it describes because `surface/subscribe.ts` drops its listeners without
// telling anybody. `configure()` is PUBLISHED and calls `resetDocSubscriptions()`, so this
// is a sequence an application performs, not a montage.
it("a second configure() leaves NOTHING claiming the document is open or synced", async () => {
const ngA = makeFakeNgWithSubscribe({ "did:ng:o:reconf": [[TYPE, `${FP}Event`]] });
inject(ngA);
await ensureRepoOpen("did:ng:o:reconf");
expect(getSyncState("did:ng:o:reconf")).toBe("synced");
// A second SDK. The subscription behind that `"synced"` is gone with the first one, so the
// barrier it reports was crossed against a verifier nobody is talking to any more. Until
// 2026-09-24 this module kept saying `"synced"` - which is AUTHORITY, a zero-row read over
// it being a definitive absence - and its own `held` entry then short-circuited every
// re-open for the rest of the session, so `refetch()` had no exit either.
const ngB = makeFakeNgWithSubscribe({ "did:ng:o:reconf": [[TYPE, `${FP}Event`]] });
inject(ngB);
expect(getSyncState("did:ng:o:reconf")).toBe("timed-out");
// ...and the re-open actually happens, against the SDK that is now injected.
await ensureRepoOpen("did:ng:o:reconf");
expect(ngB.doc_subscribe).toHaveBeenCalledTimes(1);
expect(getSyncState("did:ng:o:reconf")).toBe("synced");
});
// The second face, and the one the failure channel cannot reach.
//
// `subscribe.ts` reports a refused establish only for the attempt it is still WAITING ON, so
// a rejection that arrives after `resetDocSubscriptions` released the fan-out is logged and
// nothing else. A record written when the subscribe was REQUESTED therefore stood for the
// whole session with nothing able to clear it: measured on the previous code, zero subscribes
// under the new SDK, for ever. The repair is that nothing here records openness at all.
it("an establish still outstanding when the subscription layer is torn down leaves no claim", async () => {
let refuse: ((error: Error) => void) | null = null;
const doc_subscribe = mock(
async () =>
await new Promise<never>((_resolve, reject) => {
refuse = reject;
}),
);
const ngA = {
doc_subscribe,
sparql_query: mock(async () => ({ results: { bindings: [] } })),
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
};
inject(ngA as unknown as ReturnType<typeof makeFakeNgWithSubscribe>);
setOpenTimeoutForTests(20);
// Stops waiting on the bounded fallback, with the subscribe STILL outstanding: at this
// instant nothing is true yet, and that is precisely when the old code recorded a hold.
await ensureRepoOpen("did:ng:o:outstanding");
expect(doc_subscribe).toHaveBeenCalledTimes(1);
const ngB = makeFakeNgWithSubscribe({});
inject(ngB); // configure() -> resetDocSubscriptions(), mid-establish
// The first SDK finally refuses - into a fan-out that no longer exists, so no caller is
// told. Nothing may depend on being told.
refuse!(new Error("BrokerError: late refusal"));
await new Promise((r) => setTimeout(r, 5));
setOpenTimeoutForTests(20);
await ensureRepoOpen("did:ng:o:outstanding");
expect(ngB.doc_subscribe).toHaveBeenCalledTimes(1);
expect(getSyncState("did:ng:o:outstanding")).toBe("synced");
});
// The other half of the same question, and it is why "the channel is open" is not the whole
// answer either.
//
// A reset releases the bootstrap listener, which is what kept the repo open for the session.
// If something ELSE was following the document, the fan-out survives and `resubscribeDocs`
// re-opens its channel - so the channel is `"open"` again while this module holds nothing.
// Skipping the work there would leave the document held by a listener that is free to leave,
// and the read would not wait for the new session's barrier either.
it("a reset leaves the document to be HELD again, even though its channel is open", async () => {
const ng = makeFakeNgWithSubscribe({});
inject(ng);
const doc = "did:ng:o:alsowatched";
// Somebody else follows this document - an inbox observation, or an application's own
// subscription. It is not this module's listener and it does not hold the repo for it.
const appUnsub = subscribeDoc(doc, () => {});
await ensureRepoOpen(doc);
expect(getSyncState(doc)).toBe("synced");
// The session changes: the bootstrap listener goes, the other one stays, and the fan-out's
// channel is re-opened against the new session with its barrier forgotten.
resetOpenedRepos();
await new Promise((r) => setTimeout(r, 5));
expect(docChannel(doc).state).toBe("open");
await ensureRepoOpen(doc);
expect(getSyncState(doc)).toBe("synced");
// The other listener leaves. The document must still be open, because the bootstrap open is
// holding it - which it can only do if the call above actually took a listener out.
appUnsub();
expect(docChannel(doc).state).toBe("open");
expect(getSyncState(doc)).toBe("synced");
});
it("the anchored document read triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
+76
View File
@@ -1,6 +1,8 @@
import { test, expect, mock, afterAll } from "bun:test";
import {
docChangeType,
docChannel,
resetDocSubscriptions,
resubscribeDocs,
subscribeDoc,
subscribeDocReportingSetupFailure,
@@ -712,3 +714,77 @@ test("served in the INVERTED order, the document goes silently dead — nothing
alsoStop();
stop();
});
// -- the ONE answer to "is this document open in this session" ---------------
//
// `docChannel` is that answer, and the whole of what it has to get right is telling an
// UNANSWERED call from an open channel. Every record of openness this package has kept
// elsewhere got that wrong in the same way: written when the subscribe was REQUESTED, so it
// meant "we asked". `emulated-verifier/open-repo.ts` used to keep two of them and they
// outlived the subscription they described; it asks this instead.
test("docChannel says OPENING while the call is unanswered, OPEN once it is served", async () => {
// Accepted and not yet SERVED: the call has been placed and the broker has said nothing.
const ng = inject(undefined, undefined, new Set([A]));
expect(docChannel(A).state).toBe("none"); // nobody is listening yet
const unsub = subscribeDoc(A, () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
// The call is out and there is no channel. Reading this as `"open"` is the defect: a caller
// skips the work it still has to do, and a document that may never be subscribed is treated
// as one that is.
expect(docChannel(A).state).toBe("opening");
expect(docChannel(A).barrierCrossed).toBe(false);
ng.serveQueued(0);
await tick();
expect(docChannel(A).state).toBe("open");
// ...and the barrier is the fan-out's own record of the first `State`, so it comes with it.
expect(docChannel(A).barrierCrossed).toBe(true);
unsub();
expect(docChannel(A).state).toBe("none");
});
test("docChannel says NONE for a call the broker refused, and for a torn-down fan-out", async () => {
const ng = inject(new Set([B]));
const unsub = subscribeDoc(B, () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
// Refused: nothing stands, and the next joiner must be free to make its own attempt.
expect(docChannel(B).state).toBe("none");
expect(docChannel(B).barrierCrossed).toBe(false);
unsub();
// A fan-out dropped from under its listeners - what `resetDocSubscriptions` does, which
// `configure()` calls. The answer follows the channel because it IS the channel.
const ng2 = inject();
const live = subscribeDoc(A, () => {});
await tick();
expect(docChannel(A).state).toBe("open");
expect(ng2.doc_subscribe).toHaveBeenCalledTimes(1);
resetDocSubscriptions();
expect(docChannel(A).state).toBe("none");
expect(docChannel(A).barrierCrossed).toBe(false);
live();
});
// An injected SDK that answers with something other than a cancel function still ANSWERED, and
// the channel is open. Read as "not open", every bootstrap open over that document would take
// out a fresh listener and wait the barrier again, for the rest of the session - over a
// document that is perfectly subscribed. Upstream resolves to a function; a consumer's own
// shim need not, and this is the one place that difference could be mistaken for a failure.
test("a doc_subscribe that answers without a cancel function is still an OPEN channel", async () => {
const doc_subscribe = mock(async (_n: string, _s: unknown, cb: (r: unknown) => void) => {
queueMicrotask(() => cb({ V0: { State: {} } }));
return undefined; // no cancel to hand back
});
configure({ ng: { doc_subscribe } as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION });
const unsub = subscribeDoc(A, () => {});
await tick();
expect(doc_subscribe).toHaveBeenCalledTimes(1);
expect(docChannel(A).state).toBe("open");
expect(docChannel(A).barrierCrossed).toBe(true);
unsub();
});
@@ -221,6 +221,25 @@ function inject(ng: ReturnType<typeof makeFake>, orm: FakeOrm) {
resetCaps();
}
/**
* A SECOND `configure(...)` over a live page - exactly what {@link inject} does, minus every
* reset. An application that calls `configure` twice resets nothing itself, and a test that
* did would be performing the repair it is meant to be checking.
*/
function reconfigure(ng: ReturnType<typeof makeFake>, orm: FakeOrm): void {
const forwarded: Record<string, unknown> = { ...ng };
if (ng._noSubscribe) delete forwarded.doc_subscribe;
configure({
ng: forwarded as unknown as NgLike,
useShape: (() => {}) as unknown as UseShapeLike,
ormSubscription: orm.query,
});
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
}
const tick = () => new Promise((r) => setTimeout(r, 5));
/**
@@ -1211,6 +1230,57 @@ describe("useShapeQuery", () => {
unsub();
});
// A SECOND `configure(...)`, which is a published call an application makes - it tears the
// subscription layer down (`resetDocSubscriptions`) and says nothing to the open registry.
// Until 2026-09-24 the registry kept its own records of "this document is open" and "its
// barrier was crossed", so a named scope went on publishing `isSuccess` against a
// subscription that no longer existed, and nothing - `refetch()` included - could re-open it.
//
// Deliberately NOT `inject`: this re-configures and nothing else, because that is what an
// application does. Resetting the registry here would be the test doing the repair itself.
it("(gg) a second configure() does not let a named scope answer from a subscription that is gone", async () => {
const ngA = makeFake();
const orm = makeOrm();
inject(ngA, orm);
setCurrentUser("zoe");
const mine = await createEntityDoc("zoe", "protected");
seedEvent(orm, mine, `${mine}:q:one`, "Present");
const obs = useShapeQuery(EventShape, { graphs: [mine] });
const unsub = obs.subscribe(() => {});
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(titles(obs.getSnapshot().data)).toEqual(["Present"]);
expect(getSyncState(mine)).toBe("synced");
// A second SDK, which does not push a `State`: this session has not heard from the
// document, and has no standing to call it anything.
const ngB = makeFake({ pushState: false });
reconfigure(ngB, orm);
setOpenTimeoutForTests(30);
setCurrentUser("zoe");
obs.refetch();
for (let i = 0; i < 40 && obs.getSnapshot().isPending === false; i++) await tick();
// The defect, as an application saw it: `{isPending: false, isSuccess: true}` for a
// document this session never subscribed - an answer asserting authority it does not have.
const withheld = obs.getSnapshot();
expect(withheld.isSuccess).toBe(false);
expect(withheld.isError).toBe(false);
expect(withheld.isPending).toBe(true);
// ...and the re-open is really attempted, against the SDK that is injected NOW - measured
// as 0 on the previous code, because the registry's own record short-circuited it.
expect(ngB.doc_subscribe.mock.calls.filter((c) => String(c[0]) === mine).length).toBe(1);
// And the line that must not break: authority RETURNS with the barrier, and a document
// that has synced answers a success - here holding something, and in (h)/(m)/(p) holding
// nothing. The repair is never to turn a withheld read into an error.
ngB._pushStateNow(mine);
for (let i = 0; i < 40 && !obs.getSnapshot().isSuccess; i++) await tick();
expect(getSyncState(mine)).toBe("synced");
expect(titles(obs.getSnapshot().data)).toEqual(["Present"]);
unsub();
});
it("(ff) a refusal that has been lifted is never published again as the answer", async () => {
const ng = makeFake({ pushState: false });
const orm = makeOrm();