@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.
- package/LICENSE +201 -0
- package/README.md +3 -0
- package/jest.config.d.ts +36 -0
- package/package.json +45 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/duplicate-resource-dialog.d.ts +77 -0
- package/src/lib/components/duplicate-resource-dialog.js +217 -0
- package/src/lib/components/duplicate-resource-dialog.js.map +1 -0
- package/src/lib/constants/firebase-config.d.ts +68 -0
- package/src/lib/constants/firebase-config.js +155 -0
- package/src/lib/constants/firebase-config.js.map +1 -0
- package/src/lib/constants/firebase-emulator-hosts.d.ts +60 -0
- package/src/lib/constants/firebase-emulator-hosts.js +81 -0
- package/src/lib/constants/firebase-emulator-hosts.js.map +1 -0
- package/src/lib/constants/mock-data.d.ts +24 -0
- package/src/lib/constants/mock-data.js +49 -0
- package/src/lib/constants/mock-data.js.map +1 -0
- package/src/lib/constants/site-paths.d.ts +47 -0
- package/src/lib/constants/site-paths.js +66 -0
- package/src/lib/constants/site-paths.js.map +1 -0
- package/src/lib/hooks/firebase/auth-persistence.d.ts +74 -0
- package/src/lib/hooks/firebase/auth-persistence.js +117 -0
- package/src/lib/hooks/firebase/auth-persistence.js.map +1 -0
- package/src/lib/hooks/firebase/firebase-services.d.ts +172 -0
- package/src/lib/hooks/firebase/firebase-services.js +522 -0
- package/src/lib/hooks/firebase/firebase-services.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-cache.d.ts +125 -0
- package/src/lib/hooks/firebase/firestore-cache.js +152 -0
- package/src/lib/hooks/firebase/firestore-cache.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.d.ts +169 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.js +316 -0
- package/src/lib/hooks/firebase/firestore-shared-client-state.js.map +1 -0
- package/src/lib/hooks/firestore-denial-reporter.d.ts +192 -0
- package/src/lib/hooks/firestore-denial-reporter.js +293 -0
- package/src/lib/hooks/firestore-denial-reporter.js.map +1 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.d.ts +62 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.js +91 -0
- package/src/lib/hooks/helpers/besigner-nodes-converter.js.map +1 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.d.ts +80 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.js +89 -0
- package/src/lib/hooks/helpers/guarded-nodes-save.js.map +1 -0
- package/src/lib/hooks/helpers/guarded-seed-write.d.ts +125 -0
- package/src/lib/hooks/helpers/guarded-seed-write.js +193 -0
- package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -0
- package/src/lib/hooks/helpers/listen-options.d.ts +91 -0
- package/src/lib/hooks/helpers/listen-options.js +92 -0
- package/src/lib/hooks/helpers/listen-options.js.map +1 -0
- package/src/lib/hooks/helpers/use-doc.d.ts +38 -0
- package/src/lib/hooks/helpers/use-doc.js +213 -0
- package/src/lib/hooks/helpers/use-doc.js.map +1 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.d.ts +28 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.js +110 -0
- package/src/lib/hooks/helpers/use-modify-doc-callback.js.map +1 -0
- package/src/lib/hooks/host-collection-queries.d.ts +125 -0
- package/src/lib/hooks/host-collection-queries.js +126 -0
- package/src/lib/hooks/host-collection-queries.js.map +1 -0
- package/src/lib/hooks/list-filter-constraints.d.ts +64 -0
- package/src/lib/hooks/list-filter-constraints.js +288 -0
- package/src/lib/hooks/list-filter-constraints.js.map +1 -0
- package/src/lib/hooks/sorted-collection-window.d.ts +207 -0
- package/src/lib/hooks/sorted-collection-window.js +150 -0
- package/src/lib/hooks/sorted-collection-window.js.map +1 -0
- package/src/lib/hooks/use-component-version.d.ts +43 -0
- package/src/lib/hooks/use-component-version.js +73 -0
- package/src/lib/hooks/use-component-version.js.map +1 -0
- package/src/lib/hooks/use-component.d.ts +39 -0
- package/src/lib/hooks/use-component.js +55 -0
- package/src/lib/hooks/use-component.js.map +1 -0
- package/src/lib/hooks/use-console-host-route.d.ts +24 -0
- package/src/lib/hooks/use-console-host-route.js +77 -0
- package/src/lib/hooks/use-console-host-route.js.map +1 -0
- package/src/lib/hooks/use-duplicate-resource-api.d.ts +43 -0
- package/src/lib/hooks/use-duplicate-resource-api.js +61 -0
- package/src/lib/hooks/use-duplicate-resource-api.js.map +1 -0
- package/src/lib/hooks/use-firestore-collection.d.ts +62 -0
- package/src/lib/hooks/use-firestore-collection.js +307 -0
- package/src/lib/hooks/use-firestore-collection.js.map +1 -0
- package/src/lib/hooks/use-firestore-doc.d.ts +42 -0
- package/src/lib/hooks/use-firestore-doc.js +192 -0
- package/src/lib/hooks/use-firestore-doc.js.map +1 -0
- package/src/lib/hooks/use-form-version.d.ts +44 -0
- package/src/lib/hooks/use-form-version.js +73 -0
- package/src/lib/hooks/use-form-version.js.map +1 -0
- package/src/lib/hooks/use-host-activity-logger.d.ts +35 -0
- package/src/lib/hooks/use-host-activity-logger.js +96 -0
- package/src/lib/hooks/use-host-activity-logger.js.map +1 -0
- package/src/lib/hooks/use-host-campaigns.d.ts +53 -0
- package/src/lib/hooks/use-host-campaigns.js +93 -0
- package/src/lib/hooks/use-host-campaigns.js.map +1 -0
- package/src/lib/hooks/use-host-org-id.d.ts +96 -0
- package/src/lib/hooks/use-host-org-id.js +154 -0
- package/src/lib/hooks/use-host-org-id.js.map +1 -0
- package/src/lib/hooks/use-host-resource-api.d.ts +43 -0
- package/src/lib/hooks/use-host-resource-api.js +60 -0
- package/src/lib/hooks/use-host-resource-api.js.map +1 -0
- package/src/lib/hooks/use-host-template.d.ts +44 -0
- package/src/lib/hooks/use-host-template.js +82 -0
- package/src/lib/hooks/use-host-template.js.map +1 -0
- package/src/lib/hooks/use-host-version-api.d.ts +59 -0
- package/src/lib/hooks/use-host-version-api.js +69 -0
- package/src/lib/hooks/use-host-version-api.js.map +1 -0
- package/src/lib/hooks/use-host.d.ts +101 -0
- package/src/lib/hooks/use-host.js +45 -0
- package/src/lib/hooks/use-host.js.map +1 -0
- package/src/lib/hooks/use-layout-version.d.ts +33 -0
- package/src/lib/hooks/use-layout-version.js +64 -0
- package/src/lib/hooks/use-layout-version.js.map +1 -0
- package/src/lib/hooks/use-layout.d.ts +41 -0
- package/src/lib/hooks/use-layout.js +49 -0
- package/src/lib/hooks/use-layout.js.map +1 -0
- package/src/lib/hooks/use-live-artifact-count.d.ts +50 -0
- package/src/lib/hooks/use-live-artifact-count.js +102 -0
- package/src/lib/hooks/use-live-artifact-count.js.map +1 -0
- package/src/lib/hooks/use-org-member-options.d.ts +57 -0
- package/src/lib/hooks/use-org-member-options.js +112 -0
- package/src/lib/hooks/use-org-member-options.js.map +1 -0
- package/src/lib/hooks/use-org-plan.d.ts +46 -0
- package/src/lib/hooks/use-org-plan.js +86 -0
- package/src/lib/hooks/use-org-plan.js.map +1 -0
- package/src/lib/hooks/use-paged-collection.d.ts +95 -0
- package/src/lib/hooks/use-paged-collection.js +130 -0
- package/src/lib/hooks/use-paged-collection.js.map +1 -0
- package/src/lib/hooks/use-plugin-config.d.ts +38 -0
- package/src/lib/hooks/use-plugin-config.js +120 -0
- package/src/lib/hooks/use-plugin-config.js.map +1 -0
- package/src/lib/hooks/use-scope-tokens.d.ts +28 -0
- package/src/lib/hooks/use-scope-tokens.js +100 -0
- package/src/lib/hooks/use-scope-tokens.js.map +1 -0
- package/src/lib/hooks/use-screen-version.d.ts +33 -0
- package/src/lib/hooks/use-screen-version.js +65 -0
- package/src/lib/hooks/use-screen-version.js.map +1 -0
- package/src/lib/hooks/use-screen.d.ts +68 -0
- package/src/lib/hooks/use-screen.js +50 -0
- package/src/lib/hooks/use-screen.js.map +1 -0
- package/src/lib/hooks/use-sending-identity-api.d.ts +197 -0
- package/src/lib/hooks/use-sending-identity-api.js +95 -0
- package/src/lib/hooks/use-sending-identity-api.js.map +1 -0
- package/src/lib/hooks/use-sorted-paged-collection.d.ts +48 -0
- package/src/lib/hooks/use-sorted-paged-collection.js +213 -0
- package/src/lib/hooks/use-sorted-paged-collection.js.map +1 -0
- package/src/lib/hooks/use-switcher-collection.d.ts +105 -0
- package/src/lib/hooks/use-switcher-collection.js +275 -0
- package/src/lib/hooks/use-switcher-collection.js.map +1 -0
- package/src/lib/hooks/use-user-name.d.ts +51 -0
- package/src/lib/hooks/use-user-name.js +90 -0
- package/src/lib/hooks/use-user-name.js.map +1 -0
- package/src/lib/hooks/use-user-photo.d.ts +50 -0
- package/src/lib/hooks/use-user-photo.js +87 -0
- package/src/lib/hooks/use-user-photo.js.map +1 -0
- package/src/lib/tenant.d.ts +61 -0
- package/src/lib/tenant.js +62 -0
- 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"}
|