@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.269",
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 ?? "", /Nothing was sent for the rest/);
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 { matched, outcome, failureReason } = bulkRun;
827
- // Nothing was sent, and nothing about sending it again resolves what was
828
- // missing — so the screen carries why rather than the generic ending (#522).
829
- if (failureReason !== undefined) {
830
- return { ...NOT_STARTED, state: "commitFailed", failureReason };
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: "runStopped",
873
- matched: Math.max(matched, outcome.done),
874
- applied: outcome.done,
875
- failed: unreached,
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 at a page
10
- * boundary before the test acts on it: that is where the run reads whether it
11
- * has been told to stop, and so the only place the difference shows.
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
- /** Deletes queued at the boundary rather than answered. */
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 new Promise<void>((resolve) => waiting.push(resolve));
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 — at the next boundary.
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
- const cancelRef = useRef(false);
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) cancelRef.current = true;
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(async (): Promise<number> => {
202
- const { data } = await threadOperationsSearchThreads({
203
- path: { mailboxId },
204
- query: { ...searchQueryRef.current, count: true, results: false },
205
- throwOnError: true,
206
- });
207
- if (data.count === undefined) {
208
- throw new Error("the search returned no count for the selection");
209
- }
210
- return data.count;
211
- }, [mailboxId]);
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
- cancelRef.current = false;
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 (cancelRef.current) {
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
- cancelRef.current = true;
314
+ abortRef.current?.abort();
295
315
  }, []);
296
316
 
297
317
  const clear = useCallback(() => {
298
318
  if (runningRef.current) return;
299
- cancelRef.current = true;
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
- cancelRef.current = false;
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
- () => cancelRef.current,
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
- () => cancelRef.current,
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 that the rest is untouched", () => {
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. Nothing was sent for the rest, so they are untouched.",
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. Nothing was sent for the rest, so they are untouched.",
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
- test("never reads as a rejection — nothing was sent", () => {
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 the mail is where it was and only the user can
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)}. Nothing was sent for the rest, so they are untouched.`;
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
- neverCancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- let cancelled = false;
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) cancelled = true; // cancel after the first chunk lands
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
- () => cancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- let cancelled = false;
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
- cancelled = true;
268
+ stop.abort();
252
269
  return { successCount: chunk.length, failureCount: 0 };
253
270
  },
254
271
  () => undefined,
255
- () => cancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- neverCancelled,
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
- let cancelled = false;
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
- cancelled = true; // cancel takes effect on the next loop iteration
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
- () => cancelled,
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
- neverCancelled,
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
- neverCancelled,
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
@@ -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 = (ids: string[]) => Promise<BatchResult>;
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
- isCancelled: () => boolean,
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 (isCancelled()) {
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
- () => false,
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
- isCancelled: () => boolean,
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 (isCancelled()) {
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;