@remit/web-client 0.0.270 → 0.0.272
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 +1 -1
- package/src/components/mail/BulkRunProvider.tsx +331 -0
- package/src/components/mail/MessageList.tsx +38 -23
- package/src/components/mail/SelectionWizardHost.tsx +57 -24
- package/src/hooks/cross-account-bulk.render.test.ts +3 -3
- package/src/hooks/escalated-run-lifetime.render.test.ts +5 -4
- package/src/hooks/escalated-run-refusal.render.test.ts +219 -5
- package/src/hooks/escalated-run-survives-navigation.render.test.ts +524 -0
- package/src/hooks/update-restart-escalation.render.test.ts +340 -0
- package/src/hooks/use-system-update.ts +49 -2
- package/src/hooks/useEscalatedActions.test.ts +1 -1
- package/src/hooks/useEscalatedActions.ts +93 -187
- package/src/lib/bulk-action-copy.ts +21 -0
- package/src/lib/bulk-actions.ts +32 -0
- package/src/lib/error-classifier.test.ts +59 -0
- package/src/lib/error-classifier.ts +48 -2
- package/src/lib/self-update-state.test.ts +1 -0
- package/src/lib/self-update-state.ts +138 -30
- package/src/routes/__root.tsx +16 -11
- package/src/test-support/bulk-run.ts +13 -0
- package/src/test-support/dom.ts +6 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
1
|
import {
|
|
3
2
|
messageBulkOperationsDeleteMessages,
|
|
4
3
|
messageBulkOperationsMoveMessages,
|
|
@@ -6,30 +5,22 @@ import {
|
|
|
6
5
|
threadOperationsSearchThreads,
|
|
7
6
|
} from "@remit/api-http-client/sdk.gen.ts";
|
|
8
7
|
import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
|
|
9
|
-
import { useQueryClient } from "@tanstack/react-query";
|
|
10
8
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type BulkRunRequest,
|
|
11
|
+
type BulkRunSource,
|
|
12
|
+
useBulkRun,
|
|
13
|
+
} from "@/components/mail/BulkRunProvider";
|
|
12
14
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
13
15
|
import { buildMutationErrorBanner } from "@/components/ui/error-banners";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
type BulkActionProgress,
|
|
22
|
-
type BulkActionTarget,
|
|
23
|
-
type BulkRunOutcome,
|
|
24
|
-
type FetchIdsPage,
|
|
25
|
-
honestProgress,
|
|
26
|
-
runChunkedAction,
|
|
27
|
-
runPredicateAction,
|
|
16
|
+
import type {
|
|
17
|
+
ApplyBatch,
|
|
18
|
+
BulkActionProgress,
|
|
19
|
+
BulkActionTarget,
|
|
20
|
+
BulkRunStart,
|
|
21
|
+
EscalatedAction,
|
|
22
|
+
FetchIdsPage,
|
|
28
23
|
} from "@/lib/bulk-actions";
|
|
29
|
-
import {
|
|
30
|
-
invalidateThreadListQueries,
|
|
31
|
-
threadListCacheKeys,
|
|
32
|
-
} from "@/lib/thread-list-cache";
|
|
33
24
|
|
|
34
25
|
/** The predicate a search-scoped run re-issues on every page — the same
|
|
35
26
|
* filters the visible list is searching with, minus pagination/count knobs. */
|
|
@@ -45,16 +36,6 @@ export type EscalationSearchQuery = Pick<
|
|
|
45
36
|
| "category"
|
|
46
37
|
>;
|
|
47
38
|
|
|
48
|
-
/**
|
|
49
|
-
* What a bulk run applies to every batch it reaches (#114). Delete, move and
|
|
50
|
-
* mark-read differ only in the bulk call they issue and the caches that call
|
|
51
|
-
* invalidates; the paging, chunking, progress and cancellation are the same.
|
|
52
|
-
*/
|
|
53
|
-
export type EscalatedAction =
|
|
54
|
-
| { kind: "delete" }
|
|
55
|
-
| { kind: "move"; destinationMailboxId: string }
|
|
56
|
-
| { kind: "markRead" };
|
|
57
|
-
|
|
58
39
|
/** Page size for the execution loop. Set to the write side's own 100-id cap so
|
|
59
40
|
* an execution page IS a write chunk — no in-memory accumulation step between
|
|
60
41
|
* reading ids and sending them. */
|
|
@@ -67,6 +48,8 @@ export type EscalationPhase =
|
|
|
67
48
|
|
|
68
49
|
interface UseEscalatedActionsOptions {
|
|
69
50
|
mailboxId: string;
|
|
51
|
+
/** The mailbox in the user's words, for a commit refused while this runs. */
|
|
52
|
+
mailboxLabel?: string;
|
|
70
53
|
/** Owning account, forwarded to the unseen-count invalidation on completion. */
|
|
71
54
|
accountId?: string;
|
|
72
55
|
/** Disables escalation entirely (e.g. not searching). Resets any in-flight
|
|
@@ -76,6 +59,12 @@ interface UseEscalatedActionsOptions {
|
|
|
76
59
|
* changes (a different search is a different question). */
|
|
77
60
|
predicateKey: string;
|
|
78
61
|
searchQuery: EscalationSearchQuery;
|
|
62
|
+
/**
|
|
63
|
+
* States how a run ended, for a user who is no longer looking at any screen
|
|
64
|
+
* that could show it. Handed to the run's owner rather than called here, so
|
|
65
|
+
* an ending that lands after this hook unmounted is still said (#112).
|
|
66
|
+
*/
|
|
67
|
+
reportEnding?: BulkRunRequest["reportEnding"];
|
|
79
68
|
}
|
|
80
69
|
|
|
81
70
|
export interface UseEscalatedActionsResult {
|
|
@@ -84,11 +73,14 @@ export interface UseEscalatedActionsResult {
|
|
|
84
73
|
* selection to that predicate once it answers. */
|
|
85
74
|
escalate: () => void;
|
|
86
75
|
/**
|
|
87
|
-
* Stop whatever
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* A no-op when nothing is running.
|
|
91
|
-
*
|
|
76
|
+
* Stop whatever this mailbox has running — its count, and the run when the
|
|
77
|
+
* run is its own. The request in flight is aborted and nothing further leaves
|
|
78
|
+
* (#113); a delete the server has already accepted still applies, so the
|
|
79
|
+
* batch on the wire is what a stop is worth. A no-op when nothing is running.
|
|
80
|
+
* Cancelling a count here never reaches a run in another mailbox: that run is
|
|
81
|
+
* not what this screen is offering to stop, and ending it would be silent.
|
|
82
|
+
* The only thing that ends a run in flight: leaving the selection, the
|
|
83
|
+
* wizard, the search or the mailbox does not.
|
|
92
84
|
*/
|
|
93
85
|
stop: () => void;
|
|
94
86
|
/**
|
|
@@ -99,7 +91,7 @@ export interface UseEscalatedActionsResult {
|
|
|
99
91
|
*/
|
|
100
92
|
clear: () => void;
|
|
101
93
|
/** True while a chunked run (bounded->100 ids, or the escalated predicate)
|
|
102
|
-
* is in flight. */
|
|
94
|
+
* over this mailbox is in flight, whichever screen started it. */
|
|
103
95
|
isRunning: boolean;
|
|
104
96
|
/** The action currently in flight, for status and progress wording. */
|
|
105
97
|
runningAction: EscalatedAction | undefined;
|
|
@@ -112,61 +104,44 @@ export interface UseEscalatedActionsResult {
|
|
|
112
104
|
* as one batch the endpoint refuses whole (#872). Resolves once the run ends
|
|
113
105
|
* for any reason — cancelled, errored, or complete — with a
|
|
114
106
|
* `done`/`failedIds` outcome the caller reads to decide what is still
|
|
115
|
-
* outstanding
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
107
|
+
* outstanding, or with the refusal of a run that never started because
|
|
108
|
+
* another one is still going.
|
|
109
|
+
*
|
|
110
|
+
* The run itself belongs to `BulkRunProvider`, which outlives every screen
|
|
111
|
+
* that can show it: the caches, the refusal replay, the failure banner and
|
|
112
|
+
* the ending are its, so none of them are lost when the surface that started
|
|
113
|
+
* the run goes (#112).
|
|
119
114
|
*/
|
|
120
115
|
runAction: (
|
|
121
116
|
action: EscalatedAction,
|
|
122
117
|
targets?: readonly BulkActionTarget[],
|
|
123
|
-
|
|
118
|
+
claimEnding?: BulkRunRequest["claimEnding"],
|
|
119
|
+
) => Promise<BulkRunStart>;
|
|
124
120
|
}
|
|
125
121
|
|
|
126
|
-
/**
|
|
127
|
-
* The mailboxes whose cached listings a bulk run affects: the mailbox it ran
|
|
128
|
-
* over, plus a move's destination, which gains the messages the source loses.
|
|
129
|
-
*/
|
|
130
|
-
export const mailboxesTouchedBy = (
|
|
131
|
-
action: EscalatedAction,
|
|
132
|
-
mailboxId: string,
|
|
133
|
-
): string[] =>
|
|
134
|
-
action.kind === "move"
|
|
135
|
-
? [mailboxId, action.destinationMailboxId]
|
|
136
|
-
: [mailboxId];
|
|
137
|
-
|
|
138
122
|
export const useEscalatedActions = ({
|
|
139
123
|
mailboxId,
|
|
124
|
+
mailboxLabel,
|
|
140
125
|
accountId,
|
|
141
126
|
enabled,
|
|
142
127
|
predicateKey,
|
|
143
128
|
searchQuery,
|
|
129
|
+
reportEnding,
|
|
144
130
|
}: UseEscalatedActionsOptions): UseEscalatedActionsResult => {
|
|
145
131
|
const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
>(undefined);
|
|
149
|
-
const
|
|
150
|
-
undefined,
|
|
151
|
-
);
|
|
152
|
-
// Cancellation is the signal every call the run makes is issued under, so a
|
|
153
|
-
// stop ends the request on the wire instead of waiting for it to come back
|
|
154
|
-
// (#113). Replaced, never reused, at the start of each count and each run.
|
|
155
|
-
const abortRef = useRef<AbortController | undefined>(undefined);
|
|
156
|
-
// True from the moment a run starts until its outcome is in hand. A run is
|
|
157
|
-
// mail already leaving the mailbox, so nothing that merely changes what the
|
|
158
|
-
// list is showing gets to end it — only `stop`.
|
|
159
|
-
const runningRef = useRef(false);
|
|
160
|
-
const queryClient = useQueryClient();
|
|
132
|
+
// The count's own signal, which is not the run's: leaving the search ends a
|
|
133
|
+
// count, and never a run (#112).
|
|
134
|
+
const countAbortRef = useRef<AbortController | undefined>(undefined);
|
|
135
|
+
const { run, start, stop: stopRun } = useBulkRun();
|
|
161
136
|
const { pushError } = useErrorBanners();
|
|
162
|
-
|
|
163
|
-
// The run
|
|
164
|
-
//
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
137
|
+
|
|
138
|
+
// The live run, when it is this mailbox's. A run is mail leaving one mailbox,
|
|
139
|
+
// so it reports on that mailbox's list — not on whichever list is on screen.
|
|
140
|
+
const activeRun = run?.mailboxId === mailboxId ? run : undefined;
|
|
141
|
+
const isRunning = activeRun !== undefined;
|
|
142
|
+
// Read by callbacks that must not close over a stale render's answer.
|
|
143
|
+
const isRunningRef = useRef(false);
|
|
144
|
+
isRunningRef.current = isRunning;
|
|
170
145
|
|
|
171
146
|
// A different search (or leaving search/desktop) makes any in-flight
|
|
172
147
|
// escalation meaningless — it would otherwise keep counting or offering to
|
|
@@ -175,7 +150,7 @@ export const useEscalatedActions = ({
|
|
|
175
150
|
// against and reports what it reached, wherever the list moved on to.
|
|
176
151
|
// biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself reads neither.
|
|
177
152
|
useEffect(() => {
|
|
178
|
-
|
|
153
|
+
countAbortRef.current?.abort();
|
|
179
154
|
setPhase({ kind: "idle" });
|
|
180
155
|
}, [enabled, predicateKey]);
|
|
181
156
|
|
|
@@ -252,36 +227,9 @@ export const useEscalatedActions = ({
|
|
|
252
227
|
[],
|
|
253
228
|
);
|
|
254
229
|
|
|
255
|
-
/**
|
|
256
|
-
* The unseen counts a run moved, per account. A cross-account selection has
|
|
257
|
-
* no single owning account — the surface leaves the option undefined exactly
|
|
258
|
-
* then — so the run's own targets are what name the accounts to refresh.
|
|
259
|
-
*/
|
|
260
|
-
const invalidateAfterRun = useCallback(
|
|
261
|
-
(action: EscalatedAction, targets: readonly BulkActionTarget[]) => {
|
|
262
|
-
invalidateThreadListQueries(
|
|
263
|
-
queryClient,
|
|
264
|
-
threadListCacheKeys(mailboxesTouchedBy(action, mailboxId)),
|
|
265
|
-
);
|
|
266
|
-
const touched = new Set<string>();
|
|
267
|
-
if (accountId) touched.add(accountId);
|
|
268
|
-
for (const target of targets) {
|
|
269
|
-
if (target.accountId) touched.add(target.accountId);
|
|
270
|
-
}
|
|
271
|
-
for (const touchedAccountId of touched) {
|
|
272
|
-
queryClient.invalidateQueries({
|
|
273
|
-
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
274
|
-
path: { accountId: touchedAccountId },
|
|
275
|
-
}),
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
},
|
|
279
|
-
[queryClient, mailboxId, accountId],
|
|
280
|
-
);
|
|
281
|
-
|
|
282
230
|
const escalate = useCallback(() => {
|
|
283
231
|
const controller = new AbortController();
|
|
284
|
-
|
|
232
|
+
countAbortRef.current = controller;
|
|
285
233
|
setPhase({ kind: "counting" });
|
|
286
234
|
fetchMatchCount(controller.signal).then(
|
|
287
235
|
(total) => {
|
|
@@ -311,12 +259,13 @@ export const useEscalatedActions = ({
|
|
|
311
259
|
}, [fetchMatchCount, pushError]);
|
|
312
260
|
|
|
313
261
|
const stop = useCallback(() => {
|
|
314
|
-
|
|
315
|
-
|
|
262
|
+
countAbortRef.current?.abort();
|
|
263
|
+
if (isRunningRef.current) stopRun(mailboxId);
|
|
264
|
+
}, [stopRun, mailboxId]);
|
|
316
265
|
|
|
317
266
|
const clear = useCallback(() => {
|
|
318
|
-
if (
|
|
319
|
-
|
|
267
|
+
if (isRunningRef.current) return;
|
|
268
|
+
countAbortRef.current?.abort();
|
|
320
269
|
setPhase({ kind: "idle" });
|
|
321
270
|
}, []);
|
|
322
271
|
|
|
@@ -324,105 +273,62 @@ export const useEscalatedActions = ({
|
|
|
324
273
|
async (
|
|
325
274
|
action: EscalatedAction,
|
|
326
275
|
targets?: readonly BulkActionTarget[],
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
abortRef.current = controller;
|
|
330
|
-
runningRef.current = true;
|
|
331
|
-
setRunningAction(action);
|
|
276
|
+
claimEnding?: BulkRunRequest["claimEnding"],
|
|
277
|
+
): Promise<BulkRunStart> => {
|
|
332
278
|
// Read before the run clears the phase below, so a refusal can say how
|
|
333
279
|
// many messages the appointment's replay is about.
|
|
334
280
|
const matched =
|
|
335
281
|
targets?.length ?? (phase.kind === "escalated" ? phase.total : 0);
|
|
336
|
-
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
337
|
-
// predicate can match more by the time the run pages it than the count
|
|
338
|
-
// saw, and the bar must never show more done than out of.
|
|
339
|
-
const onProgress = (next: BulkActionProgress) =>
|
|
340
|
-
setProgress(honestProgress(next));
|
|
341
|
-
const applyBatch = applyBatchFor(action);
|
|
342
282
|
// The predicate as it read when the run was confirmed. The run outlives
|
|
343
283
|
// the screen that started it, so reading the live query on every page
|
|
344
284
|
// would let a search typed afterwards redirect what is being deleted.
|
|
345
|
-
const
|
|
285
|
+
const source: BulkRunSource =
|
|
286
|
+
targets !== undefined
|
|
287
|
+
? { kind: "targets", targets }
|
|
288
|
+
: {
|
|
289
|
+
kind: "predicate",
|
|
290
|
+
fetchPage: fetchPagesOf(searchQueryRef.current),
|
|
291
|
+
};
|
|
346
292
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
controller.signal,
|
|
359
|
-
)
|
|
360
|
-
: await runPredicateAction(
|
|
361
|
-
runPages,
|
|
362
|
-
phase.kind === "escalated" ? phase.total : 0,
|
|
363
|
-
applyBatch,
|
|
364
|
-
onProgress,
|
|
365
|
-
controller.signal,
|
|
366
|
-
);
|
|
367
|
-
} finally {
|
|
368
|
-
runningRef.current = false;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
setRunningAction(undefined);
|
|
372
|
-
setProgress(undefined);
|
|
373
|
-
setPhase({ kind: "idle" });
|
|
293
|
+
const started = await start({
|
|
294
|
+
action,
|
|
295
|
+
mailboxId,
|
|
296
|
+
mailboxLabel,
|
|
297
|
+
accountId,
|
|
298
|
+
matched,
|
|
299
|
+
source,
|
|
300
|
+
applyBatch: applyBatchFor(action),
|
|
301
|
+
reportEnding,
|
|
302
|
+
claimEnding,
|
|
303
|
+
});
|
|
374
304
|
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
? isFolderRoleRefusal(outcome.error)
|
|
382
|
-
: undefined;
|
|
383
|
-
if (refusal) {
|
|
384
|
-
requestAppointment({
|
|
385
|
-
accountId: refusal.accountId,
|
|
386
|
-
role: refusal.role,
|
|
387
|
-
reason: refusal.reason,
|
|
388
|
-
action: { kind: "delete", count: matched },
|
|
389
|
-
onAppointed: async () => {
|
|
390
|
-
await runRef.current(action, targets);
|
|
391
|
-
},
|
|
392
|
-
});
|
|
393
|
-
} else if (outcome.error) {
|
|
394
|
-
pushError(
|
|
395
|
-
buildMutationErrorBanner(
|
|
396
|
-
bulkActionFailureTitle(action.kind, outcome.done),
|
|
397
|
-
bulkActionFailureDetail(action.kind),
|
|
398
|
-
outcome.error,
|
|
399
|
-
),
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
if (outcome.done > 0) {
|
|
403
|
-
invalidateAfterRun(action, targets ?? []);
|
|
404
|
-
}
|
|
405
|
-
return outcome;
|
|
305
|
+
// The escalated selection was what the run was confirmed from, and the
|
|
306
|
+
// run has now happened to it. A refused commit leaves it standing: the
|
|
307
|
+
// selection is still the question, and stopping the other run is what
|
|
308
|
+
// makes it answerable.
|
|
309
|
+
if (started.kind === "ran") setPhase({ kind: "idle" });
|
|
310
|
+
return started;
|
|
406
311
|
},
|
|
407
312
|
[
|
|
408
313
|
applyBatchFor,
|
|
409
314
|
fetchPagesOf,
|
|
410
315
|
phase,
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
316
|
+
mailboxId,
|
|
317
|
+
mailboxLabel,
|
|
318
|
+
accountId,
|
|
319
|
+
reportEnding,
|
|
320
|
+
start,
|
|
414
321
|
],
|
|
415
322
|
);
|
|
416
|
-
runRef.current = runAction;
|
|
417
323
|
|
|
418
324
|
return {
|
|
419
325
|
phase,
|
|
420
326
|
escalate,
|
|
421
327
|
stop,
|
|
422
328
|
clear,
|
|
423
|
-
isRunning
|
|
424
|
-
runningAction,
|
|
425
|
-
progress,
|
|
329
|
+
isRunning,
|
|
330
|
+
runningAction: activeRun?.action,
|
|
331
|
+
progress: activeRun?.progress,
|
|
426
332
|
runAction,
|
|
427
333
|
};
|
|
428
334
|
};
|
|
@@ -115,6 +115,27 @@ export const bulkActionFailureTitle = (
|
|
|
115
115
|
export const bulkActionFailureDetail = (kind: BulkActionKind): string =>
|
|
116
116
|
failureDetail[kind];
|
|
117
117
|
|
|
118
|
+
const busyPhrase: Record<BulkActionKind, (count: string) => string> = {
|
|
119
|
+
delete: (count) => `A delete of ${count} messages`,
|
|
120
|
+
move: (count) => `A move of ${count} messages`,
|
|
121
|
+
markRead: (count) => `Marking ${count} messages as read`,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The commit that could not start, because the one still going owns the bar and
|
|
126
|
+
* the Stop it would need. It names the run in flight and where it is, so the
|
|
127
|
+
* user knows which screen to end it from rather than pressing a commit that
|
|
128
|
+
* silently does nothing.
|
|
129
|
+
*/
|
|
130
|
+
export const bulkRunBusyRefusal = (
|
|
131
|
+
kind: BulkActionKind,
|
|
132
|
+
matched: number,
|
|
133
|
+
mailboxLabel: string | undefined,
|
|
134
|
+
): string =>
|
|
135
|
+
`${busyPhrase[kind](formatNumber(matched))}${
|
|
136
|
+
mailboxLabel ? ` in ${mailboxLabel}` : ""
|
|
137
|
+
} is still running — stop it first.`;
|
|
138
|
+
|
|
118
139
|
/** Progress-bar tone: only delete is destructive. */
|
|
119
140
|
export const bulkActionProgressTone = (
|
|
120
141
|
kind: BulkActionKind,
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -34,6 +34,28 @@
|
|
|
34
34
|
|
|
35
35
|
export const BULK_ACTION_CHUNK_SIZE = 100;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* What a bulk run applies to every batch it reaches (#114). Delete, move and
|
|
39
|
+
* mark-read differ only in the bulk call they issue and the caches that call
|
|
40
|
+
* invalidates; the paging, chunking, progress and cancellation are the same.
|
|
41
|
+
*/
|
|
42
|
+
export type EscalatedAction =
|
|
43
|
+
| { kind: "delete" }
|
|
44
|
+
| { kind: "move"; destinationMailboxId: string }
|
|
45
|
+
| { kind: "markRead" };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The mailboxes whose cached listings a bulk run affects: the mailbox it ran
|
|
49
|
+
* over, plus a move's destination, which gains the messages the source loses.
|
|
50
|
+
*/
|
|
51
|
+
export const mailboxesTouchedBy = (
|
|
52
|
+
action: EscalatedAction,
|
|
53
|
+
mailboxId: string,
|
|
54
|
+
): string[] =>
|
|
55
|
+
action.kind === "move"
|
|
56
|
+
? [mailboxId, action.destinationMailboxId]
|
|
57
|
+
: [mailboxId];
|
|
58
|
+
|
|
37
59
|
/** For a run with no Stop control to wire a signal to. */
|
|
38
60
|
const NEVER_ABORTED = new AbortController().signal;
|
|
39
61
|
|
|
@@ -323,3 +345,13 @@ export interface BulkRunOutcome {
|
|
|
323
345
|
cancelled: boolean;
|
|
324
346
|
error?: unknown;
|
|
325
347
|
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* What asking for a run got: the run itself, or the reason there is none. One
|
|
351
|
+
* run at a time is the design — a second one would page a second predicate
|
|
352
|
+
* behind the same bar and the same Stop — so a commit pressed while one is
|
|
353
|
+
* going is answered with what is going rather than with a run that replaces it.
|
|
354
|
+
*/
|
|
355
|
+
export type BulkRunStart =
|
|
356
|
+
| { kind: "ran"; outcome: BulkRunOutcome }
|
|
357
|
+
| { kind: "refused"; reason: string };
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isNetworkError,
|
|
10
10
|
isNotFound,
|
|
11
11
|
isServerError,
|
|
12
|
+
restartExpectedMeta,
|
|
12
13
|
shouldEscalate,
|
|
13
14
|
softErrorStatuses,
|
|
14
15
|
} from "./error-classifier";
|
|
@@ -366,6 +367,13 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
|
|
|
366
367
|
);
|
|
367
368
|
});
|
|
368
369
|
|
|
370
|
+
it("does not open the restart gap for a call site that never claimed one", () => {
|
|
371
|
+
assert.equal(
|
|
372
|
+
shouldEscalate(new ApiError("bad gateway", 502), ownsNotFound, "nobody"),
|
|
373
|
+
true,
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
369
377
|
it("names the record's absence, whichever client raised it", () => {
|
|
370
378
|
assert.equal(isNotFound(new ApiError("gone", 404)), true);
|
|
371
379
|
assert.equal(isNotFound({ status: 404 }), true);
|
|
@@ -373,3 +381,54 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
|
|
|
373
381
|
assert.equal(isNotFound(new TypeError("x is undefined")), false);
|
|
374
382
|
});
|
|
375
383
|
});
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A self-update stops and starts the backend, so for those seconds the proxy in
|
|
387
|
+
* front of it has nothing to dial. That gap belongs to the surface that asked
|
|
388
|
+
* for the restart; every other 5xx, on that call site and on any other, is still
|
|
389
|
+
* the API reporting itself broken (#468).
|
|
390
|
+
*/
|
|
391
|
+
describe("meta.restartExpected (the gap in a restart this call site asked for)", () => {
|
|
392
|
+
it("does NOT escalate the gateway statuses answered while the backend is down", () => {
|
|
393
|
+
for (const status of [502, 503, 504]) {
|
|
394
|
+
assert.equal(
|
|
395
|
+
shouldEscalate(
|
|
396
|
+
new ApiError("gateway", status),
|
|
397
|
+
restartExpectedMeta,
|
|
398
|
+
"nobody",
|
|
399
|
+
),
|
|
400
|
+
false,
|
|
401
|
+
`a ${status} inside the restart window belongs to the update surface`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("escalates a 500 — a server that answered is up, and up and broken is not designed", () => {
|
|
407
|
+
assert.equal(
|
|
408
|
+
shouldEscalate(new ApiError("boom", 500), restartExpectedMeta, "nobody"),
|
|
409
|
+
true,
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("escalates the same 502 without the claim, however soft the call site is", () => {
|
|
414
|
+
assert.equal(
|
|
415
|
+
shouldEscalate(new ApiError("gateway", 502), undefined, "nobody"),
|
|
416
|
+
true,
|
|
417
|
+
);
|
|
418
|
+
assert.equal(
|
|
419
|
+
shouldEscalate(new ApiError("gateway", 502), { softError: true }, "user"),
|
|
420
|
+
true,
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("escalates a client bug raised inside the window", () => {
|
|
425
|
+
assert.equal(
|
|
426
|
+
shouldEscalate(
|
|
427
|
+
new TypeError("x is undefined"),
|
|
428
|
+
restartExpectedMeta,
|
|
429
|
+
"nobody",
|
|
430
|
+
),
|
|
431
|
+
true,
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
@@ -143,6 +143,33 @@ const isSoftErrorMeta = (
|
|
|
143
143
|
return status !== undefined && owned.includes(status);
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The statuses answered for the seconds a service is not there. Every `/api/*`
|
|
148
|
+
* request is proxied to apisix, which stays up while the backend behind it
|
|
149
|
+
* restarts and refuses in milliseconds — the 29ms 502 in #468, not a dial Caddy
|
|
150
|
+
* waited out. 503 and 504 are the same gap through a hop that answers
|
|
151
|
+
* differently.
|
|
152
|
+
*/
|
|
153
|
+
const RESTART_GAP_STATUSES = [502, 503, 504];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The gap in a restart this call site asked for. A self-update stops and starts
|
|
157
|
+
* the backend on purpose, so the poll watching that run meets a proxy with
|
|
158
|
+
* nothing behind it — the designed shape of the operation the user pressed, not
|
|
159
|
+
* our API answering "I'm broken".
|
|
160
|
+
*
|
|
161
|
+
* A 500 is not in it: a server that answered is a server that is up, and up and
|
|
162
|
+
* broken is never designed.
|
|
163
|
+
*/
|
|
164
|
+
const isDesignedRestartGap = (
|
|
165
|
+
error: unknown,
|
|
166
|
+
meta: Record<string, unknown> | undefined,
|
|
167
|
+
): boolean => {
|
|
168
|
+
if (meta?.restartExpected !== true) return false;
|
|
169
|
+
const status = getErrorStatus(error);
|
|
170
|
+
return status !== undefined && RESTART_GAP_STATUSES.includes(status);
|
|
171
|
+
};
|
|
172
|
+
|
|
146
173
|
/**
|
|
147
174
|
* The single fail-fast decision: should this error escalate to the full-screen
|
|
148
175
|
* fatal overlay? Default is YES — a non-2xx must never silently vanish.
|
|
@@ -151,7 +178,10 @@ const isSoftErrorMeta = (
|
|
|
151
178
|
* 1. DEFAULT = escalate.
|
|
152
179
|
* 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
|
|
153
180
|
* refetch, even when the call site marked `meta.softError`. Our API
|
|
154
|
-
* answered "I'm broken"; that is never benign.
|
|
181
|
+
* answered "I'm broken"; that is never benign. The single exception is a
|
|
182
|
+
* 502/503/504 on a call site that declared it is polling across a restart it
|
|
183
|
+
* asked for (`meta.restartExpected`, #468): there the backend being gone is
|
|
184
|
+
* the operation working, and that call site renders the wait itself.
|
|
155
185
|
* 3. A client-side exception ALWAYS escalates, on the same terms. It is our
|
|
156
186
|
* bug; there is nothing for the user to retry and nothing to dismiss.
|
|
157
187
|
* 4. A 401 on a request the user is waiting on ALWAYS escalates, on the same
|
|
@@ -173,7 +203,7 @@ export const shouldEscalate = (
|
|
|
173
203
|
meta?: Record<string, unknown>,
|
|
174
204
|
awaiting: Awaiting = "nobody",
|
|
175
205
|
): boolean => {
|
|
176
|
-
if (isServerError(error)) return
|
|
206
|
+
if (isServerError(error)) return !isDesignedRestartGap(error, meta);
|
|
177
207
|
if (isAbortError(error)) return false;
|
|
178
208
|
if (isNetworkError(error)) return false;
|
|
179
209
|
if (awaiting === "user" && isUnauthenticated(error)) return true;
|
|
@@ -209,6 +239,22 @@ export const softErrorStatuses = (
|
|
|
209
239
|
...statuses: number[]
|
|
210
240
|
): { softErrorStatuses: number[] } => ({ softErrorStatuses: statuses });
|
|
211
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The one opt-out from rule 2, for a request watching a restart the run it is
|
|
244
|
+
* following performs. Set it only while a run is known to be in flight — the
|
|
245
|
+
* update poll carries it while it holds a run it started or the server's last
|
|
246
|
+
* answer reported one going — because a 502 outside that window is the API being
|
|
247
|
+
* broken, which is exactly what rule 2 is for.
|
|
248
|
+
*
|
|
249
|
+
* A call site that sets this owns the whole wait, so the window outlives the
|
|
250
|
+
* budget: the give-up screen keeps polling a server it has stopped expecting,
|
|
251
|
+
* and a fatal page thrown over that verdict would replace the one screen that
|
|
252
|
+
* names the run and the log to read. A 500 from that same poll still escalates.
|
|
253
|
+
*/
|
|
254
|
+
export const restartExpectedMeta: { restartExpected: true } = {
|
|
255
|
+
restartExpected: true,
|
|
256
|
+
};
|
|
257
|
+
|
|
212
258
|
/** The record is not there. */
|
|
213
259
|
export const isNotFound = (error: unknown): boolean =>
|
|
214
260
|
getErrorStatus(error) === 404;
|