@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,293 @@
|
|
|
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
|
+
* Let a LISTENER tell the app its reads are being refused (AGL-1066).
|
|
18
|
+
*
|
|
19
|
+
* `session-health` was built around one-shot reads, because that is where
|
|
20
|
+
* AGL-1062 was first noticed. The console is listener-first, so the detector
|
|
21
|
+
* was watching the read style the console barely uses: only four one-shot
|
|
22
|
+
* call sites pass a collection label, one of them fires once per mount and
|
|
23
|
+
* so can never recur inside the 60s window, and none of the pages carrying a
|
|
24
|
+
* stale-write guard issues a labelled read at all. The verdict was
|
|
25
|
+
* unreachable on the surfaces that needed it.
|
|
26
|
+
*
|
|
27
|
+
* Listeners are on every page, and they already know which collection they
|
|
28
|
+
* are reading, so labelling is free rather than something a caller must
|
|
29
|
+
* remember. This is the seam that lets them report without the library
|
|
30
|
+
* importing the app: the console registers `reportDeniedRead` at startup and
|
|
31
|
+
* the hooks call it through here. An unregistered reporter is a no-op, which
|
|
32
|
+
* is what the tenant runtime wants — it has no client Firestore at all.
|
|
33
|
+
*
|
|
34
|
+
* ## Why this cannot fire offline
|
|
35
|
+
*
|
|
36
|
+
* Only `permission-denied` is ever reported. A client that has simply lost
|
|
37
|
+
* the network gets no error callback at all — the listen sits there and the
|
|
38
|
+
* cache keeps answering — and any one-shot it does make fails `unavailable`.
|
|
39
|
+
* That is what keeps the whole mechanism from mistaking a tunnel for a dead
|
|
40
|
+
* session, and it is why callers must NOT relax the code check.
|
|
41
|
+
*
|
|
42
|
+
* The channel runs both ways: see {@link reportFirestoreSessionHeal} at the
|
|
43
|
+
* bottom of this file for the return leg, which is how a refused listener
|
|
44
|
+
* learns the session came back. It lives here rather than in a module of its
|
|
45
|
+
* own because a heal is defined entirely in terms of the denial it reverses —
|
|
46
|
+
* split them and someone broadcasts one without the other's rules.
|
|
47
|
+
*/ let reporters = null;
|
|
48
|
+
/**
|
|
49
|
+
* Register the app's reporters. Called once, by the app that owns
|
|
50
|
+
* `session-health`. Pass `null` to unregister (tests).
|
|
51
|
+
*/ export function setFirestoreSessionReporters(next) {
|
|
52
|
+
reporters = next;
|
|
53
|
+
}
|
|
54
|
+
/** Report a refused listen. No-op when nothing is registered. */ export function reportFirestoreDenial(collection) {
|
|
55
|
+
reporters == null ? void 0 : reporters.onDenied(collection);
|
|
56
|
+
}
|
|
57
|
+
/** Report a listen the SERVER answered. No-op when nothing is registered. */ export function reportFirestoreServerRead() {
|
|
58
|
+
noteFirestoreServerRead();
|
|
59
|
+
reporters == null ? void 0 : reporters.onServerRead();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* When ANY listener in this tab last got a server-answered snapshot.
|
|
63
|
+
*
|
|
64
|
+
* Library-internal evidence, kept regardless of whether app reporters are
|
|
65
|
+
* registered — `helpers/use-doc` deliberately does not feed `session-health`
|
|
66
|
+
* (its callers are the besigner document family, where a false verdict hurts
|
|
67
|
+
* most) but its server answers are still proof the session can read, and
|
|
68
|
+
* {@link refusedRetryDelayMs} needs that proof from every hook.
|
|
69
|
+
*/ let lastServerReadAt = 0;
|
|
70
|
+
/**
|
|
71
|
+
* Record a server-answered snapshot WITHOUT reporting to `session-health`.
|
|
72
|
+
* `reportFirestoreServerRead` calls this; `helpers/use-doc` calls only this.
|
|
73
|
+
*/ export function noteFirestoreServerRead() {
|
|
74
|
+
lastServerReadAt = Date.now();
|
|
75
|
+
}
|
|
76
|
+
/** Test seam — the evidence is module scope by design. */ export function resetFirestoreServerReadEvidence() {
|
|
77
|
+
lastServerReadAt = 0;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Cadence for a refusal streak that has outlived the retry budget (AGL-1066).
|
|
81
|
+
*
|
|
82
|
+
* A `permission-denied` has two very different causes and the client cannot
|
|
83
|
+
* tell them apart from the error alone:
|
|
84
|
+
*
|
|
85
|
+
* - a SESSION fault — stale token, App Check hiccup, an AGL-1143 SSO
|
|
86
|
+
* session that refuses everything. Every listener in the tab is refused,
|
|
87
|
+
* and the heal is an AGL-664 in-place re-auth or a token that attaches
|
|
88
|
+
* late. A young fault usually heals within seconds, so it starts at 2s.
|
|
89
|
+
*
|
|
90
|
+
* - a RULES denial — the ref itself is one this user may never read (a
|
|
91
|
+
* sentinel id, a scoped collaborator's off-limits collection). Only a
|
|
92
|
+
* rules change (a membership granted mid-session) can end it, so it
|
|
93
|
+
* starts at 60s (AGL-1440).
|
|
94
|
+
*
|
|
95
|
+
* The discriminator is the one this module already trusts for the
|
|
96
|
+
* session-health verdict: A GENUINELY DEAD SESSION HAS NO SERVER ANSWER TO
|
|
97
|
+
* OFFER. If any listener has been answered by the server since this
|
|
98
|
+
* listener's streak began, the session can read and this refusal is about
|
|
99
|
+
* the ref.
|
|
100
|
+
*
|
|
101
|
+
* Either way the delay then grows with the streak's age — a tenth of it, up
|
|
102
|
+
* to five minutes (AGL-2945). A refusal that has lasted ten minutes is
|
|
103
|
+
* unlikely to end in the next two seconds, and refused listens are not free:
|
|
104
|
+
* they bill reads (AGL-2944), so a fixed cadence made every console left open
|
|
105
|
+
* on a dead session a standing cost of 1,800 refusals an hour per listener.
|
|
106
|
+
* Per listener a session fault now costs ~70 in its first hour and 12 an hour
|
|
107
|
+
* after; a rules denial ~35, then 12. The listen is never abandoned.
|
|
108
|
+
*
|
|
109
|
+
* What the fast fixed cadence used to protect is kept as WAKES rather than as
|
|
110
|
+
* a timer — see {@link scheduleRefusedReopen}. The AGL-664 heal broadcast, the
|
|
111
|
+
* tab becoming visible and the window regaining focus all reopen at once, so
|
|
112
|
+
* the slow road only governs a page nobody is touching. The AGL-1358 write
|
|
113
|
+
* guards wait on a save someone clicks, which puts someone at the page.
|
|
114
|
+
*/ export const SESSION_REFUSED_RETRY_DELAY_MS = 2000;
|
|
115
|
+
export const RULES_REFUSED_RETRY_DELAY_MS = 60000;
|
|
116
|
+
/** The longest a refused listen ever waits for its next reopen. */ export const REFUSED_RETRY_CEILING_MS = 5 * 60000;
|
|
117
|
+
/** The delay is the streak's age divided by this, between floor and ceiling. */ const REFUSED_RETRY_AGE_DIVISOR = 10;
|
|
118
|
+
/**
|
|
119
|
+
* The delay before a spent refusal streak reopens its listen.
|
|
120
|
+
*
|
|
121
|
+
* `streakStartedAt` is when the streak's FIRST refusal landed. Evaluated per
|
|
122
|
+
* retry, not once: evidence that arrives mid-streak (another listener's
|
|
123
|
+
* first server answer) raises the floor from the next reopen on, and the age
|
|
124
|
+
* term keeps growing for as long as the streak lasts.
|
|
125
|
+
*/ export function refusedRetryDelayMs(streakStartedAt) {
|
|
126
|
+
const floor = lastServerReadAt > streakStartedAt ? RULES_REFUSED_RETRY_DELAY_MS : SESSION_REFUSED_RETRY_DELAY_MS;
|
|
127
|
+
// A backwards clock reads as a young streak, never as a negative age.
|
|
128
|
+
const age = Math.max(0, Date.now() - streakStartedAt);
|
|
129
|
+
return Math.min(REFUSED_RETRY_CEILING_MS, Math.max(floor, age / REFUSED_RETRY_AGE_DIVISOR));
|
|
130
|
+
}
|
|
131
|
+
/** No `document` (SSR, a worker) counts as visible: nothing to wait for. */ function tabIsHidden() {
|
|
132
|
+
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Schedule the reopen of a spent refusal streak, and return its cancel.
|
|
136
|
+
*
|
|
137
|
+
* In a visible tab the reopen waits {@link refusedRetryDelayMs}. A HIDDEN tab
|
|
138
|
+
* sets no timer at all: nobody is there to heal for, and an unattended tab on
|
|
139
|
+
* a dead session was the Aug 20 – Sep 4 storm — 400K–1.7M rules denials a day
|
|
140
|
+
* in ~8-hour blocks, ~$2 of reads the `read_ops_count` metric never showed
|
|
141
|
+
* (AGL-2944). A tab that goes hidden while a reopen is pending drops its
|
|
142
|
+
* timer and waits the same way.
|
|
143
|
+
*
|
|
144
|
+
* A person arriving reopens at once, whatever the cadence: the tab becoming
|
|
145
|
+
* visible or the window regaining focus. (The heal broadcast is the third
|
|
146
|
+
* instant road; the hooks subscribe to it themselves.) Arrivals are held to
|
|
147
|
+
* one reopen no sooner than {@link SESSION_REFUSED_RETRY_DELAY_MS} after the
|
|
148
|
+
* refusal that scheduled this, so focus churn can never run the loop faster
|
|
149
|
+
* than the fixed cadence it replaced.
|
|
150
|
+
*/ export function scheduleRefusedReopen(reopen, streakStartedAt) {
|
|
151
|
+
const scheduledAt = Date.now();
|
|
152
|
+
let timer = null;
|
|
153
|
+
let dueAt = Number.POSITIVE_INFINITY;
|
|
154
|
+
let settled = false;
|
|
155
|
+
const disarm = ()=>{
|
|
156
|
+
if (timer !== null) clearTimeout(timer);
|
|
157
|
+
timer = null;
|
|
158
|
+
dueAt = Number.POSITIVE_INFINITY;
|
|
159
|
+
};
|
|
160
|
+
const fire = ()=>{
|
|
161
|
+
if (settled) return;
|
|
162
|
+
cancel();
|
|
163
|
+
reopen();
|
|
164
|
+
};
|
|
165
|
+
/** Reopen no later than `atMs`; an earlier due time already set wins. */ const arm = (atMs)=>{
|
|
166
|
+
if (settled || atMs >= dueAt) return;
|
|
167
|
+
if (atMs <= Date.now()) {
|
|
168
|
+
fire();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (timer !== null) clearTimeout(timer);
|
|
172
|
+
dueAt = atMs;
|
|
173
|
+
timer = setTimeout(fire, atMs - Date.now());
|
|
174
|
+
};
|
|
175
|
+
const onArrival = ()=>{
|
|
176
|
+
if (tabIsHidden()) return;
|
|
177
|
+
arm(scheduledAt + SESSION_REFUSED_RETRY_DELAY_MS);
|
|
178
|
+
};
|
|
179
|
+
// `visibilitychange` fires on the way INTO hidden as well as out of it.
|
|
180
|
+
const onVisibilityChange = ()=>{
|
|
181
|
+
if (tabIsHidden()) disarm();
|
|
182
|
+
else onArrival();
|
|
183
|
+
};
|
|
184
|
+
const cancel = ()=>{
|
|
185
|
+
settled = true;
|
|
186
|
+
disarm();
|
|
187
|
+
if (typeof document !== 'undefined') {
|
|
188
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
189
|
+
}
|
|
190
|
+
if (typeof window !== 'undefined') {
|
|
191
|
+
window.removeEventListener('focus', onArrival);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
if (typeof document !== 'undefined') {
|
|
195
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
196
|
+
}
|
|
197
|
+
if (typeof window !== 'undefined') {
|
|
198
|
+
window.addEventListener('focus', onArrival);
|
|
199
|
+
}
|
|
200
|
+
if (!tabIsHidden()) arm(scheduledAt + refusedRetryDelayMs(streakStartedAt));
|
|
201
|
+
return cancel;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The same seam in the other direction: the app telling refused listeners
|
|
205
|
+
* that the session came back (AGL-1066).
|
|
206
|
+
*
|
|
207
|
+
* ## Why this has to exist
|
|
208
|
+
*
|
|
209
|
+
* A refused listen recovers today only because the retry loop never stops —
|
|
210
|
+
* every reopen is another chance for the token to have attached. That is also
|
|
211
|
+
* the single reason the AGL-664 in-place re-auth works at all: nothing else
|
|
212
|
+
* re-subscribes. The hooks' effects depend on the query's identity
|
|
213
|
+
* (`[ref.firestore, ref.path]` and each caller's `deps`), and a `stale`-reason
|
|
214
|
+
* re-auth signs the same uid back in, so no dependency changes and no effect
|
|
215
|
+
* re-runs. `AuthenticatedLayout` deliberately holds the tree MOUNTED while a
|
|
216
|
+
* re-auth prompt is pending (AGL-664), which is what makes "resume exactly
|
|
217
|
+
* where you were" true — and it is exactly why a remount cannot be relied on
|
|
218
|
+
* to reopen anything.
|
|
219
|
+
*
|
|
220
|
+
* So any change that lets a refusal streak go terminal — the AGL-1066 item-3
|
|
221
|
+
* flip — needs this channel first, or a user who re-authenticates
|
|
222
|
+
* successfully sits in front of an errored console until they reload.
|
|
223
|
+
*
|
|
224
|
+
* ## What may broadcast, and what must not
|
|
225
|
+
*
|
|
226
|
+
* A heal is a RECOVERY FROM DENIAL, not any token event. Firebase refreshes
|
|
227
|
+
* the ID token roughly hourly; broadcasting on that would reopen every
|
|
228
|
+
* listener in the console on a timer for no reason. The console broadcasts
|
|
229
|
+
* from one place — a session fault that has been resolved — see
|
|
230
|
+
* `apps/console/utils/session-heal.ts`.
|
|
231
|
+
*
|
|
232
|
+
* Subscribers gate themselves as well: a listener the server is NOT refusing
|
|
233
|
+
* ignores the broadcast entirely, so even a spurious heal costs nothing.
|
|
234
|
+
* Nothing here clears `serverDenied` or `deniedStreak` — only a snapshot the
|
|
235
|
+
* server answered is evidence, and that invariant does not bend for a
|
|
236
|
+
* hopeful signal from the auth layer.
|
|
237
|
+
*/ const healListeners = new Set();
|
|
238
|
+
/**
|
|
239
|
+
* Listen for "the session may have recovered". Returns the unsubscribe.
|
|
240
|
+
*
|
|
241
|
+
* The callback runs synchronously inside {@link reportFirestoreSessionHeal},
|
|
242
|
+
* so it must be cheap and must tolerate being called when there is nothing
|
|
243
|
+
* to do.
|
|
244
|
+
*/ export function subscribeFirestoreSessionHeal(listener) {
|
|
245
|
+
healListeners.add(listener);
|
|
246
|
+
return ()=>void healListeners.delete(listener);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Tell every listener the session may have recovered.
|
|
250
|
+
*
|
|
251
|
+
* Iterates a COPY: a subscriber reopening its listen can unsubscribe (an
|
|
252
|
+
* unmount racing the broadcast) and mutating the set mid-iteration would skip
|
|
253
|
+
* the next one.
|
|
254
|
+
*/ export function reportFirestoreSessionHeal() {
|
|
255
|
+
for (const listener of [
|
|
256
|
+
...healListeners
|
|
257
|
+
])listener();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Consecutive refusals before a listener says anything.
|
|
261
|
+
*
|
|
262
|
+
* Matched to `firestore-one-shot-retry`'s budget so both read styles apply
|
|
263
|
+
* the same bar: surviving this many means it is not the AGL-216/217
|
|
264
|
+
* post-sign-in race, which resolves in well under two seconds.
|
|
265
|
+
*
|
|
266
|
+
* The streak this counts is NOT the hook's retry `attempt`. That one is
|
|
267
|
+
* reset by any snapshot, including one served from cache, so under
|
|
268
|
+
* `persistentLocalCache` it never reaches any threshold at all — see
|
|
269
|
+
* `use-firestore-collection-cached-retry.spec.ts`. This one is reset only by
|
|
270
|
+
* a snapshot the SERVER answered, which is the only evidence that actually
|
|
271
|
+
* bears on whether the session can read.
|
|
272
|
+
*/ export const DENIAL_STREAK_TO_REPORT = 5;
|
|
273
|
+
/**
|
|
274
|
+
* The collection a listen was against, when it can be known from public API.
|
|
275
|
+
*
|
|
276
|
+
* `DocumentReference` exposes `parent`, and a `CollectionReference` exposes
|
|
277
|
+
* `path`. A filtered `Query` exposes neither, and reaching into `_query` to
|
|
278
|
+
* get one would be reading SDK internals — so those report `undefined` and
|
|
279
|
+
* land in the shared `unknown` bucket. That can only ever fail to reach the
|
|
280
|
+
* two-collection threshold, never trip it falsely, which is the right way
|
|
281
|
+
* for this to degrade.
|
|
282
|
+
*/ export function denialLabelForQuery(target) {
|
|
283
|
+
var _candidate_parent;
|
|
284
|
+
const candidate = target;
|
|
285
|
+
if (!candidate) return undefined;
|
|
286
|
+
// A document listen: name the collection that holds it, not the document,
|
|
287
|
+
// so two docs in one collection stay ONE piece of evidence.
|
|
288
|
+
if ((_candidate_parent = candidate.parent) == null ? void 0 : _candidate_parent.path) return candidate.parent.path;
|
|
289
|
+
if (candidate.type === 'collection' && candidate.path) return candidate.path;
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
//# sourceMappingURL=firestore-denial-reporter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firestore-denial-reporter.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 * Let a LISTENER tell the app its reads are being refused (AGL-1066).\n *\n * `session-health` was built around one-shot reads, because that is where\n * AGL-1062 was first noticed. The console is listener-first, so the detector\n * was watching the read style the console barely uses: only four one-shot\n * call sites pass a collection label, one of them fires once per mount and\n * so can never recur inside the 60s window, and none of the pages carrying a\n * stale-write guard issues a labelled read at all. The verdict was\n * unreachable on the surfaces that needed it.\n *\n * Listeners are on every page, and they already know which collection they\n * are reading, so labelling is free rather than something a caller must\n * remember. This is the seam that lets them report without the library\n * importing the app: the console registers `reportDeniedRead` at startup and\n * the hooks call it through here. An unregistered reporter is a no-op, which\n * is what the tenant runtime wants — it has no client Firestore at all.\n *\n * ## Why this cannot fire offline\n *\n * Only `permission-denied` is ever reported. A client that has simply lost\n * the network gets no error callback at all — the listen sits there and the\n * cache keeps answering — and any one-shot it does make fails `unavailable`.\n * That is what keeps the whole mechanism from mistaking a tunnel for a dead\n * session, and it is why callers must NOT relax the code check.\n *\n * The channel runs both ways: see {@link reportFirestoreSessionHeal} at the\n * bottom of this file for the return leg, which is how a refused listener\n * learns the session came back. It lives here rather than in a module of its\n * own because a heal is defined entirely in terms of the denial it reverses —\n * split them and someone broadcasts one without the other's rules.\n */\n\nexport interface FirestoreSessionReporters {\n /** A listen was refused past the budget; names the collection. */\n onDenied: (collection?: string) => void\n /**\n * A listen was answered BY THE SERVER.\n *\n * Reported as well as the denials, and it is what keeps this safe to turn\n * on. A scoped collaborator (AGL-1041) has collections they may not read\n * by design, and with listeners reporting they could otherwise accumulate\n * two denied collections and be told their session is dead. But their\n * other listens keep succeeding against the server, and one server answer\n * clears the evidence outright — a genuinely dead session has no such\n * answer to offer, so it is the discriminator the denial count alone\n * cannot be.\n *\n * A CACHED snapshot must never be passed here; it proves nothing.\n */\n onServerRead: () => void\n}\n\nlet reporters: FirestoreSessionReporters | null = null\n\n/**\n * Register the app's reporters. Called once, by the app that owns\n * `session-health`. Pass `null` to unregister (tests).\n */\nexport function setFirestoreSessionReporters(\n next: FirestoreSessionReporters | null,\n): void {\n reporters = next\n}\n\n/** Report a refused listen. No-op when nothing is registered. */\nexport function reportFirestoreDenial(collection?: string): void {\n reporters?.onDenied(collection)\n}\n\n/** Report a listen the SERVER answered. No-op when nothing is registered. */\nexport function reportFirestoreServerRead(): void {\n noteFirestoreServerRead()\n reporters?.onServerRead()\n}\n\n/**\n * When ANY listener in this tab last got a server-answered snapshot.\n *\n * Library-internal evidence, kept regardless of whether app reporters are\n * registered — `helpers/use-doc` deliberately does not feed `session-health`\n * (its callers are the besigner document family, where a false verdict hurts\n * most) but its server answers are still proof the session can read, and\n * {@link refusedRetryDelayMs} needs that proof from every hook.\n */\nlet lastServerReadAt = 0\n\n/**\n * Record a server-answered snapshot WITHOUT reporting to `session-health`.\n * `reportFirestoreServerRead` calls this; `helpers/use-doc` calls only this.\n */\nexport function noteFirestoreServerRead(): void {\n lastServerReadAt = Date.now()\n}\n\n/** Test seam — the evidence is module scope by design. */\nexport function resetFirestoreServerReadEvidence(): void {\n lastServerReadAt = 0\n}\n\n/**\n * Cadence for a refusal streak that has outlived the retry budget (AGL-1066).\n *\n * A `permission-denied` has two very different causes and the client cannot\n * tell them apart from the error alone:\n *\n * - a SESSION fault — stale token, App Check hiccup, an AGL-1143 SSO\n * session that refuses everything. Every listener in the tab is refused,\n * and the heal is an AGL-664 in-place re-auth or a token that attaches\n * late. A young fault usually heals within seconds, so it starts at 2s.\n *\n * - a RULES denial — the ref itself is one this user may never read (a\n * sentinel id, a scoped collaborator's off-limits collection). Only a\n * rules change (a membership granted mid-session) can end it, so it\n * starts at 60s (AGL-1440).\n *\n * The discriminator is the one this module already trusts for the\n * session-health verdict: A GENUINELY DEAD SESSION HAS NO SERVER ANSWER TO\n * OFFER. If any listener has been answered by the server since this\n * listener's streak began, the session can read and this refusal is about\n * the ref.\n *\n * Either way the delay then grows with the streak's age — a tenth of it, up\n * to five minutes (AGL-2945). A refusal that has lasted ten minutes is\n * unlikely to end in the next two seconds, and refused listens are not free:\n * they bill reads (AGL-2944), so a fixed cadence made every console left open\n * on a dead session a standing cost of 1,800 refusals an hour per listener.\n * Per listener a session fault now costs ~70 in its first hour and 12 an hour\n * after; a rules denial ~35, then 12. The listen is never abandoned.\n *\n * What the fast fixed cadence used to protect is kept as WAKES rather than as\n * a timer — see {@link scheduleRefusedReopen}. The AGL-664 heal broadcast, the\n * tab becoming visible and the window regaining focus all reopen at once, so\n * the slow road only governs a page nobody is touching. The AGL-1358 write\n * guards wait on a save someone clicks, which puts someone at the page.\n */\nexport const SESSION_REFUSED_RETRY_DELAY_MS = 2_000\nexport const RULES_REFUSED_RETRY_DELAY_MS = 60_000\n/** The longest a refused listen ever waits for its next reopen. */\nexport const REFUSED_RETRY_CEILING_MS = 5 * 60_000\n/** The delay is the streak's age divided by this, between floor and ceiling. */\nconst REFUSED_RETRY_AGE_DIVISOR = 10\n\n/**\n * The delay before a spent refusal streak reopens its listen.\n *\n * `streakStartedAt` is when the streak's FIRST refusal landed. Evaluated per\n * retry, not once: evidence that arrives mid-streak (another listener's\n * first server answer) raises the floor from the next reopen on, and the age\n * term keeps growing for as long as the streak lasts.\n */\nexport function refusedRetryDelayMs(streakStartedAt: number): number {\n const floor =\n lastServerReadAt > streakStartedAt\n ? RULES_REFUSED_RETRY_DELAY_MS\n : SESSION_REFUSED_RETRY_DELAY_MS\n // A backwards clock reads as a young streak, never as a negative age.\n const age = Math.max(0, Date.now() - streakStartedAt)\n return Math.min(\n REFUSED_RETRY_CEILING_MS,\n Math.max(floor, age / REFUSED_RETRY_AGE_DIVISOR),\n )\n}\n\n/** No `document` (SSR, a worker) counts as visible: nothing to wait for. */\nfunction tabIsHidden(): boolean {\n return (\n typeof document !== 'undefined' && document.visibilityState === 'hidden'\n )\n}\n\n/**\n * Schedule the reopen of a spent refusal streak, and return its cancel.\n *\n * In a visible tab the reopen waits {@link refusedRetryDelayMs}. A HIDDEN tab\n * sets no timer at all: nobody is there to heal for, and an unattended tab on\n * a dead session was the Aug 20 – Sep 4 storm — 400K–1.7M rules denials a day\n * in ~8-hour blocks, ~$2 of reads the `read_ops_count` metric never showed\n * (AGL-2944). A tab that goes hidden while a reopen is pending drops its\n * timer and waits the same way.\n *\n * A person arriving reopens at once, whatever the cadence: the tab becoming\n * visible or the window regaining focus. (The heal broadcast is the third\n * instant road; the hooks subscribe to it themselves.) Arrivals are held to\n * one reopen no sooner than {@link SESSION_REFUSED_RETRY_DELAY_MS} after the\n * refusal that scheduled this, so focus churn can never run the loop faster\n * than the fixed cadence it replaced.\n */\nexport function scheduleRefusedReopen(\n reopen: () => void,\n streakStartedAt: number,\n): () => void {\n const scheduledAt = Date.now()\n let timer: ReturnType<typeof setTimeout> | null = null\n let dueAt = Number.POSITIVE_INFINITY\n let settled = false\n\n const disarm = () => {\n if (timer !== null) clearTimeout(timer)\n timer = null\n dueAt = Number.POSITIVE_INFINITY\n }\n\n const fire = () => {\n if (settled) return\n cancel()\n reopen()\n }\n\n /** Reopen no later than `atMs`; an earlier due time already set wins. */\n const arm = (atMs: number) => {\n if (settled || atMs >= dueAt) return\n if (atMs <= Date.now()) {\n fire()\n return\n }\n if (timer !== null) clearTimeout(timer)\n dueAt = atMs\n timer = setTimeout(fire, atMs - Date.now())\n }\n\n const onArrival = () => {\n if (tabIsHidden()) return\n arm(scheduledAt + SESSION_REFUSED_RETRY_DELAY_MS)\n }\n\n // `visibilitychange` fires on the way INTO hidden as well as out of it.\n const onVisibilityChange = () => {\n if (tabIsHidden()) disarm()\n else onArrival()\n }\n\n const cancel = () => {\n settled = true\n disarm()\n if (typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', onVisibilityChange)\n }\n if (typeof window !== 'undefined') {\n window.removeEventListener('focus', onArrival)\n }\n }\n\n if (typeof document !== 'undefined') {\n document.addEventListener('visibilitychange', onVisibilityChange)\n }\n if (typeof window !== 'undefined') {\n window.addEventListener('focus', onArrival)\n }\n if (!tabIsHidden()) arm(scheduledAt + refusedRetryDelayMs(streakStartedAt))\n\n return cancel\n}\n\n/**\n * The same seam in the other direction: the app telling refused listeners\n * that the session came back (AGL-1066).\n *\n * ## Why this has to exist\n *\n * A refused listen recovers today only because the retry loop never stops —\n * every reopen is another chance for the token to have attached. That is also\n * the single reason the AGL-664 in-place re-auth works at all: nothing else\n * re-subscribes. The hooks' effects depend on the query's identity\n * (`[ref.firestore, ref.path]` and each caller's `deps`), and a `stale`-reason\n * re-auth signs the same uid back in, so no dependency changes and no effect\n * re-runs. `AuthenticatedLayout` deliberately holds the tree MOUNTED while a\n * re-auth prompt is pending (AGL-664), which is what makes \"resume exactly\n * where you were\" true — and it is exactly why a remount cannot be relied on\n * to reopen anything.\n *\n * So any change that lets a refusal streak go terminal — the AGL-1066 item-3\n * flip — needs this channel first, or a user who re-authenticates\n * successfully sits in front of an errored console until they reload.\n *\n * ## What may broadcast, and what must not\n *\n * A heal is a RECOVERY FROM DENIAL, not any token event. Firebase refreshes\n * the ID token roughly hourly; broadcasting on that would reopen every\n * listener in the console on a timer for no reason. The console broadcasts\n * from one place — a session fault that has been resolved — see\n * `apps/console/utils/session-heal.ts`.\n *\n * Subscribers gate themselves as well: a listener the server is NOT refusing\n * ignores the broadcast entirely, so even a spurious heal costs nothing.\n * Nothing here clears `serverDenied` or `deniedStreak` — only a snapshot the\n * server answered is evidence, and that invariant does not bend for a\n * hopeful signal from the auth layer.\n */\nconst healListeners = new Set<() => void>()\n\n/**\n * Listen for \"the session may have recovered\". Returns the unsubscribe.\n *\n * The callback runs synchronously inside {@link reportFirestoreSessionHeal},\n * so it must be cheap and must tolerate being called when there is nothing\n * to do.\n */\nexport function subscribeFirestoreSessionHeal(\n listener: () => void,\n): () => void {\n healListeners.add(listener)\n return () => void healListeners.delete(listener)\n}\n\n/**\n * Tell every listener the session may have recovered.\n *\n * Iterates a COPY: a subscriber reopening its listen can unsubscribe (an\n * unmount racing the broadcast) and mutating the set mid-iteration would skip\n * the next one.\n */\nexport function reportFirestoreSessionHeal(): void {\n for (const listener of [...healListeners]) listener()\n}\n\n/**\n * Consecutive refusals before a listener says anything.\n *\n * Matched to `firestore-one-shot-retry`'s budget so both read styles apply\n * the same bar: surviving this many means it is not the AGL-216/217\n * post-sign-in race, which resolves in well under two seconds.\n *\n * The streak this counts is NOT the hook's retry `attempt`. That one is\n * reset by any snapshot, including one served from cache, so under\n * `persistentLocalCache` it never reaches any threshold at all — see\n * `use-firestore-collection-cached-retry.spec.ts`. This one is reset only by\n * a snapshot the SERVER answered, which is the only evidence that actually\n * bears on whether the session can read.\n */\nexport const DENIAL_STREAK_TO_REPORT = 5\n\n/**\n * The collection a listen was against, when it can be known from public API.\n *\n * `DocumentReference` exposes `parent`, and a `CollectionReference` exposes\n * `path`. A filtered `Query` exposes neither, and reaching into `_query` to\n * get one would be reading SDK internals — so those report `undefined` and\n * land in the shared `unknown` bucket. That can only ever fail to reach the\n * two-collection threshold, never trip it falsely, which is the right way\n * for this to degrade.\n */\nexport function denialLabelForQuery(target: unknown): string | undefined {\n const candidate = target as\n | { type?: string; path?: string; parent?: { path?: string } }\n | null\n | undefined\n if (!candidate) return undefined\n // A document listen: name the collection that holds it, not the document,\n // so two docs in one collection stay ONE piece of evidence.\n if (candidate.parent?.path) return candidate.parent.path\n if (candidate.type === 'collection' && candidate.path) return candidate.path\n return undefined\n}\n"],"names":["reporters","setFirestoreSessionReporters","next","reportFirestoreDenial","collection","onDenied","reportFirestoreServerRead","noteFirestoreServerRead","onServerRead","lastServerReadAt","Date","now","resetFirestoreServerReadEvidence","SESSION_REFUSED_RETRY_DELAY_MS","RULES_REFUSED_RETRY_DELAY_MS","REFUSED_RETRY_CEILING_MS","REFUSED_RETRY_AGE_DIVISOR","refusedRetryDelayMs","streakStartedAt","floor","age","Math","max","min","tabIsHidden","document","visibilityState","scheduleRefusedReopen","reopen","scheduledAt","timer","dueAt","Number","POSITIVE_INFINITY","settled","disarm","clearTimeout","fire","cancel","arm","atMs","setTimeout","onArrival","onVisibilityChange","removeEventListener","window","addEventListener","healListeners","Set","subscribeFirestoreSessionHeal","listener","add","delete","reportFirestoreSessionHeal","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","target","candidate","undefined","parent","path","type"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GAsBD,IAAIA,YAA8C;AAElD;;;CAGC,GACD,OAAO,SAASC,6BACdC,IAAsC;IAEtCF,YAAYE;AACd;AAEA,+DAA+D,GAC/D,OAAO,SAASC,sBAAsBC,UAAmB;IACvDJ,6BAAAA,UAAWK,QAAQ,CAACD;AACtB;AAEA,2EAA2E,GAC3E,OAAO,SAASE;IACdC;IACAP,6BAAAA,UAAWQ,YAAY;AACzB;AAEA;;;;;;;;CAQC,GACD,IAAIC,mBAAmB;AAEvB;;;CAGC,GACD,OAAO,SAASF;IACdE,mBAAmBC,KAAKC,GAAG;AAC7B;AAEA,wDAAwD,GACxD,OAAO,SAASC;IACdH,mBAAmB;AACrB;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GACD,OAAO,MAAMI,iCAAiC,KAAK;AACnD,OAAO,MAAMC,+BAA+B,MAAM;AAClD,iEAAiE,GACjE,OAAO,MAAMC,2BAA2B,IAAI,MAAM;AAClD,8EAA8E,GAC9E,MAAMC,4BAA4B;AAElC;;;;;;;CAOC,GACD,OAAO,SAASC,oBAAoBC,eAAuB;IACzD,MAAMC,QACJV,mBAAmBS,kBACfJ,+BACAD;IACN,sEAAsE;IACtE,MAAMO,MAAMC,KAAKC,GAAG,CAAC,GAAGZ,KAAKC,GAAG,KAAKO;IACrC,OAAOG,KAAKE,GAAG,CACbR,0BACAM,KAAKC,GAAG,CAACH,OAAOC,MAAMJ;AAE1B;AAEA,0EAA0E,GAC1E,SAASQ;IACP,OACE,OAAOC,aAAa,eAAeA,SAASC,eAAe,KAAK;AAEpE;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,sBACdC,MAAkB,EAClBV,eAAuB;IAEvB,MAAMW,cAAcnB,KAAKC,GAAG;IAC5B,IAAImB,QAA8C;IAClD,IAAIC,QAAQC,OAAOC,iBAAiB;IACpC,IAAIC,UAAU;IAEd,MAAMC,SAAS;QACb,IAAIL,UAAU,MAAMM,aAAaN;QACjCA,QAAQ;QACRC,QAAQC,OAAOC,iBAAiB;IAClC;IAEA,MAAMI,OAAO;QACX,IAAIH,SAAS;QACbI;QACAV;IACF;IAEA,uEAAuE,GACvE,MAAMW,MAAM,CAACC;QACX,IAAIN,WAAWM,QAAQT,OAAO;QAC9B,IAAIS,QAAQ9B,KAAKC,GAAG,IAAI;YACtB0B;YACA;QACF;QACA,IAAIP,UAAU,MAAMM,aAAaN;QACjCC,QAAQS;QACRV,QAAQW,WAAWJ,MAAMG,OAAO9B,KAAKC,GAAG;IAC1C;IAEA,MAAM+B,YAAY;QAChB,IAAIlB,eAAe;QACnBe,IAAIV,cAAchB;IACpB;IAEA,wEAAwE;IACxE,MAAM8B,qBAAqB;QACzB,IAAInB,eAAeW;aACdO;IACP;IAEA,MAAMJ,SAAS;QACbJ,UAAU;QACVC;QACA,IAAI,OAAOV,aAAa,aAAa;YACnCA,SAASmB,mBAAmB,CAAC,oBAAoBD;QACnD;QACA,IAAI,OAAOE,WAAW,aAAa;YACjCA,OAAOD,mBAAmB,CAAC,SAASF;QACtC;IACF;IAEA,IAAI,OAAOjB,aAAa,aAAa;QACnCA,SAASqB,gBAAgB,CAAC,oBAAoBH;IAChD;IACA,IAAI,OAAOE,WAAW,aAAa;QACjCA,OAAOC,gBAAgB,CAAC,SAASJ;IACnC;IACA,IAAI,CAAClB,eAAee,IAAIV,cAAcZ,oBAAoBC;IAE1D,OAAOoB;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD,MAAMS,gBAAgB,IAAIC;AAE1B;;;;;;CAMC,GACD,OAAO,SAASC,8BACdC,QAAoB;IAEpBH,cAAcI,GAAG,CAACD;IAClB,OAAO,IAAM,KAAKH,cAAcK,MAAM,CAACF;AACzC;AAEA;;;;;;CAMC,GACD,OAAO,SAASG;IACd,KAAK,MAAMH,YAAY;WAAIH;KAAc,CAAEG;AAC7C;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMI,0BAA0B,EAAC;AAExC;;;;;;;;;CASC,GACD,OAAO,SAASC,oBAAoBC,MAAe;QAQ7CC;IAPJ,MAAMA,YAAYD;IAIlB,IAAI,CAACC,WAAW,OAAOC;IACvB,0EAA0E;IAC1E,4DAA4D;IAC5D,KAAID,oBAAAA,UAAUE,MAAM,qBAAhBF,kBAAkBG,IAAI,EAAE,OAAOH,UAAUE,MAAM,CAACC,IAAI;IACxD,IAAIH,UAAUI,IAAI,KAAK,gBAAgBJ,UAAUG,IAAI,EAAE,OAAOH,UAAUG,IAAI;IAC5E,OAAOF;AACT"}
|
|
@@ -0,0 +1,62 @@
|
|
|
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 DocumentReference } from 'firebase/firestore';
|
|
18
|
+
/**
|
|
19
|
+
* Compression at rest for a besigner document that has no converter of its
|
|
20
|
+
* own — the two EMAIL editors (AGL-1151).
|
|
21
|
+
*
|
|
22
|
+
* ## Why a converter rather than decoding at the read site
|
|
23
|
+
*
|
|
24
|
+
* Both email besigners read their version with a bare `useFirestoreDoc` and
|
|
25
|
+
* write it with `saveNodesGuarded` on a bare `doc(...)`, and `nodes` has to
|
|
26
|
+
* be in ONE shape across four comparisons for either to work:
|
|
27
|
+
*
|
|
28
|
+
* - `useBesignerDocument` diffs the arriving snapshot against its baseline to
|
|
29
|
+
* decide whether somebody else has written, and against the tree it just
|
|
30
|
+
* sent to recognise the echo of its own save;
|
|
31
|
+
* - `saveNodesGuarded` re-reads inside its transaction and compares the
|
|
32
|
+
* stored `nodes` against the baseline the editor presents.
|
|
33
|
+
*
|
|
34
|
+
* Decoding at the read site alone would leave the stored form on one side of
|
|
35
|
+
* those comparisons and a decoded map on the other, so every save would look
|
|
36
|
+
* like a conflict and be refused. A converter puts the decode BEFORE all four,
|
|
37
|
+
* which is why the screen, layout, component and form editors — which have
|
|
38
|
+
* carried converters since AGL-1151 — never had the problem.
|
|
39
|
+
*
|
|
40
|
+
* ## What it does not do
|
|
41
|
+
*
|
|
42
|
+
* It does not stamp `updatedAt`. The version converters do, because their
|
|
43
|
+
* callers do not; both email editors pass their own stamp, and the seeding
|
|
44
|
+
* writes pass the one they also put on the template document, so a stamp
|
|
45
|
+
* applied here would silently replace a value the caller is coordinating.
|
|
46
|
+
*/
|
|
47
|
+
export declare const besignerNodesConverter: {
|
|
48
|
+
toFirestore(data: Record<string, any>): {
|
|
49
|
+
[x: string]: any;
|
|
50
|
+
};
|
|
51
|
+
fromFirestore(snapshot: any, options: any): any;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The same document, read and written through {@link besignerNodesConverter}.
|
|
55
|
+
*
|
|
56
|
+
* A named wrapper rather than `.withConverter(...)` at each call site: an
|
|
57
|
+
* editor has to apply it to BOTH the ref it reads and the ref it saves
|
|
58
|
+
* through, and the failure when only one of them gets it is a save refused as
|
|
59
|
+
* a phantom conflict rather than anything that looks like a missing converter.
|
|
60
|
+
*/
|
|
61
|
+
export declare function withBesignerNodes<T>(ref: DocumentReference<any>): DocumentReference<T>;
|
|
62
|
+
export default besignerNodesConverter;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
* Copyright 2026 Aglyn LLC
|
|
6
|
+
*
|
|
7
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
* you may not use this file except in compliance with the License.
|
|
9
|
+
* You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
* See the License for the specific language governing permissions and
|
|
17
|
+
* limitations under the License.
|
|
18
|
+
*/ import { decodeStoredNodes, encodeStoredNodes } from "@aglyn/aglyn";
|
|
19
|
+
import { Bytes } from "firebase/firestore";
|
|
20
|
+
/**
|
|
21
|
+
* Compression at rest for a besigner document that has no converter of its
|
|
22
|
+
* own — the two EMAIL editors (AGL-1151).
|
|
23
|
+
*
|
|
24
|
+
* ## Why a converter rather than decoding at the read site
|
|
25
|
+
*
|
|
26
|
+
* Both email besigners read their version with a bare `useFirestoreDoc` and
|
|
27
|
+
* write it with `saveNodesGuarded` on a bare `doc(...)`, and `nodes` has to
|
|
28
|
+
* be in ONE shape across four comparisons for either to work:
|
|
29
|
+
*
|
|
30
|
+
* - `useBesignerDocument` diffs the arriving snapshot against its baseline to
|
|
31
|
+
* decide whether somebody else has written, and against the tree it just
|
|
32
|
+
* sent to recognise the echo of its own save;
|
|
33
|
+
* - `saveNodesGuarded` re-reads inside its transaction and compares the
|
|
34
|
+
* stored `nodes` against the baseline the editor presents.
|
|
35
|
+
*
|
|
36
|
+
* Decoding at the read site alone would leave the stored form on one side of
|
|
37
|
+
* those comparisons and a decoded map on the other, so every save would look
|
|
38
|
+
* like a conflict and be refused. A converter puts the decode BEFORE all four,
|
|
39
|
+
* which is why the screen, layout, component and form editors — which have
|
|
40
|
+
* carried converters since AGL-1151 — never had the problem.
|
|
41
|
+
*
|
|
42
|
+
* ## What it does not do
|
|
43
|
+
*
|
|
44
|
+
* It does not stamp `updatedAt`. The version converters do, because their
|
|
45
|
+
* callers do not; both email editors pass their own stamp, and the seeding
|
|
46
|
+
* writes pass the one they also put on the template document, so a stamp
|
|
47
|
+
* applied here would silently replace a value the caller is coordinating.
|
|
48
|
+
*/ export const besignerNodesConverter = {
|
|
49
|
+
toFirestore (data) {
|
|
50
|
+
const { $id } = data, rest = _object_without_properties_loose(data, [
|
|
51
|
+
"$id"
|
|
52
|
+
]);
|
|
53
|
+
// Only emit `nodes` when the write actually carries them (AGL-1250).
|
|
54
|
+
// Encoding `rest.nodes` unconditionally would make a partial
|
|
55
|
+
// `setDoc(…, {merge: true})` — one that set some other field — ship an
|
|
56
|
+
// empty map, and merge faithfully merges that emptiness over the real
|
|
57
|
+
// tree, destroying the document.
|
|
58
|
+
if ((rest == null ? void 0 : rest.nodes) === undefined) return rest;
|
|
59
|
+
const nodes = encodeStoredNodes(rest.nodes);
|
|
60
|
+
// `encodeStoredNodes` passes an already-encoded value through, so a write
|
|
61
|
+
// that round-trips what it read cannot double-encode. `Bytes` is what the
|
|
62
|
+
// client SDK accepts for a bytes field.
|
|
63
|
+
return _extends({}, rest, nodes ? {
|
|
64
|
+
nodes: Bytes.fromUint8Array(nodes)
|
|
65
|
+
} : {});
|
|
66
|
+
},
|
|
67
|
+
fromFirestore (snapshot, options) {
|
|
68
|
+
if (!snapshot.exists()) return undefined;
|
|
69
|
+
const data = snapshot.data(options);
|
|
70
|
+
if ((data == null ? void 0 : data.nodes) === undefined) return data;
|
|
71
|
+
// BOTH forms, permanently. Every email version written before this
|
|
72
|
+
// converter is a plain map and nothing migrates them; `decodeStoredNodes`
|
|
73
|
+
// returns a plain map unchanged, so one call serves both.
|
|
74
|
+
return _extends({}, data, {
|
|
75
|
+
nodes: decodeStoredNodes(data.nodes)
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* The same document, read and written through {@link besignerNodesConverter}.
|
|
81
|
+
*
|
|
82
|
+
* A named wrapper rather than `.withConverter(...)` at each call site: an
|
|
83
|
+
* editor has to apply it to BOTH the ref it reads and the ref it saves
|
|
84
|
+
* through, and the failure when only one of them gets it is a save refused as
|
|
85
|
+
* a phantom conflict rather than anything that looks like a missing converter.
|
|
86
|
+
*/ export function withBesignerNodes(ref) {
|
|
87
|
+
return ref.withConverter(besignerNodesConverter);
|
|
88
|
+
}
|
|
89
|
+
export default besignerNodesConverter;
|
|
90
|
+
|
|
91
|
+
//# sourceMappingURL=besigner-nodes-converter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/besigner-nodes-converter.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 { decodeStoredNodes, encodeStoredNodes } from '@aglyn/aglyn'\nimport { Bytes, type DocumentReference } from 'firebase/firestore'\n\n/**\n * Compression at rest for a besigner document that has no converter of its\n * own — the two EMAIL editors (AGL-1151).\n *\n * ## Why a converter rather than decoding at the read site\n *\n * Both email besigners read their version with a bare `useFirestoreDoc` and\n * write it with `saveNodesGuarded` on a bare `doc(...)`, and `nodes` has to\n * be in ONE shape across four comparisons for either to work:\n *\n * - `useBesignerDocument` diffs the arriving snapshot against its baseline to\n * decide whether somebody else has written, and against the tree it just\n * sent to recognise the echo of its own save;\n * - `saveNodesGuarded` re-reads inside its transaction and compares the\n * stored `nodes` against the baseline the editor presents.\n *\n * Decoding at the read site alone would leave the stored form on one side of\n * those comparisons and a decoded map on the other, so every save would look\n * like a conflict and be refused. A converter puts the decode BEFORE all four,\n * which is why the screen, layout, component and form editors — which have\n * carried converters since AGL-1151 — never had the problem.\n *\n * ## What it does not do\n *\n * It does not stamp `updatedAt`. The version converters do, because their\n * callers do not; both email editors pass their own stamp, and the seeding\n * writes pass the one they also put on the template document, so a stamp\n * applied here would silently replace a value the caller is coordinating.\n */\nexport const besignerNodesConverter = {\n toFirestore(data: Record<string, any>) {\n const { $id, ...rest } = data\n // Only emit `nodes` when the write actually carries them (AGL-1250).\n // Encoding `rest.nodes` unconditionally would make a partial\n // `setDoc(…, {merge: true})` — one that set some other field — ship an\n // empty map, and merge faithfully merges that emptiness over the real\n // tree, destroying the document.\n if (rest?.nodes === undefined) return rest\n const nodes = encodeStoredNodes(rest.nodes)\n // `encodeStoredNodes` passes an already-encoded value through, so a write\n // that round-trips what it read cannot double-encode. `Bytes` is what the\n // client SDK accepts for a bytes field.\n return { ...rest, ...(nodes ? { nodes: Bytes.fromUint8Array(nodes) } : {}) }\n },\n fromFirestore(snapshot: any, options: any) {\n if (!snapshot.exists()) return undefined\n const data = snapshot.data(options)\n if (data?.nodes === undefined) return data\n // BOTH forms, permanently. Every email version written before this\n // converter is a plain map and nothing migrates them; `decodeStoredNodes`\n // returns a plain map unchanged, so one call serves both.\n return { ...data, nodes: decodeStoredNodes(data.nodes) }\n },\n}\n\n/**\n * The same document, read and written through {@link besignerNodesConverter}.\n *\n * A named wrapper rather than `.withConverter(...)` at each call site: an\n * editor has to apply it to BOTH the ref it reads and the ref it saves\n * through, and the failure when only one of them gets it is a save refused as\n * a phantom conflict rather than anything that looks like a missing converter.\n */\nexport function withBesignerNodes<T>(\n ref: DocumentReference<any>,\n): DocumentReference<T> {\n return ref.withConverter(\n besignerNodesConverter as never,\n ) as DocumentReference<T>\n}\n\nexport default besignerNodesConverter\n"],"names":["decodeStoredNodes","encodeStoredNodes","Bytes","besignerNodesConverter","toFirestore","data","$id","rest","nodes","undefined","fromUint8Array","fromFirestore","snapshot","options","exists","withBesignerNodes","ref","withConverter"],"mappings":";;AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,EAAEC,iBAAiB,QAAQ,eAAc;AACnE,SAASC,KAAK,QAAgC,qBAAoB;AAElE;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMC,yBAAyB;IACpCC,aAAYC,IAAyB;QACnC,MAAM,EAAEC,GAAG,EAAW,GAAGD,MAATE,wCAASF;;;QACzB,qEAAqE;QACrE,6DAA6D;QAC7D,uEAAuE;QACvE,sEAAsE;QACtE,iCAAiC;QACjC,IAAIE,CAAAA,wBAAAA,KAAMC,KAAK,MAAKC,WAAW,OAAOF;QACtC,MAAMC,QAAQP,kBAAkBM,KAAKC,KAAK;QAC1C,0EAA0E;QAC1E,0EAA0E;QAC1E,wCAAwC;QACxC,OAAO,aAAKD,MAAUC,QAAQ;YAAEA,OAAON,MAAMQ,cAAc,CAACF;QAAO,IAAI,CAAC;IAC1E;IACAG,eAAcC,QAAa,EAAEC,OAAY;QACvC,IAAI,CAACD,SAASE,MAAM,IAAI,OAAOL;QAC/B,MAAMJ,OAAOO,SAASP,IAAI,CAACQ;QAC3B,IAAIR,CAAAA,wBAAAA,KAAMG,KAAK,MAAKC,WAAW,OAAOJ;QACtC,mEAAmE;QACnE,0EAA0E;QAC1E,0DAA0D;QAC1D,OAAO,aAAKA;YAAMG,OAAOR,kBAAkBK,KAAKG,KAAK;;IACvD;AACF,EAAC;AAED;;;;;;;CAOC,GACD,OAAO,SAASO,kBACdC,GAA2B;IAE3B,OAAOA,IAAIC,aAAa,CACtBd;AAEJ;AAEA,eAAeA,uBAAsB"}
|
|
@@ -0,0 +1,80 @@
|
|
|
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 DocumentReference } from 'firebase/firestore';
|
|
18
|
+
/**
|
|
19
|
+
* What the editor believes the stored document looked like when it last
|
|
20
|
+
* agreed with it. Both fields optional: an absent baseline means "never
|
|
21
|
+
* established", which must not refuse the save (see `hasConcurrentWrite`).
|
|
22
|
+
*/
|
|
23
|
+
export interface NodesSaveBaseline {
|
|
24
|
+
/** `versionStamp(...)` of the document's `updatedAt` at load/last save. */
|
|
25
|
+
baseStamp?: string | null;
|
|
26
|
+
/** The stored nodes as of that agreement, in STORED shape. */
|
|
27
|
+
baseNodes?: unknown;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Persists a besigner node map only if the stored document still matches the
|
|
31
|
+
* baseline the editor loaded (AGL-1301).
|
|
32
|
+
*
|
|
33
|
+
* The AGL-674 guard is listener-based, so a save clicked in the window
|
|
34
|
+
* between another writer's commit and the local snapshot's delivery still
|
|
35
|
+
* overwrote it — the guard had simply not heard yet. Running the same check
|
|
36
|
+
* inside a Firestore transaction closes that window: the `get` is served by
|
|
37
|
+
* the backend (never the local cache), the check runs against what is
|
|
38
|
+
* actually stored, and the write only commits if nothing moved. Contention
|
|
39
|
+
* during the transaction re-runs it against fresh data, so the late writer
|
|
40
|
+
* aborts rather than winning by timing.
|
|
41
|
+
*
|
|
42
|
+
* TWO preconditions, deliberately:
|
|
43
|
+
*
|
|
44
|
+
* - The `updatedAt` stamp, same equality-only comparison as the listener
|
|
45
|
+
* guard.
|
|
46
|
+
* - The stored `nodes` content itself. `updatedAt` is an app-level FIELD
|
|
47
|
+
* that a writer can forget — admin backfills have updated `nodes` without
|
|
48
|
+
* touching it, and such a write was invisible to the stamp and got
|
|
49
|
+
* clobbered. The web client SDK exposes no server-side `updateTime` on a
|
|
50
|
+
* snapshot (`snapshot.metadata` carries only `hasPendingWrites` /
|
|
51
|
+
* `fromCache`; document update times are Admin-SDK-only), so comparing
|
|
52
|
+
* the content is the strongest check actually available to this client —
|
|
53
|
+
* and it is exact: this save only ever writes `nodes` (+ stamp), so
|
|
54
|
+
* "did `nodes` change under me" is precisely the conflict that loses work.
|
|
55
|
+
*
|
|
56
|
+
* Aborts by throwing `ConcurrentEditError`, which `useBesignerDocument`
|
|
57
|
+
* surfaces through the existing refusal UX. A converter on `ref` applies as
|
|
58
|
+
* usual, so compressed-at-rest documents stay compressed and keep stamping
|
|
59
|
+
* `updatedAt` on write.
|
|
60
|
+
*
|
|
61
|
+
* Trade-off, accepted: a transaction requires the backend, so this save no
|
|
62
|
+
* longer queues offline. A conflict guard that consults a local cache would
|
|
63
|
+
* be theatre — the AGL-674 refusal already implies "the server has spoken".
|
|
64
|
+
*
|
|
65
|
+
* ## Why this did NOT change when the client relaxed (AGL-2486)
|
|
66
|
+
*
|
|
67
|
+
* The client no longer refuses merely because the stored version moved: with
|
|
68
|
+
* the co-edit mirror running it usually already holds what the other writer
|
|
69
|
+
* stored, so its own write is a superset (`incorporatesStoredNodes`). What
|
|
70
|
+
* it does with that evidence is advance its BASELINE onto the write it has
|
|
71
|
+
* incorporated — and this precondition is what makes that safe. It is
|
|
72
|
+
* unchanged and strictly stricter: the baseline the client presents must
|
|
73
|
+
* still match what is actually stored when the transaction reads it, so
|
|
74
|
+
* anything that lands between the client's decision and this commit moves
|
|
75
|
+
* `nodes` away from it and aborts. The docs' promise that "even a save
|
|
76
|
+
* racing the conflict by milliseconds is refused" therefore holds exactly as
|
|
77
|
+
* before; the client simply stopped refusing saves this would have accepted.
|
|
78
|
+
*/
|
|
79
|
+
export declare function saveNodesGuarded<T>(ref: DocumentReference<T>, data: Partial<T>, baseline?: NodesSaveBaseline): Promise<void>;
|
|
80
|
+
export default saveNodesGuarded;
|