@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,125 @@
1
+ import { type FirebaseApp } from 'firebase/app';
2
+ import { type FirestoreLocalCache } from 'firebase/firestore';
3
+ import { type AuthPersistenceClass } from './auth-persistence';
4
+ import { type SharedClientStatePrunePlan } from './firestore-shared-client-state';
5
+ /**
6
+ * The durable cache's size bound: the SDK's own default, 40 MB, written down.
7
+ * See "The durable cache's size" on {@link localCacheFor}.
8
+ */
9
+ export declare const DURABLE_CACHE_SIZE_BYTES: number;
10
+ /**
11
+ * The Firestore `localCache` an origin of this persistence class may use
12
+ * (AGL-1456).
13
+ *
14
+ * ## Why this keys off the *auth* persistence class
15
+ *
16
+ * The name is historical — the class predates this file (AGL-1379) — but the
17
+ * question it answers was never auth-specific. `AuthPersistenceClass` is
18
+ * documented as *"how much of a session an origin is allowed to keep"*, and
19
+ * AGL-1099a's D6 justified `ephemeral` on the grounds that **"the customer can
20
+ * re-point the DNS and read this origin."**
21
+ *
22
+ * That reasoning does not stop at the credential. It is equally true of every
23
+ * document body Firestore caches in IndexedDB on that origin — measured, not
24
+ * argued: the PoC (`docs/design/agl-1099a-poc-findings.md` §5) dumped every
25
+ * object store on the origin and found full document contents under
26
+ * `firestore/<app>/<project>/main → remoteDocumentsV14`, alongside
27
+ * `firestore_clients_*` / `firestore_online_state_*` /
28
+ * `firestore_sequence_number_*` in `localStorage`. D6 hardened the key and
29
+ * left the safe behind it open; this closes it.
30
+ *
31
+ * **One declaration, two consequences — deliberately.** A separate
32
+ * `firestoreCache` prop would let a caller declare a custom console domain
33
+ * `ephemeral` for auth and still hand it a persistent cache, which is exactly
34
+ * the split that produced this bug. There is one fact about the origin, so
35
+ * there is one place to state it.
36
+ *
37
+ * ## The two classes
38
+ *
39
+ * - `durable` → `persistentLocalCache` with the multi-tab manager. Unchanged
40
+ * from what every host has always run, and it must stay that way:
41
+ * `*.aglyn.com` / `*.aglyn.app` are ours forever, and this cache is part of
42
+ * why console read volume is what it is (AGL-1440).
43
+ * - `ephemeral` → `memoryLocalCache`, so **nothing** reaches disk on an origin
44
+ * whose DNS the customer can re-point at their own server.
45
+ *
46
+ * ## The durable cache's size
47
+ *
48
+ * `cacheSizeBytes` is spelled out at the SDK default so the bound is stated
49
+ * here and cannot quietly become `CACHE_SIZE_UNLIMITED` if that default moves.
50
+ * Stating it changes nothing at runtime, and what it governs is narrower than
51
+ * the name suggests — measured against `@firebase/firestore` 4.17.1:
52
+ *
53
+ * - LRU collection is on without it: an unset `cacheSizeBytes` resolves to
54
+ * `LruParams.DEFAULT`, the same 40 MB threshold.
55
+ * - It runs only in the tab holding the primary lease, 1 minute after that tab
56
+ * becomes primary and every 5 minutes after.
57
+ * - The threshold is compared with the byte size of **cached documents**
58
+ * (`remoteDocumentsV14`) and nothing else. `targets` and `targetDocuments`
59
+ * rows are collected alongside documents once documents cross it, never
60
+ * before.
61
+ * - It never touches `localStorage`, where the multi-tab manager strands a
62
+ * record per tab and per query target; {@link pruneSharedClientStateFor}
63
+ * handles those.
64
+ *
65
+ * It is deliberately not lowered. A smaller cache evicts exactly the documents
66
+ * that make this cache worth having — a cold load resuming from disk, and
67
+ * listeners painting through a stale session — and a collected target is given
68
+ * a NEW id the next time its query is listened to, which adds a `localStorage`
69
+ * record rather than removing one.
70
+ *
71
+ * ## Why the memory cache gets the LRU collector, not the default
72
+ *
73
+ * `memoryLocalCache()` defaults to `memoryEagerGarbageCollector`, which drops
74
+ * a document the moment no listener references it — so navigating away from a
75
+ * console page and back re-reads its whole working set from the server. The
76
+ * LRU collector keeps documents in the JS heap up to its 40 MB default instead,
77
+ * which recovers most of the intra-session read saving `persistentLocalCache`
78
+ * was giving us.
79
+ *
80
+ * It costs nothing against the property being protected: the LRU collector is
81
+ * still **memory only**. Nothing is written to IndexedDB, nothing survives a
82
+ * tab close, and a page load on a re-pointed origin starts empty — the same
83
+ * bound `inMemoryPersistence` gives the refresh token.
84
+ *
85
+ * The read cost that remains is real and is the stated trade: a **cold** page
86
+ * load on a custom console domain re-reads its working set where a durable
87
+ * origin would have resumed from disk, and multiple tabs no longer share one
88
+ * backend connection through `persistentMultipleTabManager`. That is the right
89
+ * trade for an Enterprise-only feature, but it is a trade.
90
+ *
91
+ * ## `Failed to obtain primary lease for action '…'` is not a bug to fix
92
+ *
93
+ * Exactly one open tab holds the IndexedDB primary lease and applies remote
94
+ * events; the rest read the results it writes. When the lease changes hands —
95
+ * a tab opening, closing, or coming back to the foreground — a transaction
96
+ * already in flight on the losing tab finds itself no longer primary, and the
97
+ * SDK `logError`s that message immediately before throwing a
98
+ * `failed-precondition` that its OWN multi-tab sync engine catches
99
+ * (`ignoreIfPrimaryLeaseLoss`) and downgrades to a debug line. The tab then
100
+ * continues as a secondary. Nothing is dropped and no listener stops.
101
+ *
102
+ * It is therefore noise from a handled handoff, and it scales with how many
103
+ * tabs are churning rather than with anything the console does. The message
104
+ * names a lease, so the obvious reading is that the multi-tab manager is
105
+ * missing and should be added — it is already here, which is why the message
106
+ * appears at all: `persistentSingleTabManager` refuses the second tab's
107
+ * persistence outright instead of sharing it. Confirmed at runtime by the
108
+ * `owner` record in `firestore/<app>/<project>/main`, which carries
109
+ * `allowTabSynchronization: true`.
110
+ */
111
+ export declare function localCacheFor(originClass: AuthPersistenceClass): FirestoreLocalCache;
112
+ /**
113
+ * Prunes the multi-tab records the durable cache strands in `localStorage`
114
+ * (AGL-2845), for an app whose Firestore this page has just initialized.
115
+ *
116
+ * Keyed off the same origin class as {@link localCacheFor}, because the
117
+ * durable class is the only one running `persistentMultipleTabManager`, the
118
+ * only writer of those records. An ephemeral origin's memory cache writes
119
+ * none, so it resolves without looking.
120
+ *
121
+ * Fire-and-forget: it never rejects and nothing waits on it. What it deletes,
122
+ * and why a live tab's records never are, is in
123
+ * `firestore-shared-client-state.ts`.
124
+ */
125
+ export declare function pruneSharedClientStateFor(originClass: AuthPersistenceClass, app: FirebaseApp): Promise<SharedClientStatePrunePlan | undefined>;
@@ -0,0 +1,152 @@
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
+ */ 'use client';
17
+ import { memoryLocalCache, memoryLruGarbageCollector, persistentLocalCache, persistentMultipleTabManager } from "firebase/firestore";
18
+ import { pruneBrowserSharedClientState } from "./firestore-shared-client-state.js";
19
+ /**
20
+ * The durable cache's size bound: the SDK's own default, 40 MB, written down.
21
+ * See "The durable cache's size" on {@link localCacheFor}.
22
+ */ export const DURABLE_CACHE_SIZE_BYTES = 40 * 1024 * 1024;
23
+ /**
24
+ * The Firestore `localCache` an origin of this persistence class may use
25
+ * (AGL-1456).
26
+ *
27
+ * ## Why this keys off the *auth* persistence class
28
+ *
29
+ * The name is historical — the class predates this file (AGL-1379) — but the
30
+ * question it answers was never auth-specific. `AuthPersistenceClass` is
31
+ * documented as *"how much of a session an origin is allowed to keep"*, and
32
+ * AGL-1099a's D6 justified `ephemeral` on the grounds that **"the customer can
33
+ * re-point the DNS and read this origin."**
34
+ *
35
+ * That reasoning does not stop at the credential. It is equally true of every
36
+ * document body Firestore caches in IndexedDB on that origin — measured, not
37
+ * argued: the PoC (`docs/design/agl-1099a-poc-findings.md` §5) dumped every
38
+ * object store on the origin and found full document contents under
39
+ * `firestore/<app>/<project>/main → remoteDocumentsV14`, alongside
40
+ * `firestore_clients_*` / `firestore_online_state_*` /
41
+ * `firestore_sequence_number_*` in `localStorage`. D6 hardened the key and
42
+ * left the safe behind it open; this closes it.
43
+ *
44
+ * **One declaration, two consequences — deliberately.** A separate
45
+ * `firestoreCache` prop would let a caller declare a custom console domain
46
+ * `ephemeral` for auth and still hand it a persistent cache, which is exactly
47
+ * the split that produced this bug. There is one fact about the origin, so
48
+ * there is one place to state it.
49
+ *
50
+ * ## The two classes
51
+ *
52
+ * - `durable` → `persistentLocalCache` with the multi-tab manager. Unchanged
53
+ * from what every host has always run, and it must stay that way:
54
+ * `*.aglyn.com` / `*.aglyn.app` are ours forever, and this cache is part of
55
+ * why console read volume is what it is (AGL-1440).
56
+ * - `ephemeral` → `memoryLocalCache`, so **nothing** reaches disk on an origin
57
+ * whose DNS the customer can re-point at their own server.
58
+ *
59
+ * ## The durable cache's size
60
+ *
61
+ * `cacheSizeBytes` is spelled out at the SDK default so the bound is stated
62
+ * here and cannot quietly become `CACHE_SIZE_UNLIMITED` if that default moves.
63
+ * Stating it changes nothing at runtime, and what it governs is narrower than
64
+ * the name suggests — measured against `@firebase/firestore` 4.17.1:
65
+ *
66
+ * - LRU collection is on without it: an unset `cacheSizeBytes` resolves to
67
+ * `LruParams.DEFAULT`, the same 40 MB threshold.
68
+ * - It runs only in the tab holding the primary lease, 1 minute after that tab
69
+ * becomes primary and every 5 minutes after.
70
+ * - The threshold is compared with the byte size of **cached documents**
71
+ * (`remoteDocumentsV14`) and nothing else. `targets` and `targetDocuments`
72
+ * rows are collected alongside documents once documents cross it, never
73
+ * before.
74
+ * - It never touches `localStorage`, where the multi-tab manager strands a
75
+ * record per tab and per query target; {@link pruneSharedClientStateFor}
76
+ * handles those.
77
+ *
78
+ * It is deliberately not lowered. A smaller cache evicts exactly the documents
79
+ * that make this cache worth having — a cold load resuming from disk, and
80
+ * listeners painting through a stale session — and a collected target is given
81
+ * a NEW id the next time its query is listened to, which adds a `localStorage`
82
+ * record rather than removing one.
83
+ *
84
+ * ## Why the memory cache gets the LRU collector, not the default
85
+ *
86
+ * `memoryLocalCache()` defaults to `memoryEagerGarbageCollector`, which drops
87
+ * a document the moment no listener references it — so navigating away from a
88
+ * console page and back re-reads its whole working set from the server. The
89
+ * LRU collector keeps documents in the JS heap up to its 40 MB default instead,
90
+ * which recovers most of the intra-session read saving `persistentLocalCache`
91
+ * was giving us.
92
+ *
93
+ * It costs nothing against the property being protected: the LRU collector is
94
+ * still **memory only**. Nothing is written to IndexedDB, nothing survives a
95
+ * tab close, and a page load on a re-pointed origin starts empty — the same
96
+ * bound `inMemoryPersistence` gives the refresh token.
97
+ *
98
+ * The read cost that remains is real and is the stated trade: a **cold** page
99
+ * load on a custom console domain re-reads its working set where a durable
100
+ * origin would have resumed from disk, and multiple tabs no longer share one
101
+ * backend connection through `persistentMultipleTabManager`. That is the right
102
+ * trade for an Enterprise-only feature, but it is a trade.
103
+ *
104
+ * ## `Failed to obtain primary lease for action '…'` is not a bug to fix
105
+ *
106
+ * Exactly one open tab holds the IndexedDB primary lease and applies remote
107
+ * events; the rest read the results it writes. When the lease changes hands —
108
+ * a tab opening, closing, or coming back to the foreground — a transaction
109
+ * already in flight on the losing tab finds itself no longer primary, and the
110
+ * SDK `logError`s that message immediately before throwing a
111
+ * `failed-precondition` that its OWN multi-tab sync engine catches
112
+ * (`ignoreIfPrimaryLeaseLoss`) and downgrades to a debug line. The tab then
113
+ * continues as a secondary. Nothing is dropped and no listener stops.
114
+ *
115
+ * It is therefore noise from a handled handoff, and it scales with how many
116
+ * tabs are churning rather than with anything the console does. The message
117
+ * names a lease, so the obvious reading is that the multi-tab manager is
118
+ * missing and should be added — it is already here, which is why the message
119
+ * appears at all: `persistentSingleTabManager` refuses the second tab's
120
+ * persistence outright instead of sharing it. Confirmed at runtime by the
121
+ * `owner` record in `firestore/<app>/<project>/main`, which carries
122
+ * `allowTabSynchronization: true`.
123
+ */ export function localCacheFor(originClass) {
124
+ if (originClass === 'ephemeral') {
125
+ return memoryLocalCache({
126
+ garbageCollector: memoryLruGarbageCollector()
127
+ });
128
+ }
129
+ return persistentLocalCache({
130
+ tabManager: persistentMultipleTabManager(),
131
+ cacheSizeBytes: DURABLE_CACHE_SIZE_BYTES
132
+ });
133
+ }
134
+ /**
135
+ * Prunes the multi-tab records the durable cache strands in `localStorage`
136
+ * (AGL-2845), for an app whose Firestore this page has just initialized.
137
+ *
138
+ * Keyed off the same origin class as {@link localCacheFor}, because the
139
+ * durable class is the only one running `persistentMultipleTabManager`, the
140
+ * only writer of those records. An ephemeral origin's memory cache writes
141
+ * none, so it resolves without looking.
142
+ *
143
+ * Fire-and-forget: it never rejects and nothing waits on it. What it deletes,
144
+ * and why a live tab's records never are, is in
145
+ * `firestore-shared-client-state.ts`.
146
+ */ export function pruneSharedClientStateFor(originClass, app) {
147
+ var _app_options;
148
+ if (originClass === 'ephemeral') return Promise.resolve(undefined);
149
+ return pruneBrowserSharedClientState(app.name, (_app_options = app.options) == null ? void 0 : _app_options.projectId);
150
+ }
151
+
152
+ //# sourceMappingURL=firestore-cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-cache.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'use client'\n\nimport { type FirebaseApp } from 'firebase/app'\nimport {\n type FirestoreLocalCache,\n memoryLocalCache,\n memoryLruGarbageCollector,\n persistentLocalCache,\n persistentMultipleTabManager,\n} from 'firebase/firestore'\n\nimport { type AuthPersistenceClass } from './auth-persistence'\nimport {\n pruneBrowserSharedClientState,\n type SharedClientStatePrunePlan,\n} from './firestore-shared-client-state'\n\n/**\n * The durable cache's size bound: the SDK's own default, 40 MB, written down.\n * See \"The durable cache's size\" on {@link localCacheFor}.\n */\nexport const DURABLE_CACHE_SIZE_BYTES = 40 * 1024 * 1024\n\n/**\n * The Firestore `localCache` an origin of this persistence class may use\n * (AGL-1456).\n *\n * ## Why this keys off the *auth* persistence class\n *\n * The name is historical — the class predates this file (AGL-1379) — but the\n * question it answers was never auth-specific. `AuthPersistenceClass` is\n * documented as *\"how much of a session an origin is allowed to keep\"*, and\n * AGL-1099a's D6 justified `ephemeral` on the grounds that **\"the customer can\n * re-point the DNS and read this origin.\"**\n *\n * That reasoning does not stop at the credential. It is equally true of every\n * document body Firestore caches in IndexedDB on that origin — measured, not\n * argued: the PoC (`docs/design/agl-1099a-poc-findings.md` §5) dumped every\n * object store on the origin and found full document contents under\n * `firestore/<app>/<project>/main → remoteDocumentsV14`, alongside\n * `firestore_clients_*` / `firestore_online_state_*` /\n * `firestore_sequence_number_*` in `localStorage`. D6 hardened the key and\n * left the safe behind it open; this closes it.\n *\n * **One declaration, two consequences — deliberately.** A separate\n * `firestoreCache` prop would let a caller declare a custom console domain\n * `ephemeral` for auth and still hand it a persistent cache, which is exactly\n * the split that produced this bug. There is one fact about the origin, so\n * there is one place to state it.\n *\n * ## The two classes\n *\n * - `durable` → `persistentLocalCache` with the multi-tab manager. Unchanged\n * from what every host has always run, and it must stay that way:\n * `*.aglyn.com` / `*.aglyn.app` are ours forever, and this cache is part of\n * why console read volume is what it is (AGL-1440).\n * - `ephemeral` → `memoryLocalCache`, so **nothing** reaches disk on an origin\n * whose DNS the customer can re-point at their own server.\n *\n * ## The durable cache's size\n *\n * `cacheSizeBytes` is spelled out at the SDK default so the bound is stated\n * here and cannot quietly become `CACHE_SIZE_UNLIMITED` if that default moves.\n * Stating it changes nothing at runtime, and what it governs is narrower than\n * the name suggests — measured against `@firebase/firestore` 4.17.1:\n *\n * - LRU collection is on without it: an unset `cacheSizeBytes` resolves to\n * `LruParams.DEFAULT`, the same 40 MB threshold.\n * - It runs only in the tab holding the primary lease, 1 minute after that tab\n * becomes primary and every 5 minutes after.\n * - The threshold is compared with the byte size of **cached documents**\n * (`remoteDocumentsV14`) and nothing else. `targets` and `targetDocuments`\n * rows are collected alongside documents once documents cross it, never\n * before.\n * - It never touches `localStorage`, where the multi-tab manager strands a\n * record per tab and per query target; {@link pruneSharedClientStateFor}\n * handles those.\n *\n * It is deliberately not lowered. A smaller cache evicts exactly the documents\n * that make this cache worth having — a cold load resuming from disk, and\n * listeners painting through a stale session — and a collected target is given\n * a NEW id the next time its query is listened to, which adds a `localStorage`\n * record rather than removing one.\n *\n * ## Why the memory cache gets the LRU collector, not the default\n *\n * `memoryLocalCache()` defaults to `memoryEagerGarbageCollector`, which drops\n * a document the moment no listener references it — so navigating away from a\n * console page and back re-reads its whole working set from the server. The\n * LRU collector keeps documents in the JS heap up to its 40 MB default instead,\n * which recovers most of the intra-session read saving `persistentLocalCache`\n * was giving us.\n *\n * It costs nothing against the property being protected: the LRU collector is\n * still **memory only**. Nothing is written to IndexedDB, nothing survives a\n * tab close, and a page load on a re-pointed origin starts empty — the same\n * bound `inMemoryPersistence` gives the refresh token.\n *\n * The read cost that remains is real and is the stated trade: a **cold** page\n * load on a custom console domain re-reads its working set where a durable\n * origin would have resumed from disk, and multiple tabs no longer share one\n * backend connection through `persistentMultipleTabManager`. That is the right\n * trade for an Enterprise-only feature, but it is a trade.\n *\n * ## `Failed to obtain primary lease for action '…'` is not a bug to fix\n *\n * Exactly one open tab holds the IndexedDB primary lease and applies remote\n * events; the rest read the results it writes. When the lease changes hands —\n * a tab opening, closing, or coming back to the foreground — a transaction\n * already in flight on the losing tab finds itself no longer primary, and the\n * SDK `logError`s that message immediately before throwing a\n * `failed-precondition` that its OWN multi-tab sync engine catches\n * (`ignoreIfPrimaryLeaseLoss`) and downgrades to a debug line. The tab then\n * continues as a secondary. Nothing is dropped and no listener stops.\n *\n * It is therefore noise from a handled handoff, and it scales with how many\n * tabs are churning rather than with anything the console does. The message\n * names a lease, so the obvious reading is that the multi-tab manager is\n * missing and should be added — it is already here, which is why the message\n * appears at all: `persistentSingleTabManager` refuses the second tab's\n * persistence outright instead of sharing it. Confirmed at runtime by the\n * `owner` record in `firestore/<app>/<project>/main`, which carries\n * `allowTabSynchronization: true`.\n */\nexport function localCacheFor(\n originClass: AuthPersistenceClass,\n): FirestoreLocalCache {\n if (originClass === 'ephemeral') {\n return memoryLocalCache({ garbageCollector: memoryLruGarbageCollector() })\n }\n return persistentLocalCache({\n tabManager: persistentMultipleTabManager(),\n cacheSizeBytes: DURABLE_CACHE_SIZE_BYTES,\n })\n}\n\n/**\n * Prunes the multi-tab records the durable cache strands in `localStorage`\n * (AGL-2845), for an app whose Firestore this page has just initialized.\n *\n * Keyed off the same origin class as {@link localCacheFor}, because the\n * durable class is the only one running `persistentMultipleTabManager`, the\n * only writer of those records. An ephemeral origin's memory cache writes\n * none, so it resolves without looking.\n *\n * Fire-and-forget: it never rejects and nothing waits on it. What it deletes,\n * and why a live tab's records never are, is in\n * `firestore-shared-client-state.ts`.\n */\nexport function pruneSharedClientStateFor(\n originClass: AuthPersistenceClass,\n app: FirebaseApp,\n): Promise<SharedClientStatePrunePlan | undefined> {\n if (originClass === 'ephemeral') return Promise.resolve(undefined)\n return pruneBrowserSharedClientState(app.name, app.options?.projectId)\n}\n"],"names":["memoryLocalCache","memoryLruGarbageCollector","persistentLocalCache","persistentMultipleTabManager","pruneBrowserSharedClientState","DURABLE_CACHE_SIZE_BYTES","localCacheFor","originClass","garbageCollector","tabManager","cacheSizeBytes","pruneSharedClientStateFor","app","Promise","resolve","undefined","name","options","projectId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAGA,SAEEA,gBAAgB,EAChBC,yBAAyB,EACzBC,oBAAoB,EACpBC,4BAA4B,QACvB,qBAAoB;AAG3B,SACEC,6BAA6B,QAExB,qCAAiC;AAExC;;;CAGC,GACD,OAAO,MAAMC,2BAA2B,KAAK,OAAO,KAAI;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoGC,GACD,OAAO,SAASC,cACdC,WAAiC;IAEjC,IAAIA,gBAAgB,aAAa;QAC/B,OAAOP,iBAAiB;YAAEQ,kBAAkBP;QAA4B;IAC1E;IACA,OAAOC,qBAAqB;QAC1BO,YAAYN;QACZO,gBAAgBL;IAClB;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASM,0BACdJ,WAAiC,EACjCK,GAAgB;QAG+BA;IAD/C,IAAIL,gBAAgB,aAAa,OAAOM,QAAQC,OAAO,CAACC;IACxD,OAAOX,8BAA8BQ,IAAII,IAAI,GAAEJ,eAAAA,IAAIK,OAAO,qBAAXL,aAAaM,SAAS;AACvE"}
@@ -0,0 +1,169 @@
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
+ * The multi-tab records the Firestore SDK strands in `localStorage`, and the
19
+ * prune that bounds them (AGL-2845).
20
+ *
21
+ * ## What leaks
22
+ *
23
+ * `persistentMultipleTabManager` shares one IndexedDB cache between tabs and
24
+ * coordinates them through `localStorage` (`WebStorageSharedClientState`).
25
+ * Three of its record families grow without bound, all keyed under the
26
+ * persistence prefix `firestore/<appName>/<projectId>/`:
27
+ *
28
+ * | record | holds |
29
+ * | -- | -- |
30
+ * | `firestore_clients_<prefix>_<clientId>` | the target ids one tab listens to, and when that set last changed |
31
+ * | `firestore_targets_<prefix>_<targetId>` | a target's `current` / `not-current` / `rejected` state, written by the primary tab |
32
+ * | `firestore_zombie_<prefix>_<clientId>` | when a tab closed, so no other tab waits on its lease |
33
+ *
34
+ * Measured against `@firebase/firestore` 4.17.1, and unchanged on the SDK's
35
+ * main branch, a target record is removed in exactly one place:
36
+ * `clearQueryState`, which runs only when the PRIMARY tab unlistens a target no
37
+ * other tab holds. Every other way a target ends leaves its record behind:
38
+ *
39
+ * - a secondary tab unlistens — the primary releases the target and never
40
+ * clears its state;
41
+ * - the backend rejects the listen — `rejected` is written and nothing removes
42
+ * it, so a missing composite index or a `permission-denied` stays on disk;
43
+ * - any tab closes or reloads — `pagehide` removes that tab's own client record
44
+ * and none of its targets.
45
+ *
46
+ * A client record is stranded the same way when a tab ends without `pagehide`
47
+ * (a crash, a discarded tab, a browser quit), and a zombie marker survives
48
+ * whenever its tab's shutdown does not finish and no primary later finds that
49
+ * tab's heartbeat row. Nothing sweeps any of the three. A target id is reused
50
+ * for an identical query, so the pile grows with the number of distinct queries
51
+ * a browser has ever listened to: one production console profile held 2,531
52
+ * target records going back five weeks, against 3 live tabs.
53
+ *
54
+ * ## Why it cannot be left alone
55
+ *
56
+ * `localStorage` holds about 5 MB per origin. Once it is full, the next
57
+ * `setItem` inside the SDK throws `QuotaExceededError` on the SDK's async
58
+ * queue. That marks the queue failed, and every later Firestore operation in
59
+ * the tab dies on `INTERNAL ASSERTION FAILED: Unexpected state (ID: b815)` — the
60
+ * whole Firestore client, not only tab sync. Upstream treats a full
61
+ * `localStorage` as expected under multi-tab (firebase-js-sdk#8305) and closed
62
+ * the leak report without a fix (#9209), so no SDK upgrade removes this.
63
+ *
64
+ * ## What is safe to delete
65
+ *
66
+ * The constraint is absolute: a record a live tab depends on is never removed.
67
+ *
68
+ * - **A target record no surviving client record lists is dead data.** The SDK
69
+ * reads one only when some tab lists that target as active
70
+ * (`addLocalQueryTarget` asks `isActiveQueryTarget` first), and it ignores a
71
+ * storage event reporting one deleted. A record any readable client lists is
72
+ * kept, whether or not that client is live: without it, the next tab to join
73
+ * the target would start it `not-current`.
74
+ * - **A client record is deleted only after the SDK itself has given up on that
75
+ * tab.** Its heartbeat row must be gone from IndexedDB's `clientMetadata`
76
+ * store — a running tab rewrites that row every 4 seconds, and the primary
77
+ * deletes rows 30 minutes stale — AND the record must not have changed for
78
+ * {@link STALE_SHARED_CLIENT_STATE_MS}. Deleting a live tab's client record
79
+ * would make every other tab drop its targets, and the primary would stop
80
+ * watching them. The heartbeat is what separates a live tab left idle (fresh
81
+ * row, old record) from a dead one; the age covers a tab frozen rather than
82
+ * closed, and a tab that started between the heartbeat read and the scan.
83
+ * - **When the heartbeat cannot be read** — no `indexedDB.databases()`, no
84
+ * database yet, a schema without that store — no client record is deleted,
85
+ * and every readable client record keeps pinning its targets.
86
+ * - **A client record that does not parse** pins nothing and is never deleted,
87
+ * matching the SDK, which ignores it the same way.
88
+ * - **A zombie marker older than {@link STALE_SHARED_CLIENT_STATE_MS}** is
89
+ * deleted. The SDK consults one only while the closed tab's heartbeat could
90
+ * still count as fresh, and a day later it cannot.
91
+ *
92
+ * Mutation, online-state, sequence-number and bundle records are never touched,
93
+ * and neither is anything under another app's or another database's prefix.
94
+ *
95
+ * ## When it runs
96
+ *
97
+ * Once per page load, after the provider has initialized the durable cache
98
+ * (`pruneSharedClientStateFor` in `firestore-cache.ts`). Each load clears what
99
+ * earlier sessions stranded, which is what bounds the growth; a one-off cleanup
100
+ * would not, because the SDK strands more on every close.
101
+ */
102
+ /**
103
+ * How long a client record or zombie marker must sit unchanged before it can
104
+ * be treated as dead. A day, against the SDK's own 30-minute inactivity
105
+ * horizon, because deleting a live tab's record is the one outcome this must
106
+ * never produce.
107
+ */
108
+ export declare const STALE_SHARED_CLIENT_STATE_MS: number;
109
+ /**
110
+ * `firestore/<appName>/<projectId>/` — the SDK's `indexedDbStoragePrefix` for
111
+ * the default database, the only one the console opens. Every record family
112
+ * above hangs off it, and so does the IndexedDB database, `<prefix>main`.
113
+ */
114
+ export declare function firestorePersistencePrefix(appName: string, projectId: string): string;
115
+ export interface SharedClientStateTally {
116
+ kept: number;
117
+ removed: number;
118
+ }
119
+ export interface SharedClientStatePrunePlan {
120
+ /** Every record to delete. */
121
+ remove: string[];
122
+ targets: SharedClientStateTally;
123
+ clients: SharedClientStateTally;
124
+ zombies: SharedClientStateTally;
125
+ /** Whether the heartbeat was readable — without it no client record is eligible. */
126
+ heartbeat: boolean;
127
+ }
128
+ /**
129
+ * Decides what to delete. Reads `storage` and changes nothing.
130
+ *
131
+ * `heartbeatClientIds` is the set of client ids that still have a row in the
132
+ * SDK's `clientMetadata` store, or `undefined` when it could not be read — in
133
+ * which case no client record is eligible.
134
+ */
135
+ export declare function planSharedClientStatePrune(storage: Pick<Storage, 'length' | 'key' | 'getItem'>, prefix: string, heartbeatClientIds: ReadonlySet<string> | undefined, now: number): SharedClientStatePrunePlan;
136
+ /**
137
+ * The client ids that still have a heartbeat row in the SDK's `clientMetadata`
138
+ * store, or `undefined` when that cannot be established.
139
+ *
140
+ * Opens only a database that already exists, at whatever version it already
141
+ * has: `open(name)` on a missing database would CREATE it at version 1, and the
142
+ * SDK would then try to migrate a schema that was never written. The connection
143
+ * serves one read-only request and is closed — at once, too, on
144
+ * `versionchange`, so it can never hold up the SDK's own upgrade.
145
+ */
146
+ export declare function readHeartbeatClientIds(factory: IDBFactory | undefined, databaseName: string, timeoutMs?: number): Promise<ReadonlySet<string> | undefined>;
147
+ export interface PruneSharedClientStateOptions {
148
+ storage: Storage;
149
+ prefix: string;
150
+ heartbeatClientIds: () => Promise<ReadonlySet<string> | undefined>;
151
+ now?: () => number;
152
+ }
153
+ /**
154
+ * Reads the heartbeat, then plans and deletes in one synchronous pass, so
155
+ * nothing else in this tab can write a record between the scan and the
156
+ * deletes. The heartbeat is read BEFORE the scan: a tab that starts in between
157
+ * shows up with a brand-new record and no heartbeat row, which the age rule
158
+ * keeps.
159
+ *
160
+ * Never rejects. Resolves `undefined` when storage could not be read.
161
+ */
162
+ export declare function pruneSharedClientState({ storage, prefix, heartbeatClientIds, now, }: PruneSharedClientStateOptions): Promise<SharedClientStatePrunePlan | undefined>;
163
+ /**
164
+ * {@link pruneSharedClientState} against this page's own `localStorage` and
165
+ * IndexedDB. Resolves `undefined` off-browser, without an app name or project
166
+ * id, or where site data is blocked (reading `window.localStorage` throws
167
+ * there).
168
+ */
169
+ export declare function pruneBrowserSharedClientState(appName: string | undefined, projectId: string | undefined): Promise<SharedClientStatePrunePlan | undefined>;