@aglyn/tenant-feature-instance 1.0.0-beta.217 → 1.0.0-beta.219

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ /**
18
+ * A MULTI-TAB CACHE THAT ANOTHER TAB HAS LOCKED, AND THE WAY OUT (AGL-3428).
19
+ *
20
+ * ## The wedge the network cycle cannot clear
21
+ *
22
+ * `firestore-stall-recovery.ts` recovers a lapsed primary lease by cycling the
23
+ * network, which runs the lease election at once (AGL-3373). That needs the
24
+ * election's IndexedDB transaction to run. Measured against
25
+ * `@firebase/firestore` 4.17.2: `IndexedDbPersistence.runTransaction` opens
26
+ * EVERY transaction over EVERY object store (`getObjectStores(schemaVersion)`),
27
+ * so one tab that stops partway through a readwrite transaction — frozen or
28
+ * discarded by the browser while it runs — holds every store for every tab on
29
+ * the origin. Behind it, in every other tab:
30
+ *
31
+ * - the lease election waits, so the lease row is never renewed by anyone;
32
+ * - cache reads wait, so pages sit on their spinners;
33
+ * - the mutation-queue write waits, so a save never reaches the network;
34
+ * - a freshly opened tab's persistence `start()` waits, so a new tab hangs too;
35
+ * - `disableNetwork`, `enableNetwork` and `terminate` are queued on the same
36
+ * AsyncQueue behind the stuck operation, so none of them ever runs.
37
+ *
38
+ * On 2026-09-30 production (beta.218) did exactly this three times in two
39
+ * hours: every console tab stopped, and the `owner` row named a background
40
+ * tab that had stopped renewing it.
41
+ *
42
+ * ## How it is told apart from a slow read
43
+ *
44
+ * Only after the network cycle, and only from a visible tab on a browser that
45
+ * reports a network. Such a tab wins a lapsed lease within one 4-second
46
+ * election tick, and the cycle has just forced one. So, after a short settle,
47
+ * the tab reads the `owner` row itself:
48
+ *
49
+ * - **the read cannot complete in time** — the store is locked by another
50
+ * tab's open transaction; or
51
+ * - **the lease is older than {@link STALE_PRIMARY_LEASE_MS}** — no tab,
52
+ * including this one, is completing an election.
53
+ *
54
+ * Either is the wedge. A fresh lease — this tab's or another's — is a healthy
55
+ * cache and a merely slow server, and nothing more happens.
56
+ *
57
+ * ## The way out
58
+ *
59
+ * The tab records a {@link MemoryCacheFallback} in `sessionStorage` and
60
+ * reloads. `firestoreCacheClassFor` reads it at startup and gives this tab the
61
+ * memory cache, which shares nothing with other tabs, so the locked database
62
+ * cannot reach it. `sessionStorage` is per tab: other tabs keep the durable
63
+ * cache, and the fallback leaves when this tab closes or after
64
+ * {@link MEMORY_CACHE_FALLBACK_TTL_MS}.
65
+ *
66
+ * A reload, not an in-place swap, because nothing in place can work: the
67
+ * wedged instance's `terminate()` is queued behind the stuck operation, a
68
+ * second instance for the same app throws until the first is gone, and
69
+ * hundreds of readers hold the instance from context and from closures.
70
+ * Nothing that could be saved is lost — the wedge had already stopped every
71
+ * write in the tab from leaving it.
72
+ */ /** `sessionStorage` key of this tab's memory-cache fallback. */ export const MEMORY_CACHE_FALLBACK_KEY = 'aglyn:firestore-memory-cache-fallback';
73
+ /**
74
+ * How long a tab keeps the memory cache after falling back. The SDK's own
75
+ * inactivity horizon (`MAX_CLIENT_AGE_MS`), after which a tab that is still
76
+ * stuck no longer counts as a client; past it, a reload tries the durable
77
+ * cache again, and falls back again if it has to.
78
+ */ export const MEMORY_CACHE_FALLBACK_TTL_MS = 30 * 60 * 1000;
79
+ /**
80
+ * A lease older than this, read from a visible online tab right after a
81
+ * forced election, means no election is completing. The SDK treats a lease as
82
+ * lapsed at 5 s (`MAX_PRIMARY_ELIGIBLE_AGE_MS`) and runs the election every
83
+ * 4 s; this is three times the first, so an election that is merely late is
84
+ * never mistaken for one that cannot run.
85
+ */ export const STALE_PRIMARY_LEASE_MS = 15000;
86
+ /**
87
+ * How long the lease read may take before the store is judged locked. The
88
+ * SDK's own transactions take milliseconds.
89
+ */ export const LEASE_PROBE_TIMEOUT_MS = 4000;
90
+ /**
91
+ * How long after the network cycle the lease is read, so the election the
92
+ * cycle queued has run.
93
+ */ export const LEASE_SETTLE_MS = 3000;
94
+ /** The SDK's lease store and its single row's key (`DbPrimaryClientStore`, `DbPrimaryClientKey`). */ const OWNER_STORE = 'owner';
95
+ const OWNER_KEY = 'owner';
96
+ /**
97
+ * Reads the SDK's `owner` row from `databaseName`.
98
+ *
99
+ * Opens only a database that already exists, at whatever version it has (the
100
+ * same rule as `readHeartbeatClientIds`): `open(name)` on a missing database
101
+ * would create it at version 1 under the SDK. The connection serves one
102
+ * read-only request and is closed — also on `versionchange`, so it never
103
+ * holds up the SDK's own upgrade, and also when the read lands after the
104
+ * timeout.
105
+ */ export async function probePrimaryLease(factory, databaseName, { timeoutMs = LEASE_PROBE_TIMEOUT_MS, now = Date.now } = {}) {
106
+ if (!factory || typeof factory.databases !== 'function') return {
107
+ state: 'unknown'
108
+ };
109
+ try {
110
+ const databases = await factory.databases();
111
+ if (!databases.some((database)=>database.name === databaseName)) {
112
+ return {
113
+ state: 'unknown'
114
+ };
115
+ }
116
+ } catch (unused) {
117
+ return {
118
+ state: 'unknown'
119
+ };
120
+ }
121
+ return new Promise((resolve)=>{
122
+ let settled = false;
123
+ let database;
124
+ const settle = (probe)=>{
125
+ if (settled) return;
126
+ settled = true;
127
+ clearTimeout(timer);
128
+ resolve(probe);
129
+ };
130
+ const close = ()=>{
131
+ try {
132
+ database == null ? void 0 : database.close();
133
+ } catch (unused) {
134
+ // Already closed.
135
+ }
136
+ };
137
+ // Only a lock can hold a read-only request this long; the connection is
138
+ // closed when the request finally lands.
139
+ const timer = setTimeout(()=>settle({
140
+ state: 'locked'
141
+ }), timeoutMs);
142
+ let request;
143
+ try {
144
+ request = factory.open(databaseName);
145
+ } catch (unused) {
146
+ settle({
147
+ state: 'unknown'
148
+ });
149
+ return;
150
+ }
151
+ // The database was deleted between `databases()` and `open()`: abort the
152
+ // creation rather than leave the SDK an empty version-1 database.
153
+ request.onupgradeneeded = ()=>{
154
+ var _request_transaction;
155
+ return (_request_transaction = request.transaction) == null ? void 0 : _request_transaction.abort();
156
+ };
157
+ request.onerror = ()=>settle({
158
+ state: 'unknown'
159
+ });
160
+ request.onsuccess = ()=>{
161
+ database = request.result;
162
+ database.onversionchange = close;
163
+ if (settled || !database.objectStoreNames.contains(OWNER_STORE)) {
164
+ close();
165
+ settle({
166
+ state: 'unknown'
167
+ });
168
+ return;
169
+ }
170
+ try {
171
+ const read = database.transaction(OWNER_STORE, 'readonly').objectStore(OWNER_STORE).get(OWNER_KEY);
172
+ read.onsuccess = ()=>{
173
+ close();
174
+ const row = read.result;
175
+ if (!row || typeof row.ownerId !== 'string' || typeof row.leaseTimestampMs !== 'number') {
176
+ settle({
177
+ state: 'vacant'
178
+ });
179
+ return;
180
+ }
181
+ settle({
182
+ state: 'leased',
183
+ ownerId: row.ownerId,
184
+ ageMs: now() - row.leaseTimestampMs
185
+ });
186
+ };
187
+ read.onerror = ()=>{
188
+ close();
189
+ settle({
190
+ state: 'unknown'
191
+ });
192
+ };
193
+ } catch (unused) {
194
+ close();
195
+ settle({
196
+ state: 'unknown'
197
+ });
198
+ }
199
+ };
200
+ });
201
+ }
202
+ /**
203
+ * Whether a lease read taken right after a forced election, from a visible
204
+ * online tab, shows the wedge. Anything that could be a healthy cache —
205
+ * a fresh lease, no lease row, an unreadable one — is not evidence.
206
+ */ export function wedgeEvidenceFrom(probe, staleMs = STALE_PRIMARY_LEASE_MS) {
207
+ if (probe.state === 'locked') return {
208
+ reason: 'lease-locked'
209
+ };
210
+ if (probe.state === 'leased' && probe.ageMs >= staleMs) {
211
+ return {
212
+ reason: 'lease-stale',
213
+ ownerId: probe.ownerId,
214
+ ageMs: probe.ageMs
215
+ };
216
+ }
217
+ return undefined;
218
+ }
219
+ /**
220
+ * This tab's fallback, or `undefined` when there is none, it has expired, or
221
+ * it does not parse.
222
+ */ export function readMemoryCacheFallback(storage, now, ttlMs = MEMORY_CACHE_FALLBACK_TTL_MS) {
223
+ let raw;
224
+ try {
225
+ var _ref;
226
+ raw = (_ref = storage == null ? void 0 : storage.getItem(MEMORY_CACHE_FALLBACK_KEY)) != null ? _ref : null;
227
+ } catch (unused) {
228
+ return undefined;
229
+ }
230
+ if (raw === null) return undefined;
231
+ let parsed;
232
+ try {
233
+ parsed = JSON.parse(raw);
234
+ } catch (unused) {
235
+ return undefined;
236
+ }
237
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
238
+ const record = parsed;
239
+ if (typeof record.at !== 'number' || !Number.isFinite(record.at)) return undefined;
240
+ if (now - record.at >= ttlMs || record.at > now) return undefined;
241
+ return record;
242
+ }
243
+ const browserSessionStorage = ()=>{
244
+ try {
245
+ return typeof window === 'undefined' ? undefined : window.sessionStorage;
246
+ } catch (unused) {
247
+ // Site data blocked: reading `window.sessionStorage` throws.
248
+ return undefined;
249
+ }
250
+ };
251
+ /** Whether this browser tab is running on its memory-cache fallback. */ export function memoryCacheFallbackActive(now = Date.now()) {
252
+ return readMemoryCacheFallback(browserSessionStorage(), now) !== undefined;
253
+ }
254
+ const browserOnline = ()=>typeof navigator === 'undefined' || navigator.onLine !== false;
255
+ const documentVisible = ()=>typeof document === 'undefined' || document.visibilityState !== 'hidden';
256
+ /**
257
+ * The step `createStallRecovery` runs after its network cycle for a
258
+ * multi-tab client. Resolves `true` when it has moved the tab to the fallback.
259
+ * Never rejects.
260
+ */ export function createWedgeEscalation(deps) {
261
+ const { probe, fallBack, isFallbackActive = ()=>memoryCacheFallbackActive(), isOnline = browserOnline, isVisible = documentVisible, wait = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms)), settleMs = LEASE_SETTLE_MS } = deps;
262
+ return async ()=>{
263
+ try {
264
+ if (isFallbackActive()) return false;
265
+ await wait(settleMs);
266
+ // Both guards again: the reader may have gone, or the network with it.
267
+ if (!isOnline() || !isVisible()) return false;
268
+ const evidence = wedgeEvidenceFrom(await probe());
269
+ if (!evidence) return false;
270
+ fallBack(evidence);
271
+ return true;
272
+ } catch (unused) {
273
+ return false;
274
+ }
275
+ };
276
+ }
277
+ /**
278
+ * Records the fallback for this tab and reloads it onto the memory cache.
279
+ * When the record cannot be written it does not reload: without it, the
280
+ * reloaded tab would open the same locked database and hang again.
281
+ */ export function fallBackToMemoryCache(evidence, { storage = browserSessionStorage(), reload = ()=>window.location.reload(), now = Date.now } = {}) {
282
+ if (!storage) return;
283
+ const record = _extends({}, evidence, {
284
+ at: now()
285
+ });
286
+ try {
287
+ storage.setItem(MEMORY_CACHE_FALLBACK_KEY, JSON.stringify(record));
288
+ } catch (unused) {
289
+ return;
290
+ }
291
+ console.warn('[firestore] The multi-tab cache is locked by another tab; reloading this tab on the memory cache (AGL-3428).', record);
292
+ reload();
293
+ }
294
+ /** The IndexedDB database of each client running the durable multi-tab cache. */ const multiTabDatabases = new WeakMap();
295
+ /**
296
+ * Registers `firestore` as running the durable multi-tab cache under
297
+ * `databaseName` (`<persistence prefix>main`). Only a registered client ever
298
+ * escalates past the network cycle.
299
+ */ export function markMultiTabFirestore(firestore, databaseName) {
300
+ if (firestore && typeof firestore === 'object') {
301
+ multiTabDatabases.set(firestore, databaseName);
302
+ }
303
+ }
304
+ /**
305
+ * The browser escalation for `firestore`, or `undefined` when it does not run
306
+ * the durable multi-tab cache (the emulator configuration, an ephemeral
307
+ * origin, a tab already on the fallback).
308
+ */ export function browserWedgeEscalationFor(firestore) {
309
+ const databaseName = multiTabDatabases.get(firestore);
310
+ if (databaseName === undefined) return undefined;
311
+ return createWedgeEscalation({
312
+ probe: ()=>{
313
+ let factory;
314
+ try {
315
+ factory = typeof window === 'undefined' ? undefined : window.indexedDB;
316
+ } catch (unused) {
317
+ factory = undefined;
318
+ }
319
+ return probePrimaryLease(factory, databaseName);
320
+ },
321
+ fallBack: (evidence)=>fallBackToMemoryCache(evidence)
322
+ });
323
+ }
324
+
325
+ //# sourceMappingURL=firestore-multitab-wedge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-multitab-wedge.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type Firestore } from 'firebase/firestore'\n\n/**\n * A MULTI-TAB CACHE THAT ANOTHER TAB HAS LOCKED, AND THE WAY OUT (AGL-3428).\n *\n * ## The wedge the network cycle cannot clear\n *\n * `firestore-stall-recovery.ts` recovers a lapsed primary lease by cycling the\n * network, which runs the lease election at once (AGL-3373). That needs the\n * election's IndexedDB transaction to run. Measured against\n * `@firebase/firestore` 4.17.2: `IndexedDbPersistence.runTransaction` opens\n * EVERY transaction over EVERY object store (`getObjectStores(schemaVersion)`),\n * so one tab that stops partway through a readwrite transaction — frozen or\n * discarded by the browser while it runs — holds every store for every tab on\n * the origin. Behind it, in every other tab:\n *\n * - the lease election waits, so the lease row is never renewed by anyone;\n * - cache reads wait, so pages sit on their spinners;\n * - the mutation-queue write waits, so a save never reaches the network;\n * - a freshly opened tab's persistence `start()` waits, so a new tab hangs too;\n * - `disableNetwork`, `enableNetwork` and `terminate` are queued on the same\n * AsyncQueue behind the stuck operation, so none of them ever runs.\n *\n * On 2026-09-30 production (beta.218) did exactly this three times in two\n * hours: every console tab stopped, and the `owner` row named a background\n * tab that had stopped renewing it.\n *\n * ## How it is told apart from a slow read\n *\n * Only after the network cycle, and only from a visible tab on a browser that\n * reports a network. Such a tab wins a lapsed lease within one 4-second\n * election tick, and the cycle has just forced one. So, after a short settle,\n * the tab reads the `owner` row itself:\n *\n * - **the read cannot complete in time** — the store is locked by another\n * tab's open transaction; or\n * - **the lease is older than {@link STALE_PRIMARY_LEASE_MS}** — no tab,\n * including this one, is completing an election.\n *\n * Either is the wedge. A fresh lease — this tab's or another's — is a healthy\n * cache and a merely slow server, and nothing more happens.\n *\n * ## The way out\n *\n * The tab records a {@link MemoryCacheFallback} in `sessionStorage` and\n * reloads. `firestoreCacheClassFor` reads it at startup and gives this tab the\n * memory cache, which shares nothing with other tabs, so the locked database\n * cannot reach it. `sessionStorage` is per tab: other tabs keep the durable\n * cache, and the fallback leaves when this tab closes or after\n * {@link MEMORY_CACHE_FALLBACK_TTL_MS}.\n *\n * A reload, not an in-place swap, because nothing in place can work: the\n * wedged instance's `terminate()` is queued behind the stuck operation, a\n * second instance for the same app throws until the first is gone, and\n * hundreds of readers hold the instance from context and from closures.\n * Nothing that could be saved is lost — the wedge had already stopped every\n * write in the tab from leaving it.\n */\n\n/** `sessionStorage` key of this tab's memory-cache fallback. */\nexport const MEMORY_CACHE_FALLBACK_KEY = 'aglyn:firestore-memory-cache-fallback'\n\n/**\n * How long a tab keeps the memory cache after falling back. The SDK's own\n * inactivity horizon (`MAX_CLIENT_AGE_MS`), after which a tab that is still\n * stuck no longer counts as a client; past it, a reload tries the durable\n * cache again, and falls back again if it has to.\n */\nexport const MEMORY_CACHE_FALLBACK_TTL_MS = 30 * 60 * 1000\n\n/**\n * A lease older than this, read from a visible online tab right after a\n * forced election, means no election is completing. The SDK treats a lease as\n * lapsed at 5 s (`MAX_PRIMARY_ELIGIBLE_AGE_MS`) and runs the election every\n * 4 s; this is three times the first, so an election that is merely late is\n * never mistaken for one that cannot run.\n */\nexport const STALE_PRIMARY_LEASE_MS = 15_000\n\n/**\n * How long the lease read may take before the store is judged locked. The\n * SDK's own transactions take milliseconds.\n */\nexport const LEASE_PROBE_TIMEOUT_MS = 4_000\n\n/**\n * How long after the network cycle the lease is read, so the election the\n * cycle queued has run.\n */\nexport const LEASE_SETTLE_MS = 3_000\n\n/** The SDK's lease store and its single row's key (`DbPrimaryClientStore`, `DbPrimaryClientKey`). */\nconst OWNER_STORE = 'owner'\nconst OWNER_KEY = 'owner'\n\n/** What reading the lease found. */\nexport type PrimaryLeaseProbe =\n /** The read did not complete in time: another tab's transaction holds the store. */\n | { state: 'locked' }\n /** The lease row: who holds it and how long ago it was renewed. */\n | { state: 'leased'; ownerId: string; ageMs: number }\n /** There is no lease row. */\n | { state: 'vacant' }\n /** The lease could not be read: no IndexedDB, no database, no store, an error. */\n | { state: 'unknown' }\n\n/** Why a tab concluded the multi-tab cache is wedged. */\nexport type WedgeEvidence =\n | { reason: 'lease-locked' }\n | { reason: 'lease-stale'; ownerId: string; ageMs: number }\n\n/** The fallback this tab recorded, and why. */\nexport type MemoryCacheFallback = WedgeEvidence & { at: number }\n\n/**\n * Reads the SDK's `owner` row from `databaseName`.\n *\n * Opens only a database that already exists, at whatever version it has (the\n * same rule as `readHeartbeatClientIds`): `open(name)` on a missing database\n * would create it at version 1 under the SDK. The connection serves one\n * read-only request and is closed — also on `versionchange`, so it never\n * holds up the SDK's own upgrade, and also when the read lands after the\n * timeout.\n */\nexport async function probePrimaryLease(\n factory: IDBFactory | undefined,\n databaseName: string,\n {\n timeoutMs = LEASE_PROBE_TIMEOUT_MS,\n now = Date.now,\n }: { timeoutMs?: number; now?: () => number } = {},\n): Promise<PrimaryLeaseProbe> {\n if (!factory || typeof factory.databases !== 'function') return { state: 'unknown' }\n try {\n const databases = await factory.databases()\n if (!databases.some((database) => database.name === databaseName)) {\n return { state: 'unknown' }\n }\n } catch {\n return { state: 'unknown' }\n }\n\n return new Promise((resolve) => {\n let settled = false\n let database: IDBDatabase | undefined\n const settle = (probe: PrimaryLeaseProbe) => {\n if (settled) return\n settled = true\n clearTimeout(timer)\n resolve(probe)\n }\n const close = () => {\n try {\n database?.close()\n } catch {\n // Already closed.\n }\n }\n // Only a lock can hold a read-only request this long; the connection is\n // closed when the request finally lands.\n const timer = setTimeout(() => settle({ state: 'locked' }), timeoutMs)\n\n let request: IDBOpenDBRequest\n try {\n request = factory.open(databaseName)\n } catch {\n settle({ state: 'unknown' })\n return\n }\n // The database was deleted between `databases()` and `open()`: abort the\n // creation rather than leave the SDK an empty version-1 database.\n request.onupgradeneeded = () => request.transaction?.abort()\n request.onerror = () => settle({ state: 'unknown' })\n request.onsuccess = () => {\n database = request.result\n database.onversionchange = close\n if (settled || !database.objectStoreNames.contains(OWNER_STORE)) {\n close()\n settle({ state: 'unknown' })\n return\n }\n try {\n const read = database\n .transaction(OWNER_STORE, 'readonly')\n .objectStore(OWNER_STORE)\n .get(OWNER_KEY)\n read.onsuccess = () => {\n close()\n const row = read.result as\n | { ownerId?: unknown; leaseTimestampMs?: unknown }\n | undefined\n if (\n !row ||\n typeof row.ownerId !== 'string' ||\n typeof row.leaseTimestampMs !== 'number'\n ) {\n settle({ state: 'vacant' })\n return\n }\n settle({\n state: 'leased',\n ownerId: row.ownerId,\n ageMs: now() - row.leaseTimestampMs,\n })\n }\n read.onerror = () => {\n close()\n settle({ state: 'unknown' })\n }\n } catch {\n close()\n settle({ state: 'unknown' })\n }\n }\n })\n}\n\n/**\n * Whether a lease read taken right after a forced election, from a visible\n * online tab, shows the wedge. Anything that could be a healthy cache —\n * a fresh lease, no lease row, an unreadable one — is not evidence.\n */\nexport function wedgeEvidenceFrom(\n probe: PrimaryLeaseProbe,\n staleMs = STALE_PRIMARY_LEASE_MS,\n): WedgeEvidence | undefined {\n if (probe.state === 'locked') return { reason: 'lease-locked' }\n if (probe.state === 'leased' && probe.ageMs >= staleMs) {\n return { reason: 'lease-stale', ownerId: probe.ownerId, ageMs: probe.ageMs }\n }\n return undefined\n}\n\n/**\n * This tab's fallback, or `undefined` when there is none, it has expired, or\n * it does not parse.\n */\nexport function readMemoryCacheFallback(\n storage: Pick<Storage, 'getItem'> | undefined,\n now: number,\n ttlMs = MEMORY_CACHE_FALLBACK_TTL_MS,\n): MemoryCacheFallback | undefined {\n let raw: string | null\n try {\n raw = storage?.getItem(MEMORY_CACHE_FALLBACK_KEY) ?? null\n } catch {\n return undefined\n }\n if (raw === null) return undefined\n let parsed: unknown\n try {\n parsed = JSON.parse(raw)\n } catch {\n return undefined\n }\n if (typeof parsed !== 'object' || parsed === null) return undefined\n const record = parsed as Partial<MemoryCacheFallback>\n if (typeof record.at !== 'number' || !Number.isFinite(record.at)) return undefined\n if (now - record.at >= ttlMs || record.at > now) return undefined\n return record as MemoryCacheFallback\n}\n\nconst browserSessionStorage = (): Storage | undefined => {\n try {\n return typeof window === 'undefined' ? undefined : window.sessionStorage\n } catch {\n // Site data blocked: reading `window.sessionStorage` throws.\n return undefined\n }\n}\n\n/** Whether this browser tab is running on its memory-cache fallback. */\nexport function memoryCacheFallbackActive(now = Date.now()): boolean {\n return readMemoryCacheFallback(browserSessionStorage(), now) !== undefined\n}\n\nexport interface WedgeEscalationDeps {\n /** Reads the lease. */\n probe: () => Promise<PrimaryLeaseProbe>\n /** Moves this tab off the shared cache. */\n fallBack: (evidence: WedgeEvidence) => void\n /** Whether this tab is already on the fallback — it then never escalates. */\n isFallbackActive?: () => boolean\n isOnline?: () => boolean\n isVisible?: () => boolean\n /** Waits `ms`; the settle before the lease read. */\n wait?: (ms: number) => Promise<void>\n settleMs?: number\n}\n\nconst browserOnline = () =>\n typeof navigator === 'undefined' || navigator.onLine !== false\n\nconst documentVisible = () =>\n typeof document === 'undefined' || document.visibilityState !== 'hidden'\n\n/**\n * The step `createStallRecovery` runs after its network cycle for a\n * multi-tab client. Resolves `true` when it has moved the tab to the fallback.\n * Never rejects.\n */\nexport function createWedgeEscalation(\n deps: WedgeEscalationDeps,\n): () => Promise<boolean> {\n const {\n probe,\n fallBack,\n isFallbackActive = () => memoryCacheFallbackActive(),\n isOnline = browserOnline,\n isVisible = documentVisible,\n wait = (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),\n settleMs = LEASE_SETTLE_MS,\n } = deps\n return async () => {\n try {\n if (isFallbackActive()) return false\n await wait(settleMs)\n // Both guards again: the reader may have gone, or the network with it.\n if (!isOnline() || !isVisible()) return false\n const evidence = wedgeEvidenceFrom(await probe())\n if (!evidence) return false\n fallBack(evidence)\n return true\n } catch {\n return false\n }\n }\n}\n\n/**\n * Records the fallback for this tab and reloads it onto the memory cache.\n * When the record cannot be written it does not reload: without it, the\n * reloaded tab would open the same locked database and hang again.\n */\nexport function fallBackToMemoryCache(\n evidence: WedgeEvidence,\n {\n storage = browserSessionStorage(),\n reload = () => window.location.reload(),\n now = Date.now,\n }: {\n storage?: Pick<Storage, 'setItem'>\n reload?: () => void\n now?: () => number\n } = {},\n): void {\n if (!storage) return\n const record: MemoryCacheFallback = { ...evidence, at: now() }\n try {\n storage.setItem(MEMORY_CACHE_FALLBACK_KEY, JSON.stringify(record))\n } catch {\n return\n }\n console.warn(\n '[firestore] The multi-tab cache is locked by another tab; reloading this tab on the memory cache (AGL-3428).',\n record,\n )\n reload()\n}\n\n/** The IndexedDB database of each client running the durable multi-tab cache. */\nconst multiTabDatabases = new WeakMap<Firestore, string>()\n\n/**\n * Registers `firestore` as running the durable multi-tab cache under\n * `databaseName` (`<persistence prefix>main`). Only a registered client ever\n * escalates past the network cycle.\n */\nexport function markMultiTabFirestore(firestore: Firestore, databaseName: string): void {\n if (firestore && typeof firestore === 'object') {\n multiTabDatabases.set(firestore, databaseName)\n }\n}\n\n/**\n * The browser escalation for `firestore`, or `undefined` when it does not run\n * the durable multi-tab cache (the emulator configuration, an ephemeral\n * origin, a tab already on the fallback).\n */\nexport function browserWedgeEscalationFor(\n firestore: Firestore,\n): (() => Promise<boolean>) | undefined {\n const databaseName = multiTabDatabases.get(firestore)\n if (databaseName === undefined) return undefined\n return createWedgeEscalation({\n probe: () => {\n let factory: IDBFactory | undefined\n try {\n factory = typeof window === 'undefined' ? undefined : window.indexedDB\n } catch {\n factory = undefined\n }\n return probePrimaryLease(factory, databaseName)\n },\n fallBack: (evidence) => fallBackToMemoryCache(evidence),\n })\n}\n"],"names":["MEMORY_CACHE_FALLBACK_KEY","MEMORY_CACHE_FALLBACK_TTL_MS","STALE_PRIMARY_LEASE_MS","LEASE_PROBE_TIMEOUT_MS","LEASE_SETTLE_MS","OWNER_STORE","OWNER_KEY","probePrimaryLease","factory","databaseName","timeoutMs","now","Date","databases","state","some","database","name","Promise","resolve","settled","settle","probe","clearTimeout","timer","close","setTimeout","request","open","onupgradeneeded","transaction","abort","onerror","onsuccess","result","onversionchange","objectStoreNames","contains","read","objectStore","get","row","ownerId","leaseTimestampMs","ageMs","wedgeEvidenceFrom","staleMs","reason","undefined","readMemoryCacheFallback","storage","ttlMs","raw","getItem","parsed","JSON","parse","record","at","Number","isFinite","browserSessionStorage","window","sessionStorage","memoryCacheFallbackActive","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createWedgeEscalation","deps","fallBack","isFallbackActive","isOnline","isVisible","wait","ms","settleMs","evidence","fallBackToMemoryCache","reload","location","setItem","stringify","console","warn","multiTabDatabases","WeakMap","markMultiTabFirestore","firestore","set","browserWedgeEscalationFor","indexedDB"],"mappings":"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"}
@@ -83,6 +83,19 @@ import { type Firestore } from 'firebase/firestore';
83
83
  * a page stalls together, and one cycle serves them all. A concurrent
84
84
  * request shares the cycle already running.
85
85
  * - **The network is always re-enabled**, including when disabling fails.
86
+ * - **Each step is bounded by {@link NETWORK_STEP_TIMEOUT_MS}.** Both calls are
87
+ * queued on the client's AsyncQueue, and when another tab has locked the
88
+ * shared IndexedDB they queue behind an operation that never finishes
89
+ * (AGL-3428). Unbounded, the first such cycle never settled, and every
90
+ * later recovery request shared it forever.
91
+ *
92
+ * ## When the cycle is not enough (AGL-3428)
93
+ *
94
+ * A tab frozen partway through an IndexedDB transaction holds every store the
95
+ * SDK uses, and the election the cycle forces cannot run. For a client on the
96
+ * durable multi-tab cache the cycle is followed by the lease check in
97
+ * `firestore-multitab-wedge.ts`, which moves the tab to a memory cache when
98
+ * the lease shows the wedge.
86
99
  */
87
100
  /**
88
101
  * How long a server read or a listener's first server-confirmed snapshot may
@@ -93,6 +106,11 @@ import { type Firestore } from 'firebase/firestore';
93
106
  export declare const FIRESTORE_STALL_MS = 8000;
94
107
  /** The least time between two recoveries of the same client. */
95
108
  export declare const STALL_RECOVERY_COOLDOWN_MS = 30000;
109
+ /**
110
+ * The longest `disableNetwork` or `enableNetwork` may take. Both finish in
111
+ * milliseconds on a client whose queue is moving.
112
+ */
113
+ export declare const NETWORK_STEP_TIMEOUT_MS = 5000;
96
114
  /** What a recovery request did. */
97
115
  export type StallRecoveryOutcome =
98
116
  /** The browser reports no network: the cache is the right answer. */
@@ -103,8 +121,10 @@ export type StallRecoveryOutcome =
103
121
  | 'cooling'
104
122
  /** The network was cycled. */
105
123
  | 'recovered'
106
- /** The cycle threw; the network was still re-enabled if at all possible. */
107
- | 'failed';
124
+ /** The cycle threw or timed out; the network was still re-enabled if at all possible. */
125
+ | 'failed'
126
+ /** The multi-tab cache was wedged and this tab is moving to the memory cache. */
127
+ | 'fell-back';
108
128
  export interface StallRecoveryDeps {
109
129
  /** Take the client off the network. */
110
130
  disable: () => Promise<void>;
@@ -116,6 +136,13 @@ export interface StallRecoveryDeps {
116
136
  isVisible?: () => boolean;
117
137
  now?: () => number;
118
138
  cooldownMs?: number;
139
+ /** The bound on each of `disable` and `enable`. */
140
+ stepTimeoutMs?: number;
141
+ /**
142
+ * Runs after every cycle; resolves `true` when it has moved the tab off
143
+ * the shared cache. Only a multi-tab client has one.
144
+ */
145
+ afterCycle?: () => Promise<boolean>;
119
146
  }
120
147
  export interface StallRecovery {
121
148
  recover: () => Promise<StallRecoveryOutcome>;
@@ -14,6 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { disableNetwork, enableNetwork } from "firebase/firestore";
17
+ import { browserWedgeEscalationFor } from "./firestore-multitab-wedge.js";
17
18
  /**
18
19
  * RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).
19
20
  *
@@ -82,6 +83,19 @@
82
83
  * a page stalls together, and one cycle serves them all. A concurrent
83
84
  * request shares the cycle already running.
84
85
  * - **The network is always re-enabled**, including when disabling fails.
86
+ * - **Each step is bounded by {@link NETWORK_STEP_TIMEOUT_MS}.** Both calls are
87
+ * queued on the client's AsyncQueue, and when another tab has locked the
88
+ * shared IndexedDB they queue behind an operation that never finishes
89
+ * (AGL-3428). Unbounded, the first such cycle never settled, and every
90
+ * later recovery request shared it forever.
91
+ *
92
+ * ## When the cycle is not enough (AGL-3428)
93
+ *
94
+ * A tab frozen partway through an IndexedDB transaction holds every store the
95
+ * SDK uses, and the election the cycle forces cannot run. For a client on the
96
+ * durable multi-tab cache the cycle is followed by the lease check in
97
+ * `firestore-multitab-wedge.ts`, which moves the tab to a memory cache when
98
+ * the lease shows the wedge.
85
99
  */ /**
86
100
  * How long a server read or a listener's first server-confirmed snapshot may
87
101
  * take before the client is treated as stalled. A healthy console answers
@@ -89,23 +103,38 @@
89
103
  * leaving a reader on a spinner for long.
90
104
  */ export const FIRESTORE_STALL_MS = 8000;
91
105
  /** The least time between two recoveries of the same client. */ export const STALL_RECOVERY_COOLDOWN_MS = 30000;
106
+ /**
107
+ * The longest `disableNetwork` or `enableNetwork` may take. Both finish in
108
+ * milliseconds on a client whose queue is moving.
109
+ */ export const NETWORK_STEP_TIMEOUT_MS = 5000;
92
110
  const browserOnline = ()=>typeof navigator === 'undefined' || navigator.onLine !== false;
93
111
  const documentVisible = ()=>typeof document === 'undefined' || document.visibilityState !== 'hidden';
94
112
  /** The recovery for one client, with its guards. Pure over `deps`. */ export function createStallRecovery(deps) {
95
- const { disable, enable, isOnline = browserOnline, isVisible = documentVisible, now = Date.now, cooldownMs = STALL_RECOVERY_COOLDOWN_MS } = deps;
113
+ const { disable, enable, isOnline = browserOnline, isVisible = documentVisible, now = Date.now, cooldownMs = STALL_RECOVERY_COOLDOWN_MS, stepTimeoutMs = NETWORK_STEP_TIMEOUT_MS, afterCycle } = deps;
96
114
  let lastStartedAt;
97
115
  let running;
116
+ /** Whether `step` settled successfully within `stepTimeoutMs`. */ const bounded = (step)=>new Promise((resolve)=>{
117
+ const timer = setTimeout(()=>resolve(false), stepTimeoutMs);
118
+ const done = (ok)=>{
119
+ clearTimeout(timer);
120
+ resolve(ok);
121
+ };
122
+ try {
123
+ step().then(()=>done(true), ()=>done(false));
124
+ } catch (unused) {
125
+ done(false);
126
+ }
127
+ });
98
128
  const cycle = async ()=>{
99
129
  let outcome = 'recovered';
100
- try {
101
- await disable();
102
- } catch (unused) {
103
- outcome = 'failed';
104
- }
105
- try {
106
- await enable();
107
- } catch (unused) {
108
- outcome = 'failed';
130
+ if (!await bounded(disable)) outcome = 'failed';
131
+ if (!await bounded(enable)) outcome = 'failed';
132
+ if (afterCycle) {
133
+ try {
134
+ if (await afterCycle()) return 'fell-back';
135
+ } catch (unused) {
136
+ // The escalation is best effort; the cycle's own outcome stands.
137
+ }
109
138
  }
110
139
  return outcome;
111
140
  };
@@ -139,7 +168,8 @@ const recoveries = new WeakMap();
139
168
  if (!recovery) {
140
169
  recovery = createStallRecovery({
141
170
  disable: ()=>disableNetwork(firestore),
142
- enable: ()=>enableNetwork(firestore)
171
+ enable: ()=>enableNetwork(firestore),
172
+ afterCycle: browserWedgeEscalationFor(firestore)
143
173
  });
144
174
  recoveries.set(firestore, recovery);
145
175
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-stall-recovery.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n disableNetwork,\n enableNetwork,\n type Firestore,\n} from 'firebase/firestore'\n\n/**\n * RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).\n *\n * ## The wedge\n *\n * The console runs the persistent cache with `persistentMultipleTabManager`\n * (`firestore-cache.ts`). Only the PRIMARY tab talks to the backend; every\n * other tab reads the shared IndexedDB cache and takes its online state from\n * a `localStorage` record the primary writes. A secondary that believes the\n * client is online answers a `getDocs` only once the primary has synced the\n * query, and raises no first snapshot for a listener whose cache is empty.\n *\n * When the primary dies without handing over — a crashed, discarded or\n * frozen tab — its \"Online\" record outlives it, and until some tab runs the\n * lease election and wins, nothing syncs. The election runs on a 4-second\n * timer inside each tab, which the browser throttles to about once a minute\n * in a hidden tab. On 2026-09-28 no live tab synced for about ten minutes:\n * the notifications page never left its loading state and a staff page sat\n * on its splash until a fresh tab (which wins the election on start) was\n * opened.\n *\n * ## The recovery, and why it is this one\n *\n * `disableNetwork` then `enableNetwork`, from the tab that noticed. Both are\n * public API, and in the multi-tab client each one calls\n * `setNetworkEnabled`, which schedules the lease election for IMMEDIATE\n * execution instead of the next (possibly throttled) timer tick. A visible\n * tab that finds the lease lapsed takes it, starts its own streams and\n * writes its own online state, which replaces the dead tab's. A tab that was\n * already primary with a silently dead stream gets fresh streams.\n *\n * Nothing is lost. Writes made meanwhile sit in the mutation queue (in\n * IndexedDB, under the persistent cache) and are sent when the network comes\n * back; the SDK's own contract for `disableNetwork` is that writes queue.\n *\n * The rejected alternatives:\n *\n * - **Clearing the persistence cache** (`terminate` + `clearIndexedDbPersistence`).\n * It deletes the mutation queue, so pending writes are lost, and\n * `waitForPendingWrites` cannot drain them first because draining is the\n * very thing that is stuck. It also refuses to run while any other tab\n * holds the database, which in a multi-tab wedge is always. And\n * `terminate` invalidates the one `Firestore` instance every hook in the\n * console holds.\n * - **Pruning the dead tab's online-state record at startup**, beside the\n * AGL-2845 prune. The SDK already ignores an online-state record at\n * startup unless its writer is an active, non-zombie client\n * (`WebStorageSharedClientState.handleOnlineStateEvent`), and a freshly\n * started tab wins the election anyway — which is why a fresh tab fixed\n * the incident. The tabs that stayed stuck were the ones ALREADY running,\n * which a startup prune never reaches.\n * - **`persistentSingleTabManager`.** It removes cross-tab dependency, but\n * every tab after the first falls back to a memory cache, so writes made\n * offline in those tabs are lost when they close. And a tab still on the\n * multi-tab build that meets a single-tab lease holder fails its queue\n * with `failed-precondition` until reloaded, so the rollout itself would\n * wedge the tabs left open across it.\n *\n * ## Guards\n *\n * - **Online only.** A browser that reports no network is correctly offline,\n * and the cache answering is the cache doing its job.\n * - **Visible only.** A hidden tab's timers are throttled and its reader is\n * not waiting; if it is stalled, it recovers the moment it is looked at.\n * - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on\n * a page stalls together, and one cycle serves them all. A concurrent\n * request shares the cycle already running.\n * - **The network is always re-enabled**, including when disabling fails.\n */\n\n/**\n * How long a server read or a listener's first server-confirmed snapshot may\n * take before the client is treated as stalled. A healthy console answers\n * in well under two seconds; this leaves room for a slow network without\n * leaving a reader on a spinner for long.\n */\nexport const FIRESTORE_STALL_MS = 8_000\n\n/** The least time between two recoveries of the same client. */\nexport const STALL_RECOVERY_COOLDOWN_MS = 30_000\n\n/** What a recovery request did. */\nexport type StallRecoveryOutcome =\n /** The browser reports no network: the cache is the right answer. */\n | 'offline'\n /** The tab is hidden: nobody is waiting on it. */\n | 'hidden'\n /** A recovery ran moments ago; this request rode on it. */\n | 'cooling'\n /** The network was cycled. */\n | 'recovered'\n /** The cycle threw; the network was still re-enabled if at all possible. */\n | 'failed'\n\nexport interface StallRecoveryDeps {\n /** Take the client off the network. */\n disable: () => Promise<void>\n /** Put it back. */\n enable: () => Promise<void>\n /** `navigator.onLine`, or `true` where there is no navigator. */\n isOnline?: () => boolean\n /** Whether the document is visible, or `true` where there is none. */\n isVisible?: () => boolean\n now?: () => number\n cooldownMs?: number\n}\n\nexport interface StallRecovery {\n recover: () => Promise<StallRecoveryOutcome>\n}\n\nconst browserOnline = () =>\n typeof navigator === 'undefined' || navigator.onLine !== false\n\nconst documentVisible = () =>\n typeof document === 'undefined' || document.visibilityState !== 'hidden'\n\n/** The recovery for one client, with its guards. Pure over `deps`. */\nexport function createStallRecovery(deps: StallRecoveryDeps): StallRecovery {\n const {\n disable,\n enable,\n isOnline = browserOnline,\n isVisible = documentVisible,\n now = Date.now,\n cooldownMs = STALL_RECOVERY_COOLDOWN_MS,\n } = deps\n let lastStartedAt: number | undefined\n let running: Promise<StallRecoveryOutcome> | undefined\n\n const cycle = async (): Promise<StallRecoveryOutcome> => {\n let outcome: StallRecoveryOutcome = 'recovered'\n try {\n await disable()\n } catch {\n outcome = 'failed'\n }\n try {\n await enable()\n } catch {\n outcome = 'failed'\n }\n return outcome\n }\n\n return {\n recover: () => {\n if (running) return running\n if (!isOnline()) return Promise.resolve('offline')\n if (!isVisible()) return Promise.resolve('hidden')\n const at = now()\n if (lastStartedAt !== undefined && at - lastStartedAt < cooldownMs) {\n return Promise.resolve('cooling')\n }\n lastStartedAt = at\n running = cycle().finally(() => {\n running = undefined\n })\n return running\n },\n }\n}\n\nconst recoveries = new WeakMap<Firestore, StallRecovery>()\n\n/**\n * Recovers `firestore` if it has stalled. Safe to call from every reader\n * that notices: the guards above collapse the calls into at most one network\n * cycle per cooldown. Never rejects.\n */\nexport function recoverStalledFirestore(\n firestore: Firestore | null | undefined,\n): Promise<StallRecoveryOutcome> {\n if (!firestore || typeof firestore !== 'object') {\n return Promise.resolve('failed')\n }\n let recovery = recoveries.get(firestore)\n if (!recovery) {\n recovery = createStallRecovery({\n disable: () => disableNetwork(firestore),\n enable: () => enableNetwork(firestore),\n })\n recoveries.set(firestore, recovery)\n }\n return recovery.recover().catch(() => 'failed' as const)\n}\n\n/**\n * Arms a stall timer for one read or listen. If `disarm` has not been called\n * within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).\n * A tab that is hidden when the timer fires waits until it is shown, since\n * that is when a reader is waiting again.\n *\n * Returns `disarm`, which is idempotent.\n */\nexport function armFirestoreStallWatch(\n firestore: Firestore | null | undefined,\n options: { stallMs?: number; onStall?: () => void } = {},\n): () => void {\n const { stallMs = FIRESTORE_STALL_MS } = options\n const onStall =\n options.onStall ?? (() => void recoverStalledFirestore(firestore))\n let done = false\n let onVisible: (() => void) | undefined\n const fire = () => {\n if (done) return\n if (!documentVisible() && typeof document !== 'undefined') {\n onVisible = () => {\n if (!documentVisible()) return\n document.removeEventListener('visibilitychange', onVisible!)\n onVisible = undefined\n fire()\n }\n document.addEventListener('visibilitychange', onVisible)\n return\n }\n done = true\n onStall()\n }\n const timer = setTimeout(fire, stallMs)\n return () => {\n if (done) return\n done = true\n clearTimeout(timer)\n if (onVisible && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', onVisible)\n }\n }\n}\n"],"names":["disableNetwork","enableNetwork","FIRESTORE_STALL_MS","STALL_RECOVERY_COOLDOWN_MS","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createStallRecovery","deps","disable","enable","isOnline","isVisible","now","Date","cooldownMs","lastStartedAt","running","cycle","outcome","recover","Promise","resolve","at","undefined","finally","recoveries","WeakMap","recoverStalledFirestore","firestore","recovery","get","set","catch","armFirestoreStallWatch","options","stallMs","onStall","done","onVisible","fire","removeEventListener","addEventListener","timer","setTimeout","clearTimeout"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,cAAc,EACdC,aAAa,QAER,qBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoEC,GAED;;;;;CAKC,GACD,OAAO,MAAMC,qBAAqB,KAAK;AAEvC,8DAA8D,GAC9D,OAAO,MAAMC,6BAA6B,MAAM;AAgChD,MAAMC,gBAAgB,IACpB,OAAOC,cAAc,eAAeA,UAAUC,MAAM,KAAK;AAE3D,MAAMC,kBAAkB,IACtB,OAAOC,aAAa,eAAeA,SAASC,eAAe,KAAK;AAElE,oEAAoE,GACpE,OAAO,SAASC,oBAAoBC,IAAuB;IACzD,MAAM,EACJC,OAAO,EACPC,MAAM,EACNC,WAAWV,aAAa,EACxBW,YAAYR,eAAe,EAC3BS,MAAMC,KAAKD,GAAG,EACdE,aAAaf,0BAA0B,EACxC,GAAGQ;IACJ,IAAIQ;IACJ,IAAIC;IAEJ,MAAMC,QAAQ;QACZ,IAAIC,UAAgC;QACpC,IAAI;YACF,MAAMV;QACR,EAAE,eAAM;YACNU,UAAU;QACZ;QACA,IAAI;YACF,MAAMT;QACR,EAAE,eAAM;YACNS,UAAU;QACZ;QACA,OAAOA;IACT;IAEA,OAAO;QACLC,SAAS;YACP,IAAIH,SAAS,OAAOA;YACpB,IAAI,CAACN,YAAY,OAAOU,QAAQC,OAAO,CAAC;YACxC,IAAI,CAACV,aAAa,OAAOS,QAAQC,OAAO,CAAC;YACzC,MAAMC,KAAKV;YACX,IAAIG,kBAAkBQ,aAAaD,KAAKP,gBAAgBD,YAAY;gBAClE,OAAOM,QAAQC,OAAO,CAAC;YACzB;YACAN,gBAAgBO;YAChBN,UAAUC,QAAQO,OAAO,CAAC;gBACxBR,UAAUO;YACZ;YACA,OAAOP;QACT;IACF;AACF;AAEA,MAAMS,aAAa,IAAIC;AAEvB;;;;CAIC,GACD,OAAO,SAASC,wBACdC,SAAuC;IAEvC,IAAI,CAACA,aAAa,OAAOA,cAAc,UAAU;QAC/C,OAAOR,QAAQC,OAAO,CAAC;IACzB;IACA,IAAIQ,WAAWJ,WAAWK,GAAG,CAACF;IAC9B,IAAI,CAACC,UAAU;QACbA,WAAWvB,oBAAoB;YAC7BE,SAAS,IAAMZ,eAAegC;YAC9BnB,QAAQ,IAAMZ,cAAc+B;QAC9B;QACAH,WAAWM,GAAG,CAACH,WAAWC;IAC5B;IACA,OAAOA,SAASV,OAAO,GAAGa,KAAK,CAAC,IAAM;AACxC;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,uBACdL,SAAuC,EACvCM,UAAsD,CAAC,CAAC;QAItDA;IAFF,MAAM,EAAEC,UAAUrC,kBAAkB,EAAE,GAAGoC;IACzC,MAAME,WACJF,mBAAAA,QAAQE,OAAO,YAAfF,mBAAoB,IAAM,KAAKP,wBAAwBC;IACzD,IAAIS,OAAO;IACX,IAAIC;IACJ,MAAMC,OAAO;QACX,IAAIF,MAAM;QACV,IAAI,CAAClC,qBAAqB,OAAOC,aAAa,aAAa;YACzDkC,YAAY;gBACV,IAAI,CAACnC,mBAAmB;gBACxBC,SAASoC,mBAAmB,CAAC,oBAAoBF;gBACjDA,YAAYf;gBACZgB;YACF;YACAnC,SAASqC,gBAAgB,CAAC,oBAAoBH;YAC9C;QACF;QACAD,OAAO;QACPD;IACF;IACA,MAAMM,QAAQC,WAAWJ,MAAMJ;IAC/B,OAAO;QACL,IAAIE,MAAM;QACVA,OAAO;QACPO,aAAaF;QACb,IAAIJ,aAAa,OAAOlC,aAAa,aAAa;YAChDA,SAASoC,mBAAmB,CAAC,oBAAoBF;QACnD;IACF;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-stall-recovery.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n disableNetwork,\n enableNetwork,\n type Firestore,\n} from 'firebase/firestore'\n\nimport { browserWedgeEscalationFor } from './firestore-multitab-wedge'\n\n/**\n * RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).\n *\n * ## The wedge\n *\n * The console runs the persistent cache with `persistentMultipleTabManager`\n * (`firestore-cache.ts`). Only the PRIMARY tab talks to the backend; every\n * other tab reads the shared IndexedDB cache and takes its online state from\n * a `localStorage` record the primary writes. A secondary that believes the\n * client is online answers a `getDocs` only once the primary has synced the\n * query, and raises no first snapshot for a listener whose cache is empty.\n *\n * When the primary dies without handing over — a crashed, discarded or\n * frozen tab — its \"Online\" record outlives it, and until some tab runs the\n * lease election and wins, nothing syncs. The election runs on a 4-second\n * timer inside each tab, which the browser throttles to about once a minute\n * in a hidden tab. On 2026-09-28 no live tab synced for about ten minutes:\n * the notifications page never left its loading state and a staff page sat\n * on its splash until a fresh tab (which wins the election on start) was\n * opened.\n *\n * ## The recovery, and why it is this one\n *\n * `disableNetwork` then `enableNetwork`, from the tab that noticed. Both are\n * public API, and in the multi-tab client each one calls\n * `setNetworkEnabled`, which schedules the lease election for IMMEDIATE\n * execution instead of the next (possibly throttled) timer tick. A visible\n * tab that finds the lease lapsed takes it, starts its own streams and\n * writes its own online state, which replaces the dead tab's. A tab that was\n * already primary with a silently dead stream gets fresh streams.\n *\n * Nothing is lost. Writes made meanwhile sit in the mutation queue (in\n * IndexedDB, under the persistent cache) and are sent when the network comes\n * back; the SDK's own contract for `disableNetwork` is that writes queue.\n *\n * The rejected alternatives:\n *\n * - **Clearing the persistence cache** (`terminate` + `clearIndexedDbPersistence`).\n * It deletes the mutation queue, so pending writes are lost, and\n * `waitForPendingWrites` cannot drain them first because draining is the\n * very thing that is stuck. It also refuses to run while any other tab\n * holds the database, which in a multi-tab wedge is always. And\n * `terminate` invalidates the one `Firestore` instance every hook in the\n * console holds.\n * - **Pruning the dead tab's online-state record at startup**, beside the\n * AGL-2845 prune. The SDK already ignores an online-state record at\n * startup unless its writer is an active, non-zombie client\n * (`WebStorageSharedClientState.handleOnlineStateEvent`), and a freshly\n * started tab wins the election anyway — which is why a fresh tab fixed\n * the incident. The tabs that stayed stuck were the ones ALREADY running,\n * which a startup prune never reaches.\n * - **`persistentSingleTabManager`.** It removes cross-tab dependency, but\n * every tab after the first falls back to a memory cache, so writes made\n * offline in those tabs are lost when they close. And a tab still on the\n * multi-tab build that meets a single-tab lease holder fails its queue\n * with `failed-precondition` until reloaded, so the rollout itself would\n * wedge the tabs left open across it.\n *\n * ## Guards\n *\n * - **Online only.** A browser that reports no network is correctly offline,\n * and the cache answering is the cache doing its job.\n * - **Visible only.** A hidden tab's timers are throttled and its reader is\n * not waiting; if it is stalled, it recovers the moment it is looked at.\n * - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on\n * a page stalls together, and one cycle serves them all. A concurrent\n * request shares the cycle already running.\n * - **The network is always re-enabled**, including when disabling fails.\n * - **Each step is bounded by {@link NETWORK_STEP_TIMEOUT_MS}.** Both calls are\n * queued on the client's AsyncQueue, and when another tab has locked the\n * shared IndexedDB they queue behind an operation that never finishes\n * (AGL-3428). Unbounded, the first such cycle never settled, and every\n * later recovery request shared it forever.\n *\n * ## When the cycle is not enough (AGL-3428)\n *\n * A tab frozen partway through an IndexedDB transaction holds every store the\n * SDK uses, and the election the cycle forces cannot run. For a client on the\n * durable multi-tab cache the cycle is followed by the lease check in\n * `firestore-multitab-wedge.ts`, which moves the tab to a memory cache when\n * the lease shows the wedge.\n */\n\n/**\n * How long a server read or a listener's first server-confirmed snapshot may\n * take before the client is treated as stalled. A healthy console answers\n * in well under two seconds; this leaves room for a slow network without\n * leaving a reader on a spinner for long.\n */\nexport const FIRESTORE_STALL_MS = 8_000\n\n/** The least time between two recoveries of the same client. */\nexport const STALL_RECOVERY_COOLDOWN_MS = 30_000\n\n/**\n * The longest `disableNetwork` or `enableNetwork` may take. Both finish in\n * milliseconds on a client whose queue is moving.\n */\nexport const NETWORK_STEP_TIMEOUT_MS = 5_000\n\n/** What a recovery request did. */\nexport type StallRecoveryOutcome =\n /** The browser reports no network: the cache is the right answer. */\n | 'offline'\n /** The tab is hidden: nobody is waiting on it. */\n | 'hidden'\n /** A recovery ran moments ago; this request rode on it. */\n | 'cooling'\n /** The network was cycled. */\n | 'recovered'\n /** The cycle threw or timed out; the network was still re-enabled if at all possible. */\n | 'failed'\n /** The multi-tab cache was wedged and this tab is moving to the memory cache. */\n | 'fell-back'\n\nexport interface StallRecoveryDeps {\n /** Take the client off the network. */\n disable: () => Promise<void>\n /** Put it back. */\n enable: () => Promise<void>\n /** `navigator.onLine`, or `true` where there is no navigator. */\n isOnline?: () => boolean\n /** Whether the document is visible, or `true` where there is none. */\n isVisible?: () => boolean\n now?: () => number\n cooldownMs?: number\n /** The bound on each of `disable` and `enable`. */\n stepTimeoutMs?: number\n /**\n * Runs after every cycle; resolves `true` when it has moved the tab off\n * the shared cache. Only a multi-tab client has one.\n */\n afterCycle?: () => Promise<boolean>\n}\n\nexport interface StallRecovery {\n recover: () => Promise<StallRecoveryOutcome>\n}\n\nconst browserOnline = () =>\n typeof navigator === 'undefined' || navigator.onLine !== false\n\nconst documentVisible = () =>\n typeof document === 'undefined' || document.visibilityState !== 'hidden'\n\n/** The recovery for one client, with its guards. Pure over `deps`. */\nexport function createStallRecovery(deps: StallRecoveryDeps): StallRecovery {\n const {\n disable,\n enable,\n isOnline = browserOnline,\n isVisible = documentVisible,\n now = Date.now,\n cooldownMs = STALL_RECOVERY_COOLDOWN_MS,\n stepTimeoutMs = NETWORK_STEP_TIMEOUT_MS,\n afterCycle,\n } = deps\n let lastStartedAt: number | undefined\n let running: Promise<StallRecoveryOutcome> | undefined\n\n /** Whether `step` settled successfully within `stepTimeoutMs`. */\n const bounded = (step: () => Promise<void>): Promise<boolean> =>\n new Promise((resolve) => {\n const timer = setTimeout(() => resolve(false), stepTimeoutMs)\n const done = (ok: boolean) => {\n clearTimeout(timer)\n resolve(ok)\n }\n try {\n step().then(\n () => done(true),\n () => done(false),\n )\n } catch {\n done(false)\n }\n })\n\n const cycle = async (): Promise<StallRecoveryOutcome> => {\n let outcome: StallRecoveryOutcome = 'recovered'\n if (!(await bounded(disable))) outcome = 'failed'\n if (!(await bounded(enable))) outcome = 'failed'\n if (afterCycle) {\n try {\n if (await afterCycle()) return 'fell-back'\n } catch {\n // The escalation is best effort; the cycle's own outcome stands.\n }\n }\n return outcome\n }\n\n return {\n recover: () => {\n if (running) return running\n if (!isOnline()) return Promise.resolve('offline')\n if (!isVisible()) return Promise.resolve('hidden')\n const at = now()\n if (lastStartedAt !== undefined && at - lastStartedAt < cooldownMs) {\n return Promise.resolve('cooling')\n }\n lastStartedAt = at\n running = cycle().finally(() => {\n running = undefined\n })\n return running\n },\n }\n}\n\nconst recoveries = new WeakMap<Firestore, StallRecovery>()\n\n/**\n * Recovers `firestore` if it has stalled. Safe to call from every reader\n * that notices: the guards above collapse the calls into at most one network\n * cycle per cooldown. Never rejects.\n */\nexport function recoverStalledFirestore(\n firestore: Firestore | null | undefined,\n): Promise<StallRecoveryOutcome> {\n if (!firestore || typeof firestore !== 'object') {\n return Promise.resolve('failed')\n }\n let recovery = recoveries.get(firestore)\n if (!recovery) {\n recovery = createStallRecovery({\n disable: () => disableNetwork(firestore),\n enable: () => enableNetwork(firestore),\n afterCycle: browserWedgeEscalationFor(firestore),\n })\n recoveries.set(firestore, recovery)\n }\n return recovery.recover().catch(() => 'failed' as const)\n}\n\n/**\n * Arms a stall timer for one read or listen. If `disarm` has not been called\n * within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).\n * A tab that is hidden when the timer fires waits until it is shown, since\n * that is when a reader is waiting again.\n *\n * Returns `disarm`, which is idempotent.\n */\nexport function armFirestoreStallWatch(\n firestore: Firestore | null | undefined,\n options: { stallMs?: number; onStall?: () => void } = {},\n): () => void {\n const { stallMs = FIRESTORE_STALL_MS } = options\n const onStall =\n options.onStall ?? (() => void recoverStalledFirestore(firestore))\n let done = false\n let onVisible: (() => void) | undefined\n const fire = () => {\n if (done) return\n if (!documentVisible() && typeof document !== 'undefined') {\n onVisible = () => {\n if (!documentVisible()) return\n document.removeEventListener('visibilitychange', onVisible!)\n onVisible = undefined\n fire()\n }\n document.addEventListener('visibilitychange', onVisible)\n return\n }\n done = true\n onStall()\n }\n const timer = setTimeout(fire, stallMs)\n return () => {\n if (done) return\n done = true\n clearTimeout(timer)\n if (onVisible && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', onVisible)\n }\n }\n}\n"],"names":["disableNetwork","enableNetwork","browserWedgeEscalationFor","FIRESTORE_STALL_MS","STALL_RECOVERY_COOLDOWN_MS","NETWORK_STEP_TIMEOUT_MS","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createStallRecovery","deps","disable","enable","isOnline","isVisible","now","Date","cooldownMs","stepTimeoutMs","afterCycle","lastStartedAt","running","bounded","step","Promise","resolve","timer","setTimeout","done","ok","clearTimeout","then","cycle","outcome","recover","at","undefined","finally","recoveries","WeakMap","recoverStalledFirestore","firestore","recovery","get","set","catch","armFirestoreStallWatch","options","stallMs","onStall","onVisible","fire","removeEventListener","addEventListener"],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type * as Aglyn from '@aglyn/aglyn';
18
+ import type { DocumentReference } from 'firebase/firestore';
19
+ import { type FirestoreDocOptions } from './firebase/firebase-services';
20
+ /**
21
+ * A plugin document's working version, at
22
+ * `hosts/{hostId}/{collection}/{docId}/versions/{versionId}` — the storage a
23
+ * kind declared in `besignerDocuments` keeps (`plugin-manager/
24
+ * besigner-documents.ts`).
25
+ *
26
+ * Compressed at rest, and carrying the same asymmetry a component version
27
+ * does: the PUBLISHED tree lives on the parent document, because a placed
28
+ * document has to resolve on the hot path of a page render and one
29
+ * collection query reaches every one on the site. These documents are
30
+ * editing history — the besigner's draft surface — and nothing renders from
31
+ * them.
32
+ */
33
+ export interface HostDocumentVersion {
34
+ displayName?: string;
35
+ /** The node a placed copy grafts from; absent on a tree never published. */
36
+ rootId?: Aglyn.NodeId;
37
+ nodes?: Record<Aglyn.NodeId, Aglyn.AglynNodeSchema>;
38
+ updatedAt?: unknown;
39
+ }
40
+ export interface HostDocumentVersionIds {
41
+ hostId: string;
42
+ /** The subcollection under `hosts/{hostId}` the document lives in. */
43
+ collection: string;
44
+ docId: string;
45
+ versionId: string;
46
+ }
47
+ export declare const useHostDocumentVersionRef: ({ hostId, collection, docId, versionId, }: HostDocumentVersionIds) => DocumentReference<HostDocumentVersion>;
48
+ export declare const useHostDocumentVersion: (ids: HostDocumentVersionIds, options?: FirestoreDocOptions<HostDocumentVersion>) => {
49
+ doc: import("./firebase/firebase-services").ObservableStatus<HostDocumentVersion>;
50
+ setDoc: import("./helpers/use-modify-doc-callback").ModifyDocCallback<HostDocumentVersion>;
51
+ };
52
+ export default useHostDocumentVersion;