@aglyn/tenant-feature-instance 1.0.0-beta.143

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.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +3 -0
  3. package/jest.config.d.ts +36 -0
  4. package/package.json +45 -0
  5. package/src/index.d.ts +17 -0
  6. package/src/index.js +18 -0
  7. package/src/index.js.map +1 -0
  8. package/src/lib/components/duplicate-resource-dialog.d.ts +77 -0
  9. package/src/lib/components/duplicate-resource-dialog.js +217 -0
  10. package/src/lib/components/duplicate-resource-dialog.js.map +1 -0
  11. package/src/lib/constants/firebase-config.d.ts +68 -0
  12. package/src/lib/constants/firebase-config.js +155 -0
  13. package/src/lib/constants/firebase-config.js.map +1 -0
  14. package/src/lib/constants/firebase-emulator-hosts.d.ts +60 -0
  15. package/src/lib/constants/firebase-emulator-hosts.js +81 -0
  16. package/src/lib/constants/firebase-emulator-hosts.js.map +1 -0
  17. package/src/lib/constants/mock-data.d.ts +24 -0
  18. package/src/lib/constants/mock-data.js +49 -0
  19. package/src/lib/constants/mock-data.js.map +1 -0
  20. package/src/lib/constants/site-paths.d.ts +47 -0
  21. package/src/lib/constants/site-paths.js +66 -0
  22. package/src/lib/constants/site-paths.js.map +1 -0
  23. package/src/lib/hooks/firebase/auth-persistence.d.ts +74 -0
  24. package/src/lib/hooks/firebase/auth-persistence.js +117 -0
  25. package/src/lib/hooks/firebase/auth-persistence.js.map +1 -0
  26. package/src/lib/hooks/firebase/firebase-services.d.ts +172 -0
  27. package/src/lib/hooks/firebase/firebase-services.js +522 -0
  28. package/src/lib/hooks/firebase/firebase-services.js.map +1 -0
  29. package/src/lib/hooks/firebase/firestore-cache.d.ts +125 -0
  30. package/src/lib/hooks/firebase/firestore-cache.js +152 -0
  31. package/src/lib/hooks/firebase/firestore-cache.js.map +1 -0
  32. package/src/lib/hooks/firebase/firestore-shared-client-state.d.ts +169 -0
  33. package/src/lib/hooks/firebase/firestore-shared-client-state.js +316 -0
  34. package/src/lib/hooks/firebase/firestore-shared-client-state.js.map +1 -0
  35. package/src/lib/hooks/firestore-denial-reporter.d.ts +192 -0
  36. package/src/lib/hooks/firestore-denial-reporter.js +293 -0
  37. package/src/lib/hooks/firestore-denial-reporter.js.map +1 -0
  38. package/src/lib/hooks/helpers/besigner-nodes-converter.d.ts +62 -0
  39. package/src/lib/hooks/helpers/besigner-nodes-converter.js +91 -0
  40. package/src/lib/hooks/helpers/besigner-nodes-converter.js.map +1 -0
  41. package/src/lib/hooks/helpers/guarded-nodes-save.d.ts +80 -0
  42. package/src/lib/hooks/helpers/guarded-nodes-save.js +89 -0
  43. package/src/lib/hooks/helpers/guarded-nodes-save.js.map +1 -0
  44. package/src/lib/hooks/helpers/guarded-seed-write.d.ts +125 -0
  45. package/src/lib/hooks/helpers/guarded-seed-write.js +193 -0
  46. package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -0
  47. package/src/lib/hooks/helpers/listen-options.d.ts +91 -0
  48. package/src/lib/hooks/helpers/listen-options.js +92 -0
  49. package/src/lib/hooks/helpers/listen-options.js.map +1 -0
  50. package/src/lib/hooks/helpers/use-doc.d.ts +38 -0
  51. package/src/lib/hooks/helpers/use-doc.js +213 -0
  52. package/src/lib/hooks/helpers/use-doc.js.map +1 -0
  53. package/src/lib/hooks/helpers/use-modify-doc-callback.d.ts +28 -0
  54. package/src/lib/hooks/helpers/use-modify-doc-callback.js +110 -0
  55. package/src/lib/hooks/helpers/use-modify-doc-callback.js.map +1 -0
  56. package/src/lib/hooks/host-collection-queries.d.ts +125 -0
  57. package/src/lib/hooks/host-collection-queries.js +126 -0
  58. package/src/lib/hooks/host-collection-queries.js.map +1 -0
  59. package/src/lib/hooks/list-filter-constraints.d.ts +64 -0
  60. package/src/lib/hooks/list-filter-constraints.js +288 -0
  61. package/src/lib/hooks/list-filter-constraints.js.map +1 -0
  62. package/src/lib/hooks/sorted-collection-window.d.ts +207 -0
  63. package/src/lib/hooks/sorted-collection-window.js +150 -0
  64. package/src/lib/hooks/sorted-collection-window.js.map +1 -0
  65. package/src/lib/hooks/use-component-version.d.ts +43 -0
  66. package/src/lib/hooks/use-component-version.js +73 -0
  67. package/src/lib/hooks/use-component-version.js.map +1 -0
  68. package/src/lib/hooks/use-component.d.ts +39 -0
  69. package/src/lib/hooks/use-component.js +55 -0
  70. package/src/lib/hooks/use-component.js.map +1 -0
  71. package/src/lib/hooks/use-console-host-route.d.ts +24 -0
  72. package/src/lib/hooks/use-console-host-route.js +77 -0
  73. package/src/lib/hooks/use-console-host-route.js.map +1 -0
  74. package/src/lib/hooks/use-duplicate-resource-api.d.ts +43 -0
  75. package/src/lib/hooks/use-duplicate-resource-api.js +61 -0
  76. package/src/lib/hooks/use-duplicate-resource-api.js.map +1 -0
  77. package/src/lib/hooks/use-firestore-collection.d.ts +62 -0
  78. package/src/lib/hooks/use-firestore-collection.js +307 -0
  79. package/src/lib/hooks/use-firestore-collection.js.map +1 -0
  80. package/src/lib/hooks/use-firestore-doc.d.ts +42 -0
  81. package/src/lib/hooks/use-firestore-doc.js +192 -0
  82. package/src/lib/hooks/use-firestore-doc.js.map +1 -0
  83. package/src/lib/hooks/use-form-version.d.ts +44 -0
  84. package/src/lib/hooks/use-form-version.js +73 -0
  85. package/src/lib/hooks/use-form-version.js.map +1 -0
  86. package/src/lib/hooks/use-host-activity-logger.d.ts +35 -0
  87. package/src/lib/hooks/use-host-activity-logger.js +96 -0
  88. package/src/lib/hooks/use-host-activity-logger.js.map +1 -0
  89. package/src/lib/hooks/use-host-campaigns.d.ts +53 -0
  90. package/src/lib/hooks/use-host-campaigns.js +93 -0
  91. package/src/lib/hooks/use-host-campaigns.js.map +1 -0
  92. package/src/lib/hooks/use-host-org-id.d.ts +96 -0
  93. package/src/lib/hooks/use-host-org-id.js +154 -0
  94. package/src/lib/hooks/use-host-org-id.js.map +1 -0
  95. package/src/lib/hooks/use-host-resource-api.d.ts +43 -0
  96. package/src/lib/hooks/use-host-resource-api.js +60 -0
  97. package/src/lib/hooks/use-host-resource-api.js.map +1 -0
  98. package/src/lib/hooks/use-host-template.d.ts +44 -0
  99. package/src/lib/hooks/use-host-template.js +82 -0
  100. package/src/lib/hooks/use-host-template.js.map +1 -0
  101. package/src/lib/hooks/use-host-version-api.d.ts +59 -0
  102. package/src/lib/hooks/use-host-version-api.js +69 -0
  103. package/src/lib/hooks/use-host-version-api.js.map +1 -0
  104. package/src/lib/hooks/use-host.d.ts +101 -0
  105. package/src/lib/hooks/use-host.js +45 -0
  106. package/src/lib/hooks/use-host.js.map +1 -0
  107. package/src/lib/hooks/use-layout-version.d.ts +33 -0
  108. package/src/lib/hooks/use-layout-version.js +64 -0
  109. package/src/lib/hooks/use-layout-version.js.map +1 -0
  110. package/src/lib/hooks/use-layout.d.ts +41 -0
  111. package/src/lib/hooks/use-layout.js +49 -0
  112. package/src/lib/hooks/use-layout.js.map +1 -0
  113. package/src/lib/hooks/use-live-artifact-count.d.ts +50 -0
  114. package/src/lib/hooks/use-live-artifact-count.js +102 -0
  115. package/src/lib/hooks/use-live-artifact-count.js.map +1 -0
  116. package/src/lib/hooks/use-org-member-options.d.ts +57 -0
  117. package/src/lib/hooks/use-org-member-options.js +112 -0
  118. package/src/lib/hooks/use-org-member-options.js.map +1 -0
  119. package/src/lib/hooks/use-org-plan.d.ts +46 -0
  120. package/src/lib/hooks/use-org-plan.js +86 -0
  121. package/src/lib/hooks/use-org-plan.js.map +1 -0
  122. package/src/lib/hooks/use-paged-collection.d.ts +95 -0
  123. package/src/lib/hooks/use-paged-collection.js +130 -0
  124. package/src/lib/hooks/use-paged-collection.js.map +1 -0
  125. package/src/lib/hooks/use-plugin-config.d.ts +38 -0
  126. package/src/lib/hooks/use-plugin-config.js +120 -0
  127. package/src/lib/hooks/use-plugin-config.js.map +1 -0
  128. package/src/lib/hooks/use-scope-tokens.d.ts +28 -0
  129. package/src/lib/hooks/use-scope-tokens.js +100 -0
  130. package/src/lib/hooks/use-scope-tokens.js.map +1 -0
  131. package/src/lib/hooks/use-screen-version.d.ts +33 -0
  132. package/src/lib/hooks/use-screen-version.js +65 -0
  133. package/src/lib/hooks/use-screen-version.js.map +1 -0
  134. package/src/lib/hooks/use-screen.d.ts +68 -0
  135. package/src/lib/hooks/use-screen.js +50 -0
  136. package/src/lib/hooks/use-screen.js.map +1 -0
  137. package/src/lib/hooks/use-sending-identity-api.d.ts +197 -0
  138. package/src/lib/hooks/use-sending-identity-api.js +95 -0
  139. package/src/lib/hooks/use-sending-identity-api.js.map +1 -0
  140. package/src/lib/hooks/use-sorted-paged-collection.d.ts +48 -0
  141. package/src/lib/hooks/use-sorted-paged-collection.js +213 -0
  142. package/src/lib/hooks/use-sorted-paged-collection.js.map +1 -0
  143. package/src/lib/hooks/use-switcher-collection.d.ts +105 -0
  144. package/src/lib/hooks/use-switcher-collection.js +275 -0
  145. package/src/lib/hooks/use-switcher-collection.js.map +1 -0
  146. package/src/lib/hooks/use-user-name.d.ts +51 -0
  147. package/src/lib/hooks/use-user-name.js +90 -0
  148. package/src/lib/hooks/use-user-name.js.map +1 -0
  149. package/src/lib/hooks/use-user-photo.d.ts +50 -0
  150. package/src/lib/hooks/use-user-photo.js +87 -0
  151. package/src/lib/hooks/use-user-photo.js.map +1 -0
  152. package/src/lib/tenant.d.ts +61 -0
  153. package/src/lib/tenant.js +62 -0
  154. package/src/lib/tenant.js.map +1 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * The multi-tab records the Firestore SDK strands in `localStorage`, and the
18
+ * prune that bounds them (AGL-2845).
19
+ *
20
+ * ## What leaks
21
+ *
22
+ * `persistentMultipleTabManager` shares one IndexedDB cache between tabs and
23
+ * coordinates them through `localStorage` (`WebStorageSharedClientState`).
24
+ * Three of its record families grow without bound, all keyed under the
25
+ * persistence prefix `firestore/<appName>/<projectId>/`:
26
+ *
27
+ * | record | holds |
28
+ * | -- | -- |
29
+ * | `firestore_clients_<prefix>_<clientId>` | the target ids one tab listens to, and when that set last changed |
30
+ * | `firestore_targets_<prefix>_<targetId>` | a target's `current` / `not-current` / `rejected` state, written by the primary tab |
31
+ * | `firestore_zombie_<prefix>_<clientId>` | when a tab closed, so no other tab waits on its lease |
32
+ *
33
+ * Measured against `@firebase/firestore` 4.17.1, and unchanged on the SDK's
34
+ * main branch, a target record is removed in exactly one place:
35
+ * `clearQueryState`, which runs only when the PRIMARY tab unlistens a target no
36
+ * other tab holds. Every other way a target ends leaves its record behind:
37
+ *
38
+ * - a secondary tab unlistens — the primary releases the target and never
39
+ * clears its state;
40
+ * - the backend rejects the listen — `rejected` is written and nothing removes
41
+ * it, so a missing composite index or a `permission-denied` stays on disk;
42
+ * - any tab closes or reloads — `pagehide` removes that tab's own client record
43
+ * and none of its targets.
44
+ *
45
+ * A client record is stranded the same way when a tab ends without `pagehide`
46
+ * (a crash, a discarded tab, a browser quit), and a zombie marker survives
47
+ * whenever its tab's shutdown does not finish and no primary later finds that
48
+ * tab's heartbeat row. Nothing sweeps any of the three. A target id is reused
49
+ * for an identical query, so the pile grows with the number of distinct queries
50
+ * a browser has ever listened to: one production console profile held 2,531
51
+ * target records going back five weeks, against 3 live tabs.
52
+ *
53
+ * ## Why it cannot be left alone
54
+ *
55
+ * `localStorage` holds about 5 MB per origin. Once it is full, the next
56
+ * `setItem` inside the SDK throws `QuotaExceededError` on the SDK's async
57
+ * queue. That marks the queue failed, and every later Firestore operation in
58
+ * the tab dies on `INTERNAL ASSERTION FAILED: Unexpected state (ID: b815)` — the
59
+ * whole Firestore client, not only tab sync. Upstream treats a full
60
+ * `localStorage` as expected under multi-tab (firebase-js-sdk#8305) and closed
61
+ * the leak report without a fix (#9209), so no SDK upgrade removes this.
62
+ *
63
+ * ## What is safe to delete
64
+ *
65
+ * The constraint is absolute: a record a live tab depends on is never removed.
66
+ *
67
+ * - **A target record no surviving client record lists is dead data.** The SDK
68
+ * reads one only when some tab lists that target as active
69
+ * (`addLocalQueryTarget` asks `isActiveQueryTarget` first), and it ignores a
70
+ * storage event reporting one deleted. A record any readable client lists is
71
+ * kept, whether or not that client is live: without it, the next tab to join
72
+ * the target would start it `not-current`.
73
+ * - **A client record is deleted only after the SDK itself has given up on that
74
+ * tab.** Its heartbeat row must be gone from IndexedDB's `clientMetadata`
75
+ * store — a running tab rewrites that row every 4 seconds, and the primary
76
+ * deletes rows 30 minutes stale — AND the record must not have changed for
77
+ * {@link STALE_SHARED_CLIENT_STATE_MS}. Deleting a live tab's client record
78
+ * would make every other tab drop its targets, and the primary would stop
79
+ * watching them. The heartbeat is what separates a live tab left idle (fresh
80
+ * row, old record) from a dead one; the age covers a tab frozen rather than
81
+ * closed, and a tab that started between the heartbeat read and the scan.
82
+ * - **When the heartbeat cannot be read** — no `indexedDB.databases()`, no
83
+ * database yet, a schema without that store — no client record is deleted,
84
+ * and every readable client record keeps pinning its targets.
85
+ * - **A client record that does not parse** pins nothing and is never deleted,
86
+ * matching the SDK, which ignores it the same way.
87
+ * - **A zombie marker older than {@link STALE_SHARED_CLIENT_STATE_MS}** is
88
+ * deleted. The SDK consults one only while the closed tab's heartbeat could
89
+ * still count as fresh, and a day later it cannot.
90
+ *
91
+ * Mutation, online-state, sequence-number and bundle records are never touched,
92
+ * and neither is anything under another app's or another database's prefix.
93
+ *
94
+ * ## When it runs
95
+ *
96
+ * Once per page load, after the provider has initialized the durable cache
97
+ * (`pruneSharedClientStateFor` in `firestore-cache.ts`). Each load clears what
98
+ * earlier sessions stranded, which is what bounds the growth; a one-off cleanup
99
+ * would not, because the SDK strands more on every close.
100
+ */ /**
101
+ * How long a client record or zombie marker must sit unchanged before it can
102
+ * be treated as dead. A day, against the SDK's own 30-minute inactivity
103
+ * horizon, because deleting a live tab's record is the one outcome this must
104
+ * never produce.
105
+ */ export const STALE_SHARED_CLIENT_STATE_MS = 24 * 60 * 60 * 1000;
106
+ /** The IndexedDB store holding one heartbeat row per running tab, keyed by client id. */ const CLIENT_METADATA_STORE = 'clientMetadata';
107
+ /**
108
+ * `firestore/<appName>/<projectId>/` — the SDK's `indexedDbStoragePrefix` for
109
+ * the default database, the only one the console opens. Every record family
110
+ * above hangs off it, and so does the IndexedDB database, `<prefix>main`.
111
+ */ export function firestorePersistencePrefix(appName, projectId) {
112
+ return `firestore/${appName}/${projectId}/`;
113
+ }
114
+ /** The SDK's `RemoteClientState` parse: whatever it would reject is `undefined`. */ function parseClientRecord(value) {
115
+ if (value === null) return undefined;
116
+ let parsed;
117
+ try {
118
+ parsed = JSON.parse(value);
119
+ } catch (unused) {
120
+ return undefined;
121
+ }
122
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
123
+ const { activeTargetIds, updateTimeMs } = parsed;
124
+ if (!Array.isArray(activeTargetIds) || !activeTargetIds.every(Number.isSafeInteger)) {
125
+ return undefined;
126
+ }
127
+ return {
128
+ activeTargetIds,
129
+ updateTimeMs: typeof updateTimeMs === 'number' && Number.isFinite(updateTimeMs) ? updateTimeMs : undefined
130
+ };
131
+ }
132
+ function escapeRegExp(text) {
133
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
134
+ }
135
+ /**
136
+ * Decides what to delete. Reads `storage` and changes nothing.
137
+ *
138
+ * `heartbeatClientIds` is the set of client ids that still have a row in the
139
+ * SDK's `clientMetadata` store, or `undefined` when it could not be read — in
140
+ * which case no client record is eligible.
141
+ */ export function planSharedClientStatePrune(storage, prefix, heartbeatClientIds, now) {
142
+ const escaped = escapeRegExp(prefix);
143
+ // The SDK's own key shapes: a client id never contains `_`, a target id is digits.
144
+ const clientKey = new RegExp(`^firestore_clients_${escaped}_([^_]*)$`);
145
+ const targetKey = new RegExp(`^firestore_targets_${escaped}_(\\d+)$`);
146
+ const zombieKey = new RegExp(`^firestore_zombie_${escaped}_([^_]*)$`);
147
+ const isStale = (at)=>at !== undefined && now - at >= STALE_SHARED_CLIENT_STATE_MS;
148
+ const plan = {
149
+ remove: [],
150
+ targets: {
151
+ kept: 0,
152
+ removed: 0
153
+ },
154
+ clients: {
155
+ kept: 0,
156
+ removed: 0
157
+ },
158
+ zombies: {
159
+ kept: 0,
160
+ removed: 0
161
+ },
162
+ heartbeat: heartbeatClientIds !== undefined
163
+ };
164
+ const targets = [];
165
+ const pinned = new Set();
166
+ for(let index = 0; index < storage.length; index++){
167
+ const key = storage.key(index);
168
+ if (key === null) continue;
169
+ const target = targetKey.exec(key);
170
+ if (target) {
171
+ targets.push({
172
+ key,
173
+ targetId: Number(target[1])
174
+ });
175
+ continue;
176
+ }
177
+ const client = clientKey.exec(key);
178
+ if (client) {
179
+ const record = parseClientRecord(storage.getItem(key));
180
+ const dead = record !== undefined && heartbeatClientIds !== undefined && !heartbeatClientIds.has(client[1]) && isStale(record.updateTimeMs);
181
+ if (dead) {
182
+ plan.remove.push(key);
183
+ plan.clients.removed++;
184
+ } else {
185
+ plan.clients.kept++;
186
+ record == null ? void 0 : record.activeTargetIds.forEach((targetId)=>pinned.add(targetId));
187
+ }
188
+ continue;
189
+ }
190
+ if (zombieKey.test(key)) {
191
+ const value = storage.getItem(key);
192
+ if (value !== null && /^\d+$/.test(value) && isStale(Number(value))) {
193
+ plan.remove.push(key);
194
+ plan.zombies.removed++;
195
+ } else {
196
+ plan.zombies.kept++;
197
+ }
198
+ }
199
+ }
200
+ // Judged last, once every surviving client record has pinned its targets.
201
+ for (const { key, targetId } of targets){
202
+ if (pinned.has(targetId)) {
203
+ plan.targets.kept++;
204
+ } else {
205
+ plan.remove.push(key);
206
+ plan.targets.removed++;
207
+ }
208
+ }
209
+ return plan;
210
+ }
211
+ /**
212
+ * The client ids that still have a heartbeat row in the SDK's `clientMetadata`
213
+ * store, or `undefined` when that cannot be established.
214
+ *
215
+ * Opens only a database that already exists, at whatever version it already
216
+ * has: `open(name)` on a missing database would CREATE it at version 1, and the
217
+ * SDK would then try to migrate a schema that was never written. The connection
218
+ * serves one read-only request and is closed — at once, too, on
219
+ * `versionchange`, so it can never hold up the SDK's own upgrade.
220
+ */ export async function readHeartbeatClientIds(factory, databaseName, timeoutMs = 5000) {
221
+ if (!factory || typeof factory.databases !== 'function') return undefined;
222
+ try {
223
+ const databases = await factory.databases();
224
+ if (!databases.some((database)=>database.name === databaseName)) return undefined;
225
+ } catch (unused) {
226
+ return undefined;
227
+ }
228
+ return new Promise((resolve)=>{
229
+ let settled = false;
230
+ const settle = (value, database)=>{
231
+ database == null ? void 0 : database.close();
232
+ if (settled) return;
233
+ settled = true;
234
+ clearTimeout(timer);
235
+ resolve(value);
236
+ };
237
+ const timer = setTimeout(()=>settle(undefined), timeoutMs);
238
+ let request;
239
+ try {
240
+ request = factory.open(databaseName);
241
+ } catch (unused) {
242
+ settle(undefined);
243
+ return;
244
+ }
245
+ // The database was deleted between `databases()` and `open()`: abort the
246
+ // creation rather than leave the SDK an empty version-1 database.
247
+ request.onupgradeneeded = ()=>{
248
+ var _request_transaction;
249
+ return (_request_transaction = request.transaction) == null ? void 0 : _request_transaction.abort();
250
+ };
251
+ request.onerror = ()=>settle(undefined);
252
+ request.onsuccess = ()=>{
253
+ const database = request.result;
254
+ database.onversionchange = ()=>database.close();
255
+ if (settled || !database.objectStoreNames.contains(CLIENT_METADATA_STORE)) {
256
+ settle(undefined, database);
257
+ return;
258
+ }
259
+ try {
260
+ const keys = database.transaction(CLIENT_METADATA_STORE, 'readonly').objectStore(CLIENT_METADATA_STORE).getAllKeys();
261
+ keys.onsuccess = ()=>settle(new Set(keys.result.filter((key)=>typeof key === 'string')), database);
262
+ keys.onerror = ()=>settle(undefined, database);
263
+ } catch (unused) {
264
+ settle(undefined, database);
265
+ }
266
+ };
267
+ });
268
+ }
269
+ /**
270
+ * Reads the heartbeat, then plans and deletes in one synchronous pass, so
271
+ * nothing else in this tab can write a record between the scan and the
272
+ * deletes. The heartbeat is read BEFORE the scan: a tab that starts in between
273
+ * shows up with a brand-new record and no heartbeat row, which the age rule
274
+ * keeps.
275
+ *
276
+ * Never rejects. Resolves `undefined` when storage could not be read.
277
+ */ export async function pruneSharedClientState({ storage, prefix, heartbeatClientIds, now = Date.now }) {
278
+ let heartbeat;
279
+ try {
280
+ heartbeat = await heartbeatClientIds();
281
+ } catch (unused) {
282
+ heartbeat = undefined;
283
+ }
284
+ try {
285
+ const plan = planSharedClientStatePrune(storage, prefix, heartbeat, now());
286
+ for (const key of plan.remove)storage.removeItem(key);
287
+ return plan;
288
+ } catch (unused) {
289
+ return undefined;
290
+ }
291
+ }
292
+ /**
293
+ * {@link pruneSharedClientState} against this page's own `localStorage` and
294
+ * IndexedDB. Resolves `undefined` off-browser, without an app name or project
295
+ * id, or where site data is blocked (reading `window.localStorage` throws
296
+ * there).
297
+ */ export async function pruneBrowserSharedClientState(appName, projectId) {
298
+ if (!appName || !projectId || typeof window === 'undefined') return undefined;
299
+ let storage;
300
+ let factory;
301
+ try {
302
+ storage = window.localStorage;
303
+ factory = window.indexedDB;
304
+ } catch (unused) {
305
+ return undefined;
306
+ }
307
+ if (!storage) return undefined;
308
+ const prefix = firestorePersistencePrefix(appName, projectId);
309
+ return pruneSharedClientState({
310
+ storage,
311
+ prefix,
312
+ heartbeatClientIds: ()=>readHeartbeatClientIds(factory, `${prefix}main`)
313
+ });
314
+ }
315
+
316
+ //# sourceMappingURL=firestore-shared-client-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-shared-client-state.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\n/**\n * The multi-tab records the Firestore SDK strands in `localStorage`, and the\n * prune that bounds them (AGL-2845).\n *\n * ## What leaks\n *\n * `persistentMultipleTabManager` shares one IndexedDB cache between tabs and\n * coordinates them through `localStorage` (`WebStorageSharedClientState`).\n * Three of its record families grow without bound, all keyed under the\n * persistence prefix `firestore/<appName>/<projectId>/`:\n *\n * | record | holds |\n * | -- | -- |\n * | `firestore_clients_<prefix>_<clientId>` | the target ids one tab listens to, and when that set last changed |\n * | `firestore_targets_<prefix>_<targetId>` | a target's `current` / `not-current` / `rejected` state, written by the primary tab |\n * | `firestore_zombie_<prefix>_<clientId>` | when a tab closed, so no other tab waits on its lease |\n *\n * Measured against `@firebase/firestore` 4.17.1, and unchanged on the SDK's\n * main branch, a target record is removed in exactly one place:\n * `clearQueryState`, which runs only when the PRIMARY tab unlistens a target no\n * other tab holds. Every other way a target ends leaves its record behind:\n *\n * - a secondary tab unlistens — the primary releases the target and never\n * clears its state;\n * - the backend rejects the listen — `rejected` is written and nothing removes\n * it, so a missing composite index or a `permission-denied` stays on disk;\n * - any tab closes or reloads — `pagehide` removes that tab's own client record\n * and none of its targets.\n *\n * A client record is stranded the same way when a tab ends without `pagehide`\n * (a crash, a discarded tab, a browser quit), and a zombie marker survives\n * whenever its tab's shutdown does not finish and no primary later finds that\n * tab's heartbeat row. Nothing sweeps any of the three. A target id is reused\n * for an identical query, so the pile grows with the number of distinct queries\n * a browser has ever listened to: one production console profile held 2,531\n * target records going back five weeks, against 3 live tabs.\n *\n * ## Why it cannot be left alone\n *\n * `localStorage` holds about 5 MB per origin. Once it is full, the next\n * `setItem` inside the SDK throws `QuotaExceededError` on the SDK's async\n * queue. That marks the queue failed, and every later Firestore operation in\n * the tab dies on `INTERNAL ASSERTION FAILED: Unexpected state (ID: b815)` — the\n * whole Firestore client, not only tab sync. Upstream treats a full\n * `localStorage` as expected under multi-tab (firebase-js-sdk#8305) and closed\n * the leak report without a fix (#9209), so no SDK upgrade removes this.\n *\n * ## What is safe to delete\n *\n * The constraint is absolute: a record a live tab depends on is never removed.\n *\n * - **A target record no surviving client record lists is dead data.** The SDK\n * reads one only when some tab lists that target as active\n * (`addLocalQueryTarget` asks `isActiveQueryTarget` first), and it ignores a\n * storage event reporting one deleted. A record any readable client lists is\n * kept, whether or not that client is live: without it, the next tab to join\n * the target would start it `not-current`.\n * - **A client record is deleted only after the SDK itself has given up on that\n * tab.** Its heartbeat row must be gone from IndexedDB's `clientMetadata`\n * store — a running tab rewrites that row every 4 seconds, and the primary\n * deletes rows 30 minutes stale — AND the record must not have changed for\n * {@link STALE_SHARED_CLIENT_STATE_MS}. Deleting a live tab's client record\n * would make every other tab drop its targets, and the primary would stop\n * watching them. The heartbeat is what separates a live tab left idle (fresh\n * row, old record) from a dead one; the age covers a tab frozen rather than\n * closed, and a tab that started between the heartbeat read and the scan.\n * - **When the heartbeat cannot be read** — no `indexedDB.databases()`, no\n * database yet, a schema without that store — no client record is deleted,\n * and every readable client record keeps pinning its targets.\n * - **A client record that does not parse** pins nothing and is never deleted,\n * matching the SDK, which ignores it the same way.\n * - **A zombie marker older than {@link STALE_SHARED_CLIENT_STATE_MS}** is\n * deleted. The SDK consults one only while the closed tab's heartbeat could\n * still count as fresh, and a day later it cannot.\n *\n * Mutation, online-state, sequence-number and bundle records are never touched,\n * and neither is anything under another app's or another database's prefix.\n *\n * ## When it runs\n *\n * Once per page load, after the provider has initialized the durable cache\n * (`pruneSharedClientStateFor` in `firestore-cache.ts`). Each load clears what\n * earlier sessions stranded, which is what bounds the growth; a one-off cleanup\n * would not, because the SDK strands more on every close.\n */\n\n/**\n * How long a client record or zombie marker must sit unchanged before it can\n * be treated as dead. A day, against the SDK's own 30-minute inactivity\n * horizon, because deleting a live tab's record is the one outcome this must\n * never produce.\n */\nexport const STALE_SHARED_CLIENT_STATE_MS = 24 * 60 * 60 * 1000\n\n/** The IndexedDB store holding one heartbeat row per running tab, keyed by client id. */\nconst CLIENT_METADATA_STORE = 'clientMetadata'\n\n/**\n * `firestore/<appName>/<projectId>/` — the SDK's `indexedDbStoragePrefix` for\n * the default database, the only one the console opens. Every record family\n * above hangs off it, and so does the IndexedDB database, `<prefix>main`.\n */\nexport function firestorePersistencePrefix(appName: string, projectId: string): string {\n return `firestore/${appName}/${projectId}/`\n}\n\nexport interface SharedClientStateTally {\n kept: number\n removed: number\n}\n\nexport interface SharedClientStatePrunePlan {\n /** Every record to delete. */\n remove: string[]\n targets: SharedClientStateTally\n clients: SharedClientStateTally\n zombies: SharedClientStateTally\n /** Whether the heartbeat was readable — without it no client record is eligible. */\n heartbeat: boolean\n}\n\ninterface ClientRecord {\n activeTargetIds: readonly number[]\n updateTimeMs: number | undefined\n}\n\n/** The SDK's `RemoteClientState` parse: whatever it would reject is `undefined`. */\nfunction parseClientRecord(value: string | null): ClientRecord | undefined {\n if (value === null) return undefined\n let parsed: unknown\n try {\n parsed = JSON.parse(value)\n } catch {\n return undefined\n }\n if (typeof parsed !== 'object' || parsed === null) return undefined\n const { activeTargetIds, updateTimeMs } = parsed as Record<string, unknown>\n if (!Array.isArray(activeTargetIds) || !activeTargetIds.every(Number.isSafeInteger)) {\n return undefined\n }\n return {\n activeTargetIds,\n updateTimeMs:\n typeof updateTimeMs === 'number' && Number.isFinite(updateTimeMs)\n ? updateTimeMs\n : undefined,\n }\n}\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/**\n * Decides what to delete. Reads `storage` and changes nothing.\n *\n * `heartbeatClientIds` is the set of client ids that still have a row in the\n * SDK's `clientMetadata` store, or `undefined` when it could not be read — in\n * which case no client record is eligible.\n */\nexport function planSharedClientStatePrune(\n storage: Pick<Storage, 'length' | 'key' | 'getItem'>,\n prefix: string,\n heartbeatClientIds: ReadonlySet<string> | undefined,\n now: number,\n): SharedClientStatePrunePlan {\n const escaped = escapeRegExp(prefix)\n // The SDK's own key shapes: a client id never contains `_`, a target id is digits.\n const clientKey = new RegExp(`^firestore_clients_${escaped}_([^_]*)$`)\n const targetKey = new RegExp(`^firestore_targets_${escaped}_(\\\\d+)$`)\n const zombieKey = new RegExp(`^firestore_zombie_${escaped}_([^_]*)$`)\n const isStale = (at: number | undefined) =>\n at !== undefined && now - at >= STALE_SHARED_CLIENT_STATE_MS\n\n const plan: SharedClientStatePrunePlan = {\n remove: [],\n targets: { kept: 0, removed: 0 },\n clients: { kept: 0, removed: 0 },\n zombies: { kept: 0, removed: 0 },\n heartbeat: heartbeatClientIds !== undefined,\n }\n const targets: Array<{ key: string; targetId: number }> = []\n const pinned = new Set<number>()\n\n for (let index = 0; index < storage.length; index++) {\n const key = storage.key(index)\n if (key === null) continue\n\n const target = targetKey.exec(key)\n if (target) {\n targets.push({ key, targetId: Number(target[1]) })\n continue\n }\n\n const client = clientKey.exec(key)\n if (client) {\n const record = parseClientRecord(storage.getItem(key))\n const dead =\n record !== undefined &&\n heartbeatClientIds !== undefined &&\n !heartbeatClientIds.has(client[1]) &&\n isStale(record.updateTimeMs)\n if (dead) {\n plan.remove.push(key)\n plan.clients.removed++\n } else {\n plan.clients.kept++\n record?.activeTargetIds.forEach((targetId) => pinned.add(targetId))\n }\n continue\n }\n\n if (zombieKey.test(key)) {\n const value = storage.getItem(key)\n if (value !== null && /^\\d+$/.test(value) && isStale(Number(value))) {\n plan.remove.push(key)\n plan.zombies.removed++\n } else {\n plan.zombies.kept++\n }\n }\n }\n\n // Judged last, once every surviving client record has pinned its targets.\n for (const { key, targetId } of targets) {\n if (pinned.has(targetId)) {\n plan.targets.kept++\n } else {\n plan.remove.push(key)\n plan.targets.removed++\n }\n }\n return plan\n}\n\n/**\n * The client ids that still have a heartbeat row in the SDK's `clientMetadata`\n * store, or `undefined` when that cannot be established.\n *\n * Opens only a database that already exists, at whatever version it already\n * has: `open(name)` on a missing database would CREATE it at version 1, and the\n * SDK would then try to migrate a schema that was never written. The connection\n * serves one read-only request and is closed — at once, too, on\n * `versionchange`, so it can never hold up the SDK's own upgrade.\n */\nexport async function readHeartbeatClientIds(\n factory: IDBFactory | undefined,\n databaseName: string,\n timeoutMs = 5_000,\n): Promise<ReadonlySet<string> | undefined> {\n if (!factory || typeof factory.databases !== 'function') return undefined\n try {\n const databases = await factory.databases()\n if (!databases.some((database) => database.name === databaseName)) return undefined\n } catch {\n return undefined\n }\n\n return new Promise((resolve) => {\n let settled = false\n const settle = (value: ReadonlySet<string> | undefined, database?: IDBDatabase) => {\n database?.close()\n if (settled) return\n settled = true\n clearTimeout(timer)\n resolve(value)\n }\n const timer = setTimeout(() => settle(undefined), timeoutMs)\n\n let request: IDBOpenDBRequest\n try {\n request = factory.open(databaseName)\n } catch {\n settle(undefined)\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(undefined)\n request.onsuccess = () => {\n const database = request.result\n database.onversionchange = () => database.close()\n if (settled || !database.objectStoreNames.contains(CLIENT_METADATA_STORE)) {\n settle(undefined, database)\n return\n }\n try {\n const keys = database\n .transaction(CLIENT_METADATA_STORE, 'readonly')\n .objectStore(CLIENT_METADATA_STORE)\n .getAllKeys()\n keys.onsuccess = () =>\n settle(\n new Set(keys.result.filter((key): key is string => typeof key === 'string')),\n database,\n )\n keys.onerror = () => settle(undefined, database)\n } catch {\n settle(undefined, database)\n }\n }\n })\n}\n\nexport interface PruneSharedClientStateOptions {\n storage: Storage\n prefix: string\n heartbeatClientIds: () => Promise<ReadonlySet<string> | undefined>\n now?: () => number\n}\n\n/**\n * Reads the heartbeat, then plans and deletes in one synchronous pass, so\n * nothing else in this tab can write a record between the scan and the\n * deletes. The heartbeat is read BEFORE the scan: a tab that starts in between\n * shows up with a brand-new record and no heartbeat row, which the age rule\n * keeps.\n *\n * Never rejects. Resolves `undefined` when storage could not be read.\n */\nexport async function pruneSharedClientState({\n storage,\n prefix,\n heartbeatClientIds,\n now = Date.now,\n}: PruneSharedClientStateOptions): Promise<SharedClientStatePrunePlan | undefined> {\n let heartbeat: ReadonlySet<string> | undefined\n try {\n heartbeat = await heartbeatClientIds()\n } catch {\n heartbeat = undefined\n }\n try {\n const plan = planSharedClientStatePrune(storage, prefix, heartbeat, now())\n for (const key of plan.remove) storage.removeItem(key)\n return plan\n } catch {\n return undefined\n }\n}\n\n/**\n * {@link pruneSharedClientState} against this page's own `localStorage` and\n * IndexedDB. Resolves `undefined` off-browser, without an app name or project\n * id, or where site data is blocked (reading `window.localStorage` throws\n * there).\n */\nexport async function pruneBrowserSharedClientState(\n appName: string | undefined,\n projectId: string | undefined,\n): Promise<SharedClientStatePrunePlan | undefined> {\n if (!appName || !projectId || typeof window === 'undefined') return undefined\n let storage: Storage | undefined\n let factory: IDBFactory | undefined\n try {\n storage = window.localStorage\n factory = window.indexedDB\n } catch {\n return undefined\n }\n if (!storage) return undefined\n const prefix = firestorePersistencePrefix(appName, projectId)\n return pruneSharedClientState({\n storage,\n prefix,\n heartbeatClientIds: () => readHeartbeatClientIds(factory, `${prefix}main`),\n })\n}\n"],"names":["STALE_SHARED_CLIENT_STATE_MS","CLIENT_METADATA_STORE","firestorePersistencePrefix","appName","projectId","parseClientRecord","value","undefined","parsed","JSON","parse","activeTargetIds","updateTimeMs","Array","isArray","every","Number","isSafeInteger","isFinite","escapeRegExp","text","replace","planSharedClientStatePrune","storage","prefix","heartbeatClientIds","now","escaped","clientKey","RegExp","targetKey","zombieKey","isStale","at","plan","remove","targets","kept","removed","clients","zombies","heartbeat","pinned","Set","index","length","key","target","exec","push","targetId","client","record","getItem","dead","has","forEach","add","test","readHeartbeatClientIds","factory","databaseName","timeoutMs","databases","some","database","name","Promise","resolve","settled","settle","close","clearTimeout","timer","setTimeout","request","open","onupgradeneeded","transaction","abort","onerror","onsuccess","result","onversionchange","objectStoreNames","contains","keys","objectStore","getAllKeys","filter","pruneSharedClientState","Date","removeItem","pruneBrowserSharedClientState","window","localStorage","indexedDB"],"mappings":"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"}
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Let a LISTENER tell the app its reads are being refused (AGL-1066).
19
+ *
20
+ * `session-health` was built around one-shot reads, because that is where
21
+ * AGL-1062 was first noticed. The console is listener-first, so the detector
22
+ * was watching the read style the console barely uses: only four one-shot
23
+ * call sites pass a collection label, one of them fires once per mount and
24
+ * so can never recur inside the 60s window, and none of the pages carrying a
25
+ * stale-write guard issues a labelled read at all. The verdict was
26
+ * unreachable on the surfaces that needed it.
27
+ *
28
+ * Listeners are on every page, and they already know which collection they
29
+ * are reading, so labelling is free rather than something a caller must
30
+ * remember. This is the seam that lets them report without the library
31
+ * importing the app: the console registers `reportDeniedRead` at startup and
32
+ * the hooks call it through here. An unregistered reporter is a no-op, which
33
+ * is what the tenant runtime wants — it has no client Firestore at all.
34
+ *
35
+ * ## Why this cannot fire offline
36
+ *
37
+ * Only `permission-denied` is ever reported. A client that has simply lost
38
+ * the network gets no error callback at all — the listen sits there and the
39
+ * cache keeps answering — and any one-shot it does make fails `unavailable`.
40
+ * That is what keeps the whole mechanism from mistaking a tunnel for a dead
41
+ * session, and it is why callers must NOT relax the code check.
42
+ *
43
+ * The channel runs both ways: see {@link reportFirestoreSessionHeal} at the
44
+ * bottom of this file for the return leg, which is how a refused listener
45
+ * learns the session came back. It lives here rather than in a module of its
46
+ * own because a heal is defined entirely in terms of the denial it reverses —
47
+ * split them and someone broadcasts one without the other's rules.
48
+ */
49
+ export interface FirestoreSessionReporters {
50
+ /** A listen was refused past the budget; names the collection. */
51
+ onDenied: (collection?: string) => void;
52
+ /**
53
+ * A listen was answered BY THE SERVER.
54
+ *
55
+ * Reported as well as the denials, and it is what keeps this safe to turn
56
+ * on. A scoped collaborator (AGL-1041) has collections they may not read
57
+ * by design, and with listeners reporting they could otherwise accumulate
58
+ * two denied collections and be told their session is dead. But their
59
+ * other listens keep succeeding against the server, and one server answer
60
+ * clears the evidence outright — a genuinely dead session has no such
61
+ * answer to offer, so it is the discriminator the denial count alone
62
+ * cannot be.
63
+ *
64
+ * A CACHED snapshot must never be passed here; it proves nothing.
65
+ */
66
+ onServerRead: () => void;
67
+ }
68
+ /**
69
+ * Register the app's reporters. Called once, by the app that owns
70
+ * `session-health`. Pass `null` to unregister (tests).
71
+ */
72
+ export declare function setFirestoreSessionReporters(next: FirestoreSessionReporters | null): void;
73
+ /** Report a refused listen. No-op when nothing is registered. */
74
+ export declare function reportFirestoreDenial(collection?: string): void;
75
+ /** Report a listen the SERVER answered. No-op when nothing is registered. */
76
+ export declare function reportFirestoreServerRead(): void;
77
+ /**
78
+ * Record a server-answered snapshot WITHOUT reporting to `session-health`.
79
+ * `reportFirestoreServerRead` calls this; `helpers/use-doc` calls only this.
80
+ */
81
+ export declare function noteFirestoreServerRead(): void;
82
+ /** Test seam — the evidence is module scope by design. */
83
+ export declare function resetFirestoreServerReadEvidence(): void;
84
+ /**
85
+ * Cadence for a refusal streak that has outlived the retry budget (AGL-1066).
86
+ *
87
+ * A `permission-denied` has two very different causes and the client cannot
88
+ * tell them apart from the error alone:
89
+ *
90
+ * - a SESSION fault — stale token, App Check hiccup, an AGL-1143 SSO
91
+ * session that refuses everything. Every listener in the tab is refused,
92
+ * and the heal is an AGL-664 in-place re-auth or a token that attaches
93
+ * late. A young fault usually heals within seconds, so it starts at 2s.
94
+ *
95
+ * - a RULES denial — the ref itself is one this user may never read (a
96
+ * sentinel id, a scoped collaborator's off-limits collection). Only a
97
+ * rules change (a membership granted mid-session) can end it, so it
98
+ * starts at 60s (AGL-1440).
99
+ *
100
+ * The discriminator is the one this module already trusts for the
101
+ * session-health verdict: A GENUINELY DEAD SESSION HAS NO SERVER ANSWER TO
102
+ * OFFER. If any listener has been answered by the server since this
103
+ * listener's streak began, the session can read and this refusal is about
104
+ * the ref.
105
+ *
106
+ * Either way the delay then grows with the streak's age — a tenth of it, up
107
+ * to five minutes (AGL-2945). A refusal that has lasted ten minutes is
108
+ * unlikely to end in the next two seconds, and refused listens are not free:
109
+ * they bill reads (AGL-2944), so a fixed cadence made every console left open
110
+ * on a dead session a standing cost of 1,800 refusals an hour per listener.
111
+ * Per listener a session fault now costs ~70 in its first hour and 12 an hour
112
+ * after; a rules denial ~35, then 12. The listen is never abandoned.
113
+ *
114
+ * What the fast fixed cadence used to protect is kept as WAKES rather than as
115
+ * a timer — see {@link scheduleRefusedReopen}. The AGL-664 heal broadcast, the
116
+ * tab becoming visible and the window regaining focus all reopen at once, so
117
+ * the slow road only governs a page nobody is touching. The AGL-1358 write
118
+ * guards wait on a save someone clicks, which puts someone at the page.
119
+ */
120
+ export declare const SESSION_REFUSED_RETRY_DELAY_MS = 2000;
121
+ export declare const RULES_REFUSED_RETRY_DELAY_MS = 60000;
122
+ /** The longest a refused listen ever waits for its next reopen. */
123
+ export declare const REFUSED_RETRY_CEILING_MS: number;
124
+ /**
125
+ * The delay before a spent refusal streak reopens its listen.
126
+ *
127
+ * `streakStartedAt` is when the streak's FIRST refusal landed. Evaluated per
128
+ * retry, not once: evidence that arrives mid-streak (another listener's
129
+ * first server answer) raises the floor from the next reopen on, and the age
130
+ * term keeps growing for as long as the streak lasts.
131
+ */
132
+ export declare function refusedRetryDelayMs(streakStartedAt: number): number;
133
+ /**
134
+ * Schedule the reopen of a spent refusal streak, and return its cancel.
135
+ *
136
+ * In a visible tab the reopen waits {@link refusedRetryDelayMs}. A HIDDEN tab
137
+ * sets no timer at all: nobody is there to heal for, and an unattended tab on
138
+ * a dead session was the Aug 20 – Sep 4 storm — 400K–1.7M rules denials a day
139
+ * in ~8-hour blocks, ~$2 of reads the `read_ops_count` metric never showed
140
+ * (AGL-2944). A tab that goes hidden while a reopen is pending drops its
141
+ * timer and waits the same way.
142
+ *
143
+ * A person arriving reopens at once, whatever the cadence: the tab becoming
144
+ * visible or the window regaining focus. (The heal broadcast is the third
145
+ * instant road; the hooks subscribe to it themselves.) Arrivals are held to
146
+ * one reopen no sooner than {@link SESSION_REFUSED_RETRY_DELAY_MS} after the
147
+ * refusal that scheduled this, so focus churn can never run the loop faster
148
+ * than the fixed cadence it replaced.
149
+ */
150
+ export declare function scheduleRefusedReopen(reopen: () => void, streakStartedAt: number): () => void;
151
+ /**
152
+ * Listen for "the session may have recovered". Returns the unsubscribe.
153
+ *
154
+ * The callback runs synchronously inside {@link reportFirestoreSessionHeal},
155
+ * so it must be cheap and must tolerate being called when there is nothing
156
+ * to do.
157
+ */
158
+ export declare function subscribeFirestoreSessionHeal(listener: () => void): () => void;
159
+ /**
160
+ * Tell every listener the session may have recovered.
161
+ *
162
+ * Iterates a COPY: a subscriber reopening its listen can unsubscribe (an
163
+ * unmount racing the broadcast) and mutating the set mid-iteration would skip
164
+ * the next one.
165
+ */
166
+ export declare function reportFirestoreSessionHeal(): void;
167
+ /**
168
+ * Consecutive refusals before a listener says anything.
169
+ *
170
+ * Matched to `firestore-one-shot-retry`'s budget so both read styles apply
171
+ * the same bar: surviving this many means it is not the AGL-216/217
172
+ * post-sign-in race, which resolves in well under two seconds.
173
+ *
174
+ * The streak this counts is NOT the hook's retry `attempt`. That one is
175
+ * reset by any snapshot, including one served from cache, so under
176
+ * `persistentLocalCache` it never reaches any threshold at all — see
177
+ * `use-firestore-collection-cached-retry.spec.ts`. This one is reset only by
178
+ * a snapshot the SERVER answered, which is the only evidence that actually
179
+ * bears on whether the session can read.
180
+ */
181
+ export declare const DENIAL_STREAK_TO_REPORT = 5;
182
+ /**
183
+ * The collection a listen was against, when it can be known from public API.
184
+ *
185
+ * `DocumentReference` exposes `parent`, and a `CollectionReference` exposes
186
+ * `path`. A filtered `Query` exposes neither, and reaching into `_query` to
187
+ * get one would be reading SDK internals — so those report `undefined` and
188
+ * land in the shared `unknown` bucket. That can only ever fail to reach the
189
+ * two-collection threshold, never trip it falsely, which is the right way
190
+ * for this to degrade.
191
+ */
192
+ export declare function denialLabelForQuery(target: unknown): string | undefined;