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:
@@ -9,3 +9,4 @@
|
||||
- TOUCHED packages/polyfill/src/surface/orm.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
- TOUCHED packages/polyfill/src/surface/read-document.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
- TOUCHED examples/notebook/app.ts @2026-09-21 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
- TOUCHED packages/polyfill/src/surface/subscribe.ts @2026-09-24 (session f93872b5-293a-4916-a353-181409a96d42)
|
||||
|
||||
@@ -40,6 +40,6 @@ Three rules follow, and all three are load-bearing:
|
||||
2. **An empty document is a SUCCESS holding zero objects.** The repair for (1) must never be bought by making a real emptiness into an error; that only moves the lie. An empty document, an empty scope, a document nobody opened an inbox on, a document in a public store that exposes no key - each is an answer, and each stays one.
|
||||
3. **The unsynced/empty collision is closed by the barrier, and where the barrier was not crossed the read says so rather than answering.** Crossing it is what makes a zero-row answer mean something (`emulated-verifier/open-repo.ts`, which keeps `synced` and `timed-out` deliberately apart). A reactive read over documents it NAMES consults that distinction and stays PENDING when the barrier was not reached - "I have no authoritative answer" rather than "there is nothing". Turning it into a rejection would be inventing a third outcome the engine does not have, and answering a success would be the collapse this whole leaf is about.
|
||||
|
||||
**What an application can see of this, exactly.** `useShapeQuery` folds it into its own states, and that is the only published channel: a named scope whose barrier was not crossed does not reach `isSuccess` - **within one ordinary session**. Two situations break it, both known defects and both carried as acceptance tests by `T01.m` ("there must be one answer to whether a document is open"): a second `configure(...)`, which tears down the subscription layer without telling the open registry, and a broker refusing a subscription after this package has stopped waiting. In both, a document recorded as open is not, and the read answers an empty success. The REACHABILITY half is unaffected. `inbox.readSyncedForDocument` is the same guarantee on the inbox path. `getSyncState` is INTERNAL and is deliberately not published - this leaf offered it until 2026-09-24, which was a pointer at something no caller can import. It stays internal because it has no counterpart upstream at any level: publishing it would hand an application a polyfill-era symbol to unlearn at migration, on a surface that is busy removing exactly those. The consequence to state plainly rather than paper over: a caller using the anchored `docs.sparqlQuery` directly gets zero rows for an unsynced document and has **no published way to tell that from an empty one** - that is the collapse upstream has too, and it is why the reactive read is the one that carries the distinction.
|
||||
**What an application can see of this, exactly.** `useShapeQuery` folds it into its own states, and that is the only published channel: a named scope whose barrier was not crossed does not reach `isSuccess`. That held only **within one ordinary session** until `1.0.0-dev.10`: a second `configure(...)` tore down the subscription layer without telling the open registry, and a broker refusing a subscription after this package had stopped waiting left the document recorded as open - in both, a document recorded as open was not, and the read answered an empty success. Closed by giving the question ONE answer: openness is the state of the document's one real subscription, read rather than remembered, so no record of it can outlive the channel it describes. The REACHABILITY half was never affected. `inbox.readSyncedForDocument` is the same guarantee on the inbox path. `getSyncState` is INTERNAL and is deliberately not published - this leaf offered it until 2026-09-24, which was a pointer at something no caller can import. It stays internal because it has no counterpart upstream at any level: publishing it would hand an application a polyfill-era symbol to unlearn at migration, on a surface that is busy removing exactly those. The consequence to state plainly rather than paper over: a caller using the anchored `docs.sparqlQuery` directly gets zero rows for an unsynced document and has **no published way to tell that from an empty one** - that is the collapse upstream has too, and it is why the reactive read is the one that carries the distinction.
|
||||
|
||||
Related: `knowledge_what-a-read-scope-is-upstream` (what a read request can SAY, including the `[]` that means the empty set and the `[]` that means no filtering), and `polyfill-surface/contract_polyfill-surface` `## Guarantees` for the caller-facing form of all of this.
|
||||
|
||||
@@ -193,7 +193,7 @@ Where a call must first find out whether something already exists — a document
|
||||
|
||||
**A reactive read says "nothing" and "I could not find out" differently.** `useShapeQuery` answers in three states and only two of them are answers about your data: `isPending` while the question is still open, `isSuccess` once it has been answered, `isError` when it could not be. An empty `data` under `isSuccess` means this scope holds nothing of that shape — the distinction the surface exists for, and the one thing it will never do is publish that snapshot for a read that did not happen. And because an observable cannot take back a list a subscriber has already rendered, `data` under `isError` keeps the **last read that answered** rather than emptying — so an empty `data` is never handed to you as a failure's answer. Read the load state before `data`: **a rejection means "unknown", never "absent"** here too.
|
||||
|
||||
**A document you NAME in `graphs` is reached before the read opens, since `1.0.0-dev.9`.** Naming a document in a scope is the same act as anchoring `docs.sparqlQuery` on it, and it is now answered the same way: the key is asked of a public store if you do not already hold it, the document is refused out loud if you hold no key for it, and its repo is synced before the question is put. A named document you cannot read publishes `isError`, where until this version it reached the engine, came back, and was emptied by the per-document filter into `isSuccess` with `data: []` - the one snapshot this surface reserves for "this scope holds nothing". A named document that has never synced published that same snapshot for a document that is not empty; it now stays **PENDING** instead **within one session** - the sync barrier is consulted on every publication, and a named document whose barrier was not crossed is not an answer. **That half is narrower than the sentence above, and the two holes in it are named under `## Non-guarantees` - read them before relying on it.** Nothing failed there, so it is not `isError`: the subscribe succeeded and the read did not throw, what is missing is the authority to say the document is empty. If the document later syncs and brings data, the read answers by itself. If it never does, the read stays pending for that session - handle a `isPending` that does not end, as you already must for a shape query the engine refuses. **Naming SEVERAL documents fails as a whole** when one of them cannot be reached: a short list is the same lie as an empty one, so nothing is answered rather than half of it. What is NOT touched, and each stays a success: `graphs: "did:ng:i"` or `""` (you named no document - the per-document filter is the whole of the answer there, and an empty result means you hold nothing of that shape), `graphs: []` (no objects, by NextGraph's own definition), and a named document you can read that holds nothing of the shape.
|
||||
**A document you NAME in `graphs` is reached before the read opens, since `1.0.0-dev.9`.** Naming a document in a scope is the same act as anchoring `docs.sparqlQuery` on it, and it is now answered the same way: the key is asked of a public store if you do not already hold it, the document is refused out loud if you hold no key for it, and its repo is synced before the question is put. A named document you cannot read publishes `isError`, where until this version it reached the engine, came back, and was emptied by the per-document filter into `isSuccess` with `data: []` - the one snapshot this surface reserves for "this scope holds nothing". A named document that has never synced published that same snapshot for a document that is not empty; it now stays **PENDING** instead - the sync barrier is consulted on every publication, and a named document whose barrier was not crossed is not an answer. **Since `1.0.0-dev.10` that half carries no qualification.** It held only within one ordinary session until then, and the two situations that broke it - a second `configure(...)`, and a broker refusing a subscription after this package had stopped waiting for the document's first push - are closed: "is this document open in this session" now has exactly one answer, the state of the document's one real subscription, rather than three records that could disagree. Nothing failed there, so it is not `isError`: the subscribe succeeded and the read did not throw, what is missing is the authority to say the document is empty. If the document later syncs and brings data, the read answers by itself. If it never does, the read stays pending for that session - handle a `isPending` that does not end, as you already must for a shape query the engine refuses. **Naming SEVERAL documents fails as a whole** when one of them cannot be reached: a short list is the same lie as an empty one, so nothing is answered rather than half of it. What is NOT touched, and each stays a success: `graphs: "did:ng:i"` or `""` (you named no document - the per-document filter is the whole of the answer there, and an empty result means you hold nothing of that shape), `graphs: []` (no objects, by NextGraph's own definition), and a named document you can read that holds nothing of the shape.
|
||||
|
||||
**And it converges by itself.** A named scope refused this way is not a dead view. Whether you may reach the documents you named is re-read on every publication rather than decided once, so a key arriving turns the refusal into the document through the same channel that already re-published a filtered read - and a scope whose barrier failed outright is attempted again on that signal. The ordinary sequences all converge with no call from the application: a page whose components subscribe before the connection work has restored its keys, a document shared with you while you are looking at the screen, a broker that hiccupped once and healed, and a key arriving in the same instant the read was deciding. One consequence to design for: an interface that renders `isError` will briefly show it where it used to show an empty success, so branch on the load state rather than on `data` alone. Nothing here polls, so a scope refused in a session whose keyring never moves again stays `isError` until your own `refetch()` - which is what `isError` has always meant here, and what it means for every other failure this call reports.
|
||||
|
||||
@@ -253,13 +253,6 @@ Where a call must first find out whether something already exists — a document
|
||||
|
||||
**TWO COPIES OF THIS PACKAGE IN ONE APPLICATION MISBEHAVE SILENTLY, and nothing here can detect it.** What this package remembers it keeps in the package itself, not in any handle you hold: which documents are subscribed and who is listening for them, which identity the session settled on, which documents are open. None of that is shared between two copies. An application that ends up with two therefore runs two of everything — a document subscribed through one copy is invisible to the other, so changes to it simply never arrive; and the identity settled in one is not the identity the other acts as, so the same call writes as one user or the other depending on which copy it reached. Nothing raises, nothing warns, and no call can report it: from every entry point a second copy is indistinguishable from the first, and the symptoms surface far from the cause as missing updates and writes attributed to the wrong person. This is a packaging property, not a behaviour to code around — **declare this package once, as one dependency of the application**, and let your own code and every library built on it resolve to that copy. A library that builds on this package should declare it a *peer* rather than a dependency for exactly this reason, so that the application remains the one place it is named.
|
||||
|
||||
**The "has it arrived" half of a named scope is NOT reliable across two situations, and both are known defects rather than design.** The guarantee under `## Guarantees` - that a named document which has not synced keeps the read pending instead of answering an empty success - holds inside one ordinary session. It does **not** hold in these two, where a named scope can still publish `isSuccess` with `data: []` for a document this session never heard from:
|
||||
|
||||
- **After a second `configure(...)`.** That call tears down the subscription layer's listeners and does not tell the part of this package that records which documents are open. A document synced before it is still recorded as synced, so nothing re-subscribes it and the read answers from a subscription that no longer exists. `refetch()` does not recover it. Calling `configure` once, at start-up, avoids this entirely - which is what the deployment requirements already ask for.
|
||||
- **When the broker refuses a subscription late** - after this package has stopped waiting for the document's first push. The document stays recorded as open and the retry does not subscribe again.
|
||||
|
||||
Both come from the same cause: **this package keeps more than one record of "is this document open in this session", and they can disagree.** It is tracked in this repository as `T01.m` ("there must be one answer to whether a document is open"), which carries both failures as its acceptance tests. Until it lands, treat the pending state as a real improvement on the empty success it replaced and not as a promise that an empty success can no longer appear. **The REACHABILITY half of the same barrier is unaffected and is verified against a live broker**: a document you hold no key for is refused out loud rather than emptied out of the result, and a public document opens from a bare reference.
|
||||
|
||||
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
|
||||
|
||||
**`getObjects` does not fetch a public store's key for you either**, for the same reason as the two shape reads below: it goes through the same ORM query and spends no round-trip asking. Read the document once by naming it — `docs.sparqlQuery` anchored on it — and it appears.
|
||||
@@ -278,16 +271,22 @@ What each level means here, in this package's own terms:
|
||||
- **minor** — a symbol is added and nothing existing moves (`inbox.readSyncedForDocument` arrived this way).
|
||||
- **patch** — a fix that changes neither the published surface nor anything above under `## Guarantees`.
|
||||
|
||||
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.9`), which sorts *below* that version by construction - so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||
**A tag says where it comes from.** A release cut on `main` carries a **full version** (`1.0.0`), and the three rules above govern what changes between two full versions. Work still on a branch carries a **pre-release** of the version it is heading for (`1.0.0-dev.10`), which sorts *below* that version by construction - so you can pin what exists today while the tag itself tells you the surface has not been released and may still move before it is. Between two pre-releases of the same version nothing is promised: re-pull and read this leaf again. When the branch lands, the full version appears alongside; the pre-release keeps resolving, so no reference you pinned is ever withdrawn from under you.
|
||||
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.9` is this package, `ng-e2e-helpers/v...` is the other one. A bare `v...` tag would say nothing about which surface it froze the day the two diverge - which is the day one of them takes a major and the other does not.
|
||||
**Tags carry the package name**, because this repository publishes more than one engagement and their versions move independently: `polyfill/v1.0.0-dev.10` is this package, `ng-e2e-helpers/v...` is the other one. A bare `v...` tag would say nothing about which surface it froze the day the two diverge - which is the day one of them takes a major and the other does not.
|
||||
|
||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything - but the next release very likely is a major. What exists today is `1.0.0-dev.9`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.9`, the string you pinned, never the version it is heading for.
|
||||
`1.0.0` is a baseline, not a claim of maturity: it is the number that makes your pin mean something. Nothing was released before it, so none of the changes named above is a bump from anything - but the next release very likely is a major. What exists today is `1.0.0-dev.10`, on a branch: pin that string exactly, and anchor your `usage_` leaf's `against:` on it - `against: @ng-eventually/polyfill@1.0.0-dev.10`, the string you pinned, never the version it is heading for.
|
||||
|
||||
`1.0.0-dev.10` is a **major**, and all of it is one rule: **"is this document open in this session" now has exactly one answer.** This package kept three records of that fact - a set of live bootstrap subscriptions, a per-document barrier state, and the subscription layer's own listeners - and the first two outlived the third. Only one remains, the state of the document's one real subscription; the other two are gone rather than reconciled, because two records of one fact can disagree and these did. Nothing is added, renamed or removed from the entry.
|
||||
|
||||
- **The ARRIVAL half of a named scope's guarantee is delivered in full**, and the two non-guarantees `1.0.0-dev.9` carried against it are withdrawn. A second `configure(...)` no longer leaves a document recorded as synced against a subscription it tore down, and a subscription the broker refuses after this package stopped waiting no longer leaves it recorded as open. In both, the document is now re-opened and the read answers once it has actually been heard from.
|
||||
- **What a caller sees change**, and it is why this is counted a major on the conservative reading: in those two situations a named scope publishes `isPending` where it published `isSuccess` - with a stale `data`, or an empty one for a document that is not empty. `isPending` is a state this call already reports and this surface already requires you to handle, so no new state arrives at the caller; what changes is the value an interface branching on `isSuccess` is handed. **Calling `configure` once, at start-up, is still what the deployment requirements ask for**; this makes a second call recoverable rather than supported.
|
||||
- **Unchanged, and each is still a success**: a named document you can read that holds nothing of the shape, `graphs: []`, and `graphs: "did:ng:i"` or `""`. Turning a genuine emptiness into a failure would only move the lie, and nothing here does.
|
||||
|
||||
`1.0.0-dev.9` is a **major**, and all of it is one rule: **a read that could not reach its document now FAILS, where it used to answer as though the document were empty.** Nothing is added, nothing is renamed, nothing is removed from the entry - three existing calls narrow.
|
||||
|
||||
- **`useShapeQuery` publishes `isError` for a document you NAME and cannot read.** Until this version a document named in `scope.graphs` was handed straight to the engine; whatever came back was filtered per document by what you hold, and a document you hold no key for was emptied out of the result. The snapshot was `isSuccess` with `data: []` - indistinguishable, byte for byte, from the scope genuinely holding nothing. It now asks the public store for the key, refuses out loud if there is none to be had, and crosses the sync barrier first, so the same read answers `isError`. **Handle `isError` on a named scope**; an interface that only branched on `isPending` / `isSuccess` will show its success state with nothing in it. Naming several documents fails as a whole when one of them fails.
|
||||
- **A named document that has not SYNCED keeps the read pending**, where it used to publish the same empty success. Reaching a document and hearing back from it are two questions, and only the first was being asked. An interface must therefore expect a named scope to sit in its loading state while a document it named is not answering, rather than reading an empty success as "nothing there". The same applies after an in-page re-login, which returns a named scope to pending until its documents are confirmed under the new session - see `## Guarantees`. **This half is partial: two situations where it does not hold are named under `## Non-guarantees`, and they are defects rather than design.** The reachability half beside it has no such qualification.
|
||||
- **A named document that has not SYNCED keeps the read pending**, where it used to publish the same empty success. Reaching a document and hearing back from it are two questions, and only the first was being asked. An interface must therefore expect a named scope to sit in its loading state while a document it named is not answering, rather than reading an empty success as "nothing there". The same applies after an in-page re-login, which returns a named scope to pending until its documents are confirmed under the new session - see `## Guarantees`. Complete since `1.0.0-dev.10`; in `1.0.0-dev.9` it held only within one ordinary session, and the two situations that broke it were carried as non-guarantees.
|
||||
- **A named scope now costs round-trips it did not cost before**, once per named document per session: the key fetch, and the subscribe that syncs the repo. The opens run together, so several named documents cost the slowest wait rather than the sum. `graphs: "did:ng:i"` is unchanged and spends nothing.
|
||||
- **A refused named scope RECOVERS on its own.** Reachability is re-read on every publication rather than remembered, and a scope whose barrier failed is attempted again when the keyring moves - including when the move happened while the read was still deciding. Without that a refusal would be terminal, and trading a silent empty answer for a permanently stuck error would have been no improvement at all. Expect to see `isError` briefly on a page that subscribes before its keys are restored. There is no polling: in a session whose keyring never moves again, `refetch()` is the recovery, as it is for every other failure this call reports.
|
||||
- **`inbox.postToDocument`, `inbox.readForDocument` and `inbox.readSyncedForDocument` reject where they used to report "no inbox".** Resolving a document's inbox address reads that document, and when the read could not happen - the broker could not answer for it, or its public store could not be asked for the key - all three used to render the breakdown as the document's owner having opened nothing: a refusal on the deposit side, an empty list on the read side. They now reject. A document that genuinely has no inbox is unchanged, and still answers exactly as before.
|
||||
|
||||
@@ -95,17 +95,17 @@ export function subscribePhysicalDoc(nuri: Nuri, onChange: (r: DocChange, type:
|
||||
## 6. Bootstrap repo opening — `emulated-verifier/open-repo.ts`
|
||||
|
||||
```ts
|
||||
// open-repo.ts:90
|
||||
// open-repo.ts:98
|
||||
export type SyncState = "syncing" | "synced" | "timed-out";
|
||||
// open-repo.ts:120 - TEST-ONLY
|
||||
// open-repo.ts:197 - TEST-ONLY
|
||||
export function setOpenTimeoutForTests(ms: number): void;
|
||||
// open-repo.ts:127
|
||||
// open-repo.ts:204
|
||||
export function resetOpenedRepos(): void;
|
||||
// open-repo.ts:162
|
||||
// open-repo.ts:252 - DERIVED, nothing stores it
|
||||
export function getSyncState(nuri: Nuri): SyncState | "unknown";
|
||||
// open-repo.ts:210
|
||||
// open-repo.ts:315
|
||||
export async function ensureRepoOpen(nuri: Nuri, holderKey?: string): Promise<void>;
|
||||
// open-repo.ts:234 - the unguarded core, for ONE importer (`shared-wallet/physical.ts`)
|
||||
// open-repo.ts:339 - the unguarded core, for ONE importer (`shared-wallet/physical.ts`)
|
||||
export async function openRepoUnguarded(nuri: Nuri): Promise<void>;
|
||||
```
|
||||
|
||||
@@ -116,7 +116,9 @@ export async function openRepoUnguarded(nuri: Nuri): Promise<void>;
|
||||
> `ensureRepoOpen`'s second parameter. The rest of this document carries drift of the same
|
||||
> age and was deliberately left alone.
|
||||
|
||||
Heals the cold-start defect of the anchored read path: on a fresh session a not-yet-open repo reads empty, so before an anchored read the repo is opened by subscribing (`subscribePhysicalDoc`) and awaiting the first `State` push — the sync barrier — with a bounded timeout. The subscription is held for the session; per-nuri `SyncState` keeps `synced` and `timed-out` apart.
|
||||
Heals the cold-start defect of the anchored read path: on a fresh session a not-yet-open repo reads empty, so before an anchored read the repo is opened by subscribing (`subscribePhysicalDoc`) and awaiting the first `State` push — the sync barrier — with a bounded timeout. The subscription is held for the session; `SyncState` keeps `synced` and `timed-out` apart.
|
||||
|
||||
> Corrected 2026-09-24 (`1.0.0-dev.10`), for this module only: the line numbers above, and the fact that nothing here STORES openness or the barrier any more. This module kept a set of live bootstrap subscriptions and a per-nuri sync-state map, and both outlived the subscription they described - `surface/subscribe.ts` drops its listeners without a word, which `configure()` makes it do. There is one answer now, `surface/subscribe.ts`'s `docChannel(nuri)`, and `getSyncState` derives from it on every call; what this module records is the bootstrap listener it took out, which answers "am I still listening" and nothing more.
|
||||
|
||||
- The opening mechanism is **level 2, VERIFIED as a composition**: `doc_subscribe` exists (`sdk/js/lib-wasm/src/lib.rs:1908`), and the push variants `TabInfo`/`State`/`Patch` are the engine's `AppResponseV0` (`engine/net/src/app_protocol.rs:1354-1358`). The ORDER (TabInfo first, then the initial State) and "first State = presence guaranteed, absence definitive" are **empirical, pinned by the in-repo e2e CONTRACT-3 probe — an ASSUMPTION about ordering as far as upstream is concerned**: no upstream statement fixes the push order, so a future reordering upstream would silently break the barrier. Bound: the e2e probe fails loudly if the order changes.
|
||||
- "Hold a live subscription to keep the repo open" — **ASSUMPTION** (nothing upstream documents subscription lifetime as what retains a repo in `self.repos`); observed to work, bounded by the same probe.
|
||||
|
||||
@@ -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");
|
||||
if (mine.waiting) {
|
||||
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||
resolve();
|
||||
}
|
||||
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);
|
||||
const listening = mine.release;
|
||||
mine.release = null;
|
||||
try {
|
||||
taken();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user