@aglyn/tenant-feature-instance 1.0.0-beta.202 → 1.0.0-beta.204
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 -6
- package/src/lib/components/sender-readiness-panel.d.ts +16 -0
- package/src/lib/components/sender-readiness-panel.js +160 -0
- package/src/lib/components/sender-readiness-panel.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-bounded-read.d.ts +79 -0
- package/src/lib/hooks/firebase/firestore-bounded-read.js +80 -0
- package/src/lib/hooks/firebase/firestore-bounded-read.js.map +1 -0
- package/src/lib/hooks/firebase/firestore-stall-recovery.d.ts +142 -0
- package/src/lib/hooks/firebase/firestore-stall-recovery.js +187 -0
- package/src/lib/hooks/firebase/firestore-stall-recovery.js.map +1 -0
- package/src/lib/hooks/use-firestore-collection.js +14 -0
- package/src/lib/hooks/use-firestore-collection.js.map +1 -1
- package/src/lib/hooks/use-firestore-doc.js +14 -0
- package/src/lib/hooks/use-firestore-doc.js.map +1 -1
- package/src/lib/hooks/use-sending-identity-api.d.ts +3 -0
- package/src/lib/hooks/use-sending-identity-api.js.map +1 -1
- package/src/lib/hooks/use-switcher-collection.js +6 -4
- package/src/lib/hooks/use-switcher-collection.js.map +1 -1
- package/src/lib/tenant.d.ts +2 -0
- package/src/lib/tenant.js +2 -0
- package/src/lib/tenant.js.map +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { disableNetwork, enableNetwork } from "firebase/firestore";
|
|
17
|
+
/**
|
|
18
|
+
* RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).
|
|
19
|
+
*
|
|
20
|
+
* ## The wedge
|
|
21
|
+
*
|
|
22
|
+
* The console runs the persistent cache with `persistentMultipleTabManager`
|
|
23
|
+
* (`firestore-cache.ts`). Only the PRIMARY tab talks to the backend; every
|
|
24
|
+
* other tab reads the shared IndexedDB cache and takes its online state from
|
|
25
|
+
* a `localStorage` record the primary writes. A secondary that believes the
|
|
26
|
+
* client is online answers a `getDocs` only once the primary has synced the
|
|
27
|
+
* query, and raises no first snapshot for a listener whose cache is empty.
|
|
28
|
+
*
|
|
29
|
+
* When the primary dies without handing over — a crashed, discarded or
|
|
30
|
+
* frozen tab — its "Online" record outlives it, and until some tab runs the
|
|
31
|
+
* lease election and wins, nothing syncs. The election runs on a 4-second
|
|
32
|
+
* timer inside each tab, which the browser throttles to about once a minute
|
|
33
|
+
* in a hidden tab. On 2026-09-28 no live tab synced for about ten minutes:
|
|
34
|
+
* the notifications page never left its loading state and a staff page sat
|
|
35
|
+
* on its splash until a fresh tab (which wins the election on start) was
|
|
36
|
+
* opened.
|
|
37
|
+
*
|
|
38
|
+
* ## The recovery, and why it is this one
|
|
39
|
+
*
|
|
40
|
+
* `disableNetwork` then `enableNetwork`, from the tab that noticed. Both are
|
|
41
|
+
* public API, and in the multi-tab client each one calls
|
|
42
|
+
* `setNetworkEnabled`, which schedules the lease election for IMMEDIATE
|
|
43
|
+
* execution instead of the next (possibly throttled) timer tick. A visible
|
|
44
|
+
* tab that finds the lease lapsed takes it, starts its own streams and
|
|
45
|
+
* writes its own online state, which replaces the dead tab's. A tab that was
|
|
46
|
+
* already primary with a silently dead stream gets fresh streams.
|
|
47
|
+
*
|
|
48
|
+
* Nothing is lost. Writes made meanwhile sit in the mutation queue (in
|
|
49
|
+
* IndexedDB, under the persistent cache) and are sent when the network comes
|
|
50
|
+
* back; the SDK's own contract for `disableNetwork` is that writes queue.
|
|
51
|
+
*
|
|
52
|
+
* The rejected alternatives:
|
|
53
|
+
*
|
|
54
|
+
* - **Clearing the persistence cache** (`terminate` + `clearIndexedDbPersistence`).
|
|
55
|
+
* It deletes the mutation queue, so pending writes are lost, and
|
|
56
|
+
* `waitForPendingWrites` cannot drain them first because draining is the
|
|
57
|
+
* very thing that is stuck. It also refuses to run while any other tab
|
|
58
|
+
* holds the database, which in a multi-tab wedge is always. And
|
|
59
|
+
* `terminate` invalidates the one `Firestore` instance every hook in the
|
|
60
|
+
* console holds.
|
|
61
|
+
* - **Pruning the dead tab's online-state record at startup**, beside the
|
|
62
|
+
* AGL-2845 prune. The SDK already ignores an online-state record at
|
|
63
|
+
* startup unless its writer is an active, non-zombie client
|
|
64
|
+
* (`WebStorageSharedClientState.handleOnlineStateEvent`), and a freshly
|
|
65
|
+
* started tab wins the election anyway — which is why a fresh tab fixed
|
|
66
|
+
* the incident. The tabs that stayed stuck were the ones ALREADY running,
|
|
67
|
+
* which a startup prune never reaches.
|
|
68
|
+
* - **`persistentSingleTabManager`.** It removes cross-tab dependency, but
|
|
69
|
+
* every tab after the first falls back to a memory cache, so writes made
|
|
70
|
+
* offline in those tabs are lost when they close. And a tab still on the
|
|
71
|
+
* multi-tab build that meets a single-tab lease holder fails its queue
|
|
72
|
+
* with `failed-precondition` until reloaded, so the rollout itself would
|
|
73
|
+
* wedge the tabs left open across it.
|
|
74
|
+
*
|
|
75
|
+
* ## Guards
|
|
76
|
+
*
|
|
77
|
+
* - **Online only.** A browser that reports no network is correctly offline,
|
|
78
|
+
* and the cache answering is the cache doing its job.
|
|
79
|
+
* - **Visible only.** A hidden tab's timers are throttled and its reader is
|
|
80
|
+
* not waiting; if it is stalled, it recovers the moment it is looked at.
|
|
81
|
+
* - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on
|
|
82
|
+
* a page stalls together, and one cycle serves them all. A concurrent
|
|
83
|
+
* request shares the cycle already running.
|
|
84
|
+
* - **The network is always re-enabled**, including when disabling fails.
|
|
85
|
+
*/ /**
|
|
86
|
+
* How long a server read or a listener's first server-confirmed snapshot may
|
|
87
|
+
* take before the client is treated as stalled. A healthy console answers
|
|
88
|
+
* in well under two seconds; this leaves room for a slow network without
|
|
89
|
+
* leaving a reader on a spinner for long.
|
|
90
|
+
*/ export const FIRESTORE_STALL_MS = 8000;
|
|
91
|
+
/** The least time between two recoveries of the same client. */ export const STALL_RECOVERY_COOLDOWN_MS = 30000;
|
|
92
|
+
const browserOnline = ()=>typeof navigator === 'undefined' || navigator.onLine !== false;
|
|
93
|
+
const documentVisible = ()=>typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
94
|
+
/** The recovery for one client, with its guards. Pure over `deps`. */ export function createStallRecovery(deps) {
|
|
95
|
+
const { disable, enable, isOnline = browserOnline, isVisible = documentVisible, now = Date.now, cooldownMs = STALL_RECOVERY_COOLDOWN_MS } = deps;
|
|
96
|
+
let lastStartedAt;
|
|
97
|
+
let running;
|
|
98
|
+
const cycle = async ()=>{
|
|
99
|
+
let outcome = 'recovered';
|
|
100
|
+
try {
|
|
101
|
+
await disable();
|
|
102
|
+
} catch (unused) {
|
|
103
|
+
outcome = 'failed';
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
await enable();
|
|
107
|
+
} catch (unused) {
|
|
108
|
+
outcome = 'failed';
|
|
109
|
+
}
|
|
110
|
+
return outcome;
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
recover: ()=>{
|
|
114
|
+
if (running) return running;
|
|
115
|
+
if (!isOnline()) return Promise.resolve('offline');
|
|
116
|
+
if (!isVisible()) return Promise.resolve('hidden');
|
|
117
|
+
const at = now();
|
|
118
|
+
if (lastStartedAt !== undefined && at - lastStartedAt < cooldownMs) {
|
|
119
|
+
return Promise.resolve('cooling');
|
|
120
|
+
}
|
|
121
|
+
lastStartedAt = at;
|
|
122
|
+
running = cycle().finally(()=>{
|
|
123
|
+
running = undefined;
|
|
124
|
+
});
|
|
125
|
+
return running;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const recoveries = new WeakMap();
|
|
130
|
+
/**
|
|
131
|
+
* Recovers `firestore` if it has stalled. Safe to call from every reader
|
|
132
|
+
* that notices: the guards above collapse the calls into at most one network
|
|
133
|
+
* cycle per cooldown. Never rejects.
|
|
134
|
+
*/ export function recoverStalledFirestore(firestore) {
|
|
135
|
+
if (!firestore || typeof firestore !== 'object') {
|
|
136
|
+
return Promise.resolve('failed');
|
|
137
|
+
}
|
|
138
|
+
let recovery = recoveries.get(firestore);
|
|
139
|
+
if (!recovery) {
|
|
140
|
+
recovery = createStallRecovery({
|
|
141
|
+
disable: ()=>disableNetwork(firestore),
|
|
142
|
+
enable: ()=>enableNetwork(firestore)
|
|
143
|
+
});
|
|
144
|
+
recoveries.set(firestore, recovery);
|
|
145
|
+
}
|
|
146
|
+
return recovery.recover().catch(()=>'failed');
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Arms a stall timer for one read or listen. If `disarm` has not been called
|
|
150
|
+
* within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).
|
|
151
|
+
* A tab that is hidden when the timer fires waits until it is shown, since
|
|
152
|
+
* that is when a reader is waiting again.
|
|
153
|
+
*
|
|
154
|
+
* Returns `disarm`, which is idempotent.
|
|
155
|
+
*/ export function armFirestoreStallWatch(firestore, options = {}) {
|
|
156
|
+
var _options_onStall;
|
|
157
|
+
const { stallMs = FIRESTORE_STALL_MS } = options;
|
|
158
|
+
const onStall = (_options_onStall = options.onStall) != null ? _options_onStall : ()=>void recoverStalledFirestore(firestore);
|
|
159
|
+
let done = false;
|
|
160
|
+
let onVisible;
|
|
161
|
+
const fire = ()=>{
|
|
162
|
+
if (done) return;
|
|
163
|
+
if (!documentVisible() && typeof document !== 'undefined') {
|
|
164
|
+
onVisible = ()=>{
|
|
165
|
+
if (!documentVisible()) return;
|
|
166
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
167
|
+
onVisible = undefined;
|
|
168
|
+
fire();
|
|
169
|
+
};
|
|
170
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
done = true;
|
|
174
|
+
onStall();
|
|
175
|
+
};
|
|
176
|
+
const timer = setTimeout(fire, stallMs);
|
|
177
|
+
return ()=>{
|
|
178
|
+
if (done) return;
|
|
179
|
+
done = true;
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
if (onVisible && typeof document !== 'undefined') {
|
|
182
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
//# sourceMappingURL=firestore-stall-recovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-stall-recovery.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n disableNetwork,\n enableNetwork,\n type Firestore,\n} from 'firebase/firestore'\n\n/**\n * RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).\n *\n * ## The wedge\n *\n * The console runs the persistent cache with `persistentMultipleTabManager`\n * (`firestore-cache.ts`). Only the PRIMARY tab talks to the backend; every\n * other tab reads the shared IndexedDB cache and takes its online state from\n * a `localStorage` record the primary writes. A secondary that believes the\n * client is online answers a `getDocs` only once the primary has synced the\n * query, and raises no first snapshot for a listener whose cache is empty.\n *\n * When the primary dies without handing over — a crashed, discarded or\n * frozen tab — its \"Online\" record outlives it, and until some tab runs the\n * lease election and wins, nothing syncs. The election runs on a 4-second\n * timer inside each tab, which the browser throttles to about once a minute\n * in a hidden tab. On 2026-09-28 no live tab synced for about ten minutes:\n * the notifications page never left its loading state and a staff page sat\n * on its splash until a fresh tab (which wins the election on start) was\n * opened.\n *\n * ## The recovery, and why it is this one\n *\n * `disableNetwork` then `enableNetwork`, from the tab that noticed. Both are\n * public API, and in the multi-tab client each one calls\n * `setNetworkEnabled`, which schedules the lease election for IMMEDIATE\n * execution instead of the next (possibly throttled) timer tick. A visible\n * tab that finds the lease lapsed takes it, starts its own streams and\n * writes its own online state, which replaces the dead tab's. A tab that was\n * already primary with a silently dead stream gets fresh streams.\n *\n * Nothing is lost. Writes made meanwhile sit in the mutation queue (in\n * IndexedDB, under the persistent cache) and are sent when the network comes\n * back; the SDK's own contract for `disableNetwork` is that writes queue.\n *\n * The rejected alternatives:\n *\n * - **Clearing the persistence cache** (`terminate` + `clearIndexedDbPersistence`).\n * It deletes the mutation queue, so pending writes are lost, and\n * `waitForPendingWrites` cannot drain them first because draining is the\n * very thing that is stuck. It also refuses to run while any other tab\n * holds the database, which in a multi-tab wedge is always. And\n * `terminate` invalidates the one `Firestore` instance every hook in the\n * console holds.\n * - **Pruning the dead tab's online-state record at startup**, beside the\n * AGL-2845 prune. The SDK already ignores an online-state record at\n * startup unless its writer is an active, non-zombie client\n * (`WebStorageSharedClientState.handleOnlineStateEvent`), and a freshly\n * started tab wins the election anyway — which is why a fresh tab fixed\n * the incident. The tabs that stayed stuck were the ones ALREADY running,\n * which a startup prune never reaches.\n * - **`persistentSingleTabManager`.** It removes cross-tab dependency, but\n * every tab after the first falls back to a memory cache, so writes made\n * offline in those tabs are lost when they close. And a tab still on the\n * multi-tab build that meets a single-tab lease holder fails its queue\n * with `failed-precondition` until reloaded, so the rollout itself would\n * wedge the tabs left open across it.\n *\n * ## Guards\n *\n * - **Online only.** A browser that reports no network is correctly offline,\n * and the cache answering is the cache doing its job.\n * - **Visible only.** A hidden tab's timers are throttled and its reader is\n * not waiting; if it is stalled, it recovers the moment it is looked at.\n * - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on\n * a page stalls together, and one cycle serves them all. A concurrent\n * request shares the cycle already running.\n * - **The network is always re-enabled**, including when disabling fails.\n */\n\n/**\n * How long a server read or a listener's first server-confirmed snapshot may\n * take before the client is treated as stalled. A healthy console answers\n * in well under two seconds; this leaves room for a slow network without\n * leaving a reader on a spinner for long.\n */\nexport const FIRESTORE_STALL_MS = 8_000\n\n/** The least time between two recoveries of the same client. */\nexport const STALL_RECOVERY_COOLDOWN_MS = 30_000\n\n/** What a recovery request did. */\nexport type StallRecoveryOutcome =\n /** The browser reports no network: the cache is the right answer. */\n | 'offline'\n /** The tab is hidden: nobody is waiting on it. */\n | 'hidden'\n /** A recovery ran moments ago; this request rode on it. */\n | 'cooling'\n /** The network was cycled. */\n | 'recovered'\n /** The cycle threw; the network was still re-enabled if at all possible. */\n | 'failed'\n\nexport interface StallRecoveryDeps {\n /** Take the client off the network. */\n disable: () => Promise<void>\n /** Put it back. */\n enable: () => Promise<void>\n /** `navigator.onLine`, or `true` where there is no navigator. */\n isOnline?: () => boolean\n /** Whether the document is visible, or `true` where there is none. */\n isVisible?: () => boolean\n now?: () => number\n cooldownMs?: number\n}\n\nexport interface StallRecovery {\n recover: () => Promise<StallRecoveryOutcome>\n}\n\nconst browserOnline = () =>\n typeof navigator === 'undefined' || navigator.onLine !== false\n\nconst documentVisible = () =>\n typeof document === 'undefined' || document.visibilityState !== 'hidden'\n\n/** The recovery for one client, with its guards. Pure over `deps`. */\nexport function createStallRecovery(deps: StallRecoveryDeps): StallRecovery {\n const {\n disable,\n enable,\n isOnline = browserOnline,\n isVisible = documentVisible,\n now = Date.now,\n cooldownMs = STALL_RECOVERY_COOLDOWN_MS,\n } = deps\n let lastStartedAt: number | undefined\n let running: Promise<StallRecoveryOutcome> | undefined\n\n const cycle = async (): Promise<StallRecoveryOutcome> => {\n let outcome: StallRecoveryOutcome = 'recovered'\n try {\n await disable()\n } catch {\n outcome = 'failed'\n }\n try {\n await enable()\n } catch {\n outcome = 'failed'\n }\n return outcome\n }\n\n return {\n recover: () => {\n if (running) return running\n if (!isOnline()) return Promise.resolve('offline')\n if (!isVisible()) return Promise.resolve('hidden')\n const at = now()\n if (lastStartedAt !== undefined && at - lastStartedAt < cooldownMs) {\n return Promise.resolve('cooling')\n }\n lastStartedAt = at\n running = cycle().finally(() => {\n running = undefined\n })\n return running\n },\n }\n}\n\nconst recoveries = new WeakMap<Firestore, StallRecovery>()\n\n/**\n * Recovers `firestore` if it has stalled. Safe to call from every reader\n * that notices: the guards above collapse the calls into at most one network\n * cycle per cooldown. Never rejects.\n */\nexport function recoverStalledFirestore(\n firestore: Firestore | null | undefined,\n): Promise<StallRecoveryOutcome> {\n if (!firestore || typeof firestore !== 'object') {\n return Promise.resolve('failed')\n }\n let recovery = recoveries.get(firestore)\n if (!recovery) {\n recovery = createStallRecovery({\n disable: () => disableNetwork(firestore),\n enable: () => enableNetwork(firestore),\n })\n recoveries.set(firestore, recovery)\n }\n return recovery.recover().catch(() => 'failed' as const)\n}\n\n/**\n * Arms a stall timer for one read or listen. If `disarm` has not been called\n * within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).\n * A tab that is hidden when the timer fires waits until it is shown, since\n * that is when a reader is waiting again.\n *\n * Returns `disarm`, which is idempotent.\n */\nexport function armFirestoreStallWatch(\n firestore: Firestore | null | undefined,\n options: { stallMs?: number; onStall?: () => void } = {},\n): () => void {\n const { stallMs = FIRESTORE_STALL_MS } = options\n const onStall =\n options.onStall ?? (() => void recoverStalledFirestore(firestore))\n let done = false\n let onVisible: (() => void) | undefined\n const fire = () => {\n if (done) return\n if (!documentVisible() && typeof document !== 'undefined') {\n onVisible = () => {\n if (!documentVisible()) return\n document.removeEventListener('visibilitychange', onVisible!)\n onVisible = undefined\n fire()\n }\n document.addEventListener('visibilitychange', onVisible)\n return\n }\n done = true\n onStall()\n }\n const timer = setTimeout(fire, stallMs)\n return () => {\n if (done) return\n done = true\n clearTimeout(timer)\n if (onVisible && typeof document !== 'undefined') {\n document.removeEventListener('visibilitychange', onVisible)\n }\n }\n}\n"],"names":["disableNetwork","enableNetwork","FIRESTORE_STALL_MS","STALL_RECOVERY_COOLDOWN_MS","browserOnline","navigator","onLine","documentVisible","document","visibilityState","createStallRecovery","deps","disable","enable","isOnline","isVisible","now","Date","cooldownMs","lastStartedAt","running","cycle","outcome","recover","Promise","resolve","at","undefined","finally","recoveries","WeakMap","recoverStalledFirestore","firestore","recovery","get","set","catch","armFirestoreStallWatch","options","stallMs","onStall","done","onVisible","fire","removeEventListener","addEventListener","timer","setTimeout","clearTimeout"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,cAAc,EACdC,aAAa,QAER,qBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoEC,GAED;;;;;CAKC,GACD,OAAO,MAAMC,qBAAqB,KAAK;AAEvC,8DAA8D,GAC9D,OAAO,MAAMC,6BAA6B,MAAM;AAgChD,MAAMC,gBAAgB,IACpB,OAAOC,cAAc,eAAeA,UAAUC,MAAM,KAAK;AAE3D,MAAMC,kBAAkB,IACtB,OAAOC,aAAa,eAAeA,SAASC,eAAe,KAAK;AAElE,oEAAoE,GACpE,OAAO,SAASC,oBAAoBC,IAAuB;IACzD,MAAM,EACJC,OAAO,EACPC,MAAM,EACNC,WAAWV,aAAa,EACxBW,YAAYR,eAAe,EAC3BS,MAAMC,KAAKD,GAAG,EACdE,aAAaf,0BAA0B,EACxC,GAAGQ;IACJ,IAAIQ;IACJ,IAAIC;IAEJ,MAAMC,QAAQ;QACZ,IAAIC,UAAgC;QACpC,IAAI;YACF,MAAMV;QACR,EAAE,eAAM;YACNU,UAAU;QACZ;QACA,IAAI;YACF,MAAMT;QACR,EAAE,eAAM;YACNS,UAAU;QACZ;QACA,OAAOA;IACT;IAEA,OAAO;QACLC,SAAS;YACP,IAAIH,SAAS,OAAOA;YACpB,IAAI,CAACN,YAAY,OAAOU,QAAQC,OAAO,CAAC;YACxC,IAAI,CAACV,aAAa,OAAOS,QAAQC,OAAO,CAAC;YACzC,MAAMC,KAAKV;YACX,IAAIG,kBAAkBQ,aAAaD,KAAKP,gBAAgBD,YAAY;gBAClE,OAAOM,QAAQC,OAAO,CAAC;YACzB;YACAN,gBAAgBO;YAChBN,UAAUC,QAAQO,OAAO,CAAC;gBACxBR,UAAUO;YACZ;YACA,OAAOP;QACT;IACF;AACF;AAEA,MAAMS,aAAa,IAAIC;AAEvB;;;;CAIC,GACD,OAAO,SAASC,wBACdC,SAAuC;IAEvC,IAAI,CAACA,aAAa,OAAOA,cAAc,UAAU;QAC/C,OAAOR,QAAQC,OAAO,CAAC;IACzB;IACA,IAAIQ,WAAWJ,WAAWK,GAAG,CAACF;IAC9B,IAAI,CAACC,UAAU;QACbA,WAAWvB,oBAAoB;YAC7BE,SAAS,IAAMZ,eAAegC;YAC9BnB,QAAQ,IAAMZ,cAAc+B;QAC9B;QACAH,WAAWM,GAAG,CAACH,WAAWC;IAC5B;IACA,OAAOA,SAASV,OAAO,GAAGa,KAAK,CAAC,IAAM;AACxC;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,uBACdL,SAAuC,EACvCM,UAAsD,CAAC,CAAC;QAItDA;IAFF,MAAM,EAAEC,UAAUrC,kBAAkB,EAAE,GAAGoC;IACzC,MAAME,WACJF,mBAAAA,QAAQE,OAAO,YAAfF,mBAAoB,IAAM,KAAKP,wBAAwBC;IACzD,IAAIS,OAAO;IACX,IAAIC;IACJ,MAAMC,OAAO;QACX,IAAIF,MAAM;QACV,IAAI,CAAClC,qBAAqB,OAAOC,aAAa,aAAa;YACzDkC,YAAY;gBACV,IAAI,CAACnC,mBAAmB;gBACxBC,SAASoC,mBAAmB,CAAC,oBAAoBF;gBACjDA,YAAYf;gBACZgB;YACF;YACAnC,SAASqC,gBAAgB,CAAC,oBAAoBH;YAC9C;QACF;QACAD,OAAO;QACPD;IACF;IACA,MAAMM,QAAQC,WAAWJ,MAAMJ;IAC/B,OAAO;QACL,IAAIE,MAAM;QACVA,OAAO;QACPO,aAAaF;QACb,IAAIJ,aAAa,OAAOlC,aAAa,aAAa;YAChDA,SAASoC,mBAAmB,CAAC,oBAAoBF;QACnD;IACF;AACF"}
|
|
@@ -19,6 +19,7 @@ import { getDocsFromServer, onSnapshot } from "firebase/firestore";
|
|
|
19
19
|
import { useEffect, useRef, useState } from "react";
|
|
20
20
|
import { CONFIRMABLE_LISTEN_OPTIONS } from "./helpers/listen-options.js";
|
|
21
21
|
import { DENIAL_STREAK_TO_REPORT, denialLabelForQuery, reportFirestoreDenial, reportFirestoreServerRead, scheduleRefusedReopen, subscribeFirestoreSessionHeal } from "./firestore-denial-reporter.js";
|
|
22
|
+
import { armFirestoreStallWatch } from "./firebase/firestore-stall-recovery.js";
|
|
22
23
|
const RETRY_DELAY_MS = 400;
|
|
23
24
|
const MAX_RETRIES = 5;
|
|
24
25
|
/**
|
|
@@ -89,6 +90,16 @@ const MAX_RETRIES = 5;
|
|
|
89
90
|
* `deniedStreak`: a cached emission is not evidence about the server.
|
|
90
91
|
*/ let terminal = false;
|
|
91
92
|
const denialLabel = denialLabelForQuery(q);
|
|
93
|
+
/**
|
|
94
|
+
* A listen the server has not confirmed within `FIRESTORE_STALL_MS`,
|
|
95
|
+
* while the browser is online and the tab is visible, is the multi-tab
|
|
96
|
+
* wedge (AGL-3373): no tab is syncing, so an empty cache raises no first
|
|
97
|
+
* snapshot at all and a warm one never confirms. The client is asked to
|
|
98
|
+
* recover — a network cycle that forces the lease election — and this
|
|
99
|
+
* listener then hears from the server like any other. Disarmed by the
|
|
100
|
+
* first server-confirmed snapshot, by an error (a refusal is not a
|
|
101
|
+
* stall) and by cleanup.
|
|
102
|
+
*/ const disarmStall = armFirestoreStallWatch(q.firestore);
|
|
92
103
|
// Disappearance tracking (AGL-1196); inert unless opted in.
|
|
93
104
|
const seen = new Set();
|
|
94
105
|
const confirmedGone = new Set();
|
|
@@ -153,6 +164,7 @@ const MAX_RETRIES = 5;
|
|
|
153
164
|
* for offline; this must not change what happens there.
|
|
154
165
|
*/ // Only the SERVER answering is evidence the session can read.
|
|
155
166
|
if (!snapshot.metadata.fromCache) {
|
|
167
|
+
disarmStall();
|
|
156
168
|
attempt = 0;
|
|
157
169
|
deniedStreak = 0;
|
|
158
170
|
denialReported = false;
|
|
@@ -199,6 +211,7 @@ const MAX_RETRIES = 5;
|
|
|
199
211
|
emit(snapshot.docs, snapshot.metadata.fromCache);
|
|
200
212
|
}, (err)=>{
|
|
201
213
|
if (cancelled) return;
|
|
214
|
+
disarmStall();
|
|
202
215
|
unsubscribe == null ? void 0 : unsubscribe();
|
|
203
216
|
// Say so once per outage, and only for a refusal — a lost network
|
|
204
217
|
// produces no error callback at all, so this cannot fire offline.
|
|
@@ -287,6 +300,7 @@ const MAX_RETRIES = 5;
|
|
|
287
300
|
});
|
|
288
301
|
return ()=>{
|
|
289
302
|
cancelled = true;
|
|
303
|
+
disarmStall();
|
|
290
304
|
unsubscribeHeal();
|
|
291
305
|
if (timer) clearTimeout(timer);
|
|
292
306
|
cancelRefusedReopen == null ? void 0 : cancelRefusedReopen();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-firestore-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n getDocsFromServer,\n onSnapshot,\n type DocumentData,\n type FirestoreError,\n type Query,\n type QueryDocumentSnapshot,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState, type DependencyList } from 'react'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './helpers/listen-options'\nimport {\n DENIAL_STREAK_TO_REPORT,\n denialLabelForQuery,\n reportFirestoreDenial,\n reportFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from './firestore-denial-reporter'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n\n/*\n * The cadence once a refusal streak has outlived the retry budget lives in\n * `firestore-denial-reporter.ts` (`scheduleRefusedReopen`), shared by all\n * three listener hooks. It starts at 2s while the fault could be the whole\n * session and at 60s once another listener's server answer proves the\n * session reads and this refusal is about the ref (AGL-1440), then grows\n * with the streak's age to a five-minute ceiling (AGL-2945). A hidden tab\n * sets no timer (AGL-2944). The tab becoming visible, the window regaining\n * focus and a heal broadcast (`subscribeFirestoreSessionHeal`) all reopen\n * at once.\n */\n\nexport type FirestoreCollectionStatus = 'loading' | 'success' | 'error'\n\nexport interface UseFirestoreCollectionOptions {\n idField?: string\n /**\n * Confirm against the server when a document disappears from a NON-EMPTY\n * result, instead of trusting the live snapshot (AGL-1196).\n *\n * OPT-IN, and only worth setting for a query whose `where` reads a MUTABLE\n * field. Such a document can stop matching mid-session — a scope edit, an\n * un/republish — leave the query target, and get cached as a `noDocument`\n * tombstone at its own path, which is then served to every other reader of\n * that path including single-document reads (AGL-827/929).\n *\n * A query with no predicate, or one keyed on `documentId()`, cannot produce\n * this and must not pay for the extra read. Where the predicate can be\n * dropped entirely, drop it — that removes the mechanism rather than\n * repairing it after the fact (AGL-1190).\n */\n confirmDisappearances?: boolean\n}\n\nexport interface UseFirestoreCollectionResult<T> {\n data: T[]\n status: FirestoreCollectionStatus\n error: FirestoreError | undefined\n /**\n * `data` came from the local cache and the server has not confirmed it\n * (AGL-1066). See `ObservableStatus.fromCache` for why this matters and\n * why it is the signal to reach for rather than `staleSession`.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget, and\n * no server snapshot has arrived since (AGL-1066).\n *\n * Kept after the flip rather than folded into `status`, because it says\n * something `status` does not: WHY the listen is failing. `status:\n * 'error'` covers every terminal error; this one counts `permission-denied`\n * alone, so it cannot fire offline, and a single server snapshot clears it.\n * A consumer asking \"is what I am rendering cache-only because the session\n * is refused\" wants this; one asking \"did the read fail at all\" wants\n * `status`.\n */\n serverDenied: boolean\n}\n\n/**\n * Collection-query counterpart to reactfire's `useFirestoreCollectionData`,\n * but backed by a raw `onSnapshot` listener with its own retry/backoff\n * instead of reactfire's cached Observable. That cache terminates forever on\n * its first error — a transient `permission-denied` right after sign-in\n * (before Firestore's own credential provider has attached the user's ID\n * token) permanently breaks the query for the rest of the session, even\n * across remounts. A fresh `onSnapshot` call each retry reopens a genuinely\n * new listener, so it recovers once the token has propagated (AGL-216/217).\n *\n * `buildQuery` is re-invoked from `deps` the same way a `useEffect`/`useMemo`\n * callback would be — pass the same primitive values (ids, uid, firestore)\n * you'd put in a `useMemo` dependency array, not the `Query` object itself.\n */\nexport function useFirestoreCollection<T = DocumentData>(\n buildQuery: () => Query<DocumentData> | null | undefined,\n deps: DependencyList,\n options: UseFirestoreCollectionOptions = {},\n): UseFirestoreCollectionResult<T> {\n const [data, setData] = useState<T[]>([])\n const [status, setStatus] = useState<FirestoreCollectionStatus>('loading')\n const [error, setError] = useState<FirestoreError | undefined>(undefined)\n // Un-confirmed until a snapshot says otherwise: a hook that has not heard\n // from the server yet must not read as server-confirmed.\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const buildQueryRef = useRef(buildQuery)\n buildQueryRef.current = buildQuery\n const idField = options.idField\n const confirmDisappearances = options.confirmDisappearances\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n // Clear on EVERY dep change, not only when the query goes null: when\n // the scope moves between two live queries (org A → org B, host A →\n // host B) the old rows would otherwise stay rendered until the new\n // snapshot arrives — the org-switch \"remnants\" bug (AGL-591). Same\n // hold-nothing-rather-than-show-the-wrong-org rule as useOrgHosts.\n setData([])\n setFromCache(true)\n setServerDenied(false)\n\n const q = buildQueryRef.current()\n if (!q) {\n return\n }\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n /**\n * Refusals since the last SERVER snapshot (AGL-1066).\n *\n * Deliberately not `attempt`: that one is reset by any snapshot,\n * including a cached one, so under `persistentLocalCache` it never\n * reaches its budget and can never report anything.\n */\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n let denialReported = false\n /**\n * The retry budget is spent and the last thing the server did was refuse\n * (AGL-1066).\n *\n * This is what makes `status: 'error'` STICK. Every reopened listen emits\n * from the cache before it learns it is still refused, and an\n * unconditional `setStatus('success')` in the success handler would hand\n * the page back to `success` on that emission — so the surface would\n * oscillate error → success → error at the retry cadence, which is worse\n * to look at than either state. Only a snapshot the SERVER answered\n * clears this, for the same reason it is the only thing that clears\n * `deniedStreak`: a cached emission is not evidence about the server.\n */\n let terminal = false\n const denialLabel = denialLabelForQuery(q)\n // Disappearance tracking (AGL-1196); inert unless opted in.\n const seen = new Set<string>()\n const confirmedGone = new Set<string>()\n\n const emit = (\n docs: QueryDocumentSnapshot<DocumentData>[],\n cached: boolean,\n ) => {\n // A server-answered emission is the one thing that ends the refusal,\n // including the `getDocsFromServer` confirmation path below.\n if (!cached) terminal = false\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n setFromCache(cached)\n setData(\n docs.map((docSnap) => {\n const value = { ...docSnap.data() } as Record<string, unknown>\n if (idField) value[idField] = docSnap.id\n return value as T\n }),\n )\n }\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n q,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n /**\n * Only a SERVER-answered snapshot refunds the retry budget — the\n * AGL-1066 flip, and the reason it took four commits to become\n * safe.\n *\n * Under `persistentLocalCache` a reopened listen emits from the\n * cache BEFORE it learns the server refused it. Refunding on that\n * emission handed the budget back every cycle, so `attempt` never\n * reached `MAX_RETRIES`, `status` could never become `'error'`, and\n * a dead session looked exactly like a healthy one on every\n * listener-backed page for as long as it stayed dead.\n *\n * Landing this alone would have been a regression, and the three\n * things that made it one are now in place:\n *\n * - the budget is 5 x 400ms, and the error branch used to stop\n * retrying for good, while the heal is an AGL-664 in-place\n * re-auth that takes as long as a human takes to type a\n * password. A resolved re-auth now broadcasts and this listener\n * reopens on it (`subscribeFirestoreSessionHeal` below), and a\n * refusal streak keeps a slow road back for a recovery nobody\n * announced (see the error branch);\n * - `status: 'error'` had to become STICKY, or the cached emission\n * that precedes each refusal would flip it straight back — see\n * `terminal` above;\n * - the surfaces that BLANK on `error` had to learn the\n * difference between \"never had data\" and \"have data, the server\n * refused\". They read `hasEmitted` now, so the besigner canvas\n * and the host setup Theme tab keep rendering across a refusal\n * instead of collapsing to \"Not found\".\n *\n * The withholding is keyed on a REFUSAL having happened, not on the\n * emission being cached, and that is load-bearing: offline stays\n * exactly as inert as it was. A lost network yields `unavailable`\n * (when it yields an error callback at all), which never enters\n * `deniedStreak`, so a cached emission still refunds the budget and\n * an offline listener still never goes terminal. The cache exists\n * for offline; this must not change what happens there.\n */\n // Only the SERVER answering is evidence the session can read.\n if (!snapshot.metadata.fromCache) {\n attempt = 0\n deniedStreak = 0\n denialReported = false\n setServerDenied(false)\n // Proof the session can read — stronger than any accumulated\n // denial, and what stops a scoped collaborator's by-design\n // denials from ever adding up to a re-auth prompt.\n reportFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — so this is not the AGL-1066 fault and the budget is\n // refunded exactly as it always was.\n attempt = 0\n }\n\n if (confirmDisappearances) {\n const present = new Set(snapshot.docs.map((d) => d.id))\n const vanished = [...seen].filter(\n (id) => !present.has(id) && !confirmedGone.has(id),\n )\n if (vanished.length) {\n // A fresh (non-resumed) read ignores the resume token, returns\n // the true set and rewrites the cache, dropping any tombstone.\n getDocsFromServer(q)\n .then((fresh) => {\n if (cancelled) return\n const freshIds = new Set(fresh.docs.map((d) => d.id))\n // Only an absence the SERVER agrees with is real. One that\n // came back was a tombstone and must stay eligible to heal\n // if it vanishes again.\n vanished.forEach((id) => {\n if (!freshIds.has(id)) confirmedGone.add(id)\n })\n freshIds.forEach((id) => seen.add(id))\n // `getDocsFromServer` is server-confirmed by definition.\n emit(fresh.docs, false)\n })\n .catch(() => {\n // Couldn't confirm: show the live result rather than an\n // error. A possibly-short list beats an empty one.\n if (!cancelled) emit(snapshot.docs, snapshot.metadata.fromCache)\n })\n return\n }\n snapshot.docs.forEach((d) => seen.add(d.id))\n }\n\n emit(snapshot.docs, snapshot.metadata.fromCache)\n },\n (err) => {\n if (cancelled) return\n unsubscribe?.()\n // Say so once per outage, and only for a refusal — a lost network\n // produces no error callback at all, so this cannot fire offline.\n if (err?.code === 'permission-denied') {\n deniedStreak += 1\n // When the streak began — the reference point that lets\n // `refusedRetryDelayMs` ask whether the session has read from\n // the server SINCE this listen started being refused (AGL-1440).\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) {\n setServerDenied(true)\n if (!denialReported) {\n denialReported = true\n reportFirestoreDenial(denialLabel)\n }\n }\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n /**\n * A refusal streak keeps a slow road back (AGL-1066).\n *\n * Before the flip this listener reopened forever, which is also\n * what HEALED it — a token that attached late, an App Check\n * hiccup, one of the AGL-1143 transient token-layer denials.\n * None of those broadcast a heal, because none of them involved\n * a re-auth the console asked for, so stopping here would leave\n * every listen in the console terminal until the user reloaded.\n * The status is corrected; the recovery is not taken away.\n *\n * Any OTHER terminal error still stops exactly as before — this\n * is deliberately keyed on the refusal streak, not on `attempt`.\n * How slow the road is depends on whether the refusal looks like\n * the session or the ref (AGL-1440) and on how long it has\n * lasted (AGL-2945), and a hidden tab waits to be looked at\n * (AGL-2944) — see `scheduleRefusedReopen`.\n */\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n /**\n * The session came back — reopen NOW rather than on the next tick of a\n * cadence tuned for a session that has not (AGL-1066).\n *\n * Gated on this listener actually being refused. A healthy listener has\n * an open subscription and nothing to recover, so it must ignore the\n * broadcast entirely: without this gate a heal would tear down and\n * reopen every listen in the console at once, which is a listener storm\n * dressed up as a fix.\n *\n * What is refunded is `attempt` — the retry BUDGET, which was spent on a\n * fault that is now over. `deniedStreak` is deliberately left alone: it\n * is evidence about the server, and only the server answering may clear\n * it. So `serverDenied` stays true across the heal and goes false on the\n * snapshot that actually proves the read works, and a heal that turns\n * out to be wishful thinking falls straight back to the slow cadence\n * instead of resuming a 400ms storm.\n *\n * `denialReported` IS reset: the outage ended here. If reads are still\n * refused afterwards that is a new one, and it has to be able to raise\n * the AGL-1063 banner again.\n */\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n denialReported = false\n subscribe()\n })\n\n return () => {\n cancelled = true\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { data, status, error, fromCache, serverDenied }\n}\n\nexport default useFirestoreCollection\n"],"names":["getDocsFromServer","onSnapshot","useEffect","useRef","useState","CONFIRMABLE_LISTEN_OPTIONS","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","reportFirestoreDenial","reportFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","RETRY_DELAY_MS","MAX_RETRIES","useFirestoreCollection","buildQuery","deps","options","data","setData","status","setStatus","error","setError","undefined","fromCache","setFromCache","serverDenied","setServerDenied","buildQueryRef","current","idField","confirmDisappearances","q","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","denialReported","terminal","denialLabel","seen","Set","confirmedGone","emit","docs","cached","map","docSnap","value","id","subscribe","snapshot","metadata","present","d","vanished","filter","has","length","then","fresh","freshIds","forEach","add","catch","err","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-firestore-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n getDocsFromServer,\n onSnapshot,\n type DocumentData,\n type FirestoreError,\n type Query,\n type QueryDocumentSnapshot,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState, type DependencyList } from 'react'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './helpers/listen-options'\nimport {\n DENIAL_STREAK_TO_REPORT,\n denialLabelForQuery,\n reportFirestoreDenial,\n reportFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from './firestore-denial-reporter'\nimport { armFirestoreStallWatch } from './firebase/firestore-stall-recovery'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n\n/*\n * The cadence once a refusal streak has outlived the retry budget lives in\n * `firestore-denial-reporter.ts` (`scheduleRefusedReopen`), shared by all\n * three listener hooks. It starts at 2s while the fault could be the whole\n * session and at 60s once another listener's server answer proves the\n * session reads and this refusal is about the ref (AGL-1440), then grows\n * with the streak's age to a five-minute ceiling (AGL-2945). A hidden tab\n * sets no timer (AGL-2944). The tab becoming visible, the window regaining\n * focus and a heal broadcast (`subscribeFirestoreSessionHeal`) all reopen\n * at once.\n */\n\nexport type FirestoreCollectionStatus = 'loading' | 'success' | 'error'\n\nexport interface UseFirestoreCollectionOptions {\n idField?: string\n /**\n * Confirm against the server when a document disappears from a NON-EMPTY\n * result, instead of trusting the live snapshot (AGL-1196).\n *\n * OPT-IN, and only worth setting for a query whose `where` reads a MUTABLE\n * field. Such a document can stop matching mid-session — a scope edit, an\n * un/republish — leave the query target, and get cached as a `noDocument`\n * tombstone at its own path, which is then served to every other reader of\n * that path including single-document reads (AGL-827/929).\n *\n * A query with no predicate, or one keyed on `documentId()`, cannot produce\n * this and must not pay for the extra read. Where the predicate can be\n * dropped entirely, drop it — that removes the mechanism rather than\n * repairing it after the fact (AGL-1190).\n */\n confirmDisappearances?: boolean\n}\n\nexport interface UseFirestoreCollectionResult<T> {\n data: T[]\n status: FirestoreCollectionStatus\n error: FirestoreError | undefined\n /**\n * `data` came from the local cache and the server has not confirmed it\n * (AGL-1066). See `ObservableStatus.fromCache` for why this matters and\n * why it is the signal to reach for rather than `staleSession`.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget, and\n * no server snapshot has arrived since (AGL-1066).\n *\n * Kept after the flip rather than folded into `status`, because it says\n * something `status` does not: WHY the listen is failing. `status:\n * 'error'` covers every terminal error; this one counts `permission-denied`\n * alone, so it cannot fire offline, and a single server snapshot clears it.\n * A consumer asking \"is what I am rendering cache-only because the session\n * is refused\" wants this; one asking \"did the read fail at all\" wants\n * `status`.\n */\n serverDenied: boolean\n}\n\n/**\n * Collection-query counterpart to reactfire's `useFirestoreCollectionData`,\n * but backed by a raw `onSnapshot` listener with its own retry/backoff\n * instead of reactfire's cached Observable. That cache terminates forever on\n * its first error — a transient `permission-denied` right after sign-in\n * (before Firestore's own credential provider has attached the user's ID\n * token) permanently breaks the query for the rest of the session, even\n * across remounts. A fresh `onSnapshot` call each retry reopens a genuinely\n * new listener, so it recovers once the token has propagated (AGL-216/217).\n *\n * `buildQuery` is re-invoked from `deps` the same way a `useEffect`/`useMemo`\n * callback would be — pass the same primitive values (ids, uid, firestore)\n * you'd put in a `useMemo` dependency array, not the `Query` object itself.\n */\nexport function useFirestoreCollection<T = DocumentData>(\n buildQuery: () => Query<DocumentData> | null | undefined,\n deps: DependencyList,\n options: UseFirestoreCollectionOptions = {},\n): UseFirestoreCollectionResult<T> {\n const [data, setData] = useState<T[]>([])\n const [status, setStatus] = useState<FirestoreCollectionStatus>('loading')\n const [error, setError] = useState<FirestoreError | undefined>(undefined)\n // Un-confirmed until a snapshot says otherwise: a hook that has not heard\n // from the server yet must not read as server-confirmed.\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const buildQueryRef = useRef(buildQuery)\n buildQueryRef.current = buildQuery\n const idField = options.idField\n const confirmDisappearances = options.confirmDisappearances\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n // Clear on EVERY dep change, not only when the query goes null: when\n // the scope moves between two live queries (org A → org B, host A →\n // host B) the old rows would otherwise stay rendered until the new\n // snapshot arrives — the org-switch \"remnants\" bug (AGL-591). Same\n // hold-nothing-rather-than-show-the-wrong-org rule as useOrgHosts.\n setData([])\n setFromCache(true)\n setServerDenied(false)\n\n const q = buildQueryRef.current()\n if (!q) {\n return\n }\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n /**\n * Refusals since the last SERVER snapshot (AGL-1066).\n *\n * Deliberately not `attempt`: that one is reset by any snapshot,\n * including a cached one, so under `persistentLocalCache` it never\n * reaches its budget and can never report anything.\n */\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n let denialReported = false\n /**\n * The retry budget is spent and the last thing the server did was refuse\n * (AGL-1066).\n *\n * This is what makes `status: 'error'` STICK. Every reopened listen emits\n * from the cache before it learns it is still refused, and an\n * unconditional `setStatus('success')` in the success handler would hand\n * the page back to `success` on that emission — so the surface would\n * oscillate error → success → error at the retry cadence, which is worse\n * to look at than either state. Only a snapshot the SERVER answered\n * clears this, for the same reason it is the only thing that clears\n * `deniedStreak`: a cached emission is not evidence about the server.\n */\n let terminal = false\n const denialLabel = denialLabelForQuery(q)\n /**\n * A listen the server has not confirmed within `FIRESTORE_STALL_MS`,\n * while the browser is online and the tab is visible, is the multi-tab\n * wedge (AGL-3373): no tab is syncing, so an empty cache raises no first\n * snapshot at all and a warm one never confirms. The client is asked to\n * recover — a network cycle that forces the lease election — and this\n * listener then hears from the server like any other. Disarmed by the\n * first server-confirmed snapshot, by an error (a refusal is not a\n * stall) and by cleanup.\n */\n const disarmStall = armFirestoreStallWatch(q.firestore)\n // Disappearance tracking (AGL-1196); inert unless opted in.\n const seen = new Set<string>()\n const confirmedGone = new Set<string>()\n\n const emit = (\n docs: QueryDocumentSnapshot<DocumentData>[],\n cached: boolean,\n ) => {\n // A server-answered emission is the one thing that ends the refusal,\n // including the `getDocsFromServer` confirmation path below.\n if (!cached) terminal = false\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n setFromCache(cached)\n setData(\n docs.map((docSnap) => {\n const value = { ...docSnap.data() } as Record<string, unknown>\n if (idField) value[idField] = docSnap.id\n return value as T\n }),\n )\n }\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n q,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n /**\n * Only a SERVER-answered snapshot refunds the retry budget — the\n * AGL-1066 flip, and the reason it took four commits to become\n * safe.\n *\n * Under `persistentLocalCache` a reopened listen emits from the\n * cache BEFORE it learns the server refused it. Refunding on that\n * emission handed the budget back every cycle, so `attempt` never\n * reached `MAX_RETRIES`, `status` could never become `'error'`, and\n * a dead session looked exactly like a healthy one on every\n * listener-backed page for as long as it stayed dead.\n *\n * Landing this alone would have been a regression, and the three\n * things that made it one are now in place:\n *\n * - the budget is 5 x 400ms, and the error branch used to stop\n * retrying for good, while the heal is an AGL-664 in-place\n * re-auth that takes as long as a human takes to type a\n * password. A resolved re-auth now broadcasts and this listener\n * reopens on it (`subscribeFirestoreSessionHeal` below), and a\n * refusal streak keeps a slow road back for a recovery nobody\n * announced (see the error branch);\n * - `status: 'error'` had to become STICKY, or the cached emission\n * that precedes each refusal would flip it straight back — see\n * `terminal` above;\n * - the surfaces that BLANK on `error` had to learn the\n * difference between \"never had data\" and \"have data, the server\n * refused\". They read `hasEmitted` now, so the besigner canvas\n * and the host setup Theme tab keep rendering across a refusal\n * instead of collapsing to \"Not found\".\n *\n * The withholding is keyed on a REFUSAL having happened, not on the\n * emission being cached, and that is load-bearing: offline stays\n * exactly as inert as it was. A lost network yields `unavailable`\n * (when it yields an error callback at all), which never enters\n * `deniedStreak`, so a cached emission still refunds the budget and\n * an offline listener still never goes terminal. The cache exists\n * for offline; this must not change what happens there.\n */\n // Only the SERVER answering is evidence the session can read.\n if (!snapshot.metadata.fromCache) {\n disarmStall()\n attempt = 0\n deniedStreak = 0\n denialReported = false\n setServerDenied(false)\n // Proof the session can read — stronger than any accumulated\n // denial, and what stops a scoped collaborator's by-design\n // denials from ever adding up to a re-auth prompt.\n reportFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — so this is not the AGL-1066 fault and the budget is\n // refunded exactly as it always was.\n attempt = 0\n }\n\n if (confirmDisappearances) {\n const present = new Set(snapshot.docs.map((d) => d.id))\n const vanished = [...seen].filter(\n (id) => !present.has(id) && !confirmedGone.has(id),\n )\n if (vanished.length) {\n // A fresh (non-resumed) read ignores the resume token, returns\n // the true set and rewrites the cache, dropping any tombstone.\n getDocsFromServer(q)\n .then((fresh) => {\n if (cancelled) return\n const freshIds = new Set(fresh.docs.map((d) => d.id))\n // Only an absence the SERVER agrees with is real. One that\n // came back was a tombstone and must stay eligible to heal\n // if it vanishes again.\n vanished.forEach((id) => {\n if (!freshIds.has(id)) confirmedGone.add(id)\n })\n freshIds.forEach((id) => seen.add(id))\n // `getDocsFromServer` is server-confirmed by definition.\n emit(fresh.docs, false)\n })\n .catch(() => {\n // Couldn't confirm: show the live result rather than an\n // error. A possibly-short list beats an empty one.\n if (!cancelled) emit(snapshot.docs, snapshot.metadata.fromCache)\n })\n return\n }\n snapshot.docs.forEach((d) => seen.add(d.id))\n }\n\n emit(snapshot.docs, snapshot.metadata.fromCache)\n },\n (err) => {\n if (cancelled) return\n disarmStall()\n unsubscribe?.()\n // Say so once per outage, and only for a refusal — a lost network\n // produces no error callback at all, so this cannot fire offline.\n if (err?.code === 'permission-denied') {\n deniedStreak += 1\n // When the streak began — the reference point that lets\n // `refusedRetryDelayMs` ask whether the session has read from\n // the server SINCE this listen started being refused (AGL-1440).\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) {\n setServerDenied(true)\n if (!denialReported) {\n denialReported = true\n reportFirestoreDenial(denialLabel)\n }\n }\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n /**\n * A refusal streak keeps a slow road back (AGL-1066).\n *\n * Before the flip this listener reopened forever, which is also\n * what HEALED it — a token that attached late, an App Check\n * hiccup, one of the AGL-1143 transient token-layer denials.\n * None of those broadcast a heal, because none of them involved\n * a re-auth the console asked for, so stopping here would leave\n * every listen in the console terminal until the user reloaded.\n * The status is corrected; the recovery is not taken away.\n *\n * Any OTHER terminal error still stops exactly as before — this\n * is deliberately keyed on the refusal streak, not on `attempt`.\n * How slow the road is depends on whether the refusal looks like\n * the session or the ref (AGL-1440) and on how long it has\n * lasted (AGL-2945), and a hidden tab waits to be looked at\n * (AGL-2944) — see `scheduleRefusedReopen`.\n */\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n /**\n * The session came back — reopen NOW rather than on the next tick of a\n * cadence tuned for a session that has not (AGL-1066).\n *\n * Gated on this listener actually being refused. A healthy listener has\n * an open subscription and nothing to recover, so it must ignore the\n * broadcast entirely: without this gate a heal would tear down and\n * reopen every listen in the console at once, which is a listener storm\n * dressed up as a fix.\n *\n * What is refunded is `attempt` — the retry BUDGET, which was spent on a\n * fault that is now over. `deniedStreak` is deliberately left alone: it\n * is evidence about the server, and only the server answering may clear\n * it. So `serverDenied` stays true across the heal and goes false on the\n * snapshot that actually proves the read works, and a heal that turns\n * out to be wishful thinking falls straight back to the slow cadence\n * instead of resuming a 400ms storm.\n *\n * `denialReported` IS reset: the outage ended here. If reads are still\n * refused afterwards that is a new one, and it has to be able to raise\n * the AGL-1063 banner again.\n */\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n denialReported = false\n subscribe()\n })\n\n return () => {\n cancelled = true\n disarmStall()\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { data, status, error, fromCache, serverDenied }\n}\n\nexport default useFirestoreCollection\n"],"names":["getDocsFromServer","onSnapshot","useEffect","useRef","useState","CONFIRMABLE_LISTEN_OPTIONS","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","reportFirestoreDenial","reportFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","armFirestoreStallWatch","RETRY_DELAY_MS","MAX_RETRIES","useFirestoreCollection","buildQuery","deps","options","data","setData","status","setStatus","error","setError","undefined","fromCache","setFromCache","serverDenied","setServerDenied","buildQueryRef","current","idField","confirmDisappearances","q","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","denialReported","terminal","denialLabel","disarmStall","firestore","seen","Set","confirmedGone","emit","docs","cached","map","docSnap","value","id","subscribe","snapshot","metadata","present","d","vanished","filter","has","length","then","fresh","freshIds","forEach","add","catch","err","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout"],"mappings":"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"}
|
|
@@ -19,6 +19,7 @@ import { onSnapshot } from "firebase/firestore";
|
|
|
19
19
|
import { useEffect, useRef, useState } from "react";
|
|
20
20
|
import { CONFIRMABLE_LISTEN_OPTIONS } from "./helpers/listen-options.js";
|
|
21
21
|
import { DENIAL_STREAK_TO_REPORT, denialLabelForQuery, reportFirestoreDenial, reportFirestoreServerRead, scheduleRefusedReopen, subscribeFirestoreSessionHeal } from "./firestore-denial-reporter.js";
|
|
22
|
+
import { armFirestoreStallWatch } from "./firebase/firestore-stall-recovery.js";
|
|
22
23
|
const RETRY_DELAY_MS = 400;
|
|
23
24
|
const MAX_RETRIES = 5;
|
|
24
25
|
/**
|
|
@@ -73,6 +74,16 @@ const MAX_RETRIES = 5;
|
|
|
73
74
|
* on the same variable in `use-firestore-collection`.
|
|
74
75
|
*/ let terminal = false;
|
|
75
76
|
const denialLabel = denialLabelForQuery(ref);
|
|
77
|
+
/**
|
|
78
|
+
* A listen the server has not confirmed within `FIRESTORE_STALL_MS`,
|
|
79
|
+
* while the browser is online and the tab is visible, is the multi-tab
|
|
80
|
+
* wedge (AGL-3373): no tab is syncing, so an empty cache raises no first
|
|
81
|
+
* snapshot at all and a warm one never confirms. The client is asked to
|
|
82
|
+
* recover — a network cycle that forces the lease election — and this
|
|
83
|
+
* listener then hears from the server like any other. Disarmed by the
|
|
84
|
+
* first server-confirmed snapshot, by an error (a refusal is not a
|
|
85
|
+
* stall) and by cleanup.
|
|
86
|
+
*/ const disarmStall = armFirestoreStallWatch(ref.firestore);
|
|
76
87
|
const subscribe = ()=>{
|
|
77
88
|
unsubscribe = onSnapshot(ref, // Without this the cache→server confirmation is never delivered and
|
|
78
89
|
// `fromCache` latches true for the life of the listener, refusing
|
|
@@ -89,6 +100,7 @@ const MAX_RETRIES = 5;
|
|
|
89
100
|
// the only thing that refunds the retry budget (AGL-1066). See the
|
|
90
101
|
// long note on the same gate in `use-firestore-collection`.
|
|
91
102
|
if (!snapshot.metadata.fromCache) {
|
|
103
|
+
disarmStall();
|
|
92
104
|
attempt = 0;
|
|
93
105
|
terminal = false;
|
|
94
106
|
setStatus('success');
|
|
@@ -116,6 +128,7 @@ const MAX_RETRIES = 5;
|
|
|
116
128
|
setData(value);
|
|
117
129
|
}, (err)=>{
|
|
118
130
|
if (cancelled) return;
|
|
131
|
+
disarmStall();
|
|
119
132
|
unsubscribe == null ? void 0 : unsubscribe();
|
|
120
133
|
// Say so once per outage, and only for a refusal — a lost network
|
|
121
134
|
// produces no error callback at all, so this cannot fire offline.
|
|
@@ -171,6 +184,7 @@ const MAX_RETRIES = 5;
|
|
|
171
184
|
});
|
|
172
185
|
return ()=>{
|
|
173
186
|
cancelled = true;
|
|
187
|
+
disarmStall();
|
|
174
188
|
unsubscribeHeal();
|
|
175
189
|
if (timer) clearTimeout(timer);
|
|
176
190
|
cancelRefusedReopen == null ? void 0 : cancelRefusedReopen();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-firestore-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type FirestoreError,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState, type DependencyList } from 'react'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './helpers/listen-options'\nimport {\n DENIAL_STREAK_TO_REPORT,\n denialLabelForQuery,\n reportFirestoreDenial,\n reportFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from './firestore-denial-reporter'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n// The refused cadence is shared policy — see `scheduleRefusedReopen` in\n// `firestore-denial-reporter.ts` (AGL-1066, AGL-1440, AGL-2944, AGL-2945).\n\nexport type FirestoreDocStatus = 'loading' | 'success' | 'error'\n\nexport interface UseFirestoreDocOptions {\n idField?: string\n}\n\nexport interface UseFirestoreDocResult<T> {\n data: T | undefined\n status: FirestoreDocStatus\n error: FirestoreError | undefined\n /**\n * `data` still carries writes this client made that the server has NOT\n * acknowledged. Carried rather than dropped for the same reason as\n * `ObservableStatus.hasPendingWrites` — see that comment (AGL-1262).\n */\n hasPendingWrites: boolean\n /**\n * `data` came from the local cache and the server has not confirmed it\n * (AGL-1066). See `ObservableStatus.fromCache` for why this matters and\n * why it is the signal to reach for rather than `staleSession`.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget and\n * no server snapshot has arrived since (AGL-1066) — the reason behind an\n * `error`, not a duplicate of it. See\n * `UseFirestoreCollectionResult.serverDenied`.\n */\n serverDenied: boolean\n}\n\n/**\n * Single-document counterpart to `useFirestoreCollection` — see that hook's\n * doc comment for why this exists instead of reactfire's\n * `useFirestoreDocData` (AGL-216/217).\n *\n * `buildRef` is re-invoked from `deps` the same way a `useEffect`/`useMemo`\n * callback would be — pass the same primitive values (ids, uid, firestore)\n * you'd put in a `useMemo` dependency array, not the `DocumentReference`\n * object itself.\n */\nexport function useFirestoreDoc<T = DocumentData>(\n buildRef: () => DocumentReference<DocumentData> | null | undefined,\n deps: DependencyList,\n options: UseFirestoreDocOptions = {},\n): UseFirestoreDocResult<T> {\n const [data, setData] = useState<T | undefined>(undefined)\n const [status, setStatus] = useState<FirestoreDocStatus>('loading')\n const [error, setError] = useState<FirestoreError | undefined>(undefined)\n const [hasPendingWrites, setHasPendingWrites] = useState(false)\n // Un-confirmed until a snapshot says otherwise: a hook that has not heard\n // from the server yet must not read as server-confirmed.\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const buildRefRef = useRef(buildRef)\n buildRefRef.current = buildRef\n const idField = options.idField\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n // Clear on every dep change (AGL-591): a scope move between two live\n // refs must not keep showing the previous doc until the new snapshot\n // arrives. Mirrors use-firestore-collection.\n setData(undefined)\n setHasPendingWrites(false)\n setFromCache(true)\n setServerDenied(false)\n\n const ref = buildRefRef.current()\n if (!ref) {\n return\n }\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n // Refusals since the last SERVER snapshot (AGL-1066) — see the note on\n // `DENIAL_STREAK_TO_REPORT` for why this is not `attempt`.\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n let denialReported = false\n /**\n * The retry budget is spent and the server's last word was a refusal —\n * what keeps `status: 'error'` from being undone by the cached emission\n * that precedes every reopened refusal (AGL-1066). See the longer note\n * on the same variable in `use-firestore-collection`.\n */\n let terminal = false\n const denialLabel = denialLabelForQuery(ref)\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n setHasPendingWrites(snapshot.metadata.hasPendingWrites)\n setFromCache(snapshot.metadata.fromCache)\n // Only the SERVER answering is evidence the session can read — and\n // the only thing that refunds the retry budget (AGL-1066). See the\n // long note on the same gate in `use-firestore-collection`.\n if (!snapshot.metadata.fromCache) {\n attempt = 0\n terminal = false\n setStatus('success')\n setError(undefined)\n deniedStreak = 0\n denialReported = false\n setServerDenied(false)\n // Proof the session can read — stronger than any accumulated\n // denial, and what stops a scoped collaborator's by-design\n // denials from ever adding up to a re-auth prompt.\n reportFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — not the AGL-1066 fault, so the budget is refunded\n // exactly as it always was. This is what keeps offline inert:\n // `unavailable` never enters `deniedStreak`.\n attempt = 0\n }\n if (!snapshot.exists()) {\n setData(undefined)\n return\n }\n const value = { ...snapshot.data() } as Record<string, unknown>\n if (idField) value[idField] = snapshot.id\n setData(value as T)\n },\n (err) => {\n if (cancelled) return\n unsubscribe?.()\n // Say so once per outage, and only for a refusal — a lost network\n // produces no error callback at all, so this cannot fire offline.\n if (err?.code === 'permission-denied') {\n deniedStreak += 1\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) {\n setServerDenied(true)\n if (!denialReported) {\n denialReported = true\n reportFirestoreDenial(denialLabel)\n }\n }\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n // A refusal streak keeps a slow road back for a recovery nobody\n // announced — see the same branch in `use-firestore-collection`.\n // The cadence splits on session-vs-ref evidence (AGL-1440), grows\n // with the streak's age (AGL-2945) and waits while the tab is\n // hidden (AGL-2944).\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n // Reopen when the session heals — see the long note on the same\n // subscription in `use-firestore-collection` (AGL-1066). Gated on this\n // listen actually being refused, so a healthy one ignores the broadcast.\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n denialReported = false\n subscribe()\n })\n\n return () => {\n cancelled = true\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { data, status, error, hasPendingWrites, fromCache, serverDenied }\n}\n\nexport default useFirestoreDoc\n"],"names":["onSnapshot","useEffect","useRef","useState","CONFIRMABLE_LISTEN_OPTIONS","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","reportFirestoreDenial","reportFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","RETRY_DELAY_MS","MAX_RETRIES","useFirestoreDoc","buildRef","deps","options","data","setData","undefined","status","setStatus","error","setError","hasPendingWrites","setHasPendingWrites","fromCache","setFromCache","serverDenied","setServerDenied","buildRefRef","current","idField","ref","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","denialReported","terminal","denialLabel","subscribe","snapshot","metadata","exists","value","id","err","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-firestore-doc.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport {\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type FirestoreError,\n} from 'firebase/firestore'\nimport { useEffect, useRef, useState, type DependencyList } from 'react'\nimport { CONFIRMABLE_LISTEN_OPTIONS } from './helpers/listen-options'\nimport {\n DENIAL_STREAK_TO_REPORT,\n denialLabelForQuery,\n reportFirestoreDenial,\n reportFirestoreServerRead,\n scheduleRefusedReopen,\n subscribeFirestoreSessionHeal,\n} from './firestore-denial-reporter'\nimport { armFirestoreStallWatch } from './firebase/firestore-stall-recovery'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 5\n// The refused cadence is shared policy — see `scheduleRefusedReopen` in\n// `firestore-denial-reporter.ts` (AGL-1066, AGL-1440, AGL-2944, AGL-2945).\n\nexport type FirestoreDocStatus = 'loading' | 'success' | 'error'\n\nexport interface UseFirestoreDocOptions {\n idField?: string\n}\n\nexport interface UseFirestoreDocResult<T> {\n data: T | undefined\n status: FirestoreDocStatus\n error: FirestoreError | undefined\n /**\n * `data` still carries writes this client made that the server has NOT\n * acknowledged. Carried rather than dropped for the same reason as\n * `ObservableStatus.hasPendingWrites` — see that comment (AGL-1262).\n */\n hasPendingWrites: boolean\n /**\n * `data` came from the local cache and the server has not confirmed it\n * (AGL-1066). See `ObservableStatus.fromCache` for why this matters and\n * why it is the signal to reach for rather than `staleSession`.\n */\n fromCache: boolean\n /**\n * The server has REFUSED this listen for longer than the retry budget and\n * no server snapshot has arrived since (AGL-1066) — the reason behind an\n * `error`, not a duplicate of it. See\n * `UseFirestoreCollectionResult.serverDenied`.\n */\n serverDenied: boolean\n}\n\n/**\n * Single-document counterpart to `useFirestoreCollection` — see that hook's\n * doc comment for why this exists instead of reactfire's\n * `useFirestoreDocData` (AGL-216/217).\n *\n * `buildRef` is re-invoked from `deps` the same way a `useEffect`/`useMemo`\n * callback would be — pass the same primitive values (ids, uid, firestore)\n * you'd put in a `useMemo` dependency array, not the `DocumentReference`\n * object itself.\n */\nexport function useFirestoreDoc<T = DocumentData>(\n buildRef: () => DocumentReference<DocumentData> | null | undefined,\n deps: DependencyList,\n options: UseFirestoreDocOptions = {},\n): UseFirestoreDocResult<T> {\n const [data, setData] = useState<T | undefined>(undefined)\n const [status, setStatus] = useState<FirestoreDocStatus>('loading')\n const [error, setError] = useState<FirestoreError | undefined>(undefined)\n const [hasPendingWrites, setHasPendingWrites] = useState(false)\n // Un-confirmed until a snapshot says otherwise: a hook that has not heard\n // from the server yet must not read as server-confirmed.\n const [fromCache, setFromCache] = useState(true)\n const [serverDenied, setServerDenied] = useState(false)\n const buildRefRef = useRef(buildRef)\n buildRefRef.current = buildRef\n const idField = options.idField\n\n useEffect(() => {\n setStatus('loading')\n setError(undefined)\n // Clear on every dep change (AGL-591): a scope move between two live\n // refs must not keep showing the previous doc until the new snapshot\n // arrives. Mirrors use-firestore-collection.\n setData(undefined)\n setHasPendingWrites(false)\n setFromCache(true)\n setServerDenied(false)\n\n const ref = buildRefRef.current()\n if (!ref) {\n return\n }\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n /** Cancels a pending reopen on the refused cadence (AGL-2944). */\n let cancelRefusedReopen: (() => void) | null = null\n let attempt = 0\n // Refusals since the last SERVER snapshot (AGL-1066) — see the note on\n // `DENIAL_STREAK_TO_REPORT` for why this is not `attempt`.\n let deniedStreak = 0\n /** Epoch ms of the current streak's first refusal (AGL-1440). */\n let deniedStreakStartedAt = 0\n let denialReported = false\n /**\n * The retry budget is spent and the server's last word was a refusal —\n * what keeps `status: 'error'` from being undone by the cached emission\n * that precedes every reopened refusal (AGL-1066). See the longer note\n * on the same variable in `use-firestore-collection`.\n */\n let terminal = false\n const denialLabel = denialLabelForQuery(ref)\n /**\n * A listen the server has not confirmed within `FIRESTORE_STALL_MS`,\n * while the browser is online and the tab is visible, is the multi-tab\n * wedge (AGL-3373): no tab is syncing, so an empty cache raises no first\n * snapshot at all and a warm one never confirms. The client is asked to\n * recover — a network cycle that forces the lease election — and this\n * listener then hears from the server like any other. Disarmed by the\n * first server-confirmed snapshot, by an error (a refusal is not a\n * stall) and by cleanup.\n */\n const disarmStall = armFirestoreStallWatch(ref.firestore)\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n // Without this the cache→server confirmation is never delivered and\n // `fromCache` latches true for the life of the listener, refusing\n // every guarded save on the page (AGL-2486). See `listen-options.ts`.\n CONFIRMABLE_LISTEN_OPTIONS,\n (snapshot) => {\n if (cancelled) return\n if (!terminal) {\n setStatus('success')\n setError(undefined)\n }\n setHasPendingWrites(snapshot.metadata.hasPendingWrites)\n setFromCache(snapshot.metadata.fromCache)\n // Only the SERVER answering is evidence the session can read — and\n // the only thing that refunds the retry budget (AGL-1066). See the\n // long note on the same gate in `use-firestore-collection`.\n if (!snapshot.metadata.fromCache) {\n disarmStall()\n attempt = 0\n terminal = false\n setStatus('success')\n setError(undefined)\n deniedStreak = 0\n denialReported = false\n setServerDenied(false)\n // Proof the session can read — stronger than any accumulated\n // denial, and what stops a scoped collaborator's by-design\n // denials from ever adding up to a re-auth prompt.\n reportFirestoreServerRead()\n } else if (deniedStreak === 0) {\n // Cached, but nothing has been refused since the server last\n // answered — not the AGL-1066 fault, so the budget is refunded\n // exactly as it always was. This is what keeps offline inert:\n // `unavailable` never enters `deniedStreak`.\n attempt = 0\n }\n if (!snapshot.exists()) {\n setData(undefined)\n return\n }\n const value = { ...snapshot.data() } as Record<string, unknown>\n if (idField) value[idField] = snapshot.id\n setData(value as T)\n },\n (err) => {\n if (cancelled) return\n disarmStall()\n unsubscribe?.()\n // Say so once per outage, and only for a refusal — a lost network\n // produces no error callback at all, so this cannot fire offline.\n if (err?.code === 'permission-denied') {\n deniedStreak += 1\n if (deniedStreak === 1) deniedStreakStartedAt = Date.now()\n if (deniedStreak >= DENIAL_STREAK_TO_REPORT) {\n setServerDenied(true)\n if (!denialReported) {\n denialReported = true\n reportFirestoreDenial(denialLabel)\n }\n }\n }\n if (attempt < MAX_RETRIES) {\n attempt += 1\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n } else {\n timer = setTimeout(subscribe, RETRY_DELAY_MS)\n }\n } else {\n terminal = true\n setStatus('error')\n setError(err)\n // A refusal streak keeps a slow road back for a recovery nobody\n // announced — see the same branch in `use-firestore-collection`.\n // The cadence splits on session-vs-ref evidence (AGL-1440), grows\n // with the streak's age (AGL-2945) and waits while the tab is\n // hidden (AGL-2944).\n if (deniedStreak > MAX_RETRIES) {\n cancelRefusedReopen = scheduleRefusedReopen(\n subscribe,\n deniedStreakStartedAt,\n )\n }\n }\n },\n )\n }\n subscribe()\n\n // Reopen when the session heals — see the long note on the same\n // subscription in `use-firestore-collection` (AGL-1066). Gated on this\n // listen actually being refused, so a healthy one ignores the broadcast.\n const unsubscribeHeal = subscribeFirestoreSessionHeal(() => {\n if (cancelled || deniedStreak === 0) return\n if (timer) {\n clearTimeout(timer)\n timer = null\n }\n cancelRefusedReopen?.()\n cancelRefusedReopen = null\n unsubscribe?.()\n attempt = 0\n denialReported = false\n subscribe()\n })\n\n return () => {\n cancelled = true\n disarmStall()\n unsubscribeHeal()\n if (timer) clearTimeout(timer)\n cancelRefusedReopen?.()\n unsubscribe?.()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n return { data, status, error, hasPendingWrites, fromCache, serverDenied }\n}\n\nexport default useFirestoreDoc\n"],"names":["onSnapshot","useEffect","useRef","useState","CONFIRMABLE_LISTEN_OPTIONS","DENIAL_STREAK_TO_REPORT","denialLabelForQuery","reportFirestoreDenial","reportFirestoreServerRead","scheduleRefusedReopen","subscribeFirestoreSessionHeal","armFirestoreStallWatch","RETRY_DELAY_MS","MAX_RETRIES","useFirestoreDoc","buildRef","deps","options","data","setData","undefined","status","setStatus","error","setError","hasPendingWrites","setHasPendingWrites","fromCache","setFromCache","serverDenied","setServerDenied","buildRefRef","current","idField","ref","cancelled","unsubscribe","timer","cancelRefusedReopen","attempt","deniedStreak","deniedStreakStartedAt","denialReported","terminal","denialLabel","disarmStall","firestore","subscribe","snapshot","metadata","exists","value","id","err","code","Date","now","setTimeout","unsubscribeHeal","clearTimeout"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SenderReadiness } from '@aglyn/shared-util-email';
|
|
1
2
|
/**
|
|
2
3
|
* The client of the console's sending-identity routes (`/api/email/*`).
|
|
3
4
|
*
|
|
@@ -39,6 +40,8 @@ export interface SendingDomainView {
|
|
|
39
40
|
consequence: string;
|
|
40
41
|
} | null;
|
|
41
42
|
dmarcSuggestion?: SendingDnsRecordView | null;
|
|
43
|
+
/** How receivers will judge mail from it (AGL-3328); none before a key is issued. */
|
|
44
|
+
readiness?: SenderReadiness | null;
|
|
42
45
|
pendingProvider?: boolean;
|
|
43
46
|
providerDetail?: string | null;
|
|
44
47
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-sending-identity-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { useUser } from './firebase/firebase-services'\nimport { useCallback, useRef } from 'react'\nimport {\n describeCallFailure,\n resolveIdToken,\n type TokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\n\n/**\n * The client of the console's sending-identity routes (`/api/email/*`).\n *\n * Which address a site's mail leaves from, and the domains and senders behind\n * it, are the platform's mail rail: the console serves the routes, and every\n * message goes out under that identity whichever plugin wrote it. So the hook\n * that calls them is here, beside the other client hooks a plugin may import,\n * and a one-to-one composer reads the identity without importing the plugin\n * whose settings page edits it.\n */\n\n/** What one call answered with. `payload` is `{}` on an unparseable body. */\nexport interface SendingApiResult {\n response: Response\n payload: Record<string, any>\n}\n\n/**\n * A refusal the caller reads the same way it reads the route's own.\n *\n * `401` because that is what the request would have been answered with had it\n * gone out, and the reason travels in `payload.error`, which is the field\n * every caller already renders.\n */\nconst refusedForCaller = (message: string): SendingApiResult => ({\n response: {\n ok: false,\n status: 401,\n json: async () => ({ error: message }),\n } as unknown as Response,\n payload: { error: message },\n})\n\n/** One DNS record, exactly as the server issued it. */\nexport interface SendingDnsRecordView {\n type: 'TXT' | 'MX'\n name: string\n value: string\n priority?: number\n purpose: 'spf' | 'dkim' | 'return-path' | 'dmarc'\n required: boolean\n note: string\n}\n\n/** One org sending domain, as the domains route reports it. */\nexport interface SendingDomainView {\n domain: string\n status: 'requested' | 'records-issued' | 'verified' | 'failed'\n records?: SendingDnsRecordView[]\n lines?: string[]\n lastMissing?: string[] | null\n lastIssueError?: string | null\n verifiedAtMs?: number | null\n lastCheckedAtMs?: number | null\n dmarc?: {\n policy: 'reject' | 'quarantine' | 'none' | 'absent'\n record: string | null\n consequence: string\n } | null\n dmarcSuggestion?: SendingDnsRecordView | null\n pendingProvider?: boolean\n providerDetail?: string | null\n}\n\n/**\n * One sender this site holds — `hosts/{hostId}/senders/{senderId}`.\n *\n * `from` is the whole address, assembled by the server for the reason\n * `IdentityOption.from` is: a surface that built `${localPart}@${domain}`\n * itself would be a second derivation of the address, and the two would\n * disagree the first time either moved. It is `null` for a row whose mailbox\n * is not in effect — a site on the pooled Aglyn address has one fixed mailbox,\n * shared with the other sites on it.\n */\nexport interface HostSenderView {\n id: string\n localPart: string\n fromName: string | null\n replyTo: string | null\n /** Whether an email that names no sender goes out as this one. */\n isDefault: boolean\n from: string | null\n}\n\n/** What this site sends as, and what it is allowed to send as. */\nexport interface SendingIdentityView {\n orgId: string | null\n selected: string\n /**\n * The site's own provisioned domain inside the platform mail apex, or `''`\n * when it has none and its transactional mail rides the shared pool.\n */\n platformDomain: string\n /**\n * The plan that carries sending as a domain the CUSTOMER owns, and the plan\n * that carries a domain the platform provisions for the site — as names\n * ready to print, `null` when no plan carries that gate.\n *\n * Both come from the server because both are derived from the entitlement\n * tables there. A tier name written into this component would be pricing\n * copy that keeps rendering after the gate beneath it moves.\n */\n customDomainPlan: string | null\n dedicatedDomainPlan: string | null\n /** The mailbox this site sends as — the part before the `@`. */\n localPart: string\n /**\n * Whether that mailbox is the one actually in use.\n *\n * False for a site on the pooled Aglyn address, whose mailbox is fixed. The\n * stored value is real and is kept; it takes effect when the site has a\n * domain of its own, and until then the card says so rather than showing a\n * name that no recipient will see.\n */\n localPartInUse: boolean\n /** The site's default sender name, or null. Composers start from it. */\n fromName: string | null\n /** The site's default reply address, or null. */\n replyTo: string | null\n /**\n * Every sender this site may send as, the default among them.\n *\n * Never empty: a site with no `senders` subcollection has exactly one\n * sender, and the server synthesizes it from the three fields the host\n * document has always carried rather than reporting an absence.\n */\n senders: HostSenderView[]\n identity: string\n /**\n * `'custom'` for a domain this site has verified, `'shared'` for the pooled\n * Aglyn address a site uses until it has one, `null` when the send is\n * refused. `'platform'` is in the union because the resolver can return it,\n * and never reaches this view: it is Aglyn's own `aglyn.com` identity, which\n * a host-scoped resolution cannot produce.\n */\n identitySource: 'custom' | 'shared' | 'platform' | null\n refusal: {\n code: string\n domain: string | null\n message: string\n missing: string[]\n } | null\n options: {\n value: string\n from: string | null\n selectable: boolean\n status: string\n }[]\n domains: {\n domain: string\n status: SendingDomainView['status']\n verifiedAtMs: number | null\n lastCheckedAtMs: number | null\n /**\n * Why the last attempt to issue a key produced none, or `null`.\n *\n * Carried in the LIST and not only on the domain's own page, because one\n * of its values is not a fault: a claim held at the mail provider's domain\n * allowance is `requested` with a reason, and a list that could not see\n * the reason would label it \"Waiting on a signing key\" — a state whose\n * whole instruction is to press a button that will not move it.\n */\n lastIssueError?: string | null\n }[]\n canManage: boolean\n entitled: boolean\n /**\n * The OFFER of a platform sending domain — never the fact of having one,\n * which `platformDomain` above answers.\n *\n * A dedicated subdomain is no longer issued on upgrade: it costs a provider\n * domain slot and three records in Aglyn's own zone, so it is requested by\n * somebody who wants it rather than handed to every paying site.\n *\n * Optional because a console deployment can be older than the field. A\n * surface reading it must treat its absence as \"no offer to show\", never as\n * \"the offer was refused\".\n */\n dedicated?: {\n /** The plan carries one and this site has none. */\n available: boolean\n /** The name a request would most likely take. Not a reservation. */\n proposed: string | null\n }\n}\n\n/**\n * ONE AUTHORIZED CALL TO THE SENDING-IDENTITY AND SENDING-DOMAIN ROUTES.\n *\n * The same shape as `useCampaignSendApi`, including why the user is held in a\n * ref: this callback is a dependency of effects that load on open, and the\n * user object is a new object on most renders, so depending on it directly\n * would re-issue the request on every render of the surface. The token is\n * fetched at call time either way.\n *\n * The two paths are fixed here rather than taken as an argument, for the\n * reason the campaign hook gives: a hook that accepted a URL from a component\n * would be a way to post the console's credentials somewhere the console does\n * not own.\n *\n * The token is obtained under a deadline, for the reason `useCampaignApi`\n * gives: it is awaited in front of the request, so an unbounded refresh is a\n * call that never reaches `fetch` and never reports anything.\n *\n * ## An unobtainable token ANSWERS here rather than throwing\n *\n * Unlike the campaign hook, whose callers must tell a send that never left\n * the browser apart from one the route refused. Everything on this side reads\n * or edits a configuration surface, where both are the same news to the\n * reader — and every caller already renders `payload.error` on a refusal, so\n * answering in that shape is what puts the real reason on screen and leaves\n * the button that started the call usable.\n */\nexport function useSendingApi() {\n const { data: user } = useUser()\n const userRef = useRef(user)\n userRef.current = user\n\n return useCallback(\n async (options: {\n path: 'sending-identity' | 'sending-domains'\n method: 'GET' | 'POST' | 'DELETE'\n query?: Record<string, string>\n body?: Record<string, unknown>\n }): Promise<SendingApiResult> => {\n let idToken: string\n try {\n idToken = await resolveIdToken(\n userRef.current as TokenSource | null | undefined,\n )\n } catch (error) {\n return refusedForCaller(\n describeCallFailure(error, 'Your sign-in could not be confirmed'),\n )\n }\n const search = new URLSearchParams(options.query ?? {}).toString()\n const response = await fetch(\n `/api/email/${options.path}${search ? `?${search}` : ''}`,\n {\n method: options.method,\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${idToken}`,\n },\n ...(options.body ? { body: JSON.stringify(options.body) } : {}),\n },\n )\n const json = await response.json().catch(() => ({}))\n return { response, payload: json as Record<string, any> }\n },\n [],\n )\n}\n"],"names":["useUser","useCallback","useRef","describeCallFailure","resolveIdToken","refusedForCaller","message","response","ok","status","json","error","payload","useSendingApi","data","user","userRef","current","options","idToken","search","URLSearchParams","query","toString","fetch","path","method","headers","Authorization","body","JSON","stringify","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,OAAO,QAAQ,kCAA8B;AACtD,SAASC,WAAW,EAAEC,MAAM,QAAQ,QAAO;AAC3C,SACEC,mBAAmB,EACnBC,cAAc,QAET,2CAA0C;AAmBjD;;;;;;CAMC,GACD,MAAMC,mBAAmB,CAACC,UAAuC,CAAA;QAC/DC,UAAU;YACRC,IAAI;YACJC,QAAQ;YACRC,MAAM,UAAa,CAAA;oBAAEC,OAAOL;gBAAQ,CAAA;QACtC;QACAM,SAAS;YAAED,OAAOL;QAAQ;IAC5B,CAAA;AA2JA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASO;IACd,MAAM,EAAEC,MAAMC,IAAI,EAAE,GAAGf;IACvB,MAAMgB,UAAUd,OAAOa;IACvBC,QAAQC,OAAO,GAAGF;IAElB,OAAOd,YACL,OAAOiB;YAgB8BA;QAVnC,IAAIC;QACJ,IAAI;YACFA,UAAU,MAAMf,eACdY,QAAQC,OAAO;QAEnB,EAAE,OAAON,OAAO;YACd,OAAON,iBACLF,oBAAoBQ,OAAO;QAE/B;QACA,MAAMS,SAAS,IAAIC,iBAAgBH,iBAAAA,QAAQI,KAAK,YAAbJ,iBAAiB,CAAC,GAAGK,QAAQ;QAChE,MAAMhB,WAAW,MAAMiB,MACrB,CAAC,WAAW,EAAEN,QAAQO,IAAI,GAAGL,SAAS,CAAC,CAAC,EAAEA,QAAQ,GAAG,IAAI,EACzD;YACEM,QAAQR,QAAQQ,MAAM;YACtBC,SAAS;gBACP,gBAAgB;gBAChBC,eAAe,CAAC,OAAO,EAAET,SAAS;YACpC;WACID,QAAQW,IAAI,GAAG;YAAEA,MAAMC,KAAKC,SAAS,CAACb,QAAQW,IAAI;QAAE,IAAI,CAAC;QAGjE,MAAMnB,OAAO,MAAMH,SAASG,IAAI,GAAGsB,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;QACjD,OAAO;YAAEzB;YAAUK,SAASF;QAA4B;IAC1D,GACA,EAAE;AAEN"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-sending-identity-api.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { SenderReadiness } from '@aglyn/shared-util-email'\nimport { useUser } from './firebase/firebase-services'\nimport { useCallback, useRef } from 'react'\nimport {\n describeCallFailure,\n resolveIdToken,\n type TokenSource,\n} from '@aglyn/shared-util-http/authorized-token'\n\n/**\n * The client of the console's sending-identity routes (`/api/email/*`).\n *\n * Which address a site's mail leaves from, and the domains and senders behind\n * it, are the platform's mail rail: the console serves the routes, and every\n * message goes out under that identity whichever plugin wrote it. So the hook\n * that calls them is here, beside the other client hooks a plugin may import,\n * and a one-to-one composer reads the identity without importing the plugin\n * whose settings page edits it.\n */\n\n/** What one call answered with. `payload` is `{}` on an unparseable body. */\nexport interface SendingApiResult {\n response: Response\n payload: Record<string, any>\n}\n\n/**\n * A refusal the caller reads the same way it reads the route's own.\n *\n * `401` because that is what the request would have been answered with had it\n * gone out, and the reason travels in `payload.error`, which is the field\n * every caller already renders.\n */\nconst refusedForCaller = (message: string): SendingApiResult => ({\n response: {\n ok: false,\n status: 401,\n json: async () => ({ error: message }),\n } as unknown as Response,\n payload: { error: message },\n})\n\n/** One DNS record, exactly as the server issued it. */\nexport interface SendingDnsRecordView {\n type: 'TXT' | 'MX'\n name: string\n value: string\n priority?: number\n purpose: 'spf' | 'dkim' | 'return-path' | 'dmarc'\n required: boolean\n note: string\n}\n\n/** One org sending domain, as the domains route reports it. */\nexport interface SendingDomainView {\n domain: string\n status: 'requested' | 'records-issued' | 'verified' | 'failed'\n records?: SendingDnsRecordView[]\n lines?: string[]\n lastMissing?: string[] | null\n lastIssueError?: string | null\n verifiedAtMs?: number | null\n lastCheckedAtMs?: number | null\n dmarc?: {\n policy: 'reject' | 'quarantine' | 'none' | 'absent'\n record: string | null\n consequence: string\n } | null\n dmarcSuggestion?: SendingDnsRecordView | null\n /** How receivers will judge mail from it (AGL-3328); none before a key is issued. */\n readiness?: SenderReadiness | null\n pendingProvider?: boolean\n providerDetail?: string | null\n}\n\n/**\n * One sender this site holds — `hosts/{hostId}/senders/{senderId}`.\n *\n * `from` is the whole address, assembled by the server for the reason\n * `IdentityOption.from` is: a surface that built `${localPart}@${domain}`\n * itself would be a second derivation of the address, and the two would\n * disagree the first time either moved. It is `null` for a row whose mailbox\n * is not in effect — a site on the pooled Aglyn address has one fixed mailbox,\n * shared with the other sites on it.\n */\nexport interface HostSenderView {\n id: string\n localPart: string\n fromName: string | null\n replyTo: string | null\n /** Whether an email that names no sender goes out as this one. */\n isDefault: boolean\n from: string | null\n}\n\n/** What this site sends as, and what it is allowed to send as. */\nexport interface SendingIdentityView {\n orgId: string | null\n selected: string\n /**\n * The site's own provisioned domain inside the platform mail apex, or `''`\n * when it has none and its transactional mail rides the shared pool.\n */\n platformDomain: string\n /**\n * The plan that carries sending as a domain the CUSTOMER owns, and the plan\n * that carries a domain the platform provisions for the site — as names\n * ready to print, `null` when no plan carries that gate.\n *\n * Both come from the server because both are derived from the entitlement\n * tables there. A tier name written into this component would be pricing\n * copy that keeps rendering after the gate beneath it moves.\n */\n customDomainPlan: string | null\n dedicatedDomainPlan: string | null\n /** The mailbox this site sends as — the part before the `@`. */\n localPart: string\n /**\n * Whether that mailbox is the one actually in use.\n *\n * False for a site on the pooled Aglyn address, whose mailbox is fixed. The\n * stored value is real and is kept; it takes effect when the site has a\n * domain of its own, and until then the card says so rather than showing a\n * name that no recipient will see.\n */\n localPartInUse: boolean\n /** The site's default sender name, or null. Composers start from it. */\n fromName: string | null\n /** The site's default reply address, or null. */\n replyTo: string | null\n /**\n * Every sender this site may send as, the default among them.\n *\n * Never empty: a site with no `senders` subcollection has exactly one\n * sender, and the server synthesizes it from the three fields the host\n * document has always carried rather than reporting an absence.\n */\n senders: HostSenderView[]\n identity: string\n /**\n * `'custom'` for a domain this site has verified, `'shared'` for the pooled\n * Aglyn address a site uses until it has one, `null` when the send is\n * refused. `'platform'` is in the union because the resolver can return it,\n * and never reaches this view: it is Aglyn's own `aglyn.com` identity, which\n * a host-scoped resolution cannot produce.\n */\n identitySource: 'custom' | 'shared' | 'platform' | null\n refusal: {\n code: string\n domain: string | null\n message: string\n missing: string[]\n } | null\n options: {\n value: string\n from: string | null\n selectable: boolean\n status: string\n }[]\n domains: {\n domain: string\n status: SendingDomainView['status']\n verifiedAtMs: number | null\n lastCheckedAtMs: number | null\n /**\n * Why the last attempt to issue a key produced none, or `null`.\n *\n * Carried in the LIST and not only on the domain's own page, because one\n * of its values is not a fault: a claim held at the mail provider's domain\n * allowance is `requested` with a reason, and a list that could not see\n * the reason would label it \"Waiting on a signing key\" — a state whose\n * whole instruction is to press a button that will not move it.\n */\n lastIssueError?: string | null\n }[]\n canManage: boolean\n entitled: boolean\n /**\n * The OFFER of a platform sending domain — never the fact of having one,\n * which `platformDomain` above answers.\n *\n * A dedicated subdomain is no longer issued on upgrade: it costs a provider\n * domain slot and three records in Aglyn's own zone, so it is requested by\n * somebody who wants it rather than handed to every paying site.\n *\n * Optional because a console deployment can be older than the field. A\n * surface reading it must treat its absence as \"no offer to show\", never as\n * \"the offer was refused\".\n */\n dedicated?: {\n /** The plan carries one and this site has none. */\n available: boolean\n /** The name a request would most likely take. Not a reservation. */\n proposed: string | null\n }\n}\n\n/**\n * ONE AUTHORIZED CALL TO THE SENDING-IDENTITY AND SENDING-DOMAIN ROUTES.\n *\n * The same shape as `useCampaignSendApi`, including why the user is held in a\n * ref: this callback is a dependency of effects that load on open, and the\n * user object is a new object on most renders, so depending on it directly\n * would re-issue the request on every render of the surface. The token is\n * fetched at call time either way.\n *\n * The two paths are fixed here rather than taken as an argument, for the\n * reason the campaign hook gives: a hook that accepted a URL from a component\n * would be a way to post the console's credentials somewhere the console does\n * not own.\n *\n * The token is obtained under a deadline, for the reason `useCampaignApi`\n * gives: it is awaited in front of the request, so an unbounded refresh is a\n * call that never reaches `fetch` and never reports anything.\n *\n * ## An unobtainable token ANSWERS here rather than throwing\n *\n * Unlike the campaign hook, whose callers must tell a send that never left\n * the browser apart from one the route refused. Everything on this side reads\n * or edits a configuration surface, where both are the same news to the\n * reader — and every caller already renders `payload.error` on a refusal, so\n * answering in that shape is what puts the real reason on screen and leaves\n * the button that started the call usable.\n */\nexport function useSendingApi() {\n const { data: user } = useUser()\n const userRef = useRef(user)\n userRef.current = user\n\n return useCallback(\n async (options: {\n path: 'sending-identity' | 'sending-domains'\n method: 'GET' | 'POST' | 'DELETE'\n query?: Record<string, string>\n body?: Record<string, unknown>\n }): Promise<SendingApiResult> => {\n let idToken: string\n try {\n idToken = await resolveIdToken(\n userRef.current as TokenSource | null | undefined,\n )\n } catch (error) {\n return refusedForCaller(\n describeCallFailure(error, 'Your sign-in could not be confirmed'),\n )\n }\n const search = new URLSearchParams(options.query ?? {}).toString()\n const response = await fetch(\n `/api/email/${options.path}${search ? `?${search}` : ''}`,\n {\n method: options.method,\n headers: {\n 'Content-Type': 'application/json',\n Authorization: `Bearer ${idToken}`,\n },\n ...(options.body ? { body: JSON.stringify(options.body) } : {}),\n },\n )\n const json = await response.json().catch(() => ({}))\n return { response, payload: json as Record<string, any> }\n },\n [],\n )\n}\n"],"names":["useUser","useCallback","useRef","describeCallFailure","resolveIdToken","refusedForCaller","message","response","ok","status","json","error","payload","useSendingApi","data","user","userRef","current","options","idToken","search","URLSearchParams","query","toString","fetch","path","method","headers","Authorization","body","JSON","stringify","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAGA,SAASA,OAAO,QAAQ,kCAA8B;AACtD,SAASC,WAAW,EAAEC,MAAM,QAAQ,QAAO;AAC3C,SACEC,mBAAmB,EACnBC,cAAc,QAET,2CAA0C;AAmBjD;;;;;;CAMC,GACD,MAAMC,mBAAmB,CAACC,UAAuC,CAAA;QAC/DC,UAAU;YACRC,IAAI;YACJC,QAAQ;YACRC,MAAM,UAAa,CAAA;oBAAEC,OAAOL;gBAAQ,CAAA;QACtC;QACAM,SAAS;YAAED,OAAOL;QAAQ;IAC5B,CAAA;AA6JA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASO;IACd,MAAM,EAAEC,MAAMC,IAAI,EAAE,GAAGf;IACvB,MAAMgB,UAAUd,OAAOa;IACvBC,QAAQC,OAAO,GAAGF;IAElB,OAAOd,YACL,OAAOiB;YAgB8BA;QAVnC,IAAIC;QACJ,IAAI;YACFA,UAAU,MAAMf,eACdY,QAAQC,OAAO;QAEnB,EAAE,OAAON,OAAO;YACd,OAAON,iBACLF,oBAAoBQ,OAAO;QAE/B;QACA,MAAMS,SAAS,IAAIC,iBAAgBH,iBAAAA,QAAQI,KAAK,YAAbJ,iBAAiB,CAAC,GAAGK,QAAQ;QAChE,MAAMhB,WAAW,MAAMiB,MACrB,CAAC,WAAW,EAAEN,QAAQO,IAAI,GAAGL,SAAS,CAAC,CAAC,EAAEA,QAAQ,GAAG,IAAI,EACzD;YACEM,QAAQR,QAAQQ,MAAM;YACtBC,SAAS;gBACP,gBAAgB;gBAChBC,eAAe,CAAC,OAAO,EAAET,SAAS;YACpC;WACID,QAAQW,IAAI,GAAG;YAAEA,MAAMC,KAAKC,SAAS,CAACb,QAAQW,IAAI;QAAE,IAAI,CAAC;QAGjE,MAAMnB,OAAO,MAAMH,SAASG,IAAI,GAAGsB,KAAK,CAAC,IAAO,CAAA,CAAC,CAAA;QACjD,OAAO;YAAEzB;YAAUK,SAASF;QAA4B;IAC1D,GACA,EAAE;AAEN"}
|