@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-feature-instance",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
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.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-
|
|
32
|
-
"@aglyn/shared-util-
|
|
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;
|