@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-feature-instance",
3
- "version": "1.0.0-beta.202",
3
+ "version": "1.0.0-beta.204",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,11 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.202",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.202",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.202",
31
- "@aglyn/shared-util-http": "1.0.0-beta.202",
32
- "@aglyn/shared-util-timestamp": "1.0.0-beta.202",
28
+ "@aglyn/aglyn": "1.0.0-beta.204",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.204",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.204",
31
+ "@aglyn/shared-util-email": "1.0.0-beta.204",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.204",
33
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.204",
33
34
  "@swc/helpers": "0.5.23",
34
35
  "lodash-es": "^4.18.1"
35
36
  },
@@ -0,0 +1,16 @@
1
+ import type { SenderReadiness } from '@aglyn/shared-util-email';
2
+ /** Accessible names of the rows, spelled once for the specs. */
3
+ export declare const SENDER_READINESS_ROWS: {
4
+ readonly spf: "SPF";
5
+ readonly dkim: "DKIM";
6
+ readonly dmarc: "DMARC";
7
+ readonly alignment: "Alignment";
8
+ };
9
+ export interface SenderReadinessPanelProps {
10
+ readiness: SenderReadiness;
11
+ }
12
+ export declare function SenderReadinessPanel(props: SenderReadinessPanelProps): import("react").JSX.Element;
13
+ export declare namespace SenderReadinessPanel {
14
+ var displayName: string;
15
+ }
16
+ export default SenderReadinessPanel;
@@ -0,0 +1,160 @@
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
+ */ 'use client';
17
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
+ import { Alert, Box, Chip, Stack, Typography } from "@mui/material";
19
+ /**
20
+ * SENDER READINESS, on a card (AGL-3328): SPF, DKIM and DMARC for the domain
21
+ * mail leaves from, and whether DMARC passes on a mechanism that lines up
22
+ * with the From domain.
23
+ *
24
+ * One component for both places a sender is set up — a sending domain in
25
+ * Emails and a connected mailbox in Sequences — so the two read the same
26
+ * zone the same way. What it renders is the server's verdict
27
+ * (`assessSenderReadiness`); it decides nothing itself.
28
+ */ const STATE_LABEL = {
29
+ pass: 'Pass',
30
+ warn: 'Check',
31
+ fail: 'Fail',
32
+ unknown: 'Not answered'
33
+ };
34
+ const STATE_COLOR = {
35
+ pass: 'success',
36
+ warn: 'warning',
37
+ fail: 'error',
38
+ unknown: 'default'
39
+ };
40
+ const OVERALL_SEVERITY = {
41
+ pass: 'success',
42
+ warn: 'warning',
43
+ fail: 'error',
44
+ unknown: 'info'
45
+ };
46
+ const OVERALL_SENTENCE = {
47
+ pass: 'Receivers can tell this mail is really from this domain.',
48
+ warn: 'Mail is authenticated, with something worth fixing below.',
49
+ fail: 'Receivers cannot confirm this mail is from this domain. Fix the failing record below.',
50
+ unknown: 'Some lookups went unanswered. Nothing is known to be wrong; check again in a few minutes.'
51
+ };
52
+ /** Accessible names of the rows, spelled once for the specs. */ export const SENDER_READINESS_ROWS = {
53
+ spf: 'SPF',
54
+ dkim: 'DKIM',
55
+ dmarc: 'DMARC',
56
+ alignment: 'Alignment'
57
+ };
58
+ function ReadinessRow(props) {
59
+ const { label, check } = props;
60
+ return /*#__PURE__*/ _jsxs(Stack, {
61
+ spacing: 0.25,
62
+ role: "group",
63
+ "aria-label": label,
64
+ children: [
65
+ /*#__PURE__*/ _jsxs(Stack, {
66
+ direction: "row",
67
+ spacing: 1,
68
+ useFlexGap: true,
69
+ sx: {
70
+ alignItems: 'center',
71
+ flexWrap: 'wrap'
72
+ },
73
+ children: [
74
+ /*#__PURE__*/ _jsx(Chip, {
75
+ size: "small",
76
+ variant: "outlined",
77
+ color: STATE_COLOR[check.state],
78
+ label: STATE_LABEL[check.state]
79
+ }),
80
+ /*#__PURE__*/ _jsx(Typography, {
81
+ variant: "body2",
82
+ sx: {
83
+ fontWeight: 'bold'
84
+ },
85
+ children: label
86
+ }),
87
+ check.host ? /*#__PURE__*/ _jsx(Typography, {
88
+ variant: "caption",
89
+ color: "text.secondary",
90
+ sx: {
91
+ fontFamily: 'monospace',
92
+ overflowWrap: 'anywhere'
93
+ },
94
+ children: check.host
95
+ }) : null
96
+ ]
97
+ }),
98
+ /*#__PURE__*/ _jsx(Typography, {
99
+ variant: "body2",
100
+ color: "text.secondary",
101
+ children: check.detail
102
+ }),
103
+ check.record ? /*#__PURE__*/ _jsx(Typography, {
104
+ variant: "caption",
105
+ color: "text.secondary",
106
+ sx: {
107
+ fontFamily: 'monospace',
108
+ overflowWrap: 'anywhere'
109
+ },
110
+ children: check.record.length > 160 ? `${check.record.slice(0, 160)}…` : check.record
111
+ }) : null
112
+ ]
113
+ });
114
+ }
115
+ export function SenderReadinessPanel(props) {
116
+ const { readiness } = props;
117
+ return /*#__PURE__*/ _jsxs(Stack, {
118
+ spacing: 1.5,
119
+ "aria-label": "Sender readiness",
120
+ children: [
121
+ /*#__PURE__*/ _jsxs(Box, {
122
+ children: [
123
+ /*#__PURE__*/ _jsx(Typography, {
124
+ variant: "subtitle2",
125
+ children: 'Sender readiness'
126
+ }),
127
+ /*#__PURE__*/ _jsx(Typography, {
128
+ variant: "caption",
129
+ color: "text.secondary",
130
+ children: `How receivers judge mail from ${readiness.fromDomain} sent by ${readiness.provider}. ` + `Checked ${new Date(readiness.checkedAtMs).toLocaleString()}.`
131
+ })
132
+ ]
133
+ }),
134
+ /*#__PURE__*/ _jsx(Alert, {
135
+ severity: OVERALL_SEVERITY[readiness.overall],
136
+ children: OVERALL_SENTENCE[readiness.overall]
137
+ }),
138
+ /*#__PURE__*/ _jsx(ReadinessRow, {
139
+ label: SENDER_READINESS_ROWS.spf,
140
+ check: readiness.spf
141
+ }),
142
+ /*#__PURE__*/ _jsx(ReadinessRow, {
143
+ label: SENDER_READINESS_ROWS.dkim,
144
+ check: readiness.dkim
145
+ }),
146
+ /*#__PURE__*/ _jsx(ReadinessRow, {
147
+ label: SENDER_READINESS_ROWS.dmarc,
148
+ check: readiness.dmarc
149
+ }),
150
+ /*#__PURE__*/ _jsx(ReadinessRow, {
151
+ label: SENDER_READINESS_ROWS.alignment,
152
+ check: readiness.alignment
153
+ })
154
+ ]
155
+ });
156
+ }
157
+ SenderReadinessPanel.displayName = 'SenderReadinessPanel';
158
+ export default SenderReadinessPanel;
159
+
160
+ //# sourceMappingURL=sender-readiness-panel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/components/sender-readiness-panel.tsx"],"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 {\n SenderReadiness,\n SenderReadinessCheck,\n SenderReadinessState,\n} from '@aglyn/shared-util-email'\nimport { Alert, Box, Chip, Stack, Typography } from '@mui/material'\n\n/**\n * SENDER READINESS, on a card (AGL-3328): SPF, DKIM and DMARC for the domain\n * mail leaves from, and whether DMARC passes on a mechanism that lines up\n * with the From domain.\n *\n * One component for both places a sender is set up — a sending domain in\n * Emails and a connected mailbox in Sequences — so the two read the same\n * zone the same way. What it renders is the server's verdict\n * (`assessSenderReadiness`); it decides nothing itself.\n */\n\nconst STATE_LABEL: Record<SenderReadinessState, string> = {\n pass: 'Pass',\n warn: 'Check',\n fail: 'Fail',\n unknown: 'Not answered',\n}\n\nconst STATE_COLOR: Record<SenderReadinessState, 'success' | 'warning' | 'error' | 'default'> = {\n pass: 'success',\n warn: 'warning',\n fail: 'error',\n unknown: 'default',\n}\n\nconst OVERALL_SEVERITY: Record<SenderReadinessState, 'success' | 'warning' | 'error' | 'info'> = {\n pass: 'success',\n warn: 'warning',\n fail: 'error',\n unknown: 'info',\n}\n\nconst OVERALL_SENTENCE: Record<SenderReadinessState, string> = {\n pass: 'Receivers can tell this mail is really from this domain.',\n warn: 'Mail is authenticated, with something worth fixing below.',\n fail: 'Receivers cannot confirm this mail is from this domain. Fix the failing record below.',\n unknown: 'Some lookups went unanswered. Nothing is known to be wrong; check again in a few minutes.',\n}\n\n/** Accessible names of the rows, spelled once for the specs. */\nexport const SENDER_READINESS_ROWS = {\n spf: 'SPF',\n dkim: 'DKIM',\n dmarc: 'DMARC',\n alignment: 'Alignment',\n} as const\n\nfunction ReadinessRow(props: { label: string; check: SenderReadinessCheck }) {\n const { label, check } = props\n return (\n <Stack spacing={0.25} role=\"group\" aria-label={label}>\n <Stack direction=\"row\" spacing={1} useFlexGap sx={{ alignItems: 'center', flexWrap: 'wrap' }}>\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={STATE_COLOR[check.state]}\n label={STATE_LABEL[check.state]}\n />\n <Typography variant=\"body2\" sx={{ fontWeight: 'bold' }}>\n {label}\n </Typography>\n {check.host ? (\n <Typography variant=\"caption\" color=\"text.secondary\" sx={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>\n {check.host}\n </Typography>\n ) : null}\n </Stack>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {check.detail}\n </Typography>\n {check.record ? (\n <Typography\n variant=\"caption\"\n color=\"text.secondary\"\n sx={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}\n >\n {check.record.length > 160 ? `${check.record.slice(0, 160)}…` : check.record}\n </Typography>\n ) : null}\n </Stack>\n )\n}\n\nexport interface SenderReadinessPanelProps {\n readiness: SenderReadiness\n}\n\nexport function SenderReadinessPanel(props: SenderReadinessPanelProps) {\n const { readiness } = props\n return (\n <Stack spacing={1.5} aria-label=\"Sender readiness\">\n <Box>\n <Typography variant=\"subtitle2\">{'Sender readiness'}</Typography>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`How receivers judge mail from ${readiness.fromDomain} sent by ${readiness.provider}. ` +\n `Checked ${new Date(readiness.checkedAtMs).toLocaleString()}.`}\n </Typography>\n </Box>\n <Alert severity={OVERALL_SEVERITY[readiness.overall]}>{OVERALL_SENTENCE[readiness.overall]}</Alert>\n <ReadinessRow label={SENDER_READINESS_ROWS.spf} check={readiness.spf} />\n <ReadinessRow label={SENDER_READINESS_ROWS.dkim} check={readiness.dkim} />\n <ReadinessRow label={SENDER_READINESS_ROWS.dmarc} check={readiness.dmarc} />\n <ReadinessRow label={SENDER_READINESS_ROWS.alignment} check={readiness.alignment} />\n </Stack>\n )\n}\nSenderReadinessPanel.displayName = 'SenderReadinessPanel'\n\nexport default SenderReadinessPanel\n"],"names":["Alert","Box","Chip","Stack","Typography","STATE_LABEL","pass","warn","fail","unknown","STATE_COLOR","OVERALL_SEVERITY","OVERALL_SENTENCE","SENDER_READINESS_ROWS","spf","dkim","dmarc","alignment","ReadinessRow","props","label","check","spacing","role","aria-label","direction","useFlexGap","sx","alignItems","flexWrap","size","variant","color","state","fontWeight","host","fontFamily","overflowWrap","detail","record","length","slice","SenderReadinessPanel","readiness","fromDomain","provider","Date","checkedAtMs","toLocaleString","severity","overall","displayName"],"mappings":"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"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type DocumentData, type DocumentReference, type DocumentSnapshot, type Firestore, type Query, type QuerySnapshot } from 'firebase/firestore';
18
+ /**
19
+ * ONE-OFF READS THAT CANNOT HANG (AGL-3373).
20
+ *
21
+ * `getDocs` and `getDoc` wait for the server whenever the client believes it
22
+ * is online. When the client is wrong about that — the multi-tab wedge
23
+ * `firestore-stall-recovery.ts` describes — they never settle, and a page
24
+ * that set `loading` before awaiting one never clears it. The notifications
25
+ * feed read "No notifications match these filters" for ten minutes that way.
26
+ *
27
+ * These race the server read against {@link FIRESTORE_STALL_MS}. If the
28
+ * server has not answered by then, the client is asked to recover, and the
29
+ * read settles from the local cache with `stale: true`, so the caller can
30
+ * say "this may be out of date" instead of spinning. The server read is not
31
+ * abandoned: `fresh` resolves with its answer if it arrives, so a caller can
32
+ * swap the cached rows for confirmed ones without reading again.
33
+ *
34
+ * When the cache cannot answer either — `getDocFromCache` for a document the
35
+ * cache has never held — the read REJECTS with {@link FirestoreStallError}.
36
+ * A caller's error path is a better place to land than a spinner that never
37
+ * ends.
38
+ */
39
+ /** A read that settled, and whether it had to settle for the cache. */
40
+ export interface BoundedRead<S> {
41
+ snapshot: S;
42
+ /**
43
+ * The server did not answer in time and `snapshot` is the local cache's.
44
+ * It may be out of date.
45
+ */
46
+ stale: boolean;
47
+ /**
48
+ * For a stale read, the server's answer if it ever arrives, otherwise
49
+ * `undefined`. Never rejects. Absent for a read the server answered.
50
+ */
51
+ fresh?: Promise<S | undefined>;
52
+ }
53
+ export interface BoundedReadOptions {
54
+ /** Override {@link FIRESTORE_STALL_MS}. */
55
+ stallMs?: number;
56
+ }
57
+ /** Neither the server nor the cache could answer in time. */
58
+ export declare class FirestoreStallError extends Error {
59
+ readonly cacheError?: unknown;
60
+ readonly code = "unavailable";
61
+ /** Why the cache could not answer. */
62
+ constructor(cacheError?: unknown);
63
+ }
64
+ interface BoundedReadDeps<S> {
65
+ server: () => Promise<S>;
66
+ cache: () => Promise<S>;
67
+ firestore: Firestore | undefined;
68
+ stallMs: number;
69
+ recover?: (firestore: Firestore | undefined) => unknown;
70
+ }
71
+ /** The race itself, over injected reads. */
72
+ export declare function boundedRead<S>(deps: BoundedReadDeps<S>): Promise<BoundedRead<S>>;
73
+ /** `getDocs`, bounded. See the module comment. */
74
+ export declare function getDocsBounded<T = DocumentData>(query: Query<T>, options?: BoundedReadOptions): Promise<BoundedRead<QuerySnapshot<T>>>;
75
+ /** `getDoc`, bounded. See the module comment. */
76
+ export declare function getDocBounded<T = DocumentData>(reference: DocumentReference<T>, options?: BoundedReadOptions): Promise<BoundedRead<DocumentSnapshot<T>>>;
77
+ /** The notice a surface shows over a stale read. */
78
+ export declare const STALE_READ_NOTICE = "This may be out of date \u2014 reconnecting\u2026";
79
+ export {};
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { getDoc, getDocFromCache, getDocs, getDocsFromCache } from "firebase/firestore";
17
+ import { FIRESTORE_STALL_MS, recoverStalledFirestore } from "./firestore-stall-recovery.js";
18
+ /** Neither the server nor the cache could answer in time. */ export class FirestoreStallError extends Error {
19
+ /** Why the cache could not answer. */ constructor(cacheError){
20
+ super('The server did not answer in time and nothing is cached for this read.'), this.cacheError = cacheError, this.code = 'unavailable';
21
+ this.name = 'FirestoreStallError';
22
+ }
23
+ }
24
+ const STALLED = Symbol('stalled');
25
+ /** The race itself, over injected reads. */ export async function boundedRead(deps) {
26
+ var _deps_recover;
27
+ const { server, cache, firestore, stallMs } = deps;
28
+ const recover = (_deps_recover = deps.recover) != null ? _deps_recover : recoverStalledFirestore;
29
+ const serverRead = server();
30
+ let timer;
31
+ const stalled = new Promise((resolve)=>{
32
+ timer = setTimeout(()=>resolve(STALLED), stallMs);
33
+ });
34
+ let winner;
35
+ try {
36
+ winner = await Promise.race([
37
+ serverRead,
38
+ stalled
39
+ ]);
40
+ } finally{
41
+ clearTimeout(timer);
42
+ }
43
+ if (winner !== STALLED) return {
44
+ snapshot: winner,
45
+ stale: false
46
+ };
47
+ void recover(firestore);
48
+ const fresh = serverRead.then((snapshot)=>snapshot, ()=>undefined);
49
+ try {
50
+ const snapshot = await cache();
51
+ return {
52
+ snapshot,
53
+ stale: true,
54
+ fresh
55
+ };
56
+ } catch (error) {
57
+ throw new FirestoreStallError(error);
58
+ }
59
+ }
60
+ /** `getDocs`, bounded. See the module comment. */ export function getDocsBounded(query, options = {}) {
61
+ var _options_stallMs;
62
+ return boundedRead({
63
+ server: ()=>getDocs(query),
64
+ cache: ()=>getDocsFromCache(query),
65
+ firestore: query.firestore,
66
+ stallMs: (_options_stallMs = options.stallMs) != null ? _options_stallMs : FIRESTORE_STALL_MS
67
+ });
68
+ }
69
+ /** `getDoc`, bounded. See the module comment. */ export function getDocBounded(reference, options = {}) {
70
+ var _options_stallMs;
71
+ return boundedRead({
72
+ server: ()=>getDoc(reference),
73
+ cache: ()=>getDocFromCache(reference),
74
+ firestore: reference.firestore,
75
+ stallMs: (_options_stallMs = options.stallMs) != null ? _options_stallMs : FIRESTORE_STALL_MS
76
+ });
77
+ }
78
+ /** The notice a surface shows over a stale read. */ export const STALE_READ_NOTICE = 'This may be out of date — reconnecting…';
79
+
80
+ //# sourceMappingURL=firestore-bounded-read.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/firestore-bounded-read.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 getDoc,\n getDocFromCache,\n getDocs,\n getDocsFromCache,\n type DocumentData,\n type DocumentReference,\n type DocumentSnapshot,\n type Firestore,\n type Query,\n type QuerySnapshot,\n} from 'firebase/firestore'\nimport {\n FIRESTORE_STALL_MS,\n recoverStalledFirestore,\n} from './firestore-stall-recovery'\n\n/**\n * ONE-OFF READS THAT CANNOT HANG (AGL-3373).\n *\n * `getDocs` and `getDoc` wait for the server whenever the client believes it\n * is online. When the client is wrong about that — the multi-tab wedge\n * `firestore-stall-recovery.ts` describes — they never settle, and a page\n * that set `loading` before awaiting one never clears it. The notifications\n * feed read \"No notifications match these filters\" for ten minutes that way.\n *\n * These race the server read against {@link FIRESTORE_STALL_MS}. If the\n * server has not answered by then, the client is asked to recover, and the\n * read settles from the local cache with `stale: true`, so the caller can\n * say \"this may be out of date\" instead of spinning. The server read is not\n * abandoned: `fresh` resolves with its answer if it arrives, so a caller can\n * swap the cached rows for confirmed ones without reading again.\n *\n * When the cache cannot answer either — `getDocFromCache` for a document the\n * cache has never held — the read REJECTS with {@link FirestoreStallError}.\n * A caller's error path is a better place to land than a spinner that never\n * ends.\n */\n\n/** A read that settled, and whether it had to settle for the cache. */\nexport interface BoundedRead<S> {\n snapshot: S\n /**\n * The server did not answer in time and `snapshot` is the local cache's.\n * It may be out of date.\n */\n stale: boolean\n /**\n * For a stale read, the server's answer if it ever arrives, otherwise\n * `undefined`. Never rejects. Absent for a read the server answered.\n */\n fresh?: Promise<S | undefined>\n}\n\nexport interface BoundedReadOptions {\n /** Override {@link FIRESTORE_STALL_MS}. */\n stallMs?: number\n}\n\n/** Neither the server nor the cache could answer in time. */\nexport class FirestoreStallError extends Error {\n readonly code = 'unavailable'\n /** Why the cache could not answer. */\n constructor(readonly cacheError?: unknown) {\n super(\n 'The server did not answer in time and nothing is cached for this read.',\n )\n this.name = 'FirestoreStallError'\n }\n}\n\ninterface BoundedReadDeps<S> {\n server: () => Promise<S>\n cache: () => Promise<S>\n firestore: Firestore | undefined\n stallMs: number\n recover?: (firestore: Firestore | undefined) => unknown\n}\n\nconst STALLED = Symbol('stalled')\n\n/** The race itself, over injected reads. */\nexport async function boundedRead<S>(\n deps: BoundedReadDeps<S>,\n): Promise<BoundedRead<S>> {\n const { server, cache, firestore, stallMs } = deps\n const recover = deps.recover ?? recoverStalledFirestore\n const serverRead = server()\n let timer: ReturnType<typeof setTimeout> | undefined\n const stalled = new Promise<typeof STALLED>((resolve) => {\n timer = setTimeout(() => resolve(STALLED), stallMs)\n })\n let winner: S | typeof STALLED\n try {\n winner = await Promise.race([serverRead, stalled])\n } finally {\n clearTimeout(timer)\n }\n if (winner !== STALLED) return { snapshot: winner, stale: false }\n\n void recover(firestore)\n const fresh = serverRead.then(\n (snapshot) => snapshot,\n () => undefined,\n )\n try {\n const snapshot = await cache()\n return { snapshot, stale: true, fresh }\n } catch (error) {\n throw new FirestoreStallError(error)\n }\n}\n\n/** `getDocs`, bounded. See the module comment. */\nexport function getDocsBounded<T = DocumentData>(\n query: Query<T>,\n options: BoundedReadOptions = {},\n): Promise<BoundedRead<QuerySnapshot<T>>> {\n return boundedRead({\n server: () => getDocs(query),\n cache: () => getDocsFromCache(query),\n firestore: (query as { firestore?: Firestore }).firestore,\n stallMs: options.stallMs ?? FIRESTORE_STALL_MS,\n })\n}\n\n/** `getDoc`, bounded. See the module comment. */\nexport function getDocBounded<T = DocumentData>(\n reference: DocumentReference<T>,\n options: BoundedReadOptions = {},\n): Promise<BoundedRead<DocumentSnapshot<T>>> {\n return boundedRead({\n server: () => getDoc(reference),\n cache: () => getDocFromCache(reference),\n firestore: (reference as { firestore?: Firestore }).firestore,\n stallMs: options.stallMs ?? FIRESTORE_STALL_MS,\n })\n}\n\n/** The notice a surface shows over a stale read. */\nexport const STALE_READ_NOTICE = 'This may be out of date — reconnecting…'\n"],"names":["getDoc","getDocFromCache","getDocs","getDocsFromCache","FIRESTORE_STALL_MS","recoverStalledFirestore","FirestoreStallError","Error","cacheError","code","name","STALLED","Symbol","boundedRead","deps","server","cache","firestore","stallMs","recover","serverRead","timer","stalled","Promise","resolve","setTimeout","winner","race","clearTimeout","snapshot","stale","fresh","then","undefined","error","getDocsBounded","query","options","getDocBounded","reference","STALE_READ_NOTICE"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,MAAM,EACNC,eAAe,EACfC,OAAO,EACPC,gBAAgB,QAOX,qBAAoB;AAC3B,SACEC,kBAAkB,EAClBC,uBAAuB,QAClB,gCAA4B;AA4CnC,2DAA2D,GAC3D,OAAO,MAAMC,4BAA4BC;IAEvC,oCAAoC,GACpC,YAAY,AAASC,UAAoB,CAAE;QACzC,KAAK,CACH,gFAFiBA,aAAAA,iBAFZC,OAAO;QAMd,IAAI,CAACC,IAAI,GAAG;IACd;AACF;AAUA,MAAMC,UAAUC,OAAO;AAEvB,0CAA0C,GAC1C,OAAO,eAAeC,YACpBC,IAAwB;QAGRA;IADhB,MAAM,EAAEC,MAAM,EAAEC,KAAK,EAAEC,SAAS,EAAEC,OAAO,EAAE,GAAGJ;IAC9C,MAAMK,WAAUL,gBAAAA,KAAKK,OAAO,YAAZL,gBAAgBT;IAChC,MAAMe,aAAaL;IACnB,IAAIM;IACJ,MAAMC,UAAU,IAAIC,QAAwB,CAACC;QAC3CH,QAAQI,WAAW,IAAMD,QAAQb,UAAUO;IAC7C;IACA,IAAIQ;IACJ,IAAI;QACFA,SAAS,MAAMH,QAAQI,IAAI,CAAC;YAACP;YAAYE;SAAQ;IACnD,SAAU;QACRM,aAAaP;IACf;IACA,IAAIK,WAAWf,SAAS,OAAO;QAAEkB,UAAUH;QAAQI,OAAO;IAAM;IAEhE,KAAKX,QAAQF;IACb,MAAMc,QAAQX,WAAWY,IAAI,CAC3B,CAACH,WAAaA,UACd,IAAMI;IAER,IAAI;QACF,MAAMJ,WAAW,MAAMb;QACvB,OAAO;YAAEa;YAAUC,OAAO;YAAMC;QAAM;IACxC,EAAE,OAAOG,OAAO;QACd,MAAM,IAAI5B,oBAAoB4B;IAChC;AACF;AAEA,gDAAgD,GAChD,OAAO,SAASC,eACdC,KAAe,EACfC,UAA8B,CAAC,CAAC;QAMrBA;IAJX,OAAOxB,YAAY;QACjBE,QAAQ,IAAMb,QAAQkC;QACtBpB,OAAO,IAAMb,iBAAiBiC;QAC9BnB,WAAW,AAACmB,MAAoCnB,SAAS;QACzDC,OAAO,GAAEmB,mBAAAA,QAAQnB,OAAO,YAAfmB,mBAAmBjC;IAC9B;AACF;AAEA,+CAA+C,GAC/C,OAAO,SAASkC,cACdC,SAA+B,EAC/BF,UAA8B,CAAC,CAAC;QAMrBA;IAJX,OAAOxB,YAAY;QACjBE,QAAQ,IAAMf,OAAOuC;QACrBvB,OAAO,IAAMf,gBAAgBsC;QAC7BtB,WAAW,AAACsB,UAAwCtB,SAAS;QAC7DC,OAAO,GAAEmB,mBAAAA,QAAQnB,OAAO,YAAfmB,mBAAmBjC;IAC9B;AACF;AAEA,kDAAkD,GAClD,OAAO,MAAMoC,oBAAoB,0CAAyC"}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type Firestore } from 'firebase/firestore';
18
+ /**
19
+ * RECOVERING A FIRESTORE CLIENT THAT HAS STOPPED SYNCING (AGL-3373).
20
+ *
21
+ * ## The wedge
22
+ *
23
+ * The console runs the persistent cache with `persistentMultipleTabManager`
24
+ * (`firestore-cache.ts`). Only the PRIMARY tab talks to the backend; every
25
+ * other tab reads the shared IndexedDB cache and takes its online state from
26
+ * a `localStorage` record the primary writes. A secondary that believes the
27
+ * client is online answers a `getDocs` only once the primary has synced the
28
+ * query, and raises no first snapshot for a listener whose cache is empty.
29
+ *
30
+ * When the primary dies without handing over — a crashed, discarded or
31
+ * frozen tab — its "Online" record outlives it, and until some tab runs the
32
+ * lease election and wins, nothing syncs. The election runs on a 4-second
33
+ * timer inside each tab, which the browser throttles to about once a minute
34
+ * in a hidden tab. On 2026-09-28 no live tab synced for about ten minutes:
35
+ * the notifications page never left its loading state and a staff page sat
36
+ * on its splash until a fresh tab (which wins the election on start) was
37
+ * opened.
38
+ *
39
+ * ## The recovery, and why it is this one
40
+ *
41
+ * `disableNetwork` then `enableNetwork`, from the tab that noticed. Both are
42
+ * public API, and in the multi-tab client each one calls
43
+ * `setNetworkEnabled`, which schedules the lease election for IMMEDIATE
44
+ * execution instead of the next (possibly throttled) timer tick. A visible
45
+ * tab that finds the lease lapsed takes it, starts its own streams and
46
+ * writes its own online state, which replaces the dead tab's. A tab that was
47
+ * already primary with a silently dead stream gets fresh streams.
48
+ *
49
+ * Nothing is lost. Writes made meanwhile sit in the mutation queue (in
50
+ * IndexedDB, under the persistent cache) and are sent when the network comes
51
+ * back; the SDK's own contract for `disableNetwork` is that writes queue.
52
+ *
53
+ * The rejected alternatives:
54
+ *
55
+ * - **Clearing the persistence cache** (`terminate` + `clearIndexedDbPersistence`).
56
+ * It deletes the mutation queue, so pending writes are lost, and
57
+ * `waitForPendingWrites` cannot drain them first because draining is the
58
+ * very thing that is stuck. It also refuses to run while any other tab
59
+ * holds the database, which in a multi-tab wedge is always. And
60
+ * `terminate` invalidates the one `Firestore` instance every hook in the
61
+ * console holds.
62
+ * - **Pruning the dead tab's online-state record at startup**, beside the
63
+ * AGL-2845 prune. The SDK already ignores an online-state record at
64
+ * startup unless its writer is an active, non-zombie client
65
+ * (`WebStorageSharedClientState.handleOnlineStateEvent`), and a freshly
66
+ * started tab wins the election anyway — which is why a fresh tab fixed
67
+ * the incident. The tabs that stayed stuck were the ones ALREADY running,
68
+ * which a startup prune never reaches.
69
+ * - **`persistentSingleTabManager`.** It removes cross-tab dependency, but
70
+ * every tab after the first falls back to a memory cache, so writes made
71
+ * offline in those tabs are lost when they close. And a tab still on the
72
+ * multi-tab build that meets a single-tab lease holder fails its queue
73
+ * with `failed-precondition` until reloaded, so the rollout itself would
74
+ * wedge the tabs left open across it.
75
+ *
76
+ * ## Guards
77
+ *
78
+ * - **Online only.** A browser that reports no network is correctly offline,
79
+ * and the cache answering is the cache doing its job.
80
+ * - **Visible only.** A hidden tab's timers are throttled and its reader is
81
+ * not waiting; if it is stalled, it recovers the moment it is looked at.
82
+ * - **Once per {@link STALL_RECOVERY_COOLDOWN_MS} per client.** Every list on
83
+ * a page stalls together, and one cycle serves them all. A concurrent
84
+ * request shares the cycle already running.
85
+ * - **The network is always re-enabled**, including when disabling fails.
86
+ */
87
+ /**
88
+ * How long a server read or a listener's first server-confirmed snapshot may
89
+ * take before the client is treated as stalled. A healthy console answers
90
+ * in well under two seconds; this leaves room for a slow network without
91
+ * leaving a reader on a spinner for long.
92
+ */
93
+ export declare const FIRESTORE_STALL_MS = 8000;
94
+ /** The least time between two recoveries of the same client. */
95
+ export declare const STALL_RECOVERY_COOLDOWN_MS = 30000;
96
+ /** What a recovery request did. */
97
+ export type StallRecoveryOutcome =
98
+ /** The browser reports no network: the cache is the right answer. */
99
+ 'offline'
100
+ /** The tab is hidden: nobody is waiting on it. */
101
+ | 'hidden'
102
+ /** A recovery ran moments ago; this request rode on it. */
103
+ | 'cooling'
104
+ /** The network was cycled. */
105
+ | 'recovered'
106
+ /** The cycle threw; the network was still re-enabled if at all possible. */
107
+ | 'failed';
108
+ export interface StallRecoveryDeps {
109
+ /** Take the client off the network. */
110
+ disable: () => Promise<void>;
111
+ /** Put it back. */
112
+ enable: () => Promise<void>;
113
+ /** `navigator.onLine`, or `true` where there is no navigator. */
114
+ isOnline?: () => boolean;
115
+ /** Whether the document is visible, or `true` where there is none. */
116
+ isVisible?: () => boolean;
117
+ now?: () => number;
118
+ cooldownMs?: number;
119
+ }
120
+ export interface StallRecovery {
121
+ recover: () => Promise<StallRecoveryOutcome>;
122
+ }
123
+ /** The recovery for one client, with its guards. Pure over `deps`. */
124
+ export declare function createStallRecovery(deps: StallRecoveryDeps): StallRecovery;
125
+ /**
126
+ * Recovers `firestore` if it has stalled. Safe to call from every reader
127
+ * that notices: the guards above collapse the calls into at most one network
128
+ * cycle per cooldown. Never rejects.
129
+ */
130
+ export declare function recoverStalledFirestore(firestore: Firestore | null | undefined): Promise<StallRecoveryOutcome>;
131
+ /**
132
+ * Arms a stall timer for one read or listen. If `disarm` has not been called
133
+ * within `stallMs`, `onStall` runs (by default, {@link recoverStalledFirestore}).
134
+ * A tab that is hidden when the timer fires waits until it is shown, since
135
+ * that is when a reader is waiting again.
136
+ *
137
+ * Returns `disarm`, which is idempotent.
138
+ */
139
+ export declare function armFirestoreStallWatch(firestore: Firestore | null | undefined, options?: {
140
+ stallMs?: number;
141
+ onStall?: () => void;
142
+ }): () => void;