@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,89 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { ConcurrentEditError, hasConcurrentWrite, versionStamp } from "@aglyn/aglyn";
17
+ import { runTransaction } from "firebase/firestore";
18
+ import isEqual from "lodash-es/isEqual.js";
19
+ /**
20
+ * Persists a besigner node map only if the stored document still matches the
21
+ * baseline the editor loaded (AGL-1301).
22
+ *
23
+ * The AGL-674 guard is listener-based, so a save clicked in the window
24
+ * between another writer's commit and the local snapshot's delivery still
25
+ * overwrote it — the guard had simply not heard yet. Running the same check
26
+ * inside a Firestore transaction closes that window: the `get` is served by
27
+ * the backend (never the local cache), the check runs against what is
28
+ * actually stored, and the write only commits if nothing moved. Contention
29
+ * during the transaction re-runs it against fresh data, so the late writer
30
+ * aborts rather than winning by timing.
31
+ *
32
+ * TWO preconditions, deliberately:
33
+ *
34
+ * - The `updatedAt` stamp, same equality-only comparison as the listener
35
+ * guard.
36
+ * - The stored `nodes` content itself. `updatedAt` is an app-level FIELD
37
+ * that a writer can forget — admin backfills have updated `nodes` without
38
+ * touching it, and such a write was invisible to the stamp and got
39
+ * clobbered. The web client SDK exposes no server-side `updateTime` on a
40
+ * snapshot (`snapshot.metadata` carries only `hasPendingWrites` /
41
+ * `fromCache`; document update times are Admin-SDK-only), so comparing
42
+ * the content is the strongest check actually available to this client —
43
+ * and it is exact: this save only ever writes `nodes` (+ stamp), so
44
+ * "did `nodes` change under me" is precisely the conflict that loses work.
45
+ *
46
+ * Aborts by throwing `ConcurrentEditError`, which `useBesignerDocument`
47
+ * surfaces through the existing refusal UX. A converter on `ref` applies as
48
+ * usual, so compressed-at-rest documents stay compressed and keep stamping
49
+ * `updatedAt` on write.
50
+ *
51
+ * Trade-off, accepted: a transaction requires the backend, so this save no
52
+ * longer queues offline. A conflict guard that consults a local cache would
53
+ * be theatre — the AGL-674 refusal already implies "the server has spoken".
54
+ *
55
+ * ## Why this did NOT change when the client relaxed (AGL-2486)
56
+ *
57
+ * The client no longer refuses merely because the stored version moved: with
58
+ * the co-edit mirror running it usually already holds what the other writer
59
+ * stored, so its own write is a superset (`incorporatesStoredNodes`). What
60
+ * it does with that evidence is advance its BASELINE onto the write it has
61
+ * incorporated — and this precondition is what makes that safe. It is
62
+ * unchanged and strictly stricter: the baseline the client presents must
63
+ * still match what is actually stored when the transaction reads it, so
64
+ * anything that lands between the client's decision and this commit moves
65
+ * `nodes` away from it and aborts. The docs' promise that "even a save
66
+ * racing the conflict by milliseconds is refused" therefore holds exactly as
67
+ * before; the client simply stopped refusing saves this would have accepted.
68
+ */ export async function saveNodesGuarded(ref, data, baseline) {
69
+ await runTransaction(ref.firestore, async (transaction)=>{
70
+ const snapshot = await transaction.get(ref);
71
+ const stored = snapshot.data();
72
+ if (stored) {
73
+ var _ref;
74
+ const storedStamp = versionStamp(stored.updatedAt);
75
+ if (hasConcurrentWrite((_ref = baseline == null ? void 0 : baseline.baseStamp) != null ? _ref : null, storedStamp)) {
76
+ throw new ConcurrentEditError();
77
+ }
78
+ if ((baseline == null ? void 0 : baseline.baseNodes) != null && stored.nodes != null && !isEqual(stored.nodes, baseline.baseNodes)) {
79
+ throw new ConcurrentEditError();
80
+ }
81
+ }
82
+ transaction.set(ref, data, {
83
+ merge: true
84
+ });
85
+ });
86
+ }
87
+ export default saveNodesGuarded;
88
+
89
+ //# sourceMappingURL=guarded-nodes-save.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/guarded-nodes-save.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n ConcurrentEditError,\n hasConcurrentWrite,\n versionStamp,\n} from '@aglyn/aglyn'\nimport { runTransaction, type DocumentReference } from 'firebase/firestore'\nimport isEqual from 'lodash-es/isEqual.js'\n\n/**\n * What the editor believes the stored document looked like when it last\n * agreed with it. Both fields optional: an absent baseline means \"never\n * established\", which must not refuse the save (see `hasConcurrentWrite`).\n */\nexport interface NodesSaveBaseline {\n /** `versionStamp(...)` of the document's `updatedAt` at load/last save. */\n baseStamp?: string | null\n /** The stored nodes as of that agreement, in STORED shape. */\n baseNodes?: unknown\n}\n\n/**\n * Persists a besigner node map only if the stored document still matches the\n * baseline the editor loaded (AGL-1301).\n *\n * The AGL-674 guard is listener-based, so a save clicked in the window\n * between another writer's commit and the local snapshot's delivery still\n * overwrote it — the guard had simply not heard yet. Running the same check\n * inside a Firestore transaction closes that window: the `get` is served by\n * the backend (never the local cache), the check runs against what is\n * actually stored, and the write only commits if nothing moved. Contention\n * during the transaction re-runs it against fresh data, so the late writer\n * aborts rather than winning by timing.\n *\n * TWO preconditions, deliberately:\n *\n * - The `updatedAt` stamp, same equality-only comparison as the listener\n * guard.\n * - The stored `nodes` content itself. `updatedAt` is an app-level FIELD\n * that a writer can forget — admin backfills have updated `nodes` without\n * touching it, and such a write was invisible to the stamp and got\n * clobbered. The web client SDK exposes no server-side `updateTime` on a\n * snapshot (`snapshot.metadata` carries only `hasPendingWrites` /\n * `fromCache`; document update times are Admin-SDK-only), so comparing\n * the content is the strongest check actually available to this client —\n * and it is exact: this save only ever writes `nodes` (+ stamp), so\n * \"did `nodes` change under me\" is precisely the conflict that loses work.\n *\n * Aborts by throwing `ConcurrentEditError`, which `useBesignerDocument`\n * surfaces through the existing refusal UX. A converter on `ref` applies as\n * usual, so compressed-at-rest documents stay compressed and keep stamping\n * `updatedAt` on write.\n *\n * Trade-off, accepted: a transaction requires the backend, so this save no\n * longer queues offline. A conflict guard that consults a local cache would\n * be theatre — the AGL-674 refusal already implies \"the server has spoken\".\n *\n * ## Why this did NOT change when the client relaxed (AGL-2486)\n *\n * The client no longer refuses merely because the stored version moved: with\n * the co-edit mirror running it usually already holds what the other writer\n * stored, so its own write is a superset (`incorporatesStoredNodes`). What\n * it does with that evidence is advance its BASELINE onto the write it has\n * incorporated — and this precondition is what makes that safe. It is\n * unchanged and strictly stricter: the baseline the client presents must\n * still match what is actually stored when the transaction reads it, so\n * anything that lands between the client's decision and this commit moves\n * `nodes` away from it and aborts. The docs' promise that \"even a save\n * racing the conflict by milliseconds is refused\" therefore holds exactly as\n * before; the client simply stopped refusing saves this would have accepted.\n */\nexport async function saveNodesGuarded<T>(\n ref: DocumentReference<T>,\n data: Partial<T>,\n baseline?: NodesSaveBaseline,\n): Promise<void> {\n await runTransaction(ref.firestore, async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = snapshot.data() as\n | { updatedAt?: unknown; nodes?: unknown }\n | undefined\n if (stored) {\n const storedStamp = versionStamp(stored.updatedAt)\n if (hasConcurrentWrite(baseline?.baseStamp ?? null, storedStamp)) {\n throw new ConcurrentEditError()\n }\n if (\n baseline?.baseNodes != null &&\n stored.nodes != null &&\n !isEqual(stored.nodes, baseline.baseNodes)\n ) {\n throw new ConcurrentEditError()\n }\n }\n transaction.set(ref, data, { merge: true })\n })\n}\n\nexport default saveNodesGuarded\n"],"names":["ConcurrentEditError","hasConcurrentWrite","versionStamp","runTransaction","isEqual","saveNodesGuarded","ref","data","baseline","firestore","transaction","snapshot","get","stored","storedStamp","updatedAt","baseStamp","baseNodes","nodes","set","merge"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,mBAAmB,EACnBC,kBAAkB,EAClBC,YAAY,QACP,eAAc;AACrB,SAASC,cAAc,QAAgC,qBAAoB;AAC3E,OAAOC,aAAa,uBAAsB;AAc1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiDC,GACD,OAAO,eAAeC,iBACpBC,GAAyB,EACzBC,IAAgB,EAChBC,QAA4B;IAE5B,MAAML,eAAeG,IAAIG,SAAS,EAAE,OAAOC;QACzC,MAAMC,WAAW,MAAMD,YAAYE,GAAG,CAACN;QACvC,MAAMO,SAASF,SAASJ,IAAI;QAG5B,IAAIM,QAAQ;;YACV,MAAMC,cAAcZ,aAAaW,OAAOE,SAAS;YACjD,IAAId,2BAAmBO,4BAAAA,SAAUQ,SAAS,mBAAI,MAAMF,cAAc;gBAChE,MAAM,IAAId;YACZ;YACA,IACEQ,CAAAA,4BAAAA,SAAUS,SAAS,KAAI,QACvBJ,OAAOK,KAAK,IAAI,QAChB,CAACd,QAAQS,OAAOK,KAAK,EAAEV,SAASS,SAAS,GACzC;gBACA,MAAM,IAAIjB;YACZ;QACF;QACAU,YAAYS,GAAG,CAACb,KAAKC,MAAM;YAAEa,OAAO;QAAK;IAC3C;AACF;AAEA,eAAef,iBAAgB"}
@@ -0,0 +1,125 @@
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
+ * Refuse a whole-object write whose form was seeded from a read we cannot
19
+ * trust (AGL-1356, AGL-1358, AGL-1066).
20
+ *
21
+ * ## The shape this exists for
22
+ *
23
+ * A form seeded from a Firestore LISTENER, saved with a payload that carries
24
+ * every field rather than the one that changed. `merge: true` protects
25
+ * nothing there — the untouched fields are all present in the payload — so
26
+ * saving one edit rewrites the rest to whatever the seed held. If the seed
27
+ * came from IndexedDB during a stale session, "whatever the seed held" can
28
+ * be arbitrarily old, and the user's real values are destroyed by a save
29
+ * they asked for and had every reason to expect to be safe.
30
+ *
31
+ * ## Why it WRAPS the write instead of preceding it
32
+ *
33
+ * The guards this replaces sat in front of their writes as early returns,
34
+ * which is a shape you can have and still write — add a branch, reorder a
35
+ * line, and the guard is decoration. Here the write is only reachable
36
+ * THROUGH the verdict, so a call site cannot keep the guard and lose the
37
+ * protection. It also makes the refusal testable as a refusal: assert the
38
+ * inner function never ran.
39
+ *
40
+ * ## The three signals, in the order they are worth trusting
41
+ *
42
+ * 1. `fromCache` — the server never confirmed what is on screen (AGL-1066).
43
+ * This is the one that catches the dangerous case, because a cache-served
44
+ * read looks completely healthy: populated form, plausible values. It is
45
+ * per-listener ground truth from the SDK, so it needs no threshold and no
46
+ * window, and it clears itself the instant a server snapshot lands. It
47
+ * leads because it is also the more ACCURATE explanation whenever it is
48
+ * true — see the ordering note on `checkSeedFreshness`.
49
+ * 2. `unreadable` — the read FAILED and there is nothing cached behind it
50
+ * (AGL-1143). Saving would write blanks over a populated document.
51
+ * 3. `staleSession` — the console's own heuristic. Kept because it means
52
+ * something the other two do not (the SESSION is dead, not merely
53
+ * unconfirmed), but it must never be the ONLY guard: it needs two
54
+ * distinct labelled collections denied inside 60s, which is unreachable
55
+ * on a listener-only page. That was exactly how AGL-1356 stayed open.
56
+ *
57
+ * ## Why this lives in the library rather than the console (AGL-1358)
58
+ *
59
+ * Most of the ~forty sites wearing this shape are plugin cards in
60
+ * `libs/plugins/**`, which cannot import from `apps/console`. The guard
61
+ * itself is pure, and `fromCache` — the signal that actually fires —
62
+ * originates here, on the listener hooks. What it cannot own is
63
+ * `session-health`, which is console state.
64
+ *
65
+ * So the third signal is INJECTED, through the same seam
66
+ * `firestore-denial-reporter` already uses for the reverse direction: the
67
+ * console registers a check at startup and the guard pulls it. That keeps a
68
+ * single guard for every call site with no new dependency edge and nothing
69
+ * inverted — the library still knows nothing about the app. An unregistered
70
+ * check simply reports "not stale", which is what the tenant runtime and the
71
+ * unit tests want; the other two signals are unaffected, and they are the
72
+ * ones the doc above says must carry the guard anyway.
73
+ */
74
+ export type StaleSeedReason = 'unreadable' | 'unconfirmed' | 'stale-session';
75
+ /**
76
+ * Register the app's stale-session verdict. Called once, by the app that owns
77
+ * `session-health`. Pass `null` to unregister (tests).
78
+ *
79
+ * Registered at module scope alongside `setFirestoreSessionReporters` rather
80
+ * than in an effect, for the same reason: a save can be clicked before any
81
+ * effect has run, and a guard that consults a check which does not exist yet
82
+ * silently loses its third signal.
83
+ */
84
+ export declare function setStaleSessionCheck(check: (() => boolean) | null): void;
85
+ export interface GuardedSeedWriteOptions {
86
+ /**
87
+ * What is being saved, in the user's words — "profile", "settings".
88
+ * Used to build the refusal message, so it reads naturally in a sentence
89
+ * like "Your profile could not be loaded".
90
+ */
91
+ subject: string;
92
+ /** The seeding read FAILED (`status === 'error'`). */
93
+ unreadable?: boolean;
94
+ /** The seeding snapshot came from cache (`metadata.fromCache`). */
95
+ fromCache?: boolean;
96
+ /**
97
+ * Also consult `session-health`. On by default; the only reason to turn it
98
+ * off is a surface that deliberately writes during a known-degraded
99
+ * session.
100
+ */
101
+ checkSession?: boolean;
102
+ }
103
+ export interface GuardedSeedWriteResult {
104
+ /** True when the write RAN. */
105
+ ok: boolean;
106
+ /** Why it did not run. */
107
+ reason?: StaleSeedReason;
108
+ /**
109
+ * What to tell the user. Always populated when `ok` is false — a refused
110
+ * save that says nothing is its own bug: the user retypes the form and
111
+ * tries again, and the second attempt is refused just as silently.
112
+ */
113
+ message?: string;
114
+ }
115
+ /** The verdict on its own, for a caller that needs it before the click. */
116
+ export declare function checkSeedFreshness(options: GuardedSeedWriteOptions): GuardedSeedWriteResult;
117
+ /**
118
+ * Run `write` only if the seed can be trusted.
119
+ *
120
+ * Never throws on refusal — the caller reports `message` and leaves the
121
+ * user's typed values on screen, because the whole point is that we are not
122
+ * confident enough to touch the stored document, not that the input was bad.
123
+ */
124
+ export declare function writeGuardedBySeed(options: GuardedSeedWriteOptions, write: () => Promise<void>): Promise<GuardedSeedWriteResult>;
125
+ export default writeGuardedBySeed;
@@ -0,0 +1,193 @@
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
+ * Refuse a whole-object write whose form was seeded from a read we cannot
18
+ * trust (AGL-1356, AGL-1358, AGL-1066).
19
+ *
20
+ * ## The shape this exists for
21
+ *
22
+ * A form seeded from a Firestore LISTENER, saved with a payload that carries
23
+ * every field rather than the one that changed. `merge: true` protects
24
+ * nothing there — the untouched fields are all present in the payload — so
25
+ * saving one edit rewrites the rest to whatever the seed held. If the seed
26
+ * came from IndexedDB during a stale session, "whatever the seed held" can
27
+ * be arbitrarily old, and the user's real values are destroyed by a save
28
+ * they asked for and had every reason to expect to be safe.
29
+ *
30
+ * ## Why it WRAPS the write instead of preceding it
31
+ *
32
+ * The guards this replaces sat in front of their writes as early returns,
33
+ * which is a shape you can have and still write — add a branch, reorder a
34
+ * line, and the guard is decoration. Here the write is only reachable
35
+ * THROUGH the verdict, so a call site cannot keep the guard and lose the
36
+ * protection. It also makes the refusal testable as a refusal: assert the
37
+ * inner function never ran.
38
+ *
39
+ * ## The three signals, in the order they are worth trusting
40
+ *
41
+ * 1. `fromCache` — the server never confirmed what is on screen (AGL-1066).
42
+ * This is the one that catches the dangerous case, because a cache-served
43
+ * read looks completely healthy: populated form, plausible values. It is
44
+ * per-listener ground truth from the SDK, so it needs no threshold and no
45
+ * window, and it clears itself the instant a server snapshot lands. It
46
+ * leads because it is also the more ACCURATE explanation whenever it is
47
+ * true — see the ordering note on `checkSeedFreshness`.
48
+ * 2. `unreadable` — the read FAILED and there is nothing cached behind it
49
+ * (AGL-1143). Saving would write blanks over a populated document.
50
+ * 3. `staleSession` — the console's own heuristic. Kept because it means
51
+ * something the other two do not (the SESSION is dead, not merely
52
+ * unconfirmed), but it must never be the ONLY guard: it needs two
53
+ * distinct labelled collections denied inside 60s, which is unreachable
54
+ * on a listener-only page. That was exactly how AGL-1356 stayed open.
55
+ *
56
+ * ## Why this lives in the library rather than the console (AGL-1358)
57
+ *
58
+ * Most of the ~forty sites wearing this shape are plugin cards in
59
+ * `libs/plugins/**`, which cannot import from `apps/console`. The guard
60
+ * itself is pure, and `fromCache` — the signal that actually fires —
61
+ * originates here, on the listener hooks. What it cannot own is
62
+ * `session-health`, which is console state.
63
+ *
64
+ * So the third signal is INJECTED, through the same seam
65
+ * `firestore-denial-reporter` already uses for the reverse direction: the
66
+ * console registers a check at startup and the guard pulls it. That keeps a
67
+ * single guard for every call site with no new dependency edge and nothing
68
+ * inverted — the library still knows nothing about the app. An unregistered
69
+ * check simply reports "not stale", which is what the tenant runtime and the
70
+ * unit tests want; the other two signals are unaffected, and they are the
71
+ * ones the doc above says must carry the guard anyway.
72
+ */ /**
73
+ * The console's `session-health` verdict, injected because it is app state.
74
+ * Null until the app registers one — see {@link setStaleSessionCheck}.
75
+ */ let staleSessionCheck = null;
76
+ /**
77
+ * Register the app's stale-session verdict. Called once, by the app that owns
78
+ * `session-health`. Pass `null` to unregister (tests).
79
+ *
80
+ * Registered at module scope alongside `setFirestoreSessionReporters` rather
81
+ * than in an effect, for the same reason: a save can be clicked before any
82
+ * effect has run, and a guard that consults a check which does not exist yet
83
+ * silently loses its third signal.
84
+ */ export function setStaleSessionCheck(check) {
85
+ staleSessionCheck = check;
86
+ }
87
+ /** The registered verdict, or `false` when nothing is registered. */ function isSessionStale() {
88
+ return staleSessionCheck ? staleSessionCheck() : false;
89
+ }
90
+ /**
91
+ * Why we refused, in words that say what to DO about it.
92
+ *
93
+ * ## The subject is INTERPOLATED, so nothing here may agree with it (AGL-1446)
94
+ *
95
+ * Call sites pass "profile", "SEO settings", "theme overrides", "categories".
96
+ * The copy used to read "Your SEO settings **has** not been confirmed", "so
97
+ * **it** cannot be saved", "so **this** SEO settings may be out of date" —
98
+ * fine for the singular subjects it was written against and wrong for half the
99
+ * ~126 call sites. Every sentence below is number-neutral instead, and
100
+ * `guarded-seed-write.spec.ts` pins all three verbatim against a plural
101
+ * subject so it stays that way.
102
+ *
103
+ * ## Why `unconfirmed` names a SECOND remedy (AGL-1446)
104
+ *
105
+ * "Reload" was the only instruction, and it is the one that fails in the case
106
+ * where an author is most stuck. A long-lived console tab can go permanently
107
+ * cache-only: no Firestore `Listen` channel is ever established, every
108
+ * listener is served from IndexedDB, `fromCache` never clears, and this guard
109
+ * correctly refuses every save — forever. Observed on 2026-08-12 (AGL-1066):
110
+ * refused three times ACROSS A FULL RELOAD with the session entirely healthy
111
+ * (`securetoken` 200, `runAggregationQuery` 200). A brand-new tab fixed it on
112
+ * the first try.
113
+ *
114
+ * ## Why this is copy rather than a branch
115
+ *
116
+ * Three different states reach this message with byte-identical evidence:
117
+ *
118
+ * 1. the server has not confirmed the read YET (reload, or just wait);
119
+ * 2. the client is OFFLINE (reconnect — the case `persistentLocalCache` was
120
+ * added for in the first place);
121
+ * 3. the tab is permanently cache-only (only a new tab helps).
122
+ *
123
+ * In all three, `fromCache` is true, `unreadable` is false, and
124
+ * `session-health` reports nothing: a listen that is never issued and a listen
125
+ * that has not been answered yet both produce no error callback, so no
126
+ * `permission-denied` is ever counted and `staleSession` cannot fire. The one
127
+ * signal that separates (3) from (1) — whether {@link
128
+ * reportFirestoreServerRead} has EVER fired in this tab — is dispatched
129
+ * straight into `session-health.reportSuccessfulRead`, which returns early
130
+ * when there is no denial evidence and retains nothing; `getSessionHealth()`
131
+ * exposes only the denial verdict. So the bit is produced and discarded, and
132
+ * latching it would still not be enough: a freshly-opened tab and an offline
133
+ * tab have not had a listen answered either, so a detector would need a time
134
+ * threshold AND an online check (which lives in the app, behind another seam)
135
+ * before it could say "stuck" without mislabelling both. That is a new tuned
136
+ * heuristic in the path of every save, to choose between two remedies that
137
+ * cost the reader one sentence to be told both of.
138
+ *
139
+ * So the message names all three: connection, reload, and the new tab — with
140
+ * the reason the new tab is not just "reload harder", because an instruction
141
+ * that reads as a superstition is one people skip.
142
+ */ function refusalMessage(subject, reason) {
143
+ switch(reason){
144
+ case 'unreadable':
145
+ return `Your ${subject} could not be loaded, so there is nothing safe to ` + 'save — saving now would overwrite the stored copy with blanks. ' + 'Reload and try again.';
146
+ case 'unconfirmed':
147
+ return `We could not confirm your ${subject} with the server, so what is ` + 'on screen may be out of date — saving now could overwrite newer ' + 'values. Check your connection and reload; if it is refused again, ' + 'open this page in a new browser tab — reloading cannot restore a ' + 'connection this tab never opened.';
148
+ case 'stale-session':
149
+ return `Your session went stale, so your ${subject} may be out of date — ` + 'saving now could overwrite newer values. Sign in again and reload.';
150
+ }
151
+ }
152
+ /** The verdict on its own, for a caller that needs it before the click. */ export function checkSeedFreshness(options) {
153
+ const { subject, unreadable, fromCache, checkSession = true } = options;
154
+ /**
155
+ * `fromCache` OUTRANKS `unreadable` (AGL-1066).
156
+ *
157
+ * It did not, and the ordering was harmless only while a refused listen
158
+ * could never reach `status: 'error'`. Now that it can, the common case is
159
+ * BOTH flags true at once — the cache is serving the document and the
160
+ * server has stopped answering for it — and taking `unreadable` there
161
+ * tells the user their settings "could not be loaded" and that saving
162
+ * "would overwrite it with blanks", while a populated, plausible form sits
163
+ * in front of them. The refusal is right; that explanation is not, and a
164
+ * refusal whose reason is visibly false is one the user works around.
165
+ *
166
+ * What is left to `unreadable` is the read that failed with nothing to
167
+ * show for it, which is the AGL-1143 case it was written for and where the
168
+ * blanks are real. The heuristic still comes last.
169
+ */ const reason = fromCache ? 'unconfirmed' : unreadable ? 'unreadable' : checkSession && isSessionStale() ? 'stale-session' : undefined;
170
+ if (!reason) return {
171
+ ok: true
172
+ };
173
+ return {
174
+ ok: false,
175
+ reason,
176
+ message: refusalMessage(subject, reason)
177
+ };
178
+ }
179
+ /**
180
+ * Run `write` only if the seed can be trusted.
181
+ *
182
+ * Never throws on refusal — the caller reports `message` and leaves the
183
+ * user's typed values on screen, because the whole point is that we are not
184
+ * confident enough to touch the stored document, not that the input was bad.
185
+ */ export async function writeGuardedBySeed(options, write) {
186
+ const verdict = checkSeedFreshness(options);
187
+ if (!verdict.ok) return verdict;
188
+ await write();
189
+ return verdict;
190
+ }
191
+ export default writeGuardedBySeed;
192
+
193
+ //# sourceMappingURL=guarded-seed-write.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/guarded-seed-write.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 * Refuse a whole-object write whose form was seeded from a read we cannot\n * trust (AGL-1356, AGL-1358, AGL-1066).\n *\n * ## The shape this exists for\n *\n * A form seeded from a Firestore LISTENER, saved with a payload that carries\n * every field rather than the one that changed. `merge: true` protects\n * nothing there — the untouched fields are all present in the payload — so\n * saving one edit rewrites the rest to whatever the seed held. If the seed\n * came from IndexedDB during a stale session, \"whatever the seed held\" can\n * be arbitrarily old, and the user's real values are destroyed by a save\n * they asked for and had every reason to expect to be safe.\n *\n * ## Why it WRAPS the write instead of preceding it\n *\n * The guards this replaces sat in front of their writes as early returns,\n * which is a shape you can have and still write — add a branch, reorder a\n * line, and the guard is decoration. Here the write is only reachable\n * THROUGH the verdict, so a call site cannot keep the guard and lose the\n * protection. It also makes the refusal testable as a refusal: assert the\n * inner function never ran.\n *\n * ## The three signals, in the order they are worth trusting\n *\n * 1. `fromCache` — the server never confirmed what is on screen (AGL-1066).\n * This is the one that catches the dangerous case, because a cache-served\n * read looks completely healthy: populated form, plausible values. It is\n * per-listener ground truth from the SDK, so it needs no threshold and no\n * window, and it clears itself the instant a server snapshot lands. It\n * leads because it is also the more ACCURATE explanation whenever it is\n * true — see the ordering note on `checkSeedFreshness`.\n * 2. `unreadable` — the read FAILED and there is nothing cached behind it\n * (AGL-1143). Saving would write blanks over a populated document.\n * 3. `staleSession` — the console's own heuristic. Kept because it means\n * something the other two do not (the SESSION is dead, not merely\n * unconfirmed), but it must never be the ONLY guard: it needs two\n * distinct labelled collections denied inside 60s, which is unreachable\n * on a listener-only page. That was exactly how AGL-1356 stayed open.\n *\n * ## Why this lives in the library rather than the console (AGL-1358)\n *\n * Most of the ~forty sites wearing this shape are plugin cards in\n * `libs/plugins/**`, which cannot import from `apps/console`. The guard\n * itself is pure, and `fromCache` — the signal that actually fires —\n * originates here, on the listener hooks. What it cannot own is\n * `session-health`, which is console state.\n *\n * So the third signal is INJECTED, through the same seam\n * `firestore-denial-reporter` already uses for the reverse direction: the\n * console registers a check at startup and the guard pulls it. That keeps a\n * single guard for every call site with no new dependency edge and nothing\n * inverted — the library still knows nothing about the app. An unregistered\n * check simply reports \"not stale\", which is what the tenant runtime and the\n * unit tests want; the other two signals are unaffected, and they are the\n * ones the doc above says must carry the guard anyway.\n */\n\nexport type StaleSeedReason = 'unreadable' | 'unconfirmed' | 'stale-session'\n\n/**\n * The console's `session-health` verdict, injected because it is app state.\n * Null until the app registers one — see {@link setStaleSessionCheck}.\n */\nlet staleSessionCheck: (() => boolean) | null = null\n\n/**\n * Register the app's stale-session verdict. Called once, by the app that owns\n * `session-health`. Pass `null` to unregister (tests).\n *\n * Registered at module scope alongside `setFirestoreSessionReporters` rather\n * than in an effect, for the same reason: a save can be clicked before any\n * effect has run, and a guard that consults a check which does not exist yet\n * silently loses its third signal.\n */\nexport function setStaleSessionCheck(check: (() => boolean) | null): void {\n staleSessionCheck = check\n}\n\n/** The registered verdict, or `false` when nothing is registered. */\nfunction isSessionStale(): boolean {\n return staleSessionCheck ? staleSessionCheck() : false\n}\n\nexport interface GuardedSeedWriteOptions {\n /**\n * What is being saved, in the user's words — \"profile\", \"settings\".\n * Used to build the refusal message, so it reads naturally in a sentence\n * like \"Your profile could not be loaded\".\n */\n subject: string\n /** The seeding read FAILED (`status === 'error'`). */\n unreadable?: boolean\n /** The seeding snapshot came from cache (`metadata.fromCache`). */\n fromCache?: boolean\n /**\n * Also consult `session-health`. On by default; the only reason to turn it\n * off is a surface that deliberately writes during a known-degraded\n * session.\n */\n checkSession?: boolean\n}\n\nexport interface GuardedSeedWriteResult {\n /** True when the write RAN. */\n ok: boolean\n /** Why it did not run. */\n reason?: StaleSeedReason\n /**\n * What to tell the user. Always populated when `ok` is false — a refused\n * save that says nothing is its own bug: the user retypes the form and\n * tries again, and the second attempt is refused just as silently.\n */\n message?: string\n}\n\n/**\n * Why we refused, in words that say what to DO about it.\n *\n * ## The subject is INTERPOLATED, so nothing here may agree with it (AGL-1446)\n *\n * Call sites pass \"profile\", \"SEO settings\", \"theme overrides\", \"categories\".\n * The copy used to read \"Your SEO settings **has** not been confirmed\", \"so\n * **it** cannot be saved\", \"so **this** SEO settings may be out of date\" —\n * fine for the singular subjects it was written against and wrong for half the\n * ~126 call sites. Every sentence below is number-neutral instead, and\n * `guarded-seed-write.spec.ts` pins all three verbatim against a plural\n * subject so it stays that way.\n *\n * ## Why `unconfirmed` names a SECOND remedy (AGL-1446)\n *\n * \"Reload\" was the only instruction, and it is the one that fails in the case\n * where an author is most stuck. A long-lived console tab can go permanently\n * cache-only: no Firestore `Listen` channel is ever established, every\n * listener is served from IndexedDB, `fromCache` never clears, and this guard\n * correctly refuses every save — forever. Observed on 2026-08-12 (AGL-1066):\n * refused three times ACROSS A FULL RELOAD with the session entirely healthy\n * (`securetoken` 200, `runAggregationQuery` 200). A brand-new tab fixed it on\n * the first try.\n *\n * ## Why this is copy rather than a branch\n *\n * Three different states reach this message with byte-identical evidence:\n *\n * 1. the server has not confirmed the read YET (reload, or just wait);\n * 2. the client is OFFLINE (reconnect — the case `persistentLocalCache` was\n * added for in the first place);\n * 3. the tab is permanently cache-only (only a new tab helps).\n *\n * In all three, `fromCache` is true, `unreadable` is false, and\n * `session-health` reports nothing: a listen that is never issued and a listen\n * that has not been answered yet both produce no error callback, so no\n * `permission-denied` is ever counted and `staleSession` cannot fire. The one\n * signal that separates (3) from (1) — whether {@link\n * reportFirestoreServerRead} has EVER fired in this tab — is dispatched\n * straight into `session-health.reportSuccessfulRead`, which returns early\n * when there is no denial evidence and retains nothing; `getSessionHealth()`\n * exposes only the denial verdict. So the bit is produced and discarded, and\n * latching it would still not be enough: a freshly-opened tab and an offline\n * tab have not had a listen answered either, so a detector would need a time\n * threshold AND an online check (which lives in the app, behind another seam)\n * before it could say \"stuck\" without mislabelling both. That is a new tuned\n * heuristic in the path of every save, to choose between two remedies that\n * cost the reader one sentence to be told both of.\n *\n * So the message names all three: connection, reload, and the new tab — with\n * the reason the new tab is not just \"reload harder\", because an instruction\n * that reads as a superstition is one people skip.\n */\nfunction refusalMessage(subject: string, reason: StaleSeedReason): string {\n switch (reason) {\n case 'unreadable':\n return (\n `Your ${subject} could not be loaded, so there is nothing safe to ` +\n 'save — saving now would overwrite the stored copy with blanks. ' +\n 'Reload and try again.'\n )\n case 'unconfirmed':\n return (\n `We could not confirm your ${subject} with the server, so what is ` +\n 'on screen may be out of date — saving now could overwrite newer ' +\n 'values. Check your connection and reload; if it is refused again, ' +\n 'open this page in a new browser tab — reloading cannot restore a ' +\n 'connection this tab never opened.'\n )\n case 'stale-session':\n return (\n `Your session went stale, so your ${subject} may be out of date — ` +\n 'saving now could overwrite newer values. Sign in again and reload.'\n )\n }\n}\n\n/** The verdict on its own, for a caller that needs it before the click. */\nexport function checkSeedFreshness(\n options: GuardedSeedWriteOptions,\n): GuardedSeedWriteResult {\n const { subject, unreadable, fromCache, checkSession = true } = options\n /**\n * `fromCache` OUTRANKS `unreadable` (AGL-1066).\n *\n * It did not, and the ordering was harmless only while a refused listen\n * could never reach `status: 'error'`. Now that it can, the common case is\n * BOTH flags true at once — the cache is serving the document and the\n * server has stopped answering for it — and taking `unreadable` there\n * tells the user their settings \"could not be loaded\" and that saving\n * \"would overwrite it with blanks\", while a populated, plausible form sits\n * in front of them. The refusal is right; that explanation is not, and a\n * refusal whose reason is visibly false is one the user works around.\n *\n * What is left to `unreadable` is the read that failed with nothing to\n * show for it, which is the AGL-1143 case it was written for and where the\n * blanks are real. The heuristic still comes last.\n */\n const reason: StaleSeedReason | undefined = fromCache\n ? 'unconfirmed'\n : unreadable\n ? 'unreadable'\n : checkSession && isSessionStale()\n ? 'stale-session'\n : undefined\n if (!reason) return { ok: true }\n return { ok: false, reason, message: refusalMessage(subject, reason) }\n}\n\n/**\n * Run `write` only if the seed can be trusted.\n *\n * Never throws on refusal — the caller reports `message` and leaves the\n * user's typed values on screen, because the whole point is that we are not\n * confident enough to touch the stored document, not that the input was bad.\n */\nexport async function writeGuardedBySeed(\n options: GuardedSeedWriteOptions,\n write: () => Promise<void>,\n): Promise<GuardedSeedWriteResult> {\n const verdict = checkSeedFreshness(options)\n if (!verdict.ok) return verdict\n await write()\n return verdict\n}\n\nexport default writeGuardedBySeed\n"],"names":["staleSessionCheck","setStaleSessionCheck","check","isSessionStale","refusalMessage","subject","reason","checkSeedFreshness","options","unreadable","fromCache","checkSession","undefined","ok","message","writeGuardedBySeed","write","verdict"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwDC,GAID;;;CAGC,GACD,IAAIA,oBAA4C;AAEhD;;;;;;;;CAQC,GACD,OAAO,SAASC,qBAAqBC,KAA6B;IAChEF,oBAAoBE;AACtB;AAEA,mEAAmE,GACnE,SAASC;IACP,OAAOH,oBAAoBA,sBAAsB;AACnD;AAkCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoDC,GACD,SAASI,eAAeC,OAAe,EAAEC,MAAuB;IAC9D,OAAQA;QACN,KAAK;YACH,OACE,CAAC,KAAK,EAAED,QAAQ,kDAAkD,CAAC,GACnE,oEACA;QAEJ,KAAK;YACH,OACE,CAAC,0BAA0B,EAAEA,QAAQ,6BAA6B,CAAC,GACnE,qEACA,uEACA,sEACA;QAEJ,KAAK;YACH,OACE,CAAC,iCAAiC,EAAEA,QAAQ,sBAAsB,CAAC,GACnE;IAEN;AACF;AAEA,yEAAyE,GACzE,OAAO,SAASE,mBACdC,OAAgC;IAEhC,MAAM,EAAEH,OAAO,EAAEI,UAAU,EAAEC,SAAS,EAAEC,eAAe,IAAI,EAAE,GAAGH;IAChE;;;;;;;;;;;;;;;GAeC,GACD,MAAMF,SAAsCI,YACxC,gBACAD,aACE,eACAE,gBAAgBR,mBACd,kBACAS;IACR,IAAI,CAACN,QAAQ,OAAO;QAAEO,IAAI;IAAK;IAC/B,OAAO;QAAEA,IAAI;QAAOP;QAAQQ,SAASV,eAAeC,SAASC;IAAQ;AACvE;AAEA;;;;;;CAMC,GACD,OAAO,eAAeS,mBACpBP,OAAgC,EAChCQ,KAA0B;IAE1B,MAAMC,UAAUV,mBAAmBC;IACnC,IAAI,CAACS,QAAQJ,EAAE,EAAE,OAAOI;IACxB,MAAMD;IACN,OAAOC;AACT;AAEA,eAAeF,mBAAkB"}
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type SnapshotListenOptions } from 'firebase/firestore';
18
+ /**
19
+ * The listen options every hook that reports `fromCache` MUST pass (AGL-2486).
20
+ *
21
+ * ## What breaks without it
22
+ *
23
+ * `fromCache` is the signal `writeGuardedBySeed` refuses on, and without this
24
+ * option it can LATCH TRUE FOR THE LIFE OF THE LISTENER — so every guarded
25
+ * save on the page is refused forever, across reloads, while the connection is
26
+ * perfectly healthy. That is the "We could not confirm your SEO settings with
27
+ * the server" banner in AGL-2486, and it is not a connection fault at all.
28
+ *
29
+ * The cause is in the SDK's own event-raising rule
30
+ * (`@firebase/firestore` 4.17.0, `QueryListener.shouldRaiseEvent`):
31
+ *
32
+ * ```js
33
+ * if (snap.docChanges.length > 0) return true;
34
+ * const pendingChanged = this.snap && this.snap.hasPendingWrites !== snap.hasPendingWrites;
35
+ * return !(!snap.syncStateChanged && !pendingChanged)
36
+ * && true === this.options.includeMetadataChanges;
37
+ * ```
38
+ *
39
+ * The cache→server confirmation is a `syncStateChanged` tick and nothing else:
40
+ * when the server confirms a document the cache already held, the data is
41
+ * IDENTICAL, so `docChanges` is empty. The rule above then raises the event
42
+ * **only if `includeMetadataChanges === true`**. At the default of `false` the
43
+ * confirmation is silently dropped, `setFromCache(false)` never runs, and the
44
+ * hook keeps reporting the value it was initialised with — `true`.
45
+ *
46
+ * So the bug fires precisely when the local cache is WARM and AGREES with the
47
+ * server, which under `persistentLocalCache` is the ordinary state of any
48
+ * console page the author has opened before. It clears only by luck: if the
49
+ * server's copy happens to differ, `docChanges` is non-empty and the event is
50
+ * raised. That is why this survived — it is intermittent by data, not by
51
+ * network.
52
+ *
53
+ * ## Measured, not argued (2026-08-24)
54
+ *
55
+ * A real browser, the real SDK, `persistentLocalCache`, one document seeded so
56
+ * cache and server agree, reloading between runs:
57
+ *
58
+ * - default options → 1 event, `fromCache: true`, never clears (6s), and
59
+ * IDENTICAL on reload — while `…/Firestore/Listen/channel` returned 200.
60
+ * - `{ includeMetadataChanges: true }` → 2 events; the second at 121ms with
61
+ * `fromCache: false` and the same data. Latch cleared.
62
+ *
63
+ * Note the first bullet: the Listen channel is OPEN and ANSWERING. The old
64
+ * theory that the refusal means "a tab that never opened a listener" is wrong,
65
+ * and so is the "only a fresh tab fixes it" folklore — a fresh tab only helps
66
+ * when it happens to read a document the cache does not already agree with.
67
+ *
68
+ * ## Why this is the fix and not a workaround
69
+ *
70
+ * It does not weaken the gate: `fromCache` still comes from the SDK, still
71
+ * starts `true`, and still refuses while the server has genuinely not
72
+ * confirmed. It restores the SDK event the guard was always written to
73
+ * consume. Defaulting the flag to `false`, or adding a "save anyway" escape,
74
+ * would trade the banner for the silent whole-object overwrite the guard
75
+ * exists to prevent.
76
+ *
77
+ * ## Cost
78
+ *
79
+ * One extra callback per listener when the server confirms (measured at ~100ms
80
+ * after subscribe, i.e. at page load, long before a user has typed), plus the
81
+ * pending-write acknowledgement tick — which was ALSO being suppressed, so
82
+ * `hasPendingWrites` was equally unreliable before this. Consumers must
83
+ * therefore tolerate a snapshot whose data is unchanged; none may treat a new
84
+ * emission as "the document changed".
85
+ *
86
+ * `useOrgScope` has passed this since AGL-886 for the same reason, and
87
+ * `useConfirmedDoc` pays for a whole extra `getDocFromServer` to work around
88
+ * not passing it.
89
+ */
90
+ export declare const CONFIRMABLE_LISTEN_OPTIONS: SnapshotListenOptions;
91
+ export default CONFIRMABLE_LISTEN_OPTIONS;
@@ -0,0 +1,92 @@
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 listen options every hook that reports `fromCache` MUST pass (AGL-2486).
18
+ *
19
+ * ## What breaks without it
20
+ *
21
+ * `fromCache` is the signal `writeGuardedBySeed` refuses on, and without this
22
+ * option it can LATCH TRUE FOR THE LIFE OF THE LISTENER — so every guarded
23
+ * save on the page is refused forever, across reloads, while the connection is
24
+ * perfectly healthy. That is the "We could not confirm your SEO settings with
25
+ * the server" banner in AGL-2486, and it is not a connection fault at all.
26
+ *
27
+ * The cause is in the SDK's own event-raising rule
28
+ * (`@firebase/firestore` 4.17.0, `QueryListener.shouldRaiseEvent`):
29
+ *
30
+ * ```js
31
+ * if (snap.docChanges.length > 0) return true;
32
+ * const pendingChanged = this.snap && this.snap.hasPendingWrites !== snap.hasPendingWrites;
33
+ * return !(!snap.syncStateChanged && !pendingChanged)
34
+ * && true === this.options.includeMetadataChanges;
35
+ * ```
36
+ *
37
+ * The cache→server confirmation is a `syncStateChanged` tick and nothing else:
38
+ * when the server confirms a document the cache already held, the data is
39
+ * IDENTICAL, so `docChanges` is empty. The rule above then raises the event
40
+ * **only if `includeMetadataChanges === true`**. At the default of `false` the
41
+ * confirmation is silently dropped, `setFromCache(false)` never runs, and the
42
+ * hook keeps reporting the value it was initialised with — `true`.
43
+ *
44
+ * So the bug fires precisely when the local cache is WARM and AGREES with the
45
+ * server, which under `persistentLocalCache` is the ordinary state of any
46
+ * console page the author has opened before. It clears only by luck: if the
47
+ * server's copy happens to differ, `docChanges` is non-empty and the event is
48
+ * raised. That is why this survived — it is intermittent by data, not by
49
+ * network.
50
+ *
51
+ * ## Measured, not argued (2026-08-24)
52
+ *
53
+ * A real browser, the real SDK, `persistentLocalCache`, one document seeded so
54
+ * cache and server agree, reloading between runs:
55
+ *
56
+ * - default options → 1 event, `fromCache: true`, never clears (6s), and
57
+ * IDENTICAL on reload — while `…/Firestore/Listen/channel` returned 200.
58
+ * - `{ includeMetadataChanges: true }` → 2 events; the second at 121ms with
59
+ * `fromCache: false` and the same data. Latch cleared.
60
+ *
61
+ * Note the first bullet: the Listen channel is OPEN and ANSWERING. The old
62
+ * theory that the refusal means "a tab that never opened a listener" is wrong,
63
+ * and so is the "only a fresh tab fixes it" folklore — a fresh tab only helps
64
+ * when it happens to read a document the cache does not already agree with.
65
+ *
66
+ * ## Why this is the fix and not a workaround
67
+ *
68
+ * It does not weaken the gate: `fromCache` still comes from the SDK, still
69
+ * starts `true`, and still refuses while the server has genuinely not
70
+ * confirmed. It restores the SDK event the guard was always written to
71
+ * consume. Defaulting the flag to `false`, or adding a "save anyway" escape,
72
+ * would trade the banner for the silent whole-object overwrite the guard
73
+ * exists to prevent.
74
+ *
75
+ * ## Cost
76
+ *
77
+ * One extra callback per listener when the server confirms (measured at ~100ms
78
+ * after subscribe, i.e. at page load, long before a user has typed), plus the
79
+ * pending-write acknowledgement tick — which was ALSO being suppressed, so
80
+ * `hasPendingWrites` was equally unreliable before this. Consumers must
81
+ * therefore tolerate a snapshot whose data is unchanged; none may treat a new
82
+ * emission as "the document changed".
83
+ *
84
+ * `useOrgScope` has passed this since AGL-886 for the same reason, and
85
+ * `useConfirmedDoc` pays for a whole extra `getDocFromServer` to work around
86
+ * not passing it.
87
+ */ export const CONFIRMABLE_LISTEN_OPTIONS = {
88
+ includeMetadataChanges: true
89
+ };
90
+ export default CONFIRMABLE_LISTEN_OPTIONS;
91
+
92
+ //# sourceMappingURL=listen-options.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/listen-options.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { type SnapshotListenOptions } from 'firebase/firestore'\n\n/**\n * The listen options every hook that reports `fromCache` MUST pass (AGL-2486).\n *\n * ## What breaks without it\n *\n * `fromCache` is the signal `writeGuardedBySeed` refuses on, and without this\n * option it can LATCH TRUE FOR THE LIFE OF THE LISTENER — so every guarded\n * save on the page is refused forever, across reloads, while the connection is\n * perfectly healthy. That is the \"We could not confirm your SEO settings with\n * the server\" banner in AGL-2486, and it is not a connection fault at all.\n *\n * The cause is in the SDK's own event-raising rule\n * (`@firebase/firestore` 4.17.0, `QueryListener.shouldRaiseEvent`):\n *\n * ```js\n * if (snap.docChanges.length > 0) return true;\n * const pendingChanged = this.snap && this.snap.hasPendingWrites !== snap.hasPendingWrites;\n * return !(!snap.syncStateChanged && !pendingChanged)\n * && true === this.options.includeMetadataChanges;\n * ```\n *\n * The cache→server confirmation is a `syncStateChanged` tick and nothing else:\n * when the server confirms a document the cache already held, the data is\n * IDENTICAL, so `docChanges` is empty. The rule above then raises the event\n * **only if `includeMetadataChanges === true`**. At the default of `false` the\n * confirmation is silently dropped, `setFromCache(false)` never runs, and the\n * hook keeps reporting the value it was initialised with — `true`.\n *\n * So the bug fires precisely when the local cache is WARM and AGREES with the\n * server, which under `persistentLocalCache` is the ordinary state of any\n * console page the author has opened before. It clears only by luck: if the\n * server's copy happens to differ, `docChanges` is non-empty and the event is\n * raised. That is why this survived — it is intermittent by data, not by\n * network.\n *\n * ## Measured, not argued (2026-08-24)\n *\n * A real browser, the real SDK, `persistentLocalCache`, one document seeded so\n * cache and server agree, reloading between runs:\n *\n * - default options → 1 event, `fromCache: true`, never clears (6s), and\n * IDENTICAL on reload — while `…/Firestore/Listen/channel` returned 200.\n * - `{ includeMetadataChanges: true }` → 2 events; the second at 121ms with\n * `fromCache: false` and the same data. Latch cleared.\n *\n * Note the first bullet: the Listen channel is OPEN and ANSWERING. The old\n * theory that the refusal means \"a tab that never opened a listener\" is wrong,\n * and so is the \"only a fresh tab fixes it\" folklore — a fresh tab only helps\n * when it happens to read a document the cache does not already agree with.\n *\n * ## Why this is the fix and not a workaround\n *\n * It does not weaken the gate: `fromCache` still comes from the SDK, still\n * starts `true`, and still refuses while the server has genuinely not\n * confirmed. It restores the SDK event the guard was always written to\n * consume. Defaulting the flag to `false`, or adding a \"save anyway\" escape,\n * would trade the banner for the silent whole-object overwrite the guard\n * exists to prevent.\n *\n * ## Cost\n *\n * One extra callback per listener when the server confirms (measured at ~100ms\n * after subscribe, i.e. at page load, long before a user has typed), plus the\n * pending-write acknowledgement tick — which was ALSO being suppressed, so\n * `hasPendingWrites` was equally unreliable before this. Consumers must\n * therefore tolerate a snapshot whose data is unchanged; none may treat a new\n * emission as \"the document changed\".\n *\n * `useOrgScope` has passed this since AGL-886 for the same reason, and\n * `useConfirmedDoc` pays for a whole extra `getDocFromServer` to work around\n * not passing it.\n */\nexport const CONFIRMABLE_LISTEN_OPTIONS: SnapshotListenOptions = {\n includeMetadataChanges: true,\n}\n\nexport default CONFIRMABLE_LISTEN_OPTIONS\n"],"names":["CONFIRMABLE_LISTEN_OPTIONS","includeMetadataChanges"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuEC,GACD,OAAO,MAAMA,6BAAoD;IAC/DC,wBAAwB;AAC1B,EAAC;AAED,eAAeD,2BAA0B"}