@aglyn/tenant-feature-instance 1.0.0-beta.296 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/hooks/firebase/firestore-multitab-wedge.d.ts +12 -0
- package/src/lib/hooks/firebase/firestore-multitab-wedge.js +19 -3
- package/src/lib/hooks/firebase/firestore-multitab-wedge.js.map +1 -1
- package/src/lib/hooks/firebase/firestore-stall-recovery.d.ts +52 -4
- package/src/lib/hooks/firebase/firestore-stall-recovery.js +61 -9
- package/src/lib/hooks/firebase/firestore-stall-recovery.js.map +1 -1
- package/src/lib/hooks/helpers/guarded-seed-write.d.ts +8 -58
- package/src/lib/hooks/helpers/guarded-seed-write.js +48 -44
- package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -1
- package/src/lib/hooks/helpers/listen-confirmation.d.ts +104 -0
- package/src/lib/hooks/helpers/listen-confirmation.js +127 -0
- package/src/lib/hooks/helpers/listen-confirmation.js.map +1 -0
- package/src/lib/hooks/helpers/use-doc.js +7 -0
- package/src/lib/hooks/helpers/use-doc.js.map +1 -1
- package/src/lib/hooks/use-confirmed-doc.js +10 -1
- package/src/lib/hooks/use-confirmed-doc.js.map +1 -1
- package/src/lib/hooks/use-firestore-collection.js +17 -5
- package/src/lib/hooks/use-firestore-collection.js.map +1 -1
- package/src/lib/hooks/use-firestore-doc.js +9 -1
- package/src/lib/hooks/use-firestore-doc.js.map +1 -1
|
@@ -14,64 +14,9 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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';
|
|
17
|
+
export type StaleSeedReason = 'unreadable' | 'unconfirmed' | 'stale-session'
|
|
18
|
+
/** The server's confirmation changed the values the form was built from. */
|
|
19
|
+
| 'refreshed';
|
|
75
20
|
/**
|
|
76
21
|
* Register the app's stale-session verdict. Called once, by the app that owns
|
|
77
22
|
* `session-health`. Pass `null` to unregister (tests).
|
|
@@ -120,6 +65,11 @@ export declare function checkSeedFreshness(options: GuardedSeedWriteOptions): Gu
|
|
|
120
65
|
* Never throws on refusal — the caller reports `message` and leaves the
|
|
121
66
|
* user's typed values on screen, because the whole point is that we are not
|
|
122
67
|
* confident enough to touch the stored document, not that the input was bad.
|
|
68
|
+
*
|
|
69
|
+
* A seed that is only UNCONFIRMED is waited for before it is refused
|
|
70
|
+
* (AGL-4120): the save stays in its busy state while the server answers, which
|
|
71
|
+
* is normally a moment. The other reasons are facts about the read or the
|
|
72
|
+
* session that waiting does not change, and they refuse at once.
|
|
123
73
|
*/
|
|
124
74
|
export declare function writeGuardedBySeed(options: GuardedSeedWriteOptions, write: () => Promise<void>): Promise<GuardedSeedWriteResult>;
|
|
125
75
|
export default writeGuardedBySeed;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -44,7 +45,9 @@
|
|
|
44
45
|
* per-listener ground truth from the SDK, so it needs no threshold and no
|
|
45
46
|
* window, and it clears itself the instant a server snapshot lands. It
|
|
46
47
|
* leads because it is also the more ACCURATE explanation whenever it is
|
|
47
|
-
* true — see the ordering note on `checkSeedFreshness`.
|
|
48
|
+
* true — see the ordering note on `checkSeedFreshness`. It is also the one
|
|
49
|
+
* signal that a little patience answers, so `writeGuardedBySeed` waits for
|
|
50
|
+
* the confirmation before it refuses on this alone (AGL-4120).
|
|
48
51
|
* 2. `unreadable` — the read FAILED and there is nothing cached behind it
|
|
49
52
|
* (AGL-1143). Saving would write blanks over a populated document.
|
|
50
53
|
* 3. `staleSession` — the console's own heuristic. Kept because it means
|
|
@@ -69,7 +72,8 @@
|
|
|
69
72
|
* check simply reports "not stale", which is what the tenant runtime and the
|
|
70
73
|
* unit tests want; the other two signals are unaffected, and they are the
|
|
71
74
|
* ones the doc above says must carry the guard anyway.
|
|
72
|
-
*/
|
|
75
|
+
*/ import { whenListensConfirmed } from "./listen-confirmation.js";
|
|
76
|
+
/**
|
|
73
77
|
* The console's `session-health` verdict, injected because it is app state.
|
|
74
78
|
* Null until the app registers one — see {@link setStaleSessionCheck}.
|
|
75
79
|
*/ let staleSessionCheck = null;
|
|
@@ -100,51 +104,28 @@
|
|
|
100
104
|
* `guarded-seed-write.spec.ts` pins all three verbatim against a plural
|
|
101
105
|
* subject so it stays that way.
|
|
102
106
|
*
|
|
103
|
-
* ##
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
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.
|
|
107
|
+
* ## What `unconfirmed` may claim (AGL-4120)
|
|
108
|
+
*
|
|
109
|
+
* It used to tell people a tab could be "permanently cache-only" and to open a
|
|
110
|
+
* new one, "because reloading cannot restore a connection this tab never
|
|
111
|
+
* opened". That theory was wrong twice over. AGL-2486 showed the Listen channel
|
|
112
|
+
* open and answering all along, and AGL-4120 measured what was left: nothing
|
|
113
|
+
* had disconnected, the console's own stall recovery had taken a healthy client
|
|
114
|
+
* off the network. A fresh tab only looked like a cure because it had not been
|
|
115
|
+
* knocked off yet.
|
|
116
|
+
*
|
|
117
|
+
* By the time this reads, the save has waited {@link SEED_CONFIRM_WAIT_MS} for
|
|
118
|
+
* the confirmation. So it says the three things that are true, which are the
|
|
119
|
+
* three a person acting on it needs: the save did not happen, what they typed
|
|
120
|
+
* is still there, and trying again in a moment is the remedy.
|
|
142
121
|
*/ function refusalMessage(subject, reason) {
|
|
143
122
|
switch(reason){
|
|
144
123
|
case 'unreadable':
|
|
145
124
|
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
125
|
case 'unconfirmed':
|
|
147
|
-
return `We could not confirm your ${subject} with the server, so
|
|
126
|
+
return `We could not confirm your ${subject} with the server yet, so nothing ` + 'was saved. What you entered is still here. Check your connection and ' + 'save again in a moment.';
|
|
127
|
+
case 'refreshed':
|
|
128
|
+
return `Your ${subject} just updated from the server, so nothing was saved ` + 'yet. Check the latest values, then save again.';
|
|
148
129
|
case 'stale-session':
|
|
149
130
|
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
131
|
}
|
|
@@ -182,11 +163,34 @@
|
|
|
182
163
|
* Never throws on refusal — the caller reports `message` and leaves the
|
|
183
164
|
* user's typed values on screen, because the whole point is that we are not
|
|
184
165
|
* confident enough to touch the stored document, not that the input was bad.
|
|
166
|
+
*
|
|
167
|
+
* A seed that is only UNCONFIRMED is waited for before it is refused
|
|
168
|
+
* (AGL-4120): the save stays in its busy state while the server answers, which
|
|
169
|
+
* is normally a moment. The other reasons are facts about the read or the
|
|
170
|
+
* session that waiting does not change, and they refuse at once.
|
|
185
171
|
*/ export async function writeGuardedBySeed(options, write) {
|
|
186
172
|
const verdict = checkSeedFreshness(options);
|
|
187
|
-
if (
|
|
173
|
+
if (verdict.ok) {
|
|
174
|
+
await write();
|
|
175
|
+
return verdict;
|
|
176
|
+
}
|
|
177
|
+
if (verdict.reason !== 'unconfirmed') return verdict;
|
|
178
|
+
const settlement = await whenListensConfirmed();
|
|
179
|
+
if (settlement === 'refreshed') {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: 'refreshed',
|
|
183
|
+
message: refusalMessage(options.subject, 'refreshed')
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (settlement !== 'confirmed') return verdict;
|
|
187
|
+
// The wait took time, and the session may not have survived it.
|
|
188
|
+
const confirmed = checkSeedFreshness(_extends({}, options, {
|
|
189
|
+
fromCache: false
|
|
190
|
+
}));
|
|
191
|
+
if (!confirmed.ok) return confirmed;
|
|
188
192
|
await write();
|
|
189
|
-
return
|
|
193
|
+
return confirmed;
|
|
190
194
|
}
|
|
191
195
|
export default writeGuardedBySeed;
|
|
192
196
|
|
|
@@ -1 +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"}
|
|
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`. It is also the one\n * signal that a little patience answers, so `writeGuardedBySeed` waits for\n * the confirmation before it refuses on this alone (AGL-4120).\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\nimport { whenListensConfirmed } from './listen-confirmation'\n\nexport type StaleSeedReason =\n | 'unreadable'\n | 'unconfirmed'\n | 'stale-session'\n /** The server's confirmation changed the values the form was built from. */\n | 'refreshed'\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 * ## What `unconfirmed` may claim (AGL-4120)\n *\n * It used to tell people a tab could be \"permanently cache-only\" and to open a\n * new one, \"because reloading cannot restore a connection this tab never\n * opened\". That theory was wrong twice over. AGL-2486 showed the Listen channel\n * open and answering all along, and AGL-4120 measured what was left: nothing\n * had disconnected, the console's own stall recovery had taken a healthy client\n * off the network. A fresh tab only looked like a cure because it had not been\n * knocked off yet.\n *\n * By the time this reads, the save has waited {@link SEED_CONFIRM_WAIT_MS} for\n * the confirmation. So it says the three things that are true, which are the\n * three a person acting on it needs: the save did not happen, what they typed\n * is still there, and trying again in a moment is the remedy.\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 yet, so nothing ` +\n 'was saved. What you entered is still here. Check your connection and ' +\n 'save again in a moment.'\n )\n case 'refreshed':\n return (\n `Your ${subject} just updated from the server, so nothing was saved ` +\n 'yet. Check the latest values, then save again.'\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 *\n * A seed that is only UNCONFIRMED is waited for before it is refused\n * (AGL-4120): the save stays in its busy state while the server answers, which\n * is normally a moment. The other reasons are facts about the read or the\n * session that waiting does not change, and they refuse at once.\n */\nexport async function writeGuardedBySeed(\n options: GuardedSeedWriteOptions,\n write: () => Promise<void>,\n): Promise<GuardedSeedWriteResult> {\n const verdict = checkSeedFreshness(options)\n if (verdict.ok) {\n await write()\n return verdict\n }\n if (verdict.reason !== 'unconfirmed') return verdict\n const settlement = await whenListensConfirmed()\n if (settlement === 'refreshed') {\n return {\n ok: false,\n reason: 'refreshed',\n message: refusalMessage(options.subject, 'refreshed'),\n }\n }\n if (settlement !== 'confirmed') return verdict\n // The wait took time, and the session may not have survived it.\n const confirmed = checkSeedFreshness({ ...options, fromCache: false })\n if (!confirmed.ok) return confirmed\n await write()\n return confirmed\n}\n\nexport default writeGuardedBySeed\n"],"names":["whenListensConfirmed","staleSessionCheck","setStaleSessionCheck","check","isSessionStale","refusalMessage","subject","reason","checkSeedFreshness","options","unreadable","fromCache","checkSession","undefined","ok","message","writeGuardedBySeed","write","verdict","settlement","confirmed"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0DC,GAED,SAASA,oBAAoB,QAAQ,2BAAuB;AAS5D;;;CAGC,GACD,IAAIC,oBAA4C;AAEhD;;;;;;;;CAQC,GACD,OAAO,SAASC,qBAAqBC,KAA6B;IAChEF,oBAAoBE;AACtB;AAEA,mEAAmE,GACnE,SAASC;IACP,OAAOH,oBAAoBA,sBAAsB;AACnD;AAkCA;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,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,iCAAiC,CAAC,GACvE,0EACA;QAEJ,KAAK;YACH,OACE,CAAC,KAAK,EAAEA,QAAQ,oDAAoD,CAAC,GACrE;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;;;;;;;;;;;CAWC,GACD,OAAO,eAAeS,mBACpBP,OAAgC,EAChCQ,KAA0B;IAE1B,MAAMC,UAAUV,mBAAmBC;IACnC,IAAIS,QAAQJ,EAAE,EAAE;QACd,MAAMG;QACN,OAAOC;IACT;IACA,IAAIA,QAAQX,MAAM,KAAK,eAAe,OAAOW;IAC7C,MAAMC,aAAa,MAAMnB;IACzB,IAAImB,eAAe,aAAa;QAC9B,OAAO;YACLL,IAAI;YACJP,QAAQ;YACRQ,SAASV,eAAeI,QAAQH,OAAO,EAAE;QAC3C;IACF;IACA,IAAIa,eAAe,aAAa,OAAOD;IACvC,gEAAgE;IAChE,MAAME,YAAYZ,mBAAmB,aAAKC;QAASE,WAAW;;IAC9D,IAAI,CAACS,UAAUN,EAAE,EAAE,OAAOM;IAC1B,MAAMH;IACN,OAAOG;AACT;AAEA,eAAeJ,mBAAkB"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* THE LISTENERS THE SERVER HAS NOT CONFIRMED YET, FOR A SAVE THAT NEEDS ONE
|
|
19
|
+
* (AGL-4120).
|
|
20
|
+
*
|
|
21
|
+
* `writeGuardedBySeed` was handed one boolean, read when the button was
|
|
22
|
+
* clicked, and refused the moment it was true. So a save made a second before
|
|
23
|
+
* the server answered was thrown back at the person who made it, with an
|
|
24
|
+
* instruction to reload — though nothing was wrong and waiting would have
|
|
25
|
+
* done. Under load that window ran to a minute (measured in production, beta.297).
|
|
26
|
+
*
|
|
27
|
+
* A guard cannot watch a value it was passed by copy, and fifty-eight call
|
|
28
|
+
* sites pass one. So the four hooks that produce `fromCache` report here, and
|
|
29
|
+
* the guard asks this module instead of its caller: every listener that was
|
|
30
|
+
* unconfirmed at the click has now heard from the server.
|
|
31
|
+
*
|
|
32
|
+
* ## Which listeners
|
|
33
|
+
*
|
|
34
|
+
* All of them, because the guard cannot name its own. That errs one way only:
|
|
35
|
+
* a listener nobody is saving can make a save wait, or ask for one more click.
|
|
36
|
+
* It can never let a save through on an unconfirmed seed, because the seed's
|
|
37
|
+
* own listener is in the set being waited on.
|
|
38
|
+
*
|
|
39
|
+
* ## A confirmation can change what is on screen
|
|
40
|
+
*
|
|
41
|
+
* The seed being protected may be out of date, which is the whole point of the
|
|
42
|
+
* guard. When a listener's confirmation brought values that differ from the
|
|
43
|
+
* ones it had shown, the form the person edited was built from the old ones,
|
|
44
|
+
* so writing it now would overwrite the new ones: the exact loss the guard
|
|
45
|
+
* exists to stop. That settles as `refreshed` and the save is refused for one
|
|
46
|
+
* click, so they see the new values first.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* How long a save waits for a confirmation before saying so. Past the 8 s stall
|
|
50
|
+
* window, so a client that really is silent has been recovered by the time the
|
|
51
|
+
* wait ends, and short enough to read as a slow save rather than a dead button.
|
|
52
|
+
*/
|
|
53
|
+
export declare const SEED_CONFIRM_WAIT_MS = 12000;
|
|
54
|
+
/**
|
|
55
|
+
* How long a save waits. For a spec that pins what happens when the confirmation
|
|
56
|
+
* never comes, which is the refusal and not the wait: pass `0`, and restore the
|
|
57
|
+
* default (no argument) when done, the way `setStaleSessionCheck` is unregistered.
|
|
58
|
+
*/
|
|
59
|
+
export declare function setSeedConfirmWaitMs(ms?: number): void;
|
|
60
|
+
/** The part of a document snapshot {@link sameDocument} reads. */
|
|
61
|
+
interface DocumentValues {
|
|
62
|
+
exists: () => boolean;
|
|
63
|
+
data: () => unknown;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether two snapshots of one document hold the same values. Read only on the
|
|
67
|
+
* step from a cached snapshot to a confirmed one, never on every update, because
|
|
68
|
+
* the SDK hands a doc listener no list of what changed.
|
|
69
|
+
*/
|
|
70
|
+
export declare function sameDocument(a: DocumentValues, b: DocumentValues): boolean;
|
|
71
|
+
/** How the wait ended. */
|
|
72
|
+
export type ListenSettlement =
|
|
73
|
+
/** Every listener that was unconfirmed at the start has heard from the server. */
|
|
74
|
+
'confirmed'
|
|
75
|
+
/** They did, and at least one brought different values from the ones it showed. */
|
|
76
|
+
| 'refreshed'
|
|
77
|
+
/** The wait ran out, or a listener was refused or went away before answering. */
|
|
78
|
+
| 'unconfirmed'
|
|
79
|
+
/**
|
|
80
|
+
* No listener here was unconfirmed, so whatever said the seed is unconfirmed
|
|
81
|
+
* is not one this module can vouch for. The caller's answer stands.
|
|
82
|
+
*/
|
|
83
|
+
| 'untracked';
|
|
84
|
+
export interface ListenConfirmation {
|
|
85
|
+
/**
|
|
86
|
+
* A snapshot arrived. `changed` says a confirmation brought different values
|
|
87
|
+
* from the ones this listener had shown; a first answer showed nothing, so
|
|
88
|
+
* it cannot have changed anything.
|
|
89
|
+
*/
|
|
90
|
+
report: (fromCache: boolean, changed?: boolean) => void;
|
|
91
|
+
/** The listener stopped without a confirmation: refused, or torn down. */
|
|
92
|
+
release: () => void;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* One listener's line in the record. Unconfirmed from the start, for the same
|
|
96
|
+
* reason every hook's `fromCache` state starts true.
|
|
97
|
+
*/
|
|
98
|
+
export declare function trackListenConfirmation(): ListenConfirmation;
|
|
99
|
+
/**
|
|
100
|
+
* Resolves when every listener unconfirmed right now has settled, or after
|
|
101
|
+
* `timeoutMs`. Never rejects.
|
|
102
|
+
*/
|
|
103
|
+
export declare function whenListensConfirmed(timeoutMs?: number): Promise<ListenSettlement>;
|
|
104
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
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 LISTENERS THE SERVER HAS NOT CONFIRMED YET, FOR A SAVE THAT NEEDS ONE
|
|
18
|
+
* (AGL-4120).
|
|
19
|
+
*
|
|
20
|
+
* `writeGuardedBySeed` was handed one boolean, read when the button was
|
|
21
|
+
* clicked, and refused the moment it was true. So a save made a second before
|
|
22
|
+
* the server answered was thrown back at the person who made it, with an
|
|
23
|
+
* instruction to reload — though nothing was wrong and waiting would have
|
|
24
|
+
* done. Under load that window ran to a minute (measured in production, beta.297).
|
|
25
|
+
*
|
|
26
|
+
* A guard cannot watch a value it was passed by copy, and fifty-eight call
|
|
27
|
+
* sites pass one. So the four hooks that produce `fromCache` report here, and
|
|
28
|
+
* the guard asks this module instead of its caller: every listener that was
|
|
29
|
+
* unconfirmed at the click has now heard from the server.
|
|
30
|
+
*
|
|
31
|
+
* ## Which listeners
|
|
32
|
+
*
|
|
33
|
+
* All of them, because the guard cannot name its own. That errs one way only:
|
|
34
|
+
* a listener nobody is saving can make a save wait, or ask for one more click.
|
|
35
|
+
* It can never let a save through on an unconfirmed seed, because the seed's
|
|
36
|
+
* own listener is in the set being waited on.
|
|
37
|
+
*
|
|
38
|
+
* ## A confirmation can change what is on screen
|
|
39
|
+
*
|
|
40
|
+
* The seed being protected may be out of date, which is the whole point of the
|
|
41
|
+
* guard. When a listener's confirmation brought values that differ from the
|
|
42
|
+
* ones it had shown, the form the person edited was built from the old ones,
|
|
43
|
+
* so writing it now would overwrite the new ones: the exact loss the guard
|
|
44
|
+
* exists to stop. That settles as `refreshed` and the save is refused for one
|
|
45
|
+
* click, so they see the new values first.
|
|
46
|
+
*/ /**
|
|
47
|
+
* How long a save waits for a confirmation before saying so. Past the 8 s stall
|
|
48
|
+
* window, so a client that really is silent has been recovered by the time the
|
|
49
|
+
* wait ends, and short enough to read as a slow save rather than a dead button.
|
|
50
|
+
*/ export const SEED_CONFIRM_WAIT_MS = 12000;
|
|
51
|
+
let confirmWaitMs = SEED_CONFIRM_WAIT_MS;
|
|
52
|
+
/**
|
|
53
|
+
* How long a save waits. For a spec that pins what happens when the confirmation
|
|
54
|
+
* never comes, which is the refusal and not the wait: pass `0`, and restore the
|
|
55
|
+
* default (no argument) when done, the way `setStaleSessionCheck` is unregistered.
|
|
56
|
+
*/ export function setSeedConfirmWaitMs(ms = SEED_CONFIRM_WAIT_MS) {
|
|
57
|
+
confirmWaitMs = ms;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether two snapshots of one document hold the same values. Read only on the
|
|
61
|
+
* step from a cached snapshot to a confirmed one, never on every update, because
|
|
62
|
+
* the SDK hands a doc listener no list of what changed.
|
|
63
|
+
*/ export function sameDocument(a, b) {
|
|
64
|
+
if (a.exists() !== b.exists()) return false;
|
|
65
|
+
return JSON.stringify(a.data()) === JSON.stringify(b.data());
|
|
66
|
+
}
|
|
67
|
+
const unconfirmed = new Set();
|
|
68
|
+
const waiters = new Set();
|
|
69
|
+
function settleWaiters(token, how) {
|
|
70
|
+
for (const waiter of [
|
|
71
|
+
...waiters
|
|
72
|
+
]){
|
|
73
|
+
if (!waiter.pending.delete(token)) continue;
|
|
74
|
+
if (how.changed) waiter.refreshed = true;
|
|
75
|
+
if (how.lost) waiter.lost = true;
|
|
76
|
+
if (waiter.pending.size > 0) continue;
|
|
77
|
+
if (waiter.lost) waiter.finish('unconfirmed');
|
|
78
|
+
else waiter.finish(waiter.refreshed ? 'refreshed' : 'confirmed');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One listener's line in the record. Unconfirmed from the start, for the same
|
|
83
|
+
* reason every hook's `fromCache` state starts true.
|
|
84
|
+
*/ export function trackListenConfirmation() {
|
|
85
|
+
const token = {};
|
|
86
|
+
unconfirmed.add(token);
|
|
87
|
+
return {
|
|
88
|
+
report: (fromCache, changed = false)=>{
|
|
89
|
+
if (fromCache) {
|
|
90
|
+
unconfirmed.add(token);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
unconfirmed.delete(token);
|
|
94
|
+
settleWaiters(token, {
|
|
95
|
+
changed
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
release: ()=>{
|
|
99
|
+
unconfirmed.delete(token);
|
|
100
|
+
settleWaiters(token, {
|
|
101
|
+
lost: true
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolves when every listener unconfirmed right now has settled, or after
|
|
108
|
+
* `timeoutMs`. Never rejects.
|
|
109
|
+
*/ export function whenListensConfirmed(timeoutMs = confirmWaitMs) {
|
|
110
|
+
if (unconfirmed.size === 0) return Promise.resolve('untracked');
|
|
111
|
+
return new Promise((resolve)=>{
|
|
112
|
+
const waiter = {
|
|
113
|
+
pending: new Set(unconfirmed),
|
|
114
|
+
refreshed: false,
|
|
115
|
+
lost: false,
|
|
116
|
+
finish: (settlement)=>{
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
waiters.delete(waiter);
|
|
119
|
+
resolve(settlement);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const timer = setTimeout(()=>waiter.finish('unconfirmed'), timeoutMs);
|
|
123
|
+
waiters.add(waiter);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
//# sourceMappingURL=listen-confirmation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/listen-confirmation.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * THE LISTENERS THE SERVER HAS NOT CONFIRMED YET, FOR A SAVE THAT NEEDS ONE\n * (AGL-4120).\n *\n * `writeGuardedBySeed` was handed one boolean, read when the button was\n * clicked, and refused the moment it was true. So a save made a second before\n * the server answered was thrown back at the person who made it, with an\n * instruction to reload — though nothing was wrong and waiting would have\n * done. Under load that window ran to a minute (measured in production, beta.297).\n *\n * A guard cannot watch a value it was passed by copy, and fifty-eight call\n * sites pass one. So the four hooks that produce `fromCache` report here, and\n * the guard asks this module instead of its caller: every listener that was\n * unconfirmed at the click has now heard from the server.\n *\n * ## Which listeners\n *\n * All of them, because the guard cannot name its own. That errs one way only:\n * a listener nobody is saving can make a save wait, or ask for one more click.\n * It can never let a save through on an unconfirmed seed, because the seed's\n * own listener is in the set being waited on.\n *\n * ## A confirmation can change what is on screen\n *\n * The seed being protected may be out of date, which is the whole point of the\n * guard. When a listener's confirmation brought values that differ from the\n * ones it had shown, the form the person edited was built from the old ones,\n * so writing it now would overwrite the new ones: the exact loss the guard\n * exists to stop. That settles as `refreshed` and the save is refused for one\n * click, so they see the new values first.\n */\n\n/**\n * How long a save waits for a confirmation before saying so. Past the 8 s stall\n * window, so a client that really is silent has been recovered by the time the\n * wait ends, and short enough to read as a slow save rather than a dead button.\n */\nexport const SEED_CONFIRM_WAIT_MS = 12_000\n\nlet confirmWaitMs = SEED_CONFIRM_WAIT_MS\n\n/**\n * How long a save waits. For a spec that pins what happens when the confirmation\n * never comes, which is the refusal and not the wait: pass `0`, and restore the\n * default (no argument) when done, the way `setStaleSessionCheck` is unregistered.\n */\nexport function setSeedConfirmWaitMs(ms: number = SEED_CONFIRM_WAIT_MS): void {\n confirmWaitMs = ms\n}\n\n/** The part of a document snapshot {@link sameDocument} reads. */\ninterface DocumentValues {\n exists: () => boolean\n data: () => unknown\n}\n\n/**\n * Whether two snapshots of one document hold the same values. Read only on the\n * step from a cached snapshot to a confirmed one, never on every update, because\n * the SDK hands a doc listener no list of what changed.\n */\nexport function sameDocument(a: DocumentValues, b: DocumentValues): boolean {\n if (a.exists() !== b.exists()) return false\n return JSON.stringify(a.data()) === JSON.stringify(b.data())\n}\n\n/** How the wait ended. */\nexport type ListenSettlement =\n /** Every listener that was unconfirmed at the start has heard from the server. */\n | 'confirmed'\n /** They did, and at least one brought different values from the ones it showed. */\n | 'refreshed'\n /** The wait ran out, or a listener was refused or went away before answering. */\n | 'unconfirmed'\n /**\n * No listener here was unconfirmed, so whatever said the seed is unconfirmed\n * is not one this module can vouch for. The caller's answer stands.\n */\n | 'untracked'\n\ninterface Waiter {\n pending: Set<object>\n refreshed: boolean\n lost: boolean\n finish: (settlement: ListenSettlement) => void\n}\n\nconst unconfirmed = new Set<object>()\nconst waiters = new Set<Waiter>()\n\nfunction settleWaiters(token: object, how: { changed?: boolean; lost?: boolean }) {\n for (const waiter of [...waiters]) {\n if (!waiter.pending.delete(token)) continue\n if (how.changed) waiter.refreshed = true\n if (how.lost) waiter.lost = true\n if (waiter.pending.size > 0) continue\n if (waiter.lost) waiter.finish('unconfirmed')\n else waiter.finish(waiter.refreshed ? 'refreshed' : 'confirmed')\n }\n}\n\nexport interface ListenConfirmation {\n /**\n * A snapshot arrived. `changed` says a confirmation brought different values\n * from the ones this listener had shown; a first answer showed nothing, so\n * it cannot have changed anything.\n */\n report: (fromCache: boolean, changed?: boolean) => void\n /** The listener stopped without a confirmation: refused, or torn down. */\n release: () => void\n}\n\n/**\n * One listener's line in the record. Unconfirmed from the start, for the same\n * reason every hook's `fromCache` state starts true.\n */\nexport function trackListenConfirmation(): ListenConfirmation {\n const token = {}\n unconfirmed.add(token)\n return {\n report: (fromCache, changed = false) => {\n if (fromCache) {\n unconfirmed.add(token)\n return\n }\n unconfirmed.delete(token)\n settleWaiters(token, { changed })\n },\n release: () => {\n unconfirmed.delete(token)\n settleWaiters(token, { lost: true })\n },\n }\n}\n\n/**\n * Resolves when every listener unconfirmed right now has settled, or after\n * `timeoutMs`. Never rejects.\n */\nexport function whenListensConfirmed(\n timeoutMs: number = confirmWaitMs,\n): Promise<ListenSettlement> {\n if (unconfirmed.size === 0) return Promise.resolve('untracked')\n return new Promise((resolve) => {\n const waiter: Waiter = {\n pending: new Set(unconfirmed),\n refreshed: false,\n lost: false,\n finish: (settlement) => {\n clearTimeout(timer)\n waiters.delete(waiter)\n resolve(settlement)\n },\n }\n const timer = setTimeout(() => waiter.finish('unconfirmed'), timeoutMs)\n waiters.add(waiter)\n })\n}\n"],"names":["SEED_CONFIRM_WAIT_MS","confirmWaitMs","setSeedConfirmWaitMs","ms","sameDocument","a","b","exists","JSON","stringify","data","unconfirmed","Set","waiters","settleWaiters","token","how","waiter","pending","delete","changed","refreshed","lost","size","finish","trackListenConfirmation","add","report","fromCache","release","whenListensConfirmed","timeoutMs","Promise","resolve","settlement","clearTimeout","timer","setTimeout"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GAED;;;;CAIC,GACD,OAAO,MAAMA,uBAAuB,MAAM;AAE1C,IAAIC,gBAAgBD;AAEpB;;;;CAIC,GACD,OAAO,SAASE,qBAAqBC,KAAaH,oBAAoB;IACpEC,gBAAgBE;AAClB;AAQA;;;;CAIC,GACD,OAAO,SAASC,aAAaC,CAAiB,EAAEC,CAAiB;IAC/D,IAAID,EAAEE,MAAM,OAAOD,EAAEC,MAAM,IAAI,OAAO;IACtC,OAAOC,KAAKC,SAAS,CAACJ,EAAEK,IAAI,QAAQF,KAAKC,SAAS,CAACH,EAAEI,IAAI;AAC3D;AAuBA,MAAMC,cAAc,IAAIC;AACxB,MAAMC,UAAU,IAAID;AAEpB,SAASE,cAAcC,KAAa,EAAEC,GAA0C;IAC9E,KAAK,MAAMC,UAAU;WAAIJ;KAAQ,CAAE;QACjC,IAAI,CAACI,OAAOC,OAAO,CAACC,MAAM,CAACJ,QAAQ;QACnC,IAAIC,IAAII,OAAO,EAAEH,OAAOI,SAAS,GAAG;QACpC,IAAIL,IAAIM,IAAI,EAAEL,OAAOK,IAAI,GAAG;QAC5B,IAAIL,OAAOC,OAAO,CAACK,IAAI,GAAG,GAAG;QAC7B,IAAIN,OAAOK,IAAI,EAAEL,OAAOO,MAAM,CAAC;aAC1BP,OAAOO,MAAM,CAACP,OAAOI,SAAS,GAAG,cAAc;IACtD;AACF;AAaA;;;CAGC,GACD,OAAO,SAASI;IACd,MAAMV,QAAQ,CAAC;IACfJ,YAAYe,GAAG,CAACX;IAChB,OAAO;QACLY,QAAQ,CAACC,WAAWR,UAAU,KAAK;YACjC,IAAIQ,WAAW;gBACbjB,YAAYe,GAAG,CAACX;gBAChB;YACF;YACAJ,YAAYQ,MAAM,CAACJ;YACnBD,cAAcC,OAAO;gBAAEK;YAAQ;QACjC;QACAS,SAAS;YACPlB,YAAYQ,MAAM,CAACJ;YACnBD,cAAcC,OAAO;gBAAEO,MAAM;YAAK;QACpC;IACF;AACF;AAEA;;;CAGC,GACD,OAAO,SAASQ,qBACdC,YAAoB9B,aAAa;IAEjC,IAAIU,YAAYY,IAAI,KAAK,GAAG,OAAOS,QAAQC,OAAO,CAAC;IACnD,OAAO,IAAID,QAAQ,CAACC;QAClB,MAAMhB,SAAiB;YACrBC,SAAS,IAAIN,IAAID;YACjBU,WAAW;YACXC,MAAM;YACNE,QAAQ,CAACU;gBACPC,aAAaC;gBACbvB,QAAQM,MAAM,CAACF;gBACfgB,QAAQC;YACV;QACF;QACA,MAAME,QAAQC,WAAW,IAAMpB,OAAOO,MAAM,CAAC,gBAAgBO;QAC7DlB,QAAQa,GAAG,CAACT;IACd;AACF"}
|
|
@@ -17,6 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
17
17
|
*/ import { onSnapshot } from "firebase/firestore";
|
|
18
18
|
import { useEffect, useRef, useState } from "react";
|
|
19
19
|
import { DENIAL_STREAK_TO_REPORT, noteFirestoreServerRead, scheduleRefusedReopen, subscribeFirestoreSessionHeal } from "../firestore-denial-reporter.js";
|
|
20
|
+
import { sameDocument, trackListenConfirmation } from "./listen-confirmation.js";
|
|
20
21
|
import { CONFIRMABLE_LISTEN_OPTIONS } from "./listen-options.js";
|
|
21
22
|
import useModifyDocCallback from "./use-modify-doc-callback.js";
|
|
22
23
|
const RETRY_DELAY_MS = 400;
|
|
@@ -78,6 +79,8 @@ const MAX_RETRIES = 5;
|
|
|
78
79
|
* that precedes every reopened refusal (AGL-1066). See the longer note
|
|
79
80
|
* on the same variable in `use-firestore-collection`.
|
|
80
81
|
*/ let terminal = false;
|
|
82
|
+
/** What a save waits on while this listener is unconfirmed (AGL-4120). */ const confirmation = trackListenConfirmation();
|
|
83
|
+
/** The cached snapshot on screen, while one is (AGL-4120). */ let shownFromCache = null;
|
|
81
84
|
const subscribe = ()=>{
|
|
82
85
|
unsubscribe = onSnapshot(ref, // Without this the cache→server confirmation is never delivered and
|
|
83
86
|
// `fromCache` latches true for the life of the listener, refusing
|
|
@@ -118,6 +121,8 @@ const MAX_RETRIES = 5;
|
|
|
118
121
|
// know the difference.
|
|
119
122
|
setHasPendingWrites(snapshot.metadata.hasPendingWrites);
|
|
120
123
|
setFromCache(snapshot.metadata.fromCache);
|
|
124
|
+
confirmation.report(snapshot.metadata.fromCache, !snapshot.metadata.fromCache && shownFromCache !== null && !sameDocument(shownFromCache, snapshot));
|
|
125
|
+
shownFromCache = snapshot.metadata.fromCache ? snapshot : null;
|
|
121
126
|
const value = snapshot.exists() ? _extends({}, snapshot.data(), idField ? {
|
|
122
127
|
[idField]: snapshot.id
|
|
123
128
|
} : {}) : undefined;
|
|
@@ -125,6 +130,7 @@ const MAX_RETRIES = 5;
|
|
|
125
130
|
resolveFirstValueRef.current == null ? void 0 : resolveFirstValueRef.current.call(resolveFirstValueRef);
|
|
126
131
|
}, (err)=>{
|
|
127
132
|
if (cancelled) return;
|
|
133
|
+
confirmation.release();
|
|
128
134
|
unsubscribe == null ? void 0 : unsubscribe();
|
|
129
135
|
// Only a refusal — a lost network produces no error callback at
|
|
130
136
|
// all, and anything that does surface one carries `unavailable`,
|
|
@@ -180,6 +186,7 @@ const MAX_RETRIES = 5;
|
|
|
180
186
|
});
|
|
181
187
|
return ()=>{
|
|
182
188
|
cancelled = true;
|
|
189
|
+
confirmation.release();
|
|
183
190
|
unsubscribeHeal();
|
|
184
191
|
if (timer) clearTimeout(timer);
|
|
185
192
|
cancelRefusedReopen == null ? void 0 : cancelRefusedReopen();
|