@aglyn/tenant-feature-instance 1.0.0-beta.296 → 1.0.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-feature-instance",
3
- "version": "1.0.0-beta.296",
3
+ "version": "1.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.296",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.296",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.296",
31
- "@aglyn/shared-util-email": "1.0.0-beta.296",
32
- "@aglyn/shared-util-http": "1.0.0-beta.296",
33
- "@aglyn/shared-util-timestamp": "1.0.0-beta.296",
28
+ "@aglyn/aglyn": "1.0.0",
29
+ "@aglyn/shared-data-enums": "1.0.0",
30
+ "@aglyn/shared-ui-jsx": "1.0.0",
31
+ "@aglyn/shared-util-email": "1.0.0",
32
+ "@aglyn/shared-util-http": "1.0.0",
33
+ "@aglyn/shared-util-timestamp": "1.0.0",
34
34
  "@swc/helpers": "0.5.23",
35
35
  "lodash-es": "^4.18.1",
36
36
  "mobx": "^7.0.5"
@@ -139,8 +139,20 @@ export declare const STALE_PRIMARY_LEASE_MS = 15000;
139
139
  /**
140
140
  * How long the lease read may take before the store is judged locked. The
141
141
  * SDK's own transactions take milliseconds.
142
+ *
143
+ * Measured in time this tab was RUNNING, not on the wall clock (AGL-4120). While
144
+ * the main thread is frozen, the read's answer sits unhandled in the same task
145
+ * queue as the timeout, so a four-second freeze used to read as a locked store:
146
+ * an `owner` read measured 3,945 ms in a freeze and 0 ms after it. On that false
147
+ * verdict a healthy tab was reloaded, losing whatever was typed into it, and
148
+ * every tab opened for the next five minutes started on the memory cache.
142
149
  */
143
150
  export declare const LEASE_PROBE_TIMEOUT_MS = 4000;
151
+ /**
152
+ * The probe's clock. A browser coalesces the ticks a frozen thread missed, so
153
+ * counting ticks counts only the time the tab could have heard the answer.
154
+ */
155
+ export declare const LEASE_PROBE_TICK_MS = 250;
144
156
  /**
145
157
  * How long after the network cycle the lease is read, so the election the
146
158
  * cycle queued has run.
@@ -132,7 +132,18 @@
132
132
  /**
133
133
  * How long the lease read may take before the store is judged locked. The
134
134
  * SDK's own transactions take milliseconds.
135
+ *
136
+ * Measured in time this tab was RUNNING, not on the wall clock (AGL-4120). While
137
+ * the main thread is frozen, the read's answer sits unhandled in the same task
138
+ * queue as the timeout, so a four-second freeze used to read as a locked store:
139
+ * an `owner` read measured 3,945 ms in a freeze and 0 ms after it. On that false
140
+ * verdict a healthy tab was reloaded, losing whatever was typed into it, and
141
+ * every tab opened for the next five minutes started on the memory cache.
135
142
  */ export const LEASE_PROBE_TIMEOUT_MS = 4000;
143
+ /**
144
+ * The probe's clock. A browser coalesces the ticks a frozen thread missed, so
145
+ * counting ticks counts only the time the tab could have heard the answer.
146
+ */ export const LEASE_PROBE_TICK_MS = 250;
136
147
  /**
137
148
  * How long after the network cycle the lease is read, so the election the
138
149
  * cycle queued has run.
@@ -170,7 +181,7 @@ const OWNER_KEY = 'owner';
170
181
  const settle = (probe)=>{
171
182
  if (settled) return;
172
183
  settled = true;
173
- clearTimeout(timer);
184
+ clearInterval(clock);
174
185
  resolve(probe);
175
186
  };
176
187
  const close = ()=>{
@@ -182,9 +193,14 @@ const OWNER_KEY = 'owner';
182
193
  };
183
194
  // Only a lock can hold a read-only request this long; the connection is
184
195
  // closed when the request finally lands.
185
- const timer = setTimeout(()=>settle({
196
+ const tickMs = Math.min(LEASE_PROBE_TICK_MS, timeoutMs);
197
+ let ticks = Math.ceil(timeoutMs / tickMs);
198
+ const clock = setInterval(()=>{
199
+ ticks -= 1;
200
+ if (ticks <= 0) settle({
186
201
  state: 'locked'
187
- }), timeoutMs);
202
+ });
203
+ }, tickMs);
188
204
  let request;
189
205
  try {
190
206
  request = factory.open(databaseName);
@@ -1 +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: tabs already running keep the\n * durable 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 * ## The tabs that open next (AGL-4090)\n *\n * Every tab used to find the lock on its own, so each NEW tab spent the whole\n * path above — the 8 s stall, the cycle's two 5 s steps, the settle, the 4 s\n * read, the reload — on an empty page: about half a minute, per tab. So a tab\n * whose read found the store LOCKED also leaves a {@link SharedCacheLock} in\n * `localStorage`, and `firestoreCacheClassFor` starts every tab that opens\n * while it is fresh on the memory cache at once: no stall, no reload.\n *\n * - **Only a lock is shared.** A locked store hangs every tab that opens the\n * database, a new one included. A stale lease does not: no transaction\n * holds the store, and a freshly started tab wins a lapsed lease at its\n * first election (AGL-3373). That fallback stays per tab.\n * - **Each tab that starts from it reads the lease again**, read-only, once\n * ({@link confirmSharedCacheLock}). Still locked, it re-stamps the record;\n * anything else clears it, so the first tab after the lock lifts is the\n * last one moved. The record lasts as long as the lock while tabs keep\n * opening, and {@link SHARED_CACHE_LOCK_TTL_MS} past the last tab that saw it.\n * - **Nothing here writes to IndexedDB or touches the lease.** A tab on the\n * memory cache takes no part in the multi-tab protocol, so it can never be\n * a second primary over the shared cache (AGL-3788).\n *\n * The cost is the memory cache's, and it is why the fallback was per tab: a\n * write made offline in such a tab is lost if the tab closes first, and the\n * tab shares no cache with the others. But while the store is locked, the\n * durable cache in a new tab can do none of that either: its persistence\n * never starts, so it reads nothing and no write leaves it. The cost is real\n * only for a record that has outlived its lock, and the re-read clears that\n * at the first tab that starts from it.\n *\n * Not chosen: a lease read at boot in every tab, in place of the record. The\n * cache is fixed when `initializeFirestore` runs, synchronously in the\n * provider's first render, and IndexedDB answers asynchronously, so a boot\n * read can shorten the wait to the read plus a reload but never skip it. It\n * would also add a read to every healthy tab's boot, and at boot the store can\n * be held for seconds for healthy reasons (the primary's LRU collection, a\n * schema upgrade) that the stall and the cycle rule out today, so it would\n * reload healthy tabs.\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/** `localStorage` key of the lock a tab saw, for the tabs that open next (AGL-4090). */\nexport const SHARED_CACHE_LOCK_KEY = 'aglyn:firestore-shared-cache-locked'\n\n/**\n * How long a lock one tab saw starts new tabs on the memory cache, unless a\n * tab that starts from it sees the lock again. Long enough for the tabs a\n * person opens together (a page, then its preview); short, because a record\n * that outlives its lock moves every new tab, not one.\n */\nexport const SHARED_CACHE_LOCK_TTL_MS = 5 * 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/** A lock one tab saw, recorded for the tabs that open next (AGL-4090). */\nexport interface SharedCacheLock {\n reason: 'lease-locked'\n /** The database whose store was locked: `<persistence prefix>main`. */\n databaseName: string\n /** When a tab last saw it locked. */\n at: number\n}\n\n/** What a tab that started from a {@link SharedCacheLock} found when it read the lease again. */\nexport type SharedCacheLockCheck =\n /** There was no fresh record for this database: nothing was read. */\n | 'absent'\n /** The store is still locked; the record was re-stamped. */\n | 'still-locked'\n /** The store answered; the record is gone, so the next tab gets the durable cache. */\n | 'cleared'\n /** Storage could not be read or written; the record is left to expire. */\n | 'failed'\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 * The object stored under `key`, when it parses and its `at` is within\n * `ttlMs` of `now` and not ahead of it.\n */\nfunction readStampedRecord(\n storage: Pick<Storage, 'getItem'> | undefined,\n key: string,\n now: number,\n ttlMs: number,\n): ({ at: number } & Record<string, unknown>) | undefined {\n let raw: string | null\n try {\n raw = storage?.getItem(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 { at?: unknown } & Record<string, unknown>\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 { at: number } & Record<string, unknown>\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 return readStampedRecord(storage, MEMORY_CACHE_FALLBACK_KEY, now, ttlMs) as\n | MemoryCacheFallback\n | undefined\n}\n\n/**\n * The lock a tab saw on `databaseName`, or `undefined` when there is none, it\n * names another database, it has expired, or it does not parse.\n */\nexport function readSharedCacheLock(\n storage: Pick<Storage, 'getItem'> | undefined,\n databaseName: string | undefined,\n now: number,\n ttlMs = SHARED_CACHE_LOCK_TTL_MS,\n): SharedCacheLock | undefined {\n if (databaseName === undefined) return undefined\n const record = readStampedRecord(storage, SHARED_CACHE_LOCK_KEY, now, ttlMs)\n if (record?.reason !== 'lease-locked' || record.databaseName !== databaseName) {\n return undefined\n }\n return record as unknown as SharedCacheLock\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\nconst browserLocalStorage = (): Storage | undefined => {\n try {\n return typeof window === 'undefined' ? undefined : window.localStorage\n } catch {\n // Site data blocked: reading `window.localStorage` throws.\n return undefined\n }\n}\n\nconst browserIndexedDb = (): IDBFactory | undefined => {\n try {\n return typeof window === 'undefined' ? undefined : window.indexedDB\n } catch {\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\n/**\n * Whether another tab has just seen the store of `databaseName` locked, so\n * this tab should start on the memory cache rather than wait to find out.\n */\nexport function sharedCacheLockActive(\n databaseName: string | undefined,\n now = Date.now(),\n): boolean {\n return readSharedCacheLock(browserLocalStorage(), databaseName, now) !== undefined\n}\n\n/**\n * Records, for the tabs that open next, that the store of `databaseName` is\n * locked now. Never throws: without the record, the next tab finds the lock\n * itself, as it always has.\n */\nexport function recordSharedCacheLock(\n databaseName: string,\n {\n storage = browserLocalStorage(),\n now = Date.now,\n }: { storage?: Pick<Storage, 'setItem'>; now?: () => number } = {},\n): void {\n const record: SharedCacheLock = { reason: 'lease-locked', databaseName, at: now() }\n try {\n storage?.setItem(SHARED_CACHE_LOCK_KEY, JSON.stringify(record))\n } catch {\n // Full or blocked.\n }\n}\n\n/**\n * Run once by a tab that started on the memory cache: reads the lease of\n * `databaseName` again and keeps another tab's lock record only while the\n * store is still locked. Never creates a record and never rejects.\n */\nexport async function confirmSharedCacheLock(\n databaseName: string,\n {\n probe = () => probePrimaryLease(browserIndexedDb(), databaseName),\n storage = browserLocalStorage(),\n now = Date.now,\n }: {\n probe?: () => Promise<PrimaryLeaseProbe>\n storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>\n now?: () => number\n } = {},\n): Promise<SharedCacheLockCheck> {\n if (!storage) return 'absent'\n const seen = readSharedCacheLock(storage, databaseName, now())\n if (!seen) return 'absent'\n try {\n if ((await probe()).state === 'locked') {\n recordSharedCacheLock(databaseName, { storage, now })\n return 'still-locked'\n }\n const current = readSharedCacheLock(storage, databaseName, now())\n // Another tab saw the lock again while this read ran; its record is\n // newer than this read, so it stands.\n if (current && current.at !== seen.at) return 'still-locked'\n if (current) storage.removeItem(SHARED_CACHE_LOCK_KEY)\n return 'cleared'\n } catch {\n return 'failed'\n }\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 *\n * A lock is also recorded for the tabs that open next (AGL-4090), first and\n * whatever happens to this tab's own record: they would hang on it too.\n */\nexport function fallBackToMemoryCache(\n evidence: WedgeEvidence,\n {\n storage = browserSessionStorage(),\n sharedStorage = browserLocalStorage(),\n databaseName,\n reload = () => window.location.reload(),\n now = Date.now,\n }: {\n storage?: Pick<Storage, 'setItem'>\n /** Where the tabs that open next look for a lock this tab saw. */\n sharedStorage?: Pick<Storage, 'setItem'>\n /** The locked database. Without it, nothing is shared. */\n databaseName?: string\n reload?: () => void\n now?: () => number\n } = {},\n): void {\n if (evidence.reason === 'lease-locked' && databaseName !== undefined) {\n recordSharedCacheLock(databaseName, { storage: sharedStorage, now })\n }\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: () => probePrimaryLease(browserIndexedDb(), databaseName),\n fallBack: (evidence) => fallBackToMemoryCache(evidence, { databaseName }),\n })\n}\n"],"names":["MEMORY_CACHE_FALLBACK_KEY","MEMORY_CACHE_FALLBACK_TTL_MS","SHARED_CACHE_LOCK_KEY","SHARED_CACHE_LOCK_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","readStampedRecord","storage","key","ttlMs","raw","getItem","parsed","JSON","parse","record","at","Number","isFinite","readMemoryCacheFallback","readSharedCacheLock","browserSessionStorage","window","sessionStorage","browserLocalStorage","localStorage","browserIndexedDb","indexedDB","memoryCacheFallbackActive","sharedCacheLockActive","recordSharedCacheLock","setItem","stringify","confirmSharedCacheLock","seen","current","removeItem","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createWedgeEscalation","deps","fallBack","isFallbackActive","isOnline","isVisible","wait","ms","settleMs","evidence","fallBackToMemoryCache","sharedStorage","reload","location","console","warn","multiTabDatabases","WeakMap","markMultiTabFirestore","firestore","set","browserWedgeEscalationFor"],"mappings":"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"}
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: tabs already running keep the\n * durable 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 * ## The tabs that open next (AGL-4090)\n *\n * Every tab used to find the lock on its own, so each NEW tab spent the whole\n * path above — the 8 s stall, the cycle's two 5 s steps, the settle, the 4 s\n * read, the reload — on an empty page: about half a minute, per tab. So a tab\n * whose read found the store LOCKED also leaves a {@link SharedCacheLock} in\n * `localStorage`, and `firestoreCacheClassFor` starts every tab that opens\n * while it is fresh on the memory cache at once: no stall, no reload.\n *\n * - **Only a lock is shared.** A locked store hangs every tab that opens the\n * database, a new one included. A stale lease does not: no transaction\n * holds the store, and a freshly started tab wins a lapsed lease at its\n * first election (AGL-3373). That fallback stays per tab.\n * - **Each tab that starts from it reads the lease again**, read-only, once\n * ({@link confirmSharedCacheLock}). Still locked, it re-stamps the record;\n * anything else clears it, so the first tab after the lock lifts is the\n * last one moved. The record lasts as long as the lock while tabs keep\n * opening, and {@link SHARED_CACHE_LOCK_TTL_MS} past the last tab that saw it.\n * - **Nothing here writes to IndexedDB or touches the lease.** A tab on the\n * memory cache takes no part in the multi-tab protocol, so it can never be\n * a second primary over the shared cache (AGL-3788).\n *\n * The cost is the memory cache's, and it is why the fallback was per tab: a\n * write made offline in such a tab is lost if the tab closes first, and the\n * tab shares no cache with the others. But while the store is locked, the\n * durable cache in a new tab can do none of that either: its persistence\n * never starts, so it reads nothing and no write leaves it. The cost is real\n * only for a record that has outlived its lock, and the re-read clears that\n * at the first tab that starts from it.\n *\n * Not chosen: a lease read at boot in every tab, in place of the record. The\n * cache is fixed when `initializeFirestore` runs, synchronously in the\n * provider's first render, and IndexedDB answers asynchronously, so a boot\n * read can shorten the wait to the read plus a reload but never skip it. It\n * would also add a read to every healthy tab's boot, and at boot the store can\n * be held for seconds for healthy reasons (the primary's LRU collection, a\n * schema upgrade) that the stall and the cycle rule out today, so it would\n * reload healthy tabs.\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/** `localStorage` key of the lock a tab saw, for the tabs that open next (AGL-4090). */\nexport const SHARED_CACHE_LOCK_KEY = 'aglyn:firestore-shared-cache-locked'\n\n/**\n * How long a lock one tab saw starts new tabs on the memory cache, unless a\n * tab that starts from it sees the lock again. Long enough for the tabs a\n * person opens together (a page, then its preview); short, because a record\n * that outlives its lock moves every new tab, not one.\n */\nexport const SHARED_CACHE_LOCK_TTL_MS = 5 * 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 *\n * Measured in time this tab was RUNNING, not on the wall clock (AGL-4120). While\n * the main thread is frozen, the read's answer sits unhandled in the same task\n * queue as the timeout, so a four-second freeze used to read as a locked store:\n * an `owner` read measured 3,945 ms in a freeze and 0 ms after it. On that false\n * verdict a healthy tab was reloaded, losing whatever was typed into it, and\n * every tab opened for the next five minutes started on the memory cache.\n */\nexport const LEASE_PROBE_TIMEOUT_MS = 4_000\n\n/**\n * The probe's clock. A browser coalesces the ticks a frozen thread missed, so\n * counting ticks counts only the time the tab could have heard the answer.\n */\nexport const LEASE_PROBE_TICK_MS = 250\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/** A lock one tab saw, recorded for the tabs that open next (AGL-4090). */\nexport interface SharedCacheLock {\n reason: 'lease-locked'\n /** The database whose store was locked: `<persistence prefix>main`. */\n databaseName: string\n /** When a tab last saw it locked. */\n at: number\n}\n\n/** What a tab that started from a {@link SharedCacheLock} found when it read the lease again. */\nexport type SharedCacheLockCheck =\n /** There was no fresh record for this database: nothing was read. */\n | 'absent'\n /** The store is still locked; the record was re-stamped. */\n | 'still-locked'\n /** The store answered; the record is gone, so the next tab gets the durable cache. */\n | 'cleared'\n /** Storage could not be read or written; the record is left to expire. */\n | 'failed'\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 clearInterval(clock)\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 tickMs = Math.min(LEASE_PROBE_TICK_MS, timeoutMs)\n let ticks = Math.ceil(timeoutMs / tickMs)\n const clock = setInterval(() => {\n ticks -= 1\n if (ticks <= 0) settle({ state: 'locked' })\n }, tickMs)\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 * The object stored under `key`, when it parses and its `at` is within\n * `ttlMs` of `now` and not ahead of it.\n */\nfunction readStampedRecord(\n storage: Pick<Storage, 'getItem'> | undefined,\n key: string,\n now: number,\n ttlMs: number,\n): ({ at: number } & Record<string, unknown>) | undefined {\n let raw: string | null\n try {\n raw = storage?.getItem(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 { at?: unknown } & Record<string, unknown>\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 { at: number } & Record<string, unknown>\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 return readStampedRecord(storage, MEMORY_CACHE_FALLBACK_KEY, now, ttlMs) as\n | MemoryCacheFallback\n | undefined\n}\n\n/**\n * The lock a tab saw on `databaseName`, or `undefined` when there is none, it\n * names another database, it has expired, or it does not parse.\n */\nexport function readSharedCacheLock(\n storage: Pick<Storage, 'getItem'> | undefined,\n databaseName: string | undefined,\n now: number,\n ttlMs = SHARED_CACHE_LOCK_TTL_MS,\n): SharedCacheLock | undefined {\n if (databaseName === undefined) return undefined\n const record = readStampedRecord(storage, SHARED_CACHE_LOCK_KEY, now, ttlMs)\n if (record?.reason !== 'lease-locked' || record.databaseName !== databaseName) {\n return undefined\n }\n return record as unknown as SharedCacheLock\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\nconst browserLocalStorage = (): Storage | undefined => {\n try {\n return typeof window === 'undefined' ? undefined : window.localStorage\n } catch {\n // Site data blocked: reading `window.localStorage` throws.\n return undefined\n }\n}\n\nconst browserIndexedDb = (): IDBFactory | undefined => {\n try {\n return typeof window === 'undefined' ? undefined : window.indexedDB\n } catch {\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\n/**\n * Whether another tab has just seen the store of `databaseName` locked, so\n * this tab should start on the memory cache rather than wait to find out.\n */\nexport function sharedCacheLockActive(\n databaseName: string | undefined,\n now = Date.now(),\n): boolean {\n return readSharedCacheLock(browserLocalStorage(), databaseName, now) !== undefined\n}\n\n/**\n * Records, for the tabs that open next, that the store of `databaseName` is\n * locked now. Never throws: without the record, the next tab finds the lock\n * itself, as it always has.\n */\nexport function recordSharedCacheLock(\n databaseName: string,\n {\n storage = browserLocalStorage(),\n now = Date.now,\n }: { storage?: Pick<Storage, 'setItem'>; now?: () => number } = {},\n): void {\n const record: SharedCacheLock = { reason: 'lease-locked', databaseName, at: now() }\n try {\n storage?.setItem(SHARED_CACHE_LOCK_KEY, JSON.stringify(record))\n } catch {\n // Full or blocked.\n }\n}\n\n/**\n * Run once by a tab that started on the memory cache: reads the lease of\n * `databaseName` again and keeps another tab's lock record only while the\n * store is still locked. Never creates a record and never rejects.\n */\nexport async function confirmSharedCacheLock(\n databaseName: string,\n {\n probe = () => probePrimaryLease(browserIndexedDb(), databaseName),\n storage = browserLocalStorage(),\n now = Date.now,\n }: {\n probe?: () => Promise<PrimaryLeaseProbe>\n storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>\n now?: () => number\n } = {},\n): Promise<SharedCacheLockCheck> {\n if (!storage) return 'absent'\n const seen = readSharedCacheLock(storage, databaseName, now())\n if (!seen) return 'absent'\n try {\n if ((await probe()).state === 'locked') {\n recordSharedCacheLock(databaseName, { storage, now })\n return 'still-locked'\n }\n const current = readSharedCacheLock(storage, databaseName, now())\n // Another tab saw the lock again while this read ran; its record is\n // newer than this read, so it stands.\n if (current && current.at !== seen.at) return 'still-locked'\n if (current) storage.removeItem(SHARED_CACHE_LOCK_KEY)\n return 'cleared'\n } catch {\n return 'failed'\n }\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 *\n * A lock is also recorded for the tabs that open next (AGL-4090), first and\n * whatever happens to this tab's own record: they would hang on it too.\n */\nexport function fallBackToMemoryCache(\n evidence: WedgeEvidence,\n {\n storage = browserSessionStorage(),\n sharedStorage = browserLocalStorage(),\n databaseName,\n reload = () => window.location.reload(),\n now = Date.now,\n }: {\n storage?: Pick<Storage, 'setItem'>\n /** Where the tabs that open next look for a lock this tab saw. */\n sharedStorage?: Pick<Storage, 'setItem'>\n /** The locked database. Without it, nothing is shared. */\n databaseName?: string\n reload?: () => void\n now?: () => number\n } = {},\n): void {\n if (evidence.reason === 'lease-locked' && databaseName !== undefined) {\n recordSharedCacheLock(databaseName, { storage: sharedStorage, now })\n }\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: () => probePrimaryLease(browserIndexedDb(), databaseName),\n fallBack: (evidence) => fallBackToMemoryCache(evidence, { databaseName }),\n })\n}\n"],"names":["MEMORY_CACHE_FALLBACK_KEY","MEMORY_CACHE_FALLBACK_TTL_MS","SHARED_CACHE_LOCK_KEY","SHARED_CACHE_LOCK_TTL_MS","STALE_PRIMARY_LEASE_MS","LEASE_PROBE_TIMEOUT_MS","LEASE_PROBE_TICK_MS","LEASE_SETTLE_MS","OWNER_STORE","OWNER_KEY","probePrimaryLease","factory","databaseName","timeoutMs","now","Date","databases","state","some","database","name","Promise","resolve","settled","settle","probe","clearInterval","clock","close","tickMs","Math","min","ticks","ceil","setInterval","request","open","onupgradeneeded","transaction","abort","onerror","onsuccess","result","onversionchange","objectStoreNames","contains","read","objectStore","get","row","ownerId","leaseTimestampMs","ageMs","wedgeEvidenceFrom","staleMs","reason","undefined","readStampedRecord","storage","key","ttlMs","raw","getItem","parsed","JSON","parse","record","at","Number","isFinite","readMemoryCacheFallback","readSharedCacheLock","browserSessionStorage","window","sessionStorage","browserLocalStorage","localStorage","browserIndexedDb","indexedDB","memoryCacheFallbackActive","sharedCacheLockActive","recordSharedCacheLock","setItem","stringify","confirmSharedCacheLock","seen","current","removeItem","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createWedgeEscalation","deps","fallBack","isFallbackActive","isOnline","isVisible","wait","ms","setTimeout","settleMs","evidence","fallBackToMemoryCache","sharedStorage","reload","location","console","warn","multiTabDatabases","WeakMap","markMultiTabFirestore","firestore","set","browserWedgeEscalationFor"],"mappings":"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"}
@@ -82,6 +82,7 @@ import { type Firestore } from 'firebase/firestore';
82
82
  * - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on
83
83
  * a page stalls together, and one cycle serves them all. A concurrent
84
84
  * request shares the cycle already running.
85
+ * - **A client the server is answering is not stalled (AGL-4120).** See below.
85
86
  * - **The network is always re-enabled**, including when disabling fails.
86
87
  * - **Each step is bounded by {@link NETWORK_STEP_TIMEOUT_MS}.** Both calls are
87
88
  * queued on the client's AsyncQueue, and when another tab has locked the
@@ -89,6 +90,26 @@ import { type Firestore } from 'firebase/firestore';
89
90
  * (AGL-3428). Unbounded, the first such cycle never settled, and every
90
91
  * later recovery request shared it forever.
91
92
  *
93
+ * ## The cycle is not free, so it needs a silent client (AGL-4120)
94
+ *
95
+ * A stall watch knows one thing: ITS listener has not been confirmed. That used
96
+ * to be enough to cycle, so one slow listener among thirty-five took the whole
97
+ * client off the network, and the cycle did real damage to one that was working.
98
+ * `disableNetwork` makes a primary ineligible for its own lease
99
+ * (`canActAsPrimary`), so it hands the lease over, to a hidden tab if one is
100
+ * waiting: captured in production as `NET-OFF, LEASE-RELEASE, NET-ON`. And while
101
+ * the network is off, every view in a primary goes Offline, so every listener the
102
+ * server had already confirmed reads `fromCache` again until it is confirmed a
103
+ * second time. A contact page read server-confirmed at 9 s and unconfirmed from
104
+ * 14 s to past a minute, on a page nobody touched, refusing every save on it. A
105
+ * slow page was turned into a broken one by the code meant to rescue it.
106
+ *
107
+ * So every server-confirmed snapshot stamps the client
108
+ * ({@link noteFirestoreServerAnswer}), and a recovery within
109
+ * {@link FIRESTORE_STALL_MS} of one answers `'answering'` and does nothing. A
110
+ * client that is working through a page is left to finish. One that has really
111
+ * gone silent stops stamping, and the next watch to fire gets its cycle.
112
+ *
92
113
  * ## When the cycle is not enough (AGL-3428)
93
114
  *
94
115
  * A tab frozen partway through an IndexedDB transaction holds every store the
@@ -106,6 +127,12 @@ import { type Firestore } from 'firebase/firestore';
106
127
  export declare const FIRESTORE_STALL_MS = 8000;
107
128
  /** The least time between two recoveries of the same client. */
108
129
  export declare const STALL_RECOVERY_COOLDOWN_MS = 30000;
130
+ /**
131
+ * The longest the wait between two cycles grows to, on a client that cycling is
132
+ * not bringing back. Doubling, so that a connection too slow to answer inside
133
+ * one cooldown is not restarted before it can.
134
+ */
135
+ export declare const STALL_RECOVERY_MAX_COOLDOWN_MS: number;
109
136
  /**
110
137
  * The longest `disableNetwork` or `enableNetwork` may take. Both finish in
111
138
  * milliseconds on a client whose queue is moving.
@@ -119,6 +146,8 @@ export type StallRecoveryOutcome =
119
146
  | 'hidden'
120
147
  /** A recovery ran moments ago; this request rode on it. */
121
148
  | 'cooling'
149
+ /** The server answered this client moments ago: it is busy, not stalled. */
150
+ | 'answering'
122
151
  /** The network was cycled. */
123
152
  | 'recovered'
124
153
  /** The cycle threw or timed out; the network was still re-enabled if at all possible. */
@@ -135,6 +164,13 @@ export interface StallRecoveryDeps {
135
164
  /** Whether the document is visible, or `true` where there is none. */
136
165
  isVisible?: () => boolean;
137
166
  now?: () => number;
167
+ /**
168
+ * When the server last confirmed anything for this client, or `undefined`
169
+ * if it has not. Without one, every stall is taken at its word.
170
+ */
171
+ lastServerAnswerAt?: () => number | undefined;
172
+ /** How recent an answer proves the client is alive. */
173
+ answeringWindowMs?: number;
138
174
  cooldownMs?: number;
139
175
  /** The bound on each of `disable` and `enable`. */
140
176
  stepTimeoutMs?: number;
@@ -149,6 +185,12 @@ export interface StallRecovery {
149
185
  }
150
186
  /** The recovery for one client, with its guards. Pure over `deps`. */
151
187
  export declare function createStallRecovery(deps: StallRecoveryDeps): StallRecovery;
188
+ /**
189
+ * Records that the server just confirmed something for `firestore`: the
190
+ * evidence a stall watch does not have that this client is alive. Every listener
191
+ * that arms a watch calls this on a server-confirmed snapshot.
192
+ */
193
+ export declare function noteFirestoreServerAnswer(firestore: Firestore | null | undefined): void;
152
194
  /**
153
195
  * Recovers `firestore` if it has stalled. Safe to call from every reader
154
196
  * that notices: the guards above collapse the calls into at most one network
@@ -156,10 +198,16 @@ export declare function createStallRecovery(deps: StallRecoveryDeps): StallRecov
156
198
  */
157
199
  export declare function recoverStalledFirestore(firestore: Firestore | null | undefined): Promise<StallRecoveryOutcome>;
158
200
  /**
159
- * Arms a stall timer for one read or listen. If `disarm` has not been called
160
- * within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).
161
- * A tab that is hidden when the timer fires waits until it is shown, since
162
- * that is when a reader is waiting again.
201
+ * Arms a stall timer for one read or listen. Until `disarm` is called, `onStall`
202
+ * runs (by default, {@link recoverStalledFirestore}) every `stallMs`. A tab that
203
+ * is hidden when the timer fires waits until it is shown, since that is when a
204
+ * reader is waiting again.
205
+ *
206
+ * It keeps firing because one call can do nothing: the request may land inside
207
+ * a cooldown, or on a client that is still answering. A watch that spent itself
208
+ * there left its listener unconfirmed for life (AGL-4120), which is how a tab's
209
+ * first page worked and a later page's saves were refused. The calls cost nothing
210
+ * until one of them finds a client that really needs a cycle.
163
211
  *
164
212
  * Returns `disarm`, which is idempotent.
165
213
  */
@@ -82,6 +82,7 @@ import { browserWedgeEscalationFor } from "./firestore-multitab-wedge.js";
82
82
  * - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on
83
83
  * a page stalls together, and one cycle serves them all. A concurrent
84
84
  * request shares the cycle already running.
85
+ * - **A client the server is answering is not stalled (AGL-4120).** See below.
85
86
  * - **The network is always re-enabled**, including when disabling fails.
86
87
  * - **Each step is bounded by {@link NETWORK_STEP_TIMEOUT_MS}.** Both calls are
87
88
  * queued on the client's AsyncQueue, and when another tab has locked the
@@ -89,6 +90,26 @@ import { browserWedgeEscalationFor } from "./firestore-multitab-wedge.js";
89
90
  * (AGL-3428). Unbounded, the first such cycle never settled, and every
90
91
  * later recovery request shared it forever.
91
92
  *
93
+ * ## The cycle is not free, so it needs a silent client (AGL-4120)
94
+ *
95
+ * A stall watch knows one thing: ITS listener has not been confirmed. That used
96
+ * to be enough to cycle, so one slow listener among thirty-five took the whole
97
+ * client off the network, and the cycle did real damage to one that was working.
98
+ * `disableNetwork` makes a primary ineligible for its own lease
99
+ * (`canActAsPrimary`), so it hands the lease over, to a hidden tab if one is
100
+ * waiting: captured in production as `NET-OFF, LEASE-RELEASE, NET-ON`. And while
101
+ * the network is off, every view in a primary goes Offline, so every listener the
102
+ * server had already confirmed reads `fromCache` again until it is confirmed a
103
+ * second time. A contact page read server-confirmed at 9 s and unconfirmed from
104
+ * 14 s to past a minute, on a page nobody touched, refusing every save on it. A
105
+ * slow page was turned into a broken one by the code meant to rescue it.
106
+ *
107
+ * So every server-confirmed snapshot stamps the client
108
+ * ({@link noteFirestoreServerAnswer}), and a recovery within
109
+ * {@link FIRESTORE_STALL_MS} of one answers `'answering'` and does nothing. A
110
+ * client that is working through a page is left to finish. One that has really
111
+ * gone silent stops stamping, and the next watch to fire gets its cycle.
112
+ *
92
113
  * ## When the cycle is not enough (AGL-3428)
93
114
  *
94
115
  * A tab frozen partway through an IndexedDB transaction holds every store the
@@ -103,6 +124,11 @@ import { browserWedgeEscalationFor } from "./firestore-multitab-wedge.js";
103
124
  * leaving a reader on a spinner for long.
104
125
  */ export const FIRESTORE_STALL_MS = 8000;
105
126
  /** The least time between two recoveries of the same client. */ export const STALL_RECOVERY_COOLDOWN_MS = 30000;
127
+ /**
128
+ * The longest the wait between two cycles grows to, on a client that cycling is
129
+ * not bringing back. Doubling, so that a connection too slow to answer inside
130
+ * one cooldown is not restarted before it can.
131
+ */ export const STALL_RECOVERY_MAX_COOLDOWN_MS = 5 * 60000;
106
132
  /**
107
133
  * The longest `disableNetwork` or `enableNetwork` may take. Both finish in
108
134
  * milliseconds on a client whose queue is moving.
@@ -110,8 +136,9 @@ import { browserWedgeEscalationFor } from "./firestore-multitab-wedge.js";
110
136
  const browserOnline = ()=>typeof navigator === 'undefined' || navigator.onLine !== false;
111
137
  const documentVisible = ()=>typeof document === 'undefined' || document.visibilityState !== 'hidden';
112
138
  /** The recovery for one client, with its guards. Pure over `deps`. */ export function createStallRecovery(deps) {
113
- const { disable, enable, isOnline = browserOnline, isVisible = documentVisible, now = Date.now, cooldownMs = STALL_RECOVERY_COOLDOWN_MS, stepTimeoutMs = NETWORK_STEP_TIMEOUT_MS, afterCycle } = deps;
139
+ const { disable, enable, isOnline = browserOnline, isVisible = documentVisible, now = Date.now, lastServerAnswerAt, answeringWindowMs = FIRESTORE_STALL_MS, cooldownMs = STALL_RECOVERY_COOLDOWN_MS, stepTimeoutMs = NETWORK_STEP_TIMEOUT_MS, afterCycle } = deps;
114
140
  let lastStartedAt;
141
+ /** Cycles in a row that the server has not answered since. */ let quietCycles = 0;
115
142
  let running;
116
143
  /** Whether `step` settled successfully within `stepTimeoutMs`. */ const bounded = (step)=>new Promise((resolve)=>{
117
144
  const timer = setTimeout(()=>resolve(false), stepTimeoutMs);
@@ -144,8 +171,15 @@ const documentVisible = ()=>typeof document === 'undefined' || document.visibili
144
171
  if (!isOnline()) return Promise.resolve('offline');
145
172
  if (!isVisible()) return Promise.resolve('hidden');
146
173
  const at = now();
147
- if (lastStartedAt !== undefined && at - lastStartedAt < cooldownMs) {
148
- return Promise.resolve('cooling');
174
+ const answeredAt = lastServerAnswerAt == null ? void 0 : lastServerAnswerAt();
175
+ if (answeredAt !== undefined && at - answeredAt < answeringWindowMs) {
176
+ return Promise.resolve('answering');
177
+ }
178
+ if (lastStartedAt !== undefined) {
179
+ const cooldown = Math.min(cooldownMs * 2 ** quietCycles, STALL_RECOVERY_MAX_COOLDOWN_MS);
180
+ if (at - lastStartedAt < cooldown) return Promise.resolve('cooling');
181
+ const answeredSince = answeredAt !== undefined && answeredAt >= lastStartedAt;
182
+ quietCycles = answeredSince ? 0 : quietCycles + 1;
149
183
  }
150
184
  lastStartedAt = at;
151
185
  running = cycle().finally(()=>{
@@ -156,6 +190,16 @@ const documentVisible = ()=>typeof document === 'undefined' || document.visibili
156
190
  };
157
191
  }
158
192
  const recoveries = new WeakMap();
193
+ /** When the server last confirmed a snapshot for each client. */ const serverAnswers = new WeakMap();
194
+ /**
195
+ * Records that the server just confirmed something for `firestore`: the
196
+ * evidence a stall watch does not have that this client is alive. Every listener
197
+ * that arms a watch calls this on a server-confirmed snapshot.
198
+ */ export function noteFirestoreServerAnswer(firestore) {
199
+ if (firestore && typeof firestore === 'object') {
200
+ serverAnswers.set(firestore, Date.now());
201
+ }
202
+ }
159
203
  /**
160
204
  * Recovers `firestore` if it has stalled. Safe to call from every reader
161
205
  * that notices: the guards above collapse the calls into at most one network
@@ -169,6 +213,7 @@ const recoveries = new WeakMap();
169
213
  recovery = createStallRecovery({
170
214
  disable: ()=>disableNetwork(firestore),
171
215
  enable: ()=>enableNetwork(firestore),
216
+ lastServerAnswerAt: ()=>serverAnswers.get(firestore),
172
217
  afterCycle: browserWedgeEscalationFor(firestore)
173
218
  });
174
219
  recoveries.set(firestore, recovery);
@@ -176,10 +221,16 @@ const recoveries = new WeakMap();
176
221
  return recovery.recover().catch(()=>'failed');
177
222
  }
178
223
  /**
179
- * Arms a stall timer for one read or listen. If `disarm` has not been called
180
- * within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).
181
- * A tab that is hidden when the timer fires waits until it is shown, since
182
- * that is when a reader is waiting again.
224
+ * Arms a stall timer for one read or listen. Until `disarm` is called, `onStall`
225
+ * runs (by default, {@link recoverStalledFirestore}) every `stallMs`. A tab that
226
+ * is hidden when the timer fires waits until it is shown, since that is when a
227
+ * reader is waiting again.
228
+ *
229
+ * It keeps firing because one call can do nothing: the request may land inside
230
+ * a cooldown, or on a client that is still answering. A watch that spent itself
231
+ * there left its listener unconfirmed for life (AGL-4120), which is how a tab's
232
+ * first page worked and a later page's saves were refused. The calls cost nothing
233
+ * until one of them finds a client that really needs a cycle.
183
234
  *
184
235
  * Returns `disarm`, which is idempotent.
185
236
  */ export function armFirestoreStallWatch(firestore, options = {}) {
@@ -187,6 +238,7 @@ const recoveries = new WeakMap();
187
238
  const { stallMs = FIRESTORE_STALL_MS } = options;
188
239
  const onStall = (_options_onStall = options.onStall) != null ? _options_onStall : ()=>void recoverStalledFirestore(firestore);
189
240
  let done = false;
241
+ let timer;
190
242
  let onVisible;
191
243
  const fire = ()=>{
192
244
  if (done) return;
@@ -200,10 +252,10 @@ const recoveries = new WeakMap();
200
252
  document.addEventListener('visibilitychange', onVisible);
201
253
  return;
202
254
  }
203
- done = true;
204
255
  onStall();
256
+ timer = setTimeout(fire, stallMs);
205
257
  };
206
- const timer = setTimeout(fire, stallMs);
258
+ timer = setTimeout(fire, stallMs);
207
259
  return ()=>{
208
260
  if (done) return;
209
261
  done = true;
@@ -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\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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"}
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 * - **A client the server is answering is not stalled (AGL-4120).** See below.\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 * ## The cycle is not free, so it needs a silent client (AGL-4120)\n *\n * A stall watch knows one thing: ITS listener has not been confirmed. That used\n * to be enough to cycle, so one slow listener among thirty-five took the whole\n * client off the network, and the cycle did real damage to one that was working.\n * `disableNetwork` makes a primary ineligible for its own lease\n * (`canActAsPrimary`), so it hands the lease over, to a hidden tab if one is\n * waiting: captured in production as `NET-OFF, LEASE-RELEASE, NET-ON`. And while\n * the network is off, every view in a primary goes Offline, so every listener the\n * server had already confirmed reads `fromCache` again until it is confirmed a\n * second time. A contact page read server-confirmed at 9 s and unconfirmed from\n * 14 s to past a minute, on a page nobody touched, refusing every save on it. A\n * slow page was turned into a broken one by the code meant to rescue it.\n *\n * So every server-confirmed snapshot stamps the client\n * ({@link noteFirestoreServerAnswer}), and a recovery within\n * {@link FIRESTORE_STALL_MS} of one answers `'answering'` and does nothing. A\n * client that is working through a page is left to finish. One that has really\n * gone silent stops stamping, and the next watch to fire gets its cycle.\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 the wait between two cycles grows to, on a client that cycling is\n * not bringing back. Doubling, so that a connection too slow to answer inside\n * one cooldown is not restarted before it can.\n */\nexport const STALL_RECOVERY_MAX_COOLDOWN_MS = 5 * 60_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 server answered this client moments ago: it is busy, not stalled. */\n | 'answering'\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 /**\n * When the server last confirmed anything for this client, or `undefined`\n * if it has not. Without one, every stall is taken at its word.\n */\n lastServerAnswerAt?: () => number | undefined\n /** How recent an answer proves the client is alive. */\n answeringWindowMs?: 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 lastServerAnswerAt,\n answeringWindowMs = FIRESTORE_STALL_MS,\n cooldownMs = STALL_RECOVERY_COOLDOWN_MS,\n stepTimeoutMs = NETWORK_STEP_TIMEOUT_MS,\n afterCycle,\n } = deps\n let lastStartedAt: number | undefined\n /** Cycles in a row that the server has not answered since. */\n let quietCycles = 0\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 const answeredAt = lastServerAnswerAt?.()\n if (answeredAt !== undefined && at - answeredAt < answeringWindowMs) {\n return Promise.resolve('answering')\n }\n if (lastStartedAt !== undefined) {\n const cooldown = Math.min(\n cooldownMs * 2 ** quietCycles,\n STALL_RECOVERY_MAX_COOLDOWN_MS,\n )\n if (at - lastStartedAt < cooldown) return Promise.resolve('cooling')\n const answeredSince =\n answeredAt !== undefined && answeredAt >= lastStartedAt\n quietCycles = answeredSince ? 0 : quietCycles + 1\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/** When the server last confirmed a snapshot for each client. */\nconst serverAnswers = new WeakMap<Firestore, number>()\n\n/**\n * Records that the server just confirmed something for `firestore`: the\n * evidence a stall watch does not have that this client is alive. Every listener\n * that arms a watch calls this on a server-confirmed snapshot.\n */\nexport function noteFirestoreServerAnswer(\n firestore: Firestore | null | undefined,\n): void {\n if (firestore && typeof firestore === 'object') {\n serverAnswers.set(firestore, Date.now())\n }\n}\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 lastServerAnswerAt: () => serverAnswers.get(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. Until `disarm` is called, `onStall`\n * runs (by default, {@link recoverStalledFirestore}) every `stallMs`. A tab that\n * is hidden when the timer fires waits until it is shown, since that is when a\n * reader is waiting again.\n *\n * It keeps firing because one call can do nothing: the request may land inside\n * a cooldown, or on a client that is still answering. A watch that spent itself\n * there left its listener unconfirmed for life (AGL-4120), which is how a tab's\n * first page worked and a later page's saves were refused. The calls cost nothing\n * until one of them finds a client that really needs a cycle.\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 timer: ReturnType<typeof setTimeout> | undefined\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 onStall()\n timer = setTimeout(fire, stallMs)\n }\n 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","STALL_RECOVERY_MAX_COOLDOWN_MS","NETWORK_STEP_TIMEOUT_MS","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createStallRecovery","deps","disable","enable","isOnline","isVisible","now","Date","lastServerAnswerAt","answeringWindowMs","cooldownMs","stepTimeoutMs","afterCycle","lastStartedAt","quietCycles","running","bounded","step","Promise","resolve","timer","setTimeout","done","ok","clearTimeout","then","cycle","outcome","recover","at","answeredAt","undefined","cooldown","Math","min","answeredSince","finally","recoveries","WeakMap","serverAnswers","noteFirestoreServerAnswer","firestore","set","recoverStalledFirestore","recovery","get","catch","armFirestoreStallWatch","options","stallMs","onStall","onVisible","fire","removeEventListener","addEventListener"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,cAAc,EACdC,aAAa,QAER,qBAAoB;AAE3B,SAASC,yBAAyB,QAAQ,gCAA4B;AAEtE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsGC,GAED;;;;;CAKC,GACD,OAAO,MAAMC,qBAAqB,KAAK;AAEvC,8DAA8D,GAC9D,OAAO,MAAMC,6BAA6B,MAAM;AAEhD;;;;CAIC,GACD,OAAO,MAAMC,iCAAiC,IAAI,MAAM;AAExD;;;CAGC,GACD,OAAO,MAAMC,0BAA0B,KAAK;AAkD5C,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,kBAAkB,EAClBC,oBAAoBnB,kBAAkB,EACtCoB,aAAanB,0BAA0B,EACvCoB,gBAAgBlB,uBAAuB,EACvCmB,UAAU,EACX,GAAGX;IACJ,IAAIY;IACJ,4DAA4D,GAC5D,IAAIC,cAAc;IAClB,IAAIC;IAEJ,gEAAgE,GAChE,MAAMC,UAAU,CAACC,OACf,IAAIC,QAAQ,CAACC;YACX,MAAMC,QAAQC,WAAW,IAAMF,QAAQ,QAAQR;YAC/C,MAAMW,OAAO,CAACC;gBACZC,aAAaJ;gBACbD,QAAQI;YACV;YACA,IAAI;gBACFN,OAAOQ,IAAI,CACT,IAAMH,KAAK,OACX,IAAMA,KAAK;YAEf,EAAE,eAAM;gBACNA,KAAK;YACP;QACF;IAEF,MAAMI,QAAQ;QACZ,IAAIC,UAAgC;QACpC,IAAI,CAAE,MAAMX,QAAQd,UAAWyB,UAAU;QACzC,IAAI,CAAE,MAAMX,QAAQb,SAAUwB,UAAU;QACxC,IAAIf,YAAY;YACd,IAAI;gBACF,IAAI,MAAMA,cAAc,OAAO;YACjC,EAAE,eAAM;YACN,iEAAiE;YACnE;QACF;QACA,OAAOe;IACT;IAEA,OAAO;QACLC,SAAS;YACP,IAAIb,SAAS,OAAOA;YACpB,IAAI,CAACX,YAAY,OAAOc,QAAQC,OAAO,CAAC;YACxC,IAAI,CAACd,aAAa,OAAOa,QAAQC,OAAO,CAAC;YACzC,MAAMU,KAAKvB;YACX,MAAMwB,aAAatB,sCAAAA;YACnB,IAAIsB,eAAeC,aAAaF,KAAKC,aAAarB,mBAAmB;gBACnE,OAAOS,QAAQC,OAAO,CAAC;YACzB;YACA,IAAIN,kBAAkBkB,WAAW;gBAC/B,MAAMC,WAAWC,KAAKC,GAAG,CACvBxB,aAAa,KAAKI,aAClBtB;gBAEF,IAAIqC,KAAKhB,gBAAgBmB,UAAU,OAAOd,QAAQC,OAAO,CAAC;gBAC1D,MAAMgB,gBACJL,eAAeC,aAAaD,cAAcjB;gBAC5CC,cAAcqB,gBAAgB,IAAIrB,cAAc;YAClD;YACAD,gBAAgBgB;YAChBd,UAAUW,QAAQU,OAAO,CAAC;gBACxBrB,UAAUgB;YACZ;YACA,OAAOhB;QACT;IACF;AACF;AAEA,MAAMsB,aAAa,IAAIC;AAEvB,+DAA+D,GAC/D,MAAMC,gBAAgB,IAAID;AAE1B;;;;CAIC,GACD,OAAO,SAASE,0BACdC,SAAuC;IAEvC,IAAIA,aAAa,OAAOA,cAAc,UAAU;QAC9CF,cAAcG,GAAG,CAACD,WAAWlC,KAAKD,GAAG;IACvC;AACF;AAEA;;;;CAIC,GACD,OAAO,SAASqC,wBACdF,SAAuC;IAEvC,IAAI,CAACA,aAAa,OAAOA,cAAc,UAAU;QAC/C,OAAOvB,QAAQC,OAAO,CAAC;IACzB;IACA,IAAIyB,WAAWP,WAAWQ,GAAG,CAACJ;IAC9B,IAAI,CAACG,UAAU;QACbA,WAAW5C,oBAAoB;YAC7BE,SAAS,IAAMf,eAAesD;YAC9BtC,QAAQ,IAAMf,cAAcqD;YAC5BjC,oBAAoB,IAAM+B,cAAcM,GAAG,CAACJ;YAC5C7B,YAAYvB,0BAA0BoD;QACxC;QACAJ,WAAWK,GAAG,CAACD,WAAWG;IAC5B;IACA,OAAOA,SAAShB,OAAO,GAAGkB,KAAK,CAAC,IAAM;AACxC;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,uBACdN,SAAuC,EACvCO,UAAsD,CAAC,CAAC;QAItDA;IAFF,MAAM,EAAEC,UAAU3D,kBAAkB,EAAE,GAAG0D;IACzC,MAAME,WACJF,mBAAAA,QAAQE,OAAO,YAAfF,mBAAoB,IAAM,KAAKL,wBAAwBF;IACzD,IAAInB,OAAO;IACX,IAAIF;IACJ,IAAI+B;IACJ,MAAMC,OAAO;QACX,IAAI9B,MAAM;QACV,IAAI,CAACzB,qBAAqB,OAAOC,aAAa,aAAa;YACzDqD,YAAY;gBACV,IAAI,CAACtD,mBAAmB;gBACxBC,SAASuD,mBAAmB,CAAC,oBAAoBF;gBACjDA,YAAYpB;gBACZqB;YACF;YACAtD,SAASwD,gBAAgB,CAAC,oBAAoBH;YAC9C;QACF;QACAD;QACA9B,QAAQC,WAAW+B,MAAMH;IAC3B;IACA7B,QAAQC,WAAW+B,MAAMH;IACzB,OAAO;QACL,IAAI3B,MAAM;QACVA,OAAO;QACPE,aAAaJ;QACb,IAAI+B,aAAa,OAAOrD,aAAa,aAAa;YAChDA,SAASuD,mBAAmB,CAAC,oBAAoBF;QACnD;IACF;AACF"}