@remit/web-client 0.0.269 → 0.0.270
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/MessageList.run-reporting.test.ts +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +15 -50
- package/src/components/mail/bulk-run-state.test.ts +100 -0
- package/src/components/mail/bulk-run-state.ts +99 -0
- package/src/hooks/escalated-run-lifetime.render.test.ts +87 -6
- package/src/hooks/useEscalatedActions.ts +45 -24
- package/src/lib/bulk-action-copy.test.ts +14 -4
- package/src/lib/bulk-action-copy.ts +5 -3
- package/src/lib/bulk-actions.test.ts +115 -26
- package/src/lib/bulk-actions.ts +40 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.270",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -63,7 +63,7 @@ describe("reporting how a run ended", () => {
|
|
|
63
63
|
);
|
|
64
64
|
assert.equal(stopped?.title, "Stopped after 100");
|
|
65
65
|
assert.match(stopped?.detail ?? "", /100 of 3,000 moved to Trash\./);
|
|
66
|
-
assert.match(stopped?.detail ?? "", /
|
|
66
|
+
assert.match(stopped?.detail ?? "", /The rest were not sent/);
|
|
67
67
|
|
|
68
68
|
const covered = runEndingBanner("delete", 100, ended(), "trash");
|
|
69
69
|
assert.match(covered?.title ?? "", /^100 moved to Trash\./);
|
|
@@ -75,6 +75,7 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
|
|
|
75
75
|
import { useSearchConversion } from "@/lib/search-conversion";
|
|
76
76
|
import type { WizardSelectionMessage } from "@/lib/wizard-selection";
|
|
77
77
|
import { useWizardEntryValue, useWizardStep } from "@/routing";
|
|
78
|
+
import { bulkRunReport } from "./bulk-run-state";
|
|
78
79
|
import { organizeRunState } from "./organize-run-state";
|
|
79
80
|
import {
|
|
80
81
|
retryIntent,
|
|
@@ -823,57 +824,21 @@ function SelectionWizardSession({
|
|
|
823
824
|
|
|
824
825
|
const bulkSnapshot = useCallback((): RunSnapshot => {
|
|
825
826
|
if (!bulkRun) return NOT_STARTED;
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
832
|
-
if (!outcome) {
|
|
833
|
-
const applied = runProgress?.done ?? 0;
|
|
834
|
-
// A predicate matches more by the time the run re-pages it than the count
|
|
835
|
-
// saw, so what it has covered can overtake what it was offered against.
|
|
836
|
-
// The bar never reads more done than out of, and neither does this.
|
|
837
|
-
return {
|
|
838
|
-
state: "backApplyRunning",
|
|
839
|
-
matched: Math.max(matched, applied),
|
|
840
|
-
applied,
|
|
841
|
-
failed: 0,
|
|
842
|
-
failures: [],
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
|
-
const stopped = outcome.cancelled || outcome.error !== undefined;
|
|
846
|
-
if (!stopped) {
|
|
847
|
-
return {
|
|
848
|
-
state: "backApplyComplete",
|
|
849
|
-
matched: Math.max(matched, outcome.done),
|
|
850
|
-
applied: outcome.done,
|
|
851
|
-
failed: 0,
|
|
852
|
-
failures: [],
|
|
853
|
-
};
|
|
854
|
-
}
|
|
855
|
-
if (outcome.done === 0) {
|
|
856
|
-
return { ...NOT_STARTED, state: "commitFailed" };
|
|
857
|
-
}
|
|
858
|
-
// The run stopped part-way. Nothing here was rejected: a returned bulk call
|
|
859
|
-
// accepts every id in it, so the only failure this layer sees is a call that
|
|
860
|
-
// threw, and everything after it was never sent. A bounded run hands back
|
|
861
|
-
// exactly those ids; a predicate run re-resolves its match on every pass and
|
|
862
|
-
// has no remainder to hand back, so what is left is the difference between
|
|
863
|
-
// the count and what the run covered.
|
|
864
|
-
const failures = outcome.failedIds
|
|
865
|
-
.map((id) => rowsById.get(id))
|
|
866
|
-
.filter((message): message is WizardMessage => message !== undefined);
|
|
867
|
-
const unreached =
|
|
868
|
-
outcome.failedIds.length > 0
|
|
869
|
-
? outcome.failedIds.length
|
|
870
|
-
: Math.max(matched - outcome.done, 1);
|
|
827
|
+
const report = bulkRunReport({
|
|
828
|
+
matched: bulkRun.matched,
|
|
829
|
+
outcome: bulkRun.outcome,
|
|
830
|
+
failureReason: bulkRun.failureReason,
|
|
831
|
+
progressDone: runProgress?.done ?? 0,
|
|
832
|
+
});
|
|
871
833
|
return {
|
|
872
|
-
state:
|
|
873
|
-
matched:
|
|
874
|
-
applied:
|
|
875
|
-
failed:
|
|
876
|
-
failures
|
|
834
|
+
state: report.state,
|
|
835
|
+
matched: report.matched,
|
|
836
|
+
applied: report.applied,
|
|
837
|
+
failed: report.failed,
|
|
838
|
+
failures: report.failedIds
|
|
839
|
+
.map((id) => rowsById.get(id))
|
|
840
|
+
.filter((message): message is WizardMessage => message !== undefined),
|
|
841
|
+
failureReason: report.failureReason,
|
|
877
842
|
};
|
|
878
843
|
}, [bulkRun, runProgress, rowsById]);
|
|
879
844
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
4
|
+
import { bulkRunReport } from "./bulk-run-state";
|
|
5
|
+
|
|
6
|
+
const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
|
|
7
|
+
done: 0,
|
|
8
|
+
failedIds: [],
|
|
9
|
+
cancelled: false,
|
|
10
|
+
...over,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const report = (
|
|
14
|
+
over: Partial<Parameters<typeof bulkRunReport>[0]> = {},
|
|
15
|
+
): ReturnType<typeof bulkRunReport> =>
|
|
16
|
+
bulkRunReport({
|
|
17
|
+
matched: 100,
|
|
18
|
+
outcome: undefined,
|
|
19
|
+
failureReason: undefined,
|
|
20
|
+
progressDone: 0,
|
|
21
|
+
...over,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("what the run screen says a bulk run did", () => {
|
|
25
|
+
it("shows a run still going against what it was started with", () => {
|
|
26
|
+
assert.deepEqual(report({ progressDone: 40 }), {
|
|
27
|
+
state: "backApplyRunning",
|
|
28
|
+
matched: 100,
|
|
29
|
+
applied: 40,
|
|
30
|
+
failed: 0,
|
|
31
|
+
failedIds: [],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("never reads more done than out of", () => {
|
|
36
|
+
const going = report({ matched: 1284, progressDone: 1340 });
|
|
37
|
+
assert.equal(going.matched, 1340);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ends a run that covered its match on the success screen", () => {
|
|
41
|
+
const covered = report({ outcome: ended({ done: 100 }) });
|
|
42
|
+
assert.equal(covered.state, "backApplyComplete");
|
|
43
|
+
assert.equal(covered.applied, 100);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("carries a commit that never started, and why", () => {
|
|
47
|
+
const never = report({ failureReason: "There is no Junk folder" });
|
|
48
|
+
assert.equal(never.state, "commitFailed");
|
|
49
|
+
assert.equal(never.failureReason, "There is no Junk folder");
|
|
50
|
+
assert.equal(never.matched, 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("says a run that errored before anything landed never started", () => {
|
|
54
|
+
const threw = report({ outcome: ended({ error: new Error("500") }) });
|
|
55
|
+
assert.equal(threw.state, "commitFailed");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Regression for #113. A stop used to be read only between pages, so it always
|
|
61
|
+
* landed with at least one batch already applied and `done === 0` could only
|
|
62
|
+
* mean a failure. Aborting the request in flight makes zero the ordinary
|
|
63
|
+
* outcome of stopping during the first batch — the only batch a selection of a
|
|
64
|
+
* hundred or fewer ever has — and answering that with the danger screen told
|
|
65
|
+
* the user nothing had changed while the server had most likely taken all of
|
|
66
|
+
* them.
|
|
67
|
+
*/
|
|
68
|
+
describe("a run the user stopped", () => {
|
|
69
|
+
it("reports a stop during the first batch as stopped, not as a failed start", () => {
|
|
70
|
+
const stopped = report({
|
|
71
|
+
matched: 100,
|
|
72
|
+
outcome: ended({ cancelled: true, failedIds: Array(100).fill("m") }),
|
|
73
|
+
});
|
|
74
|
+
assert.equal(stopped.state, "runStopped");
|
|
75
|
+
assert.notEqual(stopped.state, "commitFailed");
|
|
76
|
+
assert.equal(stopped.applied, 0);
|
|
77
|
+
assert.equal(stopped.failed, 100);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reports a stop that reached nothing at all as stopped too", () => {
|
|
81
|
+
// The escalated path hands back no remainder: the predicate re-resolves on
|
|
82
|
+
// every run, so there are no ids to list, only a difference to name.
|
|
83
|
+
const stopped = report({
|
|
84
|
+
matched: 3000,
|
|
85
|
+
outcome: ended({ cancelled: true }),
|
|
86
|
+
});
|
|
87
|
+
assert.equal(stopped.state, "runStopped");
|
|
88
|
+
assert.equal(stopped.failed, 3000);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("still reports a stop that got part-way", () => {
|
|
92
|
+
const stopped = report({
|
|
93
|
+
matched: 3000,
|
|
94
|
+
outcome: ended({ done: 1200, cancelled: true }),
|
|
95
|
+
});
|
|
96
|
+
assert.equal(stopped.state, "runStopped");
|
|
97
|
+
assert.equal(stopped.applied, 1200);
|
|
98
|
+
assert.equal(stopped.failed, 1800);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { RunState } from "@remit/ui";
|
|
2
|
+
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
3
|
+
|
|
4
|
+
/** What the run screen knows about a chunked bulk run it is reporting on. */
|
|
5
|
+
export interface BulkRunReading {
|
|
6
|
+
/** The count the run was started against. */
|
|
7
|
+
matched: number;
|
|
8
|
+
/** Set once the run has ended, for any reason. */
|
|
9
|
+
outcome: BulkRunOutcome | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Why the commit never started, when the reason is one the same commit cannot
|
|
12
|
+
* get past (#522).
|
|
13
|
+
*/
|
|
14
|
+
failureReason: string | undefined;
|
|
15
|
+
/** Ids covered so far, read off the progress bar while the run is going. */
|
|
16
|
+
progressDone: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BulkRunReport {
|
|
20
|
+
state: RunState;
|
|
21
|
+
matched: number;
|
|
22
|
+
applied: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
/** Ids the run leaves unconfirmed; the screen resolves them to rows. */
|
|
25
|
+
failedIds: readonly string[];
|
|
26
|
+
failureReason?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NOTHING_STARTED = {
|
|
30
|
+
matched: 0,
|
|
31
|
+
applied: 0,
|
|
32
|
+
failed: 0,
|
|
33
|
+
failedIds: [] as readonly string[],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What the run screen says a bulk run did.
|
|
38
|
+
*
|
|
39
|
+
* Stopping is read before counting, because a stop is not a failure however
|
|
40
|
+
* little it reached (#113). Pressing Stop during the first batch — the only
|
|
41
|
+
* batch a selection of 100 or fewer has — leaves `done` at zero while the
|
|
42
|
+
* server has most likely applied all of them, and answering that with
|
|
43
|
+
* "Couldn't start deleting / Nothing has changed" is both alarming and wrong.
|
|
44
|
+
* Only a run that errored before anything landed has nothing to report but the
|
|
45
|
+
* failure.
|
|
46
|
+
*/
|
|
47
|
+
export const bulkRunReport = ({
|
|
48
|
+
matched,
|
|
49
|
+
outcome,
|
|
50
|
+
failureReason,
|
|
51
|
+
progressDone,
|
|
52
|
+
}: BulkRunReading): BulkRunReport => {
|
|
53
|
+
// Nothing was sent, and nothing about sending it again resolves what was
|
|
54
|
+
// missing — so the screen carries why rather than the generic ending (#522).
|
|
55
|
+
if (failureReason !== undefined) {
|
|
56
|
+
return { ...NOTHING_STARTED, state: "commitFailed", failureReason };
|
|
57
|
+
}
|
|
58
|
+
if (!outcome) {
|
|
59
|
+
// A predicate matches more by the time the run re-pages it than the count
|
|
60
|
+
// saw, so what it has covered can overtake what it was offered against.
|
|
61
|
+
// The bar never reads more done than out of, and neither does this.
|
|
62
|
+
return {
|
|
63
|
+
state: "backApplyRunning",
|
|
64
|
+
matched: Math.max(matched, progressDone),
|
|
65
|
+
applied: progressDone,
|
|
66
|
+
failed: 0,
|
|
67
|
+
failedIds: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (!outcome.cancelled && outcome.error === undefined) {
|
|
71
|
+
return {
|
|
72
|
+
state: "backApplyComplete",
|
|
73
|
+
matched: Math.max(matched, outcome.done),
|
|
74
|
+
applied: outcome.done,
|
|
75
|
+
failed: 0,
|
|
76
|
+
failedIds: [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!outcome.cancelled && outcome.done === 0) {
|
|
80
|
+
return { ...NOTHING_STARTED, state: "commitFailed" };
|
|
81
|
+
}
|
|
82
|
+
// The run stopped part-way. Nothing here was rejected: a returned bulk call
|
|
83
|
+
// accepts every id in it, so the only failure this layer sees is a call that
|
|
84
|
+
// threw or was aborted, and everything after it was never sent. A bounded run
|
|
85
|
+
// hands back exactly those ids; a predicate run re-resolves its match on every
|
|
86
|
+
// pass and has no remainder to hand back, so what is left is the difference
|
|
87
|
+
// between the count and what the run covered.
|
|
88
|
+
const unreached =
|
|
89
|
+
outcome.failedIds.length > 0
|
|
90
|
+
? outcome.failedIds.length
|
|
91
|
+
: Math.max(matched - outcome.done, 1);
|
|
92
|
+
return {
|
|
93
|
+
state: "runStopped",
|
|
94
|
+
matched: Math.max(matched, outcome.done),
|
|
95
|
+
applied: outcome.done,
|
|
96
|
+
failed: unreached,
|
|
97
|
+
failedIds: outcome.failedIds,
|
|
98
|
+
};
|
|
99
|
+
};
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* These mount the real hook against the real fetch seam and count the batches
|
|
8
8
|
* that actually left, so what is pinned is the run's own lifetime rather than
|
|
9
|
-
* the wording of whichever screen started it. Every run is parked
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* the wording of whichever screen started it. Every run is parked on a request
|
|
10
|
+
* the server is holding before the test acts on it: that is where leaving the
|
|
11
|
+
* selection and stopping the run tell each other apart.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import assert from "node:assert/strict";
|
|
@@ -32,8 +32,12 @@ let server: MailServer | undefined;
|
|
|
32
32
|
interface MailServer {
|
|
33
33
|
/** Every message id a delete call carried, in the order they were sent. */
|
|
34
34
|
deleted: () => string[];
|
|
35
|
-
/**
|
|
35
|
+
/** Requests queued at the boundary rather than answered. */
|
|
36
36
|
held: () => number;
|
|
37
|
+
/** Requests the client cancelled while they were held. */
|
|
38
|
+
aborted: () => number;
|
|
39
|
+
/** Hold the count request too, so a stop can be pressed during counting. */
|
|
40
|
+
holdCounts: (on: boolean) => void;
|
|
37
41
|
/** Answer everything held, and stop holding. */
|
|
38
42
|
release: () => void;
|
|
39
43
|
restore: () => void;
|
|
@@ -61,14 +65,33 @@ const searchPage = (url: URL): unknown => {
|
|
|
61
65
|
|
|
62
66
|
/**
|
|
63
67
|
* Answers the search and the bulk delete, holding every delete after the first
|
|
64
|
-
* so a run can be caught mid-flight with real progress behind it.
|
|
68
|
+
* so a run can be caught mid-flight with real progress behind it. A held
|
|
69
|
+
* request honours its own abort signal, the way the platform's `fetch` does —
|
|
70
|
+
* that is the difference a stop is now supposed to make (#113).
|
|
65
71
|
*/
|
|
66
72
|
const startMailServer = (): MailServer => {
|
|
67
73
|
const original = globalThis.fetch;
|
|
68
74
|
const deleted: string[] = [];
|
|
69
75
|
let waiting: Array<() => void> = [];
|
|
70
76
|
let holding = true;
|
|
77
|
+
let holdingCounts = false;
|
|
71
78
|
let served = 0;
|
|
79
|
+
let cancelled = 0;
|
|
80
|
+
|
|
81
|
+
const hold = (signal: AbortSignal | undefined): Promise<void> =>
|
|
82
|
+
new Promise<void>((resolve, reject) => {
|
|
83
|
+
const fail = () => {
|
|
84
|
+
cancelled += 1;
|
|
85
|
+
waiting = waiting.filter((held) => held !== resolve);
|
|
86
|
+
reject(signal?.reason);
|
|
87
|
+
};
|
|
88
|
+
if (signal?.aborted) {
|
|
89
|
+
fail();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
signal?.addEventListener("abort", fail, { once: true });
|
|
93
|
+
waiting.push(resolve);
|
|
94
|
+
});
|
|
72
95
|
|
|
73
96
|
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
|
|
74
97
|
const request = input instanceof Request ? input : undefined;
|
|
@@ -76,6 +99,9 @@ const startMailServer = (): MailServer => {
|
|
|
76
99
|
const url = new URL(request ? request.url : String(input), "http://x");
|
|
77
100
|
|
|
78
101
|
if (url.pathname.endsWith("/threads/search")) {
|
|
102
|
+
if (holdingCounts && url.searchParams.get("results") === "false") {
|
|
103
|
+
await hold(request?.signal);
|
|
104
|
+
}
|
|
79
105
|
return Response.json(searchPage(url));
|
|
80
106
|
}
|
|
81
107
|
|
|
@@ -84,7 +110,7 @@ const startMailServer = (): MailServer => {
|
|
|
84
110
|
: undefined;
|
|
85
111
|
served += 1;
|
|
86
112
|
if (holding && served > 1) {
|
|
87
|
-
await
|
|
113
|
+
await hold(request?.signal);
|
|
88
114
|
}
|
|
89
115
|
deleted.push(...(body?.messageIds ?? []));
|
|
90
116
|
return Response.json({ successCount: 0, failureCount: 0 });
|
|
@@ -93,8 +119,13 @@ const startMailServer = (): MailServer => {
|
|
|
93
119
|
return {
|
|
94
120
|
deleted: () => deleted,
|
|
95
121
|
held: () => waiting.length,
|
|
122
|
+
aborted: () => cancelled,
|
|
123
|
+
holdCounts: (on) => {
|
|
124
|
+
holdingCounts = on;
|
|
125
|
+
},
|
|
96
126
|
release: () => {
|
|
97
127
|
holding = false;
|
|
128
|
+
holdingCounts = false;
|
|
98
129
|
const queued = waiting;
|
|
99
130
|
waiting = [];
|
|
100
131
|
for (const resolve of queued) resolve();
|
|
@@ -235,6 +266,56 @@ describe("an escalated run the user walks away from", () => {
|
|
|
235
266
|
});
|
|
236
267
|
});
|
|
237
268
|
|
|
269
|
+
// Regression for #113: a stop used to be read only between pages, so the delete
|
|
270
|
+
// already on the wire ran to completion and its whole page — up to a hundred
|
|
271
|
+
// messages — was deleted after the press.
|
|
272
|
+
describe("stopping a run with a delete already on the wire", () => {
|
|
273
|
+
it("cancels that delete and sends nothing more", async () => {
|
|
274
|
+
const { hook } = mountEscalation({ query: "npm" });
|
|
275
|
+
await escalateTo(hook);
|
|
276
|
+
|
|
277
|
+
const run = press(() => hook().runAction({ kind: "delete" }));
|
|
278
|
+
await parkAtBoundary();
|
|
279
|
+
const sentBeforeStop = server?.deleted().length ?? 0;
|
|
280
|
+
press(() => hook().stop());
|
|
281
|
+
press(() => server?.release());
|
|
282
|
+
await settle();
|
|
283
|
+
const outcome = await run;
|
|
284
|
+
|
|
285
|
+
assert.equal(server?.aborted(), 1, "the delete in flight was cancelled");
|
|
286
|
+
assert.equal(
|
|
287
|
+
server?.deleted().length,
|
|
288
|
+
sentBeforeStop,
|
|
289
|
+
"no further ids reached the server",
|
|
290
|
+
);
|
|
291
|
+
assert.equal(outcome.cancelled, true);
|
|
292
|
+
assert.equal(
|
|
293
|
+
outcome.error,
|
|
294
|
+
undefined,
|
|
295
|
+
"a stop the user pressed is not a failure to report",
|
|
296
|
+
);
|
|
297
|
+
assert.equal(outcome.done, sentBeforeStop);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("cancels a count in flight without raising an error", async () => {
|
|
301
|
+
server?.holdCounts(true);
|
|
302
|
+
const { hook } = mountEscalation({ query: "npm" });
|
|
303
|
+
|
|
304
|
+
press(() => hook().escalate());
|
|
305
|
+
await parkAtBoundary();
|
|
306
|
+
press(() => hook().stop());
|
|
307
|
+
press(() => server?.release());
|
|
308
|
+
await settle();
|
|
309
|
+
|
|
310
|
+
assert.equal(server?.aborted(), 1);
|
|
311
|
+
assert.deepEqual(hook().phase, { kind: "idle" });
|
|
312
|
+
assert.equal(
|
|
313
|
+
harness?.text().includes("Couldn't count matching messages"),
|
|
314
|
+
false,
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
238
319
|
describe("stopping the run, which is a different press", () => {
|
|
239
320
|
it("ends it at the next page boundary and says what it reached", async () => {
|
|
240
321
|
const { hook } = mountEscalation({ query: "npm" });
|
|
@@ -84,7 +84,9 @@ export interface UseEscalatedActionsResult {
|
|
|
84
84
|
* selection to that predicate once it answers. */
|
|
85
85
|
escalate: () => void;
|
|
86
86
|
/**
|
|
87
|
-
* Stop whatever's running — the count or an action
|
|
87
|
+
* Stop whatever's running — the count or an action. The request in flight is
|
|
88
|
+
* aborted and nothing further leaves (#113); a delete the server has already
|
|
89
|
+
* accepted still applies, so the batch on the wire is what a stop is worth.
|
|
88
90
|
* A no-op when nothing is running. The only thing that ends a run in
|
|
89
91
|
* flight: leaving the selection, the wizard or the search does not.
|
|
90
92
|
*/
|
|
@@ -147,7 +149,10 @@ export const useEscalatedActions = ({
|
|
|
147
149
|
const [progress, setProgress] = useState<BulkActionProgress | undefined>(
|
|
148
150
|
undefined,
|
|
149
151
|
);
|
|
150
|
-
|
|
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);
|
|
151
156
|
// True from the moment a run starts until its outcome is in hand. A run is
|
|
152
157
|
// mail already leaving the mailbox, so nothing that merely changes what the
|
|
153
158
|
// list is showing gets to end it — only `stop`.
|
|
@@ -170,7 +175,7 @@ export const useEscalatedActions = ({
|
|
|
170
175
|
// against and reports what it reached, wherever the list moved on to.
|
|
171
176
|
// biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself reads neither.
|
|
172
177
|
useEffect(() => {
|
|
173
|
-
if (!runningRef.current)
|
|
178
|
+
if (!runningRef.current) abortRef.current?.abort();
|
|
174
179
|
setPhase({ kind: "idle" });
|
|
175
180
|
}, [enabled, predicateKey]);
|
|
176
181
|
|
|
@@ -179,10 +184,11 @@ export const useEscalatedActions = ({
|
|
|
179
184
|
|
|
180
185
|
const fetchPagesOf = useCallback(
|
|
181
186
|
(query: EscalationSearchQuery): FetchIdsPage =>
|
|
182
|
-
async (continuationToken) => {
|
|
187
|
+
async (continuationToken, signal) => {
|
|
183
188
|
const { data } = await threadOperationsSearchThreads({
|
|
184
189
|
path: { mailboxId },
|
|
185
190
|
query: { ...query, continuationToken, limit: PAGE_SIZE },
|
|
191
|
+
signal,
|
|
186
192
|
throwOnError: true,
|
|
187
193
|
});
|
|
188
194
|
return {
|
|
@@ -198,27 +204,32 @@ export const useEscalatedActions = ({
|
|
|
198
204
|
* resolves it (#509). One count-only request: `limit` is a page size and has
|
|
199
205
|
* no bearing on the answer, so nothing is paged to arrive at it.
|
|
200
206
|
*/
|
|
201
|
-
const fetchMatchCount = useCallback(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
const fetchMatchCount = useCallback(
|
|
208
|
+
async (signal: AbortSignal): Promise<number> => {
|
|
209
|
+
const { data } = await threadOperationsSearchThreads({
|
|
210
|
+
path: { mailboxId },
|
|
211
|
+
query: { ...searchQueryRef.current, count: true, results: false },
|
|
212
|
+
signal,
|
|
213
|
+
throwOnError: true,
|
|
214
|
+
});
|
|
215
|
+
if (data.count === undefined) {
|
|
216
|
+
throw new Error("the search returned no count for the selection");
|
|
217
|
+
}
|
|
218
|
+
return data.count;
|
|
219
|
+
},
|
|
220
|
+
[mailboxId],
|
|
221
|
+
);
|
|
212
222
|
|
|
213
223
|
const applyBatchFor = useCallback(
|
|
214
224
|
(action: EscalatedAction): ApplyBatch =>
|
|
215
|
-
async (ids: string[]) => {
|
|
225
|
+
async (ids: string[], signal: AbortSignal) => {
|
|
216
226
|
if (action.kind === "move") {
|
|
217
227
|
const { data } = await messageBulkOperationsMoveMessages({
|
|
218
228
|
body: {
|
|
219
229
|
messageIds: ids,
|
|
220
230
|
destinationMailboxId: action.destinationMailboxId,
|
|
221
231
|
},
|
|
232
|
+
signal,
|
|
222
233
|
throwOnError: true,
|
|
223
234
|
});
|
|
224
235
|
return data;
|
|
@@ -226,12 +237,14 @@ export const useEscalatedActions = ({
|
|
|
226
237
|
if (action.kind === "markRead") {
|
|
227
238
|
const { data } = await messageBulkOperationsUpdateFlags({
|
|
228
239
|
body: { messageIds: ids, isRead: true },
|
|
240
|
+
signal,
|
|
229
241
|
throwOnError: true,
|
|
230
242
|
});
|
|
231
243
|
return data;
|
|
232
244
|
}
|
|
233
245
|
const { data } = await messageBulkOperationsDeleteMessages({
|
|
234
246
|
body: { messageIds: ids },
|
|
247
|
+
signal,
|
|
235
248
|
throwOnError: true,
|
|
236
249
|
});
|
|
237
250
|
return data;
|
|
@@ -267,17 +280,24 @@ export const useEscalatedActions = ({
|
|
|
267
280
|
);
|
|
268
281
|
|
|
269
282
|
const escalate = useCallback(() => {
|
|
270
|
-
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
abortRef.current = controller;
|
|
271
285
|
setPhase({ kind: "counting" });
|
|
272
|
-
fetchMatchCount().then(
|
|
286
|
+
fetchMatchCount(controller.signal).then(
|
|
273
287
|
(total) => {
|
|
274
|
-
if (
|
|
288
|
+
if (controller.signal.aborted) {
|
|
275
289
|
setPhase({ kind: "idle" });
|
|
276
290
|
return;
|
|
277
291
|
}
|
|
278
292
|
setPhase({ kind: "escalated", total });
|
|
279
293
|
},
|
|
280
294
|
(error: unknown) => {
|
|
295
|
+
// A stopped count rejects with its own abort. That is the press the
|
|
296
|
+
// user made, not a failure to report back to them.
|
|
297
|
+
if (controller.signal.aborted) {
|
|
298
|
+
setPhase({ kind: "idle" });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
281
301
|
pushError(
|
|
282
302
|
buildMutationErrorBanner(
|
|
283
303
|
"Couldn't count matching messages",
|
|
@@ -291,12 +311,12 @@ export const useEscalatedActions = ({
|
|
|
291
311
|
}, [fetchMatchCount, pushError]);
|
|
292
312
|
|
|
293
313
|
const stop = useCallback(() => {
|
|
294
|
-
|
|
314
|
+
abortRef.current?.abort();
|
|
295
315
|
}, []);
|
|
296
316
|
|
|
297
317
|
const clear = useCallback(() => {
|
|
298
318
|
if (runningRef.current) return;
|
|
299
|
-
|
|
319
|
+
abortRef.current?.abort();
|
|
300
320
|
setPhase({ kind: "idle" });
|
|
301
321
|
}, []);
|
|
302
322
|
|
|
@@ -305,7 +325,8 @@ export const useEscalatedActions = ({
|
|
|
305
325
|
action: EscalatedAction,
|
|
306
326
|
targets?: readonly BulkActionTarget[],
|
|
307
327
|
): Promise<BulkRunOutcome> => {
|
|
308
|
-
|
|
328
|
+
const controller = new AbortController();
|
|
329
|
+
abortRef.current = controller;
|
|
309
330
|
runningRef.current = true;
|
|
310
331
|
setRunningAction(action);
|
|
311
332
|
// Read before the run clears the phase below, so a refusal can say how
|
|
@@ -334,14 +355,14 @@ export const useEscalatedActions = ({
|
|
|
334
355
|
targets,
|
|
335
356
|
applyBatch,
|
|
336
357
|
onProgress,
|
|
337
|
-
|
|
358
|
+
controller.signal,
|
|
338
359
|
)
|
|
339
360
|
: await runPredicateAction(
|
|
340
361
|
runPages,
|
|
341
362
|
phase.kind === "escalated" ? phase.total : 0,
|
|
342
363
|
applyBatch,
|
|
343
364
|
onProgress,
|
|
344
|
-
|
|
365
|
+
controller.signal,
|
|
345
366
|
);
|
|
346
367
|
} finally {
|
|
347
368
|
runningRef.current = false;
|
|
@@ -68,19 +68,29 @@ describe("bulkActionFailureTitle", () => {
|
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
describe("a run that stopped short of its match", () => {
|
|
71
|
-
test("states what it reached and
|
|
71
|
+
test("states what it reached and what became of the rest", () => {
|
|
72
72
|
assert.equal(bulkActionStoppedTitle(1200), "Stopped after 1,200");
|
|
73
73
|
assert.equal(
|
|
74
74
|
bulkActionStoppedDetail("delete", 1200, 3412),
|
|
75
|
-
"1,200 of 3,412 moved to Trash.
|
|
75
|
+
"1,200 of 3,412 moved to Trash. The rest were not sent, apart from the ones already on their way when you stopped — those may still have gone through.",
|
|
76
76
|
);
|
|
77
77
|
assert.equal(
|
|
78
78
|
bulkActionStoppedDetail("markRead", 1200, 3412),
|
|
79
|
-
"1,200 of 3,412 marked as read.
|
|
79
|
+
"1,200 of 3,412 marked as read. The rest were not sent, apart from the ones already on their way when you stopped — those may still have gone through.",
|
|
80
80
|
);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// Stopping aborts the request on the wire; it does not un-send one the server
|
|
84
|
+
// already took (#113). Promising the remainder is untouched would be a claim
|
|
85
|
+
// the client cannot make about the batch that was in flight.
|
|
86
|
+
test("stops short of promising the batch in flight never landed", () => {
|
|
87
|
+
assert.match(
|
|
88
|
+
bulkActionStoppedDetail("move", 1, 2),
|
|
89
|
+
/may still have gone through/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("never reads as a rejection — nothing was refused", () => {
|
|
84
94
|
assert.doesNotMatch(
|
|
85
95
|
bulkActionStoppedDetail("move", 1, 2),
|
|
86
96
|
/rejected|couldn't/i,
|
|
@@ -86,9 +86,11 @@ export const bulkActionCompletionText = (
|
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Shown when a run ended before it covered what it was started against. The
|
|
89
|
-
* remainder was never sent, so
|
|
89
|
+
* remainder was never sent, so that mail is where it was and only the user can
|
|
90
90
|
* decide to run it again — which is why this is stated rather than left to a
|
|
91
|
-
* list that quietly stops changing.
|
|
91
|
+
* list that quietly stops changing. Stopping aborts the request already on the
|
|
92
|
+
* wire rather than un-sending it (#113), so what the copy can promise about the
|
|
93
|
+
* remainder stops short of the batch that was in flight.
|
|
92
94
|
*/
|
|
93
95
|
export const bulkActionStoppedTitle = (done: number): string =>
|
|
94
96
|
`Stopped after ${formatNumber(done)}`;
|
|
@@ -99,7 +101,7 @@ export const bulkActionStoppedDetail = (
|
|
|
99
101
|
total: number,
|
|
100
102
|
outcome: DeleteOutcome = "trash",
|
|
101
103
|
): string =>
|
|
102
|
-
`${formatNumber(done)} of ${formatNumber(total)} ${pastTenseFor(kind, outcome)}.
|
|
104
|
+
`${formatNumber(done)} of ${formatNumber(total)} ${pastTenseFor(kind, outcome)}. The rest were not sent, apart from the ones already on their way when you stopped — those may still have gone through.`;
|
|
103
105
|
|
|
104
106
|
/** Error-banner title for a run stopped by an infrastructure failure. */
|
|
105
107
|
export const bulkActionFailureTitle = (
|
|
@@ -11,6 +11,24 @@ import {
|
|
|
11
11
|
runPredicateAction,
|
|
12
12
|
} from "./bulk-actions.js";
|
|
13
13
|
|
|
14
|
+
/** A run nobody stops. */
|
|
15
|
+
const neverAborted = (): AbortSignal => new AbortController().signal;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What an aborted request does: reject with the signal's own reason, whether it
|
|
19
|
+
* was already aborted when the call was made or aborted while in flight.
|
|
20
|
+
*/
|
|
21
|
+
const abortable = (signal: AbortSignal): Promise<never> =>
|
|
22
|
+
new Promise((_, reject) => {
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
reject(signal.reason);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
signal.addEventListener("abort", () => reject(signal.reason), {
|
|
28
|
+
once: true,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
14
32
|
const ids = (count: number, prefix = "m"): string[] =>
|
|
15
33
|
Array.from({ length: count }, (_, i) => `${prefix}${i}`);
|
|
16
34
|
|
|
@@ -112,7 +130,6 @@ describe("chunkTargets", () => {
|
|
|
112
130
|
});
|
|
113
131
|
|
|
114
132
|
describe("runChunkedAction", () => {
|
|
115
|
-
const neverCancelled = () => false;
|
|
116
133
|
const noopProgress = () => undefined;
|
|
117
134
|
|
|
118
135
|
test("zero ids does nothing and reports done=0", async () => {
|
|
@@ -124,7 +141,7 @@ describe("runChunkedAction", () => {
|
|
|
124
141
|
return { successCount: chunk.length, failureCount: 0 };
|
|
125
142
|
},
|
|
126
143
|
noopProgress,
|
|
127
|
-
|
|
144
|
+
neverAborted(),
|
|
128
145
|
);
|
|
129
146
|
assert.deepEqual(outcome, {
|
|
130
147
|
done: 0,
|
|
@@ -144,7 +161,7 @@ describe("runChunkedAction", () => {
|
|
|
144
161
|
return { successCount: chunk.length, failureCount: 0 };
|
|
145
162
|
},
|
|
146
163
|
noopProgress,
|
|
147
|
-
|
|
164
|
+
neverAborted(),
|
|
148
165
|
);
|
|
149
166
|
assert.equal(calls.length, 2);
|
|
150
167
|
assert.equal(calls[0].length, BULK_ACTION_CHUNK_SIZE);
|
|
@@ -159,7 +176,7 @@ describe("runChunkedAction", () => {
|
|
|
159
176
|
input,
|
|
160
177
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
161
178
|
noopProgress,
|
|
162
|
-
|
|
179
|
+
neverAborted(),
|
|
163
180
|
);
|
|
164
181
|
assert.equal(outcome.done, input.length);
|
|
165
182
|
assert.deepEqual(outcome.failedIds, []);
|
|
@@ -168,16 +185,16 @@ describe("runChunkedAction", () => {
|
|
|
168
185
|
test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
|
|
169
186
|
const input = targets(BULK_ACTION_CHUNK_SIZE * 3);
|
|
170
187
|
let calls = 0;
|
|
171
|
-
|
|
188
|
+
const stop = new AbortController();
|
|
172
189
|
const outcome = await runChunkedAction(
|
|
173
190
|
input,
|
|
174
191
|
async (chunk) => {
|
|
175
192
|
calls++;
|
|
176
|
-
if (calls === 1)
|
|
193
|
+
if (calls === 1) stop.abort(); // cancel after the first chunk lands
|
|
177
194
|
return { successCount: chunk.length, failureCount: 0 };
|
|
178
195
|
},
|
|
179
196
|
() => undefined,
|
|
180
|
-
|
|
197
|
+
stop.signal,
|
|
181
198
|
);
|
|
182
199
|
assert.equal(outcome.cancelled, true);
|
|
183
200
|
assert.equal(calls, 1);
|
|
@@ -195,7 +212,7 @@ describe("runChunkedAction", () => {
|
|
|
195
212
|
throw boom;
|
|
196
213
|
},
|
|
197
214
|
noopProgress,
|
|
198
|
-
|
|
215
|
+
neverAborted(),
|
|
199
216
|
);
|
|
200
217
|
assert.equal(outcome.error, boom);
|
|
201
218
|
assert.equal(outcome.done, 0);
|
|
@@ -215,7 +232,7 @@ describe("runChunkedAction", () => {
|
|
|
215
232
|
return { successCount: chunk.length, failureCount: 0 };
|
|
216
233
|
},
|
|
217
234
|
noopProgress,
|
|
218
|
-
|
|
235
|
+
neverAborted(),
|
|
219
236
|
);
|
|
220
237
|
assert.deepEqual(calls, [["a1", "a2"], ["b1"]]);
|
|
221
238
|
assert.equal(outcome.done, 3);
|
|
@@ -231,7 +248,7 @@ describe("runChunkedAction", () => {
|
|
|
231
248
|
],
|
|
232
249
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
233
250
|
(p) => seen.push(p),
|
|
234
|
-
|
|
251
|
+
neverAborted(),
|
|
235
252
|
);
|
|
236
253
|
assert.deepEqual(seen, [
|
|
237
254
|
{ done: 1, total: 2 },
|
|
@@ -240,7 +257,7 @@ describe("runChunkedAction", () => {
|
|
|
240
257
|
});
|
|
241
258
|
|
|
242
259
|
test("cancelling at an account boundary hands back the accounts never reached", async () => {
|
|
243
|
-
|
|
260
|
+
const stop = new AbortController();
|
|
244
261
|
const outcome = await runChunkedAction(
|
|
245
262
|
[
|
|
246
263
|
{ id: "a1", accountId: "acct-a" },
|
|
@@ -248,11 +265,11 @@ describe("runChunkedAction", () => {
|
|
|
248
265
|
{ id: "c1", accountId: "acct-c" },
|
|
249
266
|
],
|
|
250
267
|
async (chunk) => {
|
|
251
|
-
|
|
268
|
+
stop.abort();
|
|
252
269
|
return { successCount: chunk.length, failureCount: 0 };
|
|
253
270
|
},
|
|
254
271
|
() => undefined,
|
|
255
|
-
|
|
272
|
+
stop.signal,
|
|
256
273
|
);
|
|
257
274
|
assert.equal(outcome.cancelled, true);
|
|
258
275
|
assert.equal(outcome.done, 1);
|
|
@@ -271,7 +288,7 @@ describe("runChunkedAction", () => {
|
|
|
271
288
|
return { successCount: chunk.length, failureCount: 0 };
|
|
272
289
|
},
|
|
273
290
|
() => undefined,
|
|
274
|
-
|
|
291
|
+
neverAborted(),
|
|
275
292
|
);
|
|
276
293
|
assert.equal(outcome.error, boom);
|
|
277
294
|
assert.equal(outcome.done, 1);
|
|
@@ -291,7 +308,7 @@ describe("runChunkedAction", () => {
|
|
|
291
308
|
throw new Error("auth expired");
|
|
292
309
|
},
|
|
293
310
|
() => undefined,
|
|
294
|
-
|
|
311
|
+
neverAborted(),
|
|
295
312
|
);
|
|
296
313
|
// The run stops where it threw rather than trying the accounts behind it:
|
|
297
314
|
// what it hands back is exactly what is still untouched, which is what a
|
|
@@ -308,7 +325,7 @@ describe("runChunkedAction", () => {
|
|
|
308
325
|
input,
|
|
309
326
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
310
327
|
(p) => progressCalls.push(p),
|
|
311
|
-
|
|
328
|
+
neverAborted(),
|
|
312
329
|
);
|
|
313
330
|
assert.deepEqual(progressCalls, [
|
|
314
331
|
{ done: BULK_ACTION_CHUNK_SIZE, total: input.length },
|
|
@@ -318,7 +335,6 @@ describe("runChunkedAction", () => {
|
|
|
318
335
|
});
|
|
319
336
|
|
|
320
337
|
describe("runPredicateAction", () => {
|
|
321
|
-
const neverCancelled = () => false;
|
|
322
338
|
const noopProgress = () => undefined;
|
|
323
339
|
|
|
324
340
|
/** Builds a paged fixture: `pages[i]` is what the i-th call returns. */
|
|
@@ -338,7 +354,7 @@ describe("runPredicateAction", () => {
|
|
|
338
354
|
0,
|
|
339
355
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
340
356
|
noopProgress,
|
|
341
|
-
|
|
357
|
+
neverAborted(),
|
|
342
358
|
);
|
|
343
359
|
assert.deepEqual(outcome, { done: 0, failedIds: [], cancelled: false });
|
|
344
360
|
});
|
|
@@ -354,7 +370,7 @@ describe("runPredicateAction", () => {
|
|
|
354
370
|
return { successCount: chunk.length, failureCount: 0 };
|
|
355
371
|
},
|
|
356
372
|
noopProgress,
|
|
357
|
-
|
|
373
|
+
neverAborted(),
|
|
358
374
|
);
|
|
359
375
|
assert.equal(deleteCalls.length, 1);
|
|
360
376
|
assert.equal(outcome.done, BULK_ACTION_CHUNK_SIZE);
|
|
@@ -374,7 +390,7 @@ describe("runPredicateAction", () => {
|
|
|
374
390
|
return { successCount: chunk.length, failureCount: 0 };
|
|
375
391
|
},
|
|
376
392
|
noopProgress,
|
|
377
|
-
|
|
393
|
+
neverAborted(),
|
|
378
394
|
);
|
|
379
395
|
assert.equal(deleteCalls.length, 2);
|
|
380
396
|
assert.equal(outcome.done, 150);
|
|
@@ -397,7 +413,7 @@ describe("runPredicateAction", () => {
|
|
|
397
413
|
return { successCount: chunk.length, failureCount: 0 };
|
|
398
414
|
},
|
|
399
415
|
noopProgress,
|
|
400
|
-
|
|
416
|
+
neverAborted(),
|
|
401
417
|
);
|
|
402
418
|
assert.deepEqual(deleteCalls, [
|
|
403
419
|
["a", "b"],
|
|
@@ -408,7 +424,7 @@ describe("runPredicateAction", () => {
|
|
|
408
424
|
|
|
409
425
|
test("cancelling mid-delete stops paging without inventing failedIds for unfetched pages", async () => {
|
|
410
426
|
let fetchCalls = 0;
|
|
411
|
-
|
|
427
|
+
const stop = new AbortController();
|
|
412
428
|
const fetch = async (): Promise<FetchIdsPageResult> => {
|
|
413
429
|
fetchCalls++;
|
|
414
430
|
return { ids: ["a", "b"], continuationToken: "more" };
|
|
@@ -417,11 +433,11 @@ describe("runPredicateAction", () => {
|
|
|
417
433
|
fetch,
|
|
418
434
|
1000,
|
|
419
435
|
async (chunk) => {
|
|
420
|
-
|
|
436
|
+
stop.abort(); // cancel takes effect on the next loop iteration
|
|
421
437
|
return { successCount: chunk.length, failureCount: 0 };
|
|
422
438
|
},
|
|
423
439
|
() => undefined,
|
|
424
|
-
|
|
440
|
+
stop.signal,
|
|
425
441
|
);
|
|
426
442
|
assert.equal(outcome.cancelled, true);
|
|
427
443
|
assert.equal(fetchCalls, 1);
|
|
@@ -439,7 +455,7 @@ describe("runPredicateAction", () => {
|
|
|
439
455
|
100,
|
|
440
456
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
441
457
|
noopProgress,
|
|
442
|
-
|
|
458
|
+
neverAborted(),
|
|
443
459
|
);
|
|
444
460
|
assert.equal(outcome.error, boom);
|
|
445
461
|
assert.equal(outcome.done, 0);
|
|
@@ -455,12 +471,85 @@ describe("runPredicateAction", () => {
|
|
|
455
471
|
throw boom;
|
|
456
472
|
},
|
|
457
473
|
noopProgress,
|
|
458
|
-
|
|
474
|
+
neverAborted(),
|
|
459
475
|
);
|
|
460
476
|
assert.equal(outcome.error, boom);
|
|
461
477
|
});
|
|
462
478
|
});
|
|
463
479
|
|
|
480
|
+
// Regression for #113: a stop used to be a flag read between pages, so the
|
|
481
|
+
// search or delete already on the wire ran to completion — up to a hundred
|
|
482
|
+
// messages deleted after the press. The run's signal is now the call's signal.
|
|
483
|
+
describe("stopping a run that has a request in flight", () => {
|
|
484
|
+
test("a bounded run reports the aborted batch as a clean stop, never an error", async () => {
|
|
485
|
+
const stop = new AbortController();
|
|
486
|
+
const calls: string[][] = [];
|
|
487
|
+
const outcome = await runChunkedAction(
|
|
488
|
+
targets(BULK_ACTION_CHUNK_SIZE * 3),
|
|
489
|
+
async (chunk, signal) => {
|
|
490
|
+
calls.push(chunk);
|
|
491
|
+
stop.abort();
|
|
492
|
+
return abortable(signal);
|
|
493
|
+
},
|
|
494
|
+
() => undefined,
|
|
495
|
+
stop.signal,
|
|
496
|
+
);
|
|
497
|
+
assert.equal(outcome.cancelled, true);
|
|
498
|
+
assert.equal(outcome.error, undefined);
|
|
499
|
+
assert.equal(calls.length, 1, "nothing further left after the stop");
|
|
500
|
+
assert.equal(outcome.done, 0);
|
|
501
|
+
// The aborted batch may or may not have been accepted server-side, so it
|
|
502
|
+
// comes back with the chunks never attempted rather than counted as done.
|
|
503
|
+
assert.equal(outcome.failedIds.length, BULK_ACTION_CHUNK_SIZE * 3);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("a predicate run whose delete is aborted sends nothing more and reports a clean stop", async () => {
|
|
507
|
+
const stop = new AbortController();
|
|
508
|
+
let fetches = 0;
|
|
509
|
+
let deletes = 0;
|
|
510
|
+
const outcome = await runPredicateAction(
|
|
511
|
+
async () => {
|
|
512
|
+
fetches++;
|
|
513
|
+
return { ids: ["a", "b"], continuationToken: "more" };
|
|
514
|
+
},
|
|
515
|
+
1000,
|
|
516
|
+
async (_chunk, signal) => {
|
|
517
|
+
deletes++;
|
|
518
|
+
stop.abort();
|
|
519
|
+
return abortable(signal);
|
|
520
|
+
},
|
|
521
|
+
() => undefined,
|
|
522
|
+
stop.signal,
|
|
523
|
+
);
|
|
524
|
+
assert.equal(outcome.cancelled, true);
|
|
525
|
+
assert.equal(outcome.error, undefined);
|
|
526
|
+
assert.equal(deletes, 1);
|
|
527
|
+
assert.equal(fetches, 1);
|
|
528
|
+
assert.equal(outcome.done, 0);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("a predicate run whose search page is aborted deletes nothing", async () => {
|
|
532
|
+
const stop = new AbortController();
|
|
533
|
+
let deletes = 0;
|
|
534
|
+
const outcome = await runPredicateAction(
|
|
535
|
+
async (_token, signal) => {
|
|
536
|
+
stop.abort();
|
|
537
|
+
return abortable(signal);
|
|
538
|
+
},
|
|
539
|
+
1000,
|
|
540
|
+
async (chunk) => {
|
|
541
|
+
deletes++;
|
|
542
|
+
return { successCount: chunk.length, failureCount: 0 };
|
|
543
|
+
},
|
|
544
|
+
() => undefined,
|
|
545
|
+
stop.signal,
|
|
546
|
+
);
|
|
547
|
+
assert.equal(outcome.cancelled, true);
|
|
548
|
+
assert.equal(outcome.error, undefined);
|
|
549
|
+
assert.equal(deletes, 0);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
464
553
|
describe("honestProgress", () => {
|
|
465
554
|
// Regression for #109: the count and `runPredicateAction` resolve the same
|
|
466
555
|
// predicate independently, so the delete can outrun the frozen `total` it
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
* failure (auth, the write, or the enqueue) that takes out the whole batch and
|
|
16
16
|
* stops the run.
|
|
17
17
|
*
|
|
18
|
+
* Cancellation is an `AbortSignal` the caller owns, handed to every call the run
|
|
19
|
+
* makes (#113). Aborting ends the request in flight rather than waiting for it,
|
|
20
|
+
* so at most the batch already on the wire is at stake instead of a whole page;
|
|
21
|
+
* the run then stops before the next request leaves. A rejection raised while
|
|
22
|
+
* the signal is aborted is that abort, so it reports `cancelled` rather than an
|
|
23
|
+
* error — the user asked for it.
|
|
24
|
+
*
|
|
25
|
+
* That classification reads `signal.aborted`, deliberately, and not the shape of
|
|
26
|
+
* the rejection (`isAbortError`): a genuine failure that raced the user's Stop
|
|
27
|
+
* should read as a stop. The user pressed it and the run is over either way, and
|
|
28
|
+
* an error banner for a run they ended themselves is the wrong answer.
|
|
29
|
+
*
|
|
18
30
|
* Pure, framework-agnostic, and independently testable — no React, no fetch.
|
|
19
31
|
* `useEscalatedActions.ts` supplies the real `ApplyBatch`/`FetchIdsPage`
|
|
20
32
|
* implementations (the generated SDK client) and owns the React state.
|
|
@@ -22,6 +34,9 @@
|
|
|
22
34
|
|
|
23
35
|
export const BULK_ACTION_CHUNK_SIZE = 100;
|
|
24
36
|
|
|
37
|
+
/** For a run with no Stop control to wire a signal to. */
|
|
38
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Resolves a promise to a discriminated result instead of throwing, so a
|
|
27
42
|
* caller can branch on infrastructure failure as a value — the app's
|
|
@@ -104,7 +119,10 @@ export interface BatchResult {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
/** Applies the action to one batch of ids (≤100) with a single bulk call. */
|
|
107
|
-
export type ApplyBatch = (
|
|
122
|
+
export type ApplyBatch = (
|
|
123
|
+
ids: string[],
|
|
124
|
+
signal: AbortSignal,
|
|
125
|
+
) => Promise<BatchResult>;
|
|
108
126
|
|
|
109
127
|
export interface BulkActionProgress {
|
|
110
128
|
/** Ids the action has been applied to so far. */
|
|
@@ -122,6 +140,9 @@ export interface BulkActionOutcome {
|
|
|
122
140
|
* cancellation or a thrown error, the chunks the bounded run never attempted
|
|
123
141
|
* (see `runChunkedAction`). Empty in the predicate case, which re-resolves on
|
|
124
142
|
* every run rather than handing back a remainder (see `runPredicateAction`).
|
|
143
|
+
* An aborted batch is counted here too: aborting the request does not
|
|
144
|
+
* un-send what the server already accepted, so its ids are "may not have
|
|
145
|
+
* landed" rather than "did not", and every action here is idempotent.
|
|
125
146
|
* There is no per-id failure source: a returned batch call counts every id in
|
|
126
147
|
* it as accepted (see the module header).
|
|
127
148
|
*/
|
|
@@ -150,7 +171,7 @@ export const runChunkedAction = async (
|
|
|
150
171
|
targets: readonly BulkActionTarget[],
|
|
151
172
|
applyBatch: ApplyBatch,
|
|
152
173
|
onProgress: (progress: BulkActionProgress) => void,
|
|
153
|
-
|
|
174
|
+
signal: AbortSignal,
|
|
154
175
|
): Promise<BulkActionOutcome> => {
|
|
155
176
|
const chunks = chunkTargets(targets);
|
|
156
177
|
const total = targets.length;
|
|
@@ -158,14 +179,15 @@ export const runChunkedAction = async (
|
|
|
158
179
|
const failedIds: string[] = [];
|
|
159
180
|
|
|
160
181
|
for (let i = 0; i < chunks.length; i++) {
|
|
161
|
-
if (
|
|
182
|
+
if (signal.aborted) {
|
|
162
183
|
failedIds.push(...chunks.slice(i).flat());
|
|
163
184
|
return { done, failedIds, cancelled: true };
|
|
164
185
|
}
|
|
165
186
|
const chunk = chunks[i];
|
|
166
|
-
const attempted = await attempt(applyBatch(chunk));
|
|
187
|
+
const attempted = await attempt(applyBatch(chunk, signal));
|
|
167
188
|
if (!attempted.ok) {
|
|
168
189
|
failedIds.push(...chunks.slice(i).flat());
|
|
190
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
169
191
|
onProgress({ done, total });
|
|
170
192
|
return { done, failedIds, cancelled: false, error: attempted.error };
|
|
171
193
|
}
|
|
@@ -194,6 +216,10 @@ export const runChunkedAction = async (
|
|
|
194
216
|
* focused row, or a selection the surface already scoped — so nothing here
|
|
195
217
|
* carries an account to split by. A caller with a cross-account selection
|
|
196
218
|
* belongs on `runChunkedAction`, whose targets name one (#872).
|
|
219
|
+
*
|
|
220
|
+
* These callers have no Stop control to wire a signal to — a mutation over a
|
|
221
|
+
* handful of ids has no progress surface to stop from — so the run is handed a
|
|
222
|
+
* signal that never aborts.
|
|
197
223
|
*/
|
|
198
224
|
export const runChunkedMutation = async (
|
|
199
225
|
ids: readonly string[],
|
|
@@ -206,7 +232,7 @@ export const runChunkedMutation = async (
|
|
|
206
232
|
return { successCount: chunk.length, failureCount: 0 };
|
|
207
233
|
},
|
|
208
234
|
() => {},
|
|
209
|
-
|
|
235
|
+
NEVER_ABORTED,
|
|
210
236
|
);
|
|
211
237
|
};
|
|
212
238
|
|
|
@@ -218,6 +244,7 @@ export interface FetchIdsPageResult {
|
|
|
218
244
|
/** Fetches one page of matching message ids for the active predicate. */
|
|
219
245
|
export type FetchIdsPage = (
|
|
220
246
|
continuationToken: string | undefined,
|
|
247
|
+
signal: AbortSignal,
|
|
221
248
|
) => Promise<FetchIdsPageResult>;
|
|
222
249
|
|
|
223
250
|
/**
|
|
@@ -230,33 +257,36 @@ export type FetchIdsPage = (
|
|
|
230
257
|
* `failedIds` for the unreached remainder, because those ids were never
|
|
231
258
|
* fetched — there is nothing to hand back. A predicate resolves fresh on
|
|
232
259
|
* every run (D2), so resuming is re-invoking this same function with the same
|
|
233
|
-
* predicate.
|
|
260
|
+
* predicate. Aborting also ends the page or batch on the wire, so the run gives
|
|
261
|
+
* up at most the one request rather than the whole page it was mid-way through.
|
|
234
262
|
*/
|
|
235
263
|
export const runPredicateAction = async (
|
|
236
264
|
fetchIdsPage: FetchIdsPage,
|
|
237
265
|
total: number,
|
|
238
266
|
applyBatch: ApplyBatch,
|
|
239
267
|
onProgress: (progress: BulkActionProgress) => void,
|
|
240
|
-
|
|
268
|
+
signal: AbortSignal,
|
|
241
269
|
): Promise<BulkActionOutcome> => {
|
|
242
270
|
let done = 0;
|
|
243
271
|
const failedIds: string[] = [];
|
|
244
272
|
let token: string | undefined;
|
|
245
273
|
|
|
246
274
|
do {
|
|
247
|
-
if (
|
|
275
|
+
if (signal.aborted) {
|
|
248
276
|
return { done, failedIds, cancelled: true };
|
|
249
277
|
}
|
|
250
278
|
|
|
251
|
-
const fetched = await attempt(fetchIdsPage(token));
|
|
279
|
+
const fetched = await attempt(fetchIdsPage(token, signal));
|
|
252
280
|
if (!fetched.ok) {
|
|
281
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
253
282
|
return { done, failedIds, cancelled: false, error: fetched.error };
|
|
254
283
|
}
|
|
255
284
|
const page = fetched.value;
|
|
256
285
|
|
|
257
286
|
if (page.ids.length > 0) {
|
|
258
|
-
const attempted = await attempt(applyBatch(page.ids));
|
|
287
|
+
const attempted = await attempt(applyBatch(page.ids, signal));
|
|
259
288
|
if (!attempted.ok) {
|
|
289
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
260
290
|
return { done, failedIds, cancelled: false, error: attempted.error };
|
|
261
291
|
}
|
|
262
292
|
done += page.ids.length;
|