@remit/web-client 0.0.268 → 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/components/ui/placement-refusal.test.ts +33 -2
- package/src/components/ui/placement-refusal.ts +14 -6
- package/src/hooks/escalated-run-lifetime.render.test.ts +87 -6
- package/src/hooks/refusal-escalation.render.test.ts +56 -0
- package/src/hooks/useDeleteMessages.ts +1 -1
- package/src/hooks/useEscalatedActions.ts +45 -24
- package/src/hooks/useMoveMessages.ts +16 -0
- 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
|
@@ -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;
|
|
@@ -8,7 +8,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
8
8
|
import { useCallback } from "react";
|
|
9
9
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
10
10
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
11
|
+
import {
|
|
12
|
+
isPlacementRefusal,
|
|
13
|
+
placementRefusalBanner,
|
|
14
|
+
} from "@/components/ui/placement-refusal";
|
|
11
15
|
import { runChunkedMutation } from "@/lib/bulk-actions";
|
|
16
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
12
17
|
import {
|
|
13
18
|
cancelThreadListQueries,
|
|
14
19
|
invalidateThreadListQueries,
|
|
@@ -57,6 +62,10 @@ export const useMoveMessages = ({
|
|
|
57
62
|
|
|
58
63
|
const { mutateAsync, isPending, isError } = useMutation({
|
|
59
64
|
...messageBulkOperationsMoveMessagesMutation(),
|
|
65
|
+
// A coded 409 is answered here — a banner — so it must not also take the
|
|
66
|
+
// whole screen. Only 409: a 401, 403 or 5xx on a move is still the fatal
|
|
67
|
+
// page's (#1059).
|
|
68
|
+
meta: softErrorStatuses(409),
|
|
60
69
|
onMutate: async (variables): Promise<ThreadMutationContext> => {
|
|
61
70
|
const messageIds = new Set(variables.body.messageIds ?? []);
|
|
62
71
|
|
|
@@ -136,6 +145,13 @@ export const useMoveMessages = ({
|
|
|
136
145
|
restoreThreadListQueries(queryClient, context.previousThreadsList);
|
|
137
146
|
}
|
|
138
147
|
const count = vars.body.messageIds?.length ?? 0;
|
|
148
|
+
// A placement refusal is a wait or a resync, not a failure to report as
|
|
149
|
+
// one: the move was never made, and the banner says which.
|
|
150
|
+
const placement = isPlacementRefusal(err);
|
|
151
|
+
if (placement) {
|
|
152
|
+
pushError(placementRefusalBanner(placement, count, "move"));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
139
155
|
pushError({
|
|
140
156
|
title:
|
|
141
157
|
count > 1
|
|
@@ -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
|