@remit/web-client 0.0.25 → 0.0.26

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.
@@ -0,0 +1,244 @@
1
+ import {
2
+ mailboxOperationsListMailboxesQueryKey,
3
+ threadOperationsListThreadsQueryKey,
4
+ threadOperationsSearchThreadsQueryKey,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import {
7
+ messageBulkOperationsDeleteMessages,
8
+ threadOperationsSearchThreads,
9
+ } from "@remit/api-http-client/sdk.gen.ts";
10
+ import type { ThreadOperationsSearchThreadsData } from "@remit/api-http-client/types.gen.ts";
11
+ import { useQueryClient } from "@tanstack/react-query";
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
14
+ import { buildMutationErrorBanner } from "@/components/ui/error-banners";
15
+ import {
16
+ type BulkDeleteProgress,
17
+ countMatches,
18
+ type DeleteRunOutcome,
19
+ type FetchIdsPage,
20
+ runChunkedDelete,
21
+ runPredicateDelete,
22
+ } from "@/lib/bulk-delete";
23
+
24
+ /** The predicate a search-scoped delete re-issues on every page — the same
25
+ * filters the visible list is searching with, minus pagination/count knobs. */
26
+ export type EscalationSearchQuery = Pick<
27
+ NonNullable<ThreadOperationsSearchThreadsData["query"]>,
28
+ "order" | "query" | "subject" | "from" | "unread" | "starred" | "attachments"
29
+ >;
30
+
31
+ /** Page size for both the counting and the execution loop. Set to the write
32
+ * side's own 100-id cap so an execution page IS a delete chunk — no
33
+ * in-memory accumulation step between reading ids and sending them. Counting
34
+ * doesn't have that constraint but reuses the same page size rather than
35
+ * adding a second one to reason about. */
36
+ const PAGE_SIZE = 100;
37
+
38
+ export type EscalationPhase =
39
+ | { kind: "idle" }
40
+ | { kind: "counting"; countSoFar: number }
41
+ | { kind: "escalated"; total: number };
42
+
43
+ interface UseEscalatedDeleteOptions {
44
+ mailboxId: string;
45
+ /** Owning account, forwarded to the unseen-count invalidation on completion. */
46
+ accountId?: string;
47
+ /** Disables escalation entirely (e.g. not searching, or desktop — this is a
48
+ * mobile-only affordance). Resets any in-flight phase back to idle. */
49
+ enabled: boolean;
50
+ /** Identifies the active predicate; escalation resets to idle whenever this
51
+ * changes (a different search is a different question). */
52
+ predicateKey: string;
53
+ searchQuery: EscalationSearchQuery;
54
+ }
55
+
56
+ export interface UseEscalatedDeleteResult {
57
+ phase: EscalationPhase;
58
+ /** Begin paging the predicate's full match set to find its total. */
59
+ escalate: () => void;
60
+ /** Stop whatever's running — counting or a delete — at the next page
61
+ * boundary. A no-op when nothing is running. */
62
+ stop: () => void;
63
+ /** Drop an escalated selection back to bounded without confirming anything. */
64
+ clear: () => void;
65
+ /** True while a chunked delete (bounded->100 ids, or the escalated
66
+ * predicate) is running. */
67
+ isDeleting: boolean;
68
+ deleteProgress: BulkDeleteProgress | undefined;
69
+ /**
70
+ * Runs a chunked delete. Pass `ids` for a materialized (bounded) selection;
71
+ * omit it to delete the escalated predicate (`phase` must be "escalated").
72
+ * Resolves once the run ends for any reason — cancelled, errored, or
73
+ * complete — with a `done`/`failedIds` outcome the caller feeds to
74
+ * `resolveSelectionAfterDelete` to decide what selection looks like next.
75
+ * Infrastructure failures are reported through the app's existing
76
+ * escalation seam (`pushError`, which itself escalates a 5xx/exception to
77
+ * the fatal overlay) — not swallowed here.
78
+ */
79
+ runDelete: (ids?: string[]) => Promise<DeleteRunOutcome>;
80
+ }
81
+
82
+ export const useEscalatedDelete = ({
83
+ mailboxId,
84
+ accountId,
85
+ enabled,
86
+ predicateKey,
87
+ searchQuery,
88
+ }: UseEscalatedDeleteOptions): UseEscalatedDeleteResult => {
89
+ const [phase, setPhase] = useState<EscalationPhase>({ kind: "idle" });
90
+ const [isDeleting, setIsDeleting] = useState(false);
91
+ const [deleteProgress, setDeleteProgress] = useState<
92
+ BulkDeleteProgress | undefined
93
+ >(undefined);
94
+ const cancelRef = useRef(false);
95
+ const queryClient = useQueryClient();
96
+ const { pushError } = useErrorBanners();
97
+
98
+ // A different search (or leaving search/desktop) makes any in-flight
99
+ // escalation meaningless — it would otherwise keep counting or offering to
100
+ // delete a predicate the visible list no longer reflects.
101
+ // biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself is unconditional, not a value read from either.
102
+ useEffect(() => {
103
+ cancelRef.current = true;
104
+ setPhase({ kind: "idle" });
105
+ }, [enabled, predicateKey]);
106
+
107
+ const searchQueryRef = useRef(searchQuery);
108
+ searchQueryRef.current = searchQuery;
109
+
110
+ const fetchIdsPage = useCallback<FetchIdsPage>(
111
+ async (continuationToken) => {
112
+ const { data } = await threadOperationsSearchThreads({
113
+ path: { mailboxId },
114
+ query: {
115
+ ...searchQueryRef.current,
116
+ continuationToken,
117
+ limit: PAGE_SIZE,
118
+ },
119
+ throwOnError: true,
120
+ });
121
+ return {
122
+ ids: (data.items ?? []).map((item) => item.messageId),
123
+ continuationToken: data.continuationToken,
124
+ };
125
+ },
126
+ [mailboxId],
127
+ );
128
+
129
+ const deleteBatch = useCallback(async (ids: string[]) => {
130
+ const { data } = await messageBulkOperationsDeleteMessages({
131
+ body: { messageIds: ids },
132
+ throwOnError: true,
133
+ });
134
+ return data;
135
+ }, []);
136
+
137
+ const invalidateAfterDelete = useCallback(() => {
138
+ queryClient.invalidateQueries({
139
+ queryKey: threadOperationsListThreadsQueryKey({ path: { mailboxId } }),
140
+ });
141
+ queryClient.invalidateQueries({
142
+ queryKey: threadOperationsSearchThreadsQueryKey({ path: { mailboxId } }),
143
+ });
144
+ if (accountId) {
145
+ queryClient.invalidateQueries({
146
+ queryKey: mailboxOperationsListMailboxesQueryKey({
147
+ path: { accountId },
148
+ }),
149
+ });
150
+ }
151
+ }, [queryClient, mailboxId, accountId]);
152
+
153
+ const escalate = useCallback(() => {
154
+ cancelRef.current = false;
155
+ setPhase({ kind: "counting", countSoFar: 0 });
156
+ countMatches(
157
+ fetchIdsPage,
158
+ (countSoFar) => setPhase({ kind: "counting", countSoFar }),
159
+ () => cancelRef.current,
160
+ ).then((result) => {
161
+ if (result.error) {
162
+ pushError(
163
+ buildMutationErrorBanner(
164
+ "Couldn't count matching messages",
165
+ "The count didn't finish.",
166
+ result.error,
167
+ ),
168
+ );
169
+ setPhase({ kind: "idle" });
170
+ return;
171
+ }
172
+ if (result.cancelled) {
173
+ setPhase({ kind: "idle" });
174
+ return;
175
+ }
176
+ setPhase({ kind: "escalated", total: result.total });
177
+ });
178
+ }, [fetchIdsPage, pushError]);
179
+
180
+ const stop = useCallback(() => {
181
+ cancelRef.current = true;
182
+ }, []);
183
+
184
+ const clear = useCallback(() => {
185
+ cancelRef.current = true;
186
+ setPhase({ kind: "idle" });
187
+ }, []);
188
+
189
+ const runDelete = useCallback(
190
+ async (ids?: string[]): Promise<DeleteRunOutcome> => {
191
+ cancelRef.current = false;
192
+ setIsDeleting(true);
193
+ const onProgress = (progress: BulkDeleteProgress) =>
194
+ setDeleteProgress(progress);
195
+
196
+ const outcome =
197
+ ids !== undefined
198
+ ? await runChunkedDelete(
199
+ ids,
200
+ deleteBatch,
201
+ onProgress,
202
+ () => cancelRef.current,
203
+ )
204
+ : await runPredicateDelete(
205
+ fetchIdsPage,
206
+ phase.kind === "escalated" ? phase.total : 0,
207
+ deleteBatch,
208
+ onProgress,
209
+ () => cancelRef.current,
210
+ );
211
+
212
+ setIsDeleting(false);
213
+ setDeleteProgress(undefined);
214
+ setPhase({ kind: "idle" });
215
+
216
+ if (outcome.error) {
217
+ pushError(
218
+ buildMutationErrorBanner(
219
+ outcome.done > 0
220
+ ? `Stopped after ${outcome.done} — some messages weren't deleted`
221
+ : "Couldn't delete these messages",
222
+ "The delete didn't finish.",
223
+ outcome.error,
224
+ ),
225
+ );
226
+ }
227
+ if (outcome.done > 0) {
228
+ invalidateAfterDelete();
229
+ }
230
+ return outcome;
231
+ },
232
+ [deleteBatch, fetchIdsPage, phase, pushError, invalidateAfterDelete],
233
+ );
234
+
235
+ return {
236
+ phase,
237
+ escalate,
238
+ stop,
239
+ clear,
240
+ isDeleting,
241
+ deleteProgress,
242
+ runDelete,
243
+ };
244
+ };
@@ -0,0 +1,456 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ BULK_DELETE_CHUNK_SIZE,
5
+ chunkIds,
6
+ countMatches,
7
+ type DeleteBatchResult,
8
+ type FetchIdsPageResult,
9
+ resolveSelectionAfterDelete,
10
+ runChunkedDelete,
11
+ runPredicateDelete,
12
+ } from "./bulk-delete.js";
13
+
14
+ const ids = (count: number, prefix = "m"): string[] =>
15
+ Array.from({ length: count }, (_, i) => `${prefix}${i}`);
16
+
17
+ describe("chunkIds", () => {
18
+ test("empty input yields no chunks", () => {
19
+ assert.deepEqual(chunkIds([]), []);
20
+ });
21
+
22
+ test("exactly one chunk's worth stays a single chunk", () => {
23
+ const got = chunkIds(ids(BULK_DELETE_CHUNK_SIZE));
24
+ assert.equal(got.length, 1);
25
+ assert.equal(got[0].length, BULK_DELETE_CHUNK_SIZE);
26
+ });
27
+
28
+ test("one over the boundary spills a second chunk of one", () => {
29
+ const got = chunkIds(ids(BULK_DELETE_CHUNK_SIZE + 1));
30
+ assert.equal(got.length, 2);
31
+ assert.equal(got[0].length, BULK_DELETE_CHUNK_SIZE);
32
+ assert.equal(got[1].length, 1);
33
+ });
34
+
35
+ test("preserves order across chunk boundaries", () => {
36
+ const input = ids(BULK_DELETE_CHUNK_SIZE + 5);
37
+ const got = chunkIds(input).flat();
38
+ assert.deepEqual(got, input);
39
+ });
40
+
41
+ test("a custom size chunks accordingly", () => {
42
+ assert.deepEqual(chunkIds(["a", "b", "c", "d", "e"], 2), [
43
+ ["a", "b"],
44
+ ["c", "d"],
45
+ ["e"],
46
+ ]);
47
+ });
48
+ });
49
+
50
+ describe("runChunkedDelete", () => {
51
+ const neverCancelled = () => false;
52
+ const noopProgress = () => undefined;
53
+
54
+ test("zero ids does nothing and reports done=0", async () => {
55
+ const calls: string[][] = [];
56
+ const outcome = await runChunkedDelete(
57
+ [],
58
+ async (chunk) => {
59
+ calls.push(chunk);
60
+ return { successCount: chunk.length, failureCount: 0 };
61
+ },
62
+ noopProgress,
63
+ neverCancelled,
64
+ );
65
+ assert.deepEqual(outcome, {
66
+ done: 0,
67
+ failedIds: [],
68
+ cancelled: false,
69
+ });
70
+ assert.equal(calls.length, 0);
71
+ });
72
+
73
+ test("sequences one call per 100-id chunk, in order", async () => {
74
+ const input = ids(BULK_DELETE_CHUNK_SIZE + 1);
75
+ const calls: string[][] = [];
76
+ const outcome = await runChunkedDelete(
77
+ input,
78
+ async (chunk) => {
79
+ calls.push(chunk);
80
+ return { successCount: chunk.length, failureCount: 0 };
81
+ },
82
+ noopProgress,
83
+ neverCancelled,
84
+ );
85
+ assert.equal(calls.length, 2);
86
+ assert.equal(calls[0].length, BULK_DELETE_CHUNK_SIZE);
87
+ assert.equal(calls[1].length, 1);
88
+ assert.equal(outcome.done, input.length);
89
+ assert.deepEqual(outcome.failedIds, []);
90
+ });
91
+
92
+ test("the count deleted matches exactly what the batches reported succeeded", async () => {
93
+ const input = ids(5);
94
+ const outcome = await runChunkedDelete(
95
+ input,
96
+ async (chunk) => ({
97
+ successCount: chunk.length - 2,
98
+ failureCount: 2,
99
+ failedIds: chunk.slice(0, 2),
100
+ }),
101
+ noopProgress,
102
+ neverCancelled,
103
+ );
104
+ assert.equal(outcome.done, 3);
105
+ assert.deepEqual(outcome.failedIds, input.slice(0, 2));
106
+ });
107
+
108
+ test("partial failure: failed ids are reported, not rolled into done", async () => {
109
+ const input = ids(3);
110
+ const outcome = await runChunkedDelete(
111
+ input,
112
+ async (chunk) => ({
113
+ successCount: 2,
114
+ failureCount: 1,
115
+ failedIds: [chunk[1]],
116
+ }),
117
+ noopProgress,
118
+ neverCancelled,
119
+ );
120
+ assert.equal(outcome.done, 2);
121
+ assert.deepEqual(outcome.failedIds, [input[1]]);
122
+ });
123
+
124
+ test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
125
+ const input = ids(BULK_DELETE_CHUNK_SIZE * 3);
126
+ let calls = 0;
127
+ let cancelled = false;
128
+ const outcome = await runChunkedDelete(
129
+ input,
130
+ async (chunk) => {
131
+ calls++;
132
+ if (calls === 1) cancelled = true; // cancel after the first chunk lands
133
+ return { successCount: chunk.length, failureCount: 0 };
134
+ },
135
+ () => undefined,
136
+ () => cancelled,
137
+ );
138
+ assert.equal(outcome.cancelled, true);
139
+ assert.equal(calls, 1);
140
+ assert.equal(outcome.done, BULK_DELETE_CHUNK_SIZE);
141
+ // The two chunks never attempted come back as not-yet-deleted.
142
+ assert.equal(outcome.failedIds.length, BULK_DELETE_CHUNK_SIZE * 2);
143
+ });
144
+
145
+ test("an infra failure mid-run stops the run and reports the error", async () => {
146
+ const input = ids(BULK_DELETE_CHUNK_SIZE * 2);
147
+ const boom = new Error("network blip");
148
+ const outcome = await runChunkedDelete(
149
+ input,
150
+ async () => {
151
+ throw boom;
152
+ },
153
+ noopProgress,
154
+ neverCancelled,
155
+ );
156
+ assert.equal(outcome.error, boom);
157
+ assert.equal(outcome.done, 0);
158
+ assert.equal(outcome.failedIds.length, input.length);
159
+ });
160
+
161
+ test("reports progress after each chunk", async () => {
162
+ const input = ids(BULK_DELETE_CHUNK_SIZE + 1);
163
+ const progressCalls: { done: number; total: number }[] = [];
164
+ await runChunkedDelete(
165
+ input,
166
+ async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
167
+ (p) => progressCalls.push(p),
168
+ neverCancelled,
169
+ );
170
+ assert.deepEqual(progressCalls, [
171
+ { done: BULK_DELETE_CHUNK_SIZE, total: input.length },
172
+ { done: input.length, total: input.length },
173
+ ]);
174
+ });
175
+ });
176
+
177
+ describe("runPredicateDelete", () => {
178
+ const neverCancelled = () => false;
179
+ const noopProgress = () => undefined;
180
+
181
+ /** Builds a paged fixture: `pages[i]` is what the i-th call returns. */
182
+ const pagedFetcher = (pages: FetchIdsPageResult[]) => {
183
+ let call = 0;
184
+ return async (): Promise<FetchIdsPageResult> => {
185
+ const page = pages[call];
186
+ call++;
187
+ return page;
188
+ };
189
+ };
190
+
191
+ test("zero matches deletes nothing", async () => {
192
+ const fetch = pagedFetcher([{ ids: [] }]);
193
+ const outcome = await runPredicateDelete(
194
+ fetch,
195
+ 0,
196
+ async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
197
+ noopProgress,
198
+ neverCancelled,
199
+ );
200
+ assert.deepEqual(outcome, { done: 0, failedIds: [], cancelled: false });
201
+ });
202
+
203
+ test("exactly 100 matches — a page size's worth — resolves in a single page with no continuation", async () => {
204
+ const fetch = pagedFetcher([{ ids: ids(BULK_DELETE_CHUNK_SIZE) }]);
205
+ const deleteCalls: string[][] = [];
206
+ const outcome = await runPredicateDelete(
207
+ fetch,
208
+ BULK_DELETE_CHUNK_SIZE,
209
+ async (chunk) => {
210
+ deleteCalls.push(chunk);
211
+ return { successCount: chunk.length, failureCount: 0 };
212
+ },
213
+ noopProgress,
214
+ neverCancelled,
215
+ );
216
+ assert.equal(deleteCalls.length, 1);
217
+ assert.equal(outcome.done, BULK_DELETE_CHUNK_SIZE);
218
+ });
219
+
220
+ test("pages until the continuation token is exhausted, one delete call per page", async () => {
221
+ const fetch = pagedFetcher([
222
+ { ids: ids(100, "a"), continuationToken: "t1" },
223
+ { ids: ids(50, "b") },
224
+ ]);
225
+ const deleteCalls: string[][] = [];
226
+ const outcome = await runPredicateDelete(
227
+ fetch,
228
+ 150,
229
+ async (chunk) => {
230
+ deleteCalls.push(chunk);
231
+ return { successCount: chunk.length, failureCount: 0 };
232
+ },
233
+ noopProgress,
234
+ neverCancelled,
235
+ );
236
+ assert.equal(deleteCalls.length, 2);
237
+ assert.equal(outcome.done, 150);
238
+ });
239
+
240
+ test("a list refresh mid-run that adds and removes rows just changes what later pages contain — the predicate resolves fresh, never a stale materialized set", async () => {
241
+ // Page 1 sees the original matches. Page 2 (fetched only after page 1's
242
+ // batch has already been deleted) reflects new mail having arrived and
243
+ // one previously-seen id having been filed elsewhere mid-run.
244
+ const fetch = pagedFetcher([
245
+ { ids: ["a", "b"], continuationToken: "t1" },
246
+ { ids: ["new-c", "d"] }, // "b" is gone (filed away), "new-c" just arrived
247
+ ]);
248
+ const deleteCalls: string[][] = [];
249
+ const outcome = await runPredicateDelete(
250
+ fetch,
251
+ 4,
252
+ async (chunk) => {
253
+ deleteCalls.push(chunk);
254
+ return { successCount: chunk.length, failureCount: 0 };
255
+ },
256
+ noopProgress,
257
+ neverCancelled,
258
+ );
259
+ assert.deepEqual(deleteCalls, [
260
+ ["a", "b"],
261
+ ["new-c", "d"],
262
+ ]);
263
+ assert.equal(outcome.done, 4);
264
+ });
265
+
266
+ test("partial failure across pages accumulates failedIds without rolling into done", async () => {
267
+ const fetch = pagedFetcher([
268
+ { ids: ["a", "b"], continuationToken: "t1" },
269
+ { ids: ["c"] },
270
+ ]);
271
+ const outcome = await runPredicateDelete(
272
+ fetch,
273
+ 3,
274
+ async (chunk): Promise<DeleteBatchResult> => {
275
+ if (chunk.includes("b")) {
276
+ return { successCount: 1, failureCount: 1, failedIds: ["b"] };
277
+ }
278
+ return { successCount: chunk.length, failureCount: 0 };
279
+ },
280
+ noopProgress,
281
+ neverCancelled,
282
+ );
283
+ assert.equal(outcome.done, 2);
284
+ assert.deepEqual(outcome.failedIds, ["b"]);
285
+ });
286
+
287
+ test("cancelling mid-delete stops paging without inventing failedIds for unfetched pages", async () => {
288
+ let fetchCalls = 0;
289
+ let cancelled = false;
290
+ const fetch = async (): Promise<FetchIdsPageResult> => {
291
+ fetchCalls++;
292
+ return { ids: ["a", "b"], continuationToken: "more" };
293
+ };
294
+ const outcome = await runPredicateDelete(
295
+ fetch,
296
+ 1000,
297
+ async (chunk) => {
298
+ cancelled = true; // cancel takes effect on the next loop iteration
299
+ return { successCount: chunk.length, failureCount: 0 };
300
+ },
301
+ () => undefined,
302
+ () => cancelled,
303
+ );
304
+ assert.equal(outcome.cancelled, true);
305
+ assert.equal(fetchCalls, 1);
306
+ assert.equal(outcome.done, 2);
307
+ assert.deepEqual(outcome.failedIds, []);
308
+ });
309
+
310
+ test("an infra failure while fetching a page stops the run and reports the error", async () => {
311
+ const boom = new Error("timed out");
312
+ const fetch = async (): Promise<FetchIdsPageResult> => {
313
+ throw boom;
314
+ };
315
+ const outcome = await runPredicateDelete(
316
+ fetch,
317
+ 100,
318
+ async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
319
+ noopProgress,
320
+ neverCancelled,
321
+ );
322
+ assert.equal(outcome.error, boom);
323
+ assert.equal(outcome.done, 0);
324
+ });
325
+
326
+ test("an infra failure from the delete call itself stops the run and reports the error", async () => {
327
+ const boom = new Error("500");
328
+ const fetch = pagedFetcher([{ ids: ["a"] }]);
329
+ const outcome = await runPredicateDelete(
330
+ fetch,
331
+ 1,
332
+ async () => {
333
+ throw boom;
334
+ },
335
+ noopProgress,
336
+ neverCancelled,
337
+ );
338
+ assert.equal(outcome.error, boom);
339
+ });
340
+ });
341
+
342
+ describe("countMatches", () => {
343
+ const neverCancelled = () => false;
344
+
345
+ test("counts across every page until the token is exhausted", async () => {
346
+ let call = 0;
347
+ const pages: FetchIdsPageResult[] = [
348
+ { ids: ids(500, "a"), continuationToken: "t1" },
349
+ { ids: ids(500, "b"), continuationToken: "t2" },
350
+ { ids: ids(412, "c") },
351
+ ];
352
+ const fetch = async () => pages[call++];
353
+ const outcome = await countMatches(fetch, () => undefined, neverCancelled);
354
+ assert.deepEqual(outcome, { total: 1412, cancelled: false });
355
+ });
356
+
357
+ test("reports a running total after each page", async () => {
358
+ let call = 0;
359
+ const pages: FetchIdsPageResult[] = [
360
+ { ids: ids(500), continuationToken: "t1" },
361
+ { ids: ids(300) },
362
+ ];
363
+ const fetch = async () => pages[call++];
364
+ const progressCalls: number[] = [];
365
+ await countMatches(fetch, (n) => progressCalls.push(n), neverCancelled);
366
+ assert.deepEqual(progressCalls, [500, 800]);
367
+ });
368
+
369
+ test("cancelling mid-count stops paging and reports what it had so far", async () => {
370
+ let call = 0;
371
+ let cancelled = false;
372
+ const fetch = async (): Promise<FetchIdsPageResult> => {
373
+ call++;
374
+ return { ids: ids(500), continuationToken: "more" };
375
+ };
376
+ const outcome = await countMatches(
377
+ fetch,
378
+ () => {
379
+ cancelled = true;
380
+ },
381
+ () => cancelled,
382
+ );
383
+ assert.equal(outcome.cancelled, true);
384
+ assert.equal(call, 1);
385
+ assert.equal(outcome.total, 500);
386
+ });
387
+
388
+ test("an infra failure while paging stops the count and reports the error", async () => {
389
+ const boom = new Error("network blip");
390
+ const fetch = async (): Promise<FetchIdsPageResult> => {
391
+ throw boom;
392
+ };
393
+ const outcome = await countMatches(fetch, () => undefined, neverCancelled);
394
+ assert.equal(outcome.error, boom);
395
+ assert.equal(outcome.total, 0);
396
+ });
397
+ });
398
+
399
+ describe("resolveSelectionAfterDelete", () => {
400
+ test("a clean run with nothing failed exits selection mode", () => {
401
+ assert.deepEqual(
402
+ resolveSelectionAfterDelete({
403
+ done: 3412,
404
+ failedIds: [],
405
+ cancelled: false,
406
+ }),
407
+ { exit: true, retryIds: [] },
408
+ );
409
+ });
410
+
411
+ test("explicit failures stay selected for a precise retry, even alongside a clean stop", () => {
412
+ assert.deepEqual(
413
+ resolveSelectionAfterDelete({
414
+ done: 3072,
415
+ failedIds: ["a", "b"],
416
+ cancelled: false,
417
+ }),
418
+ { exit: false, retryIds: ["a", "b"] },
419
+ );
420
+ });
421
+
422
+ test("a clean cancel with nothing yet confirmed failed leaves nothing to retry, but does not exit", () => {
423
+ assert.deepEqual(
424
+ resolveSelectionAfterDelete({
425
+ done: 100,
426
+ failedIds: [],
427
+ cancelled: true,
428
+ }),
429
+ { exit: false, retryIds: [] },
430
+ );
431
+ });
432
+
433
+ test("an infra failure with no explicit per-item failures still keeps selection mode open", () => {
434
+ assert.deepEqual(
435
+ resolveSelectionAfterDelete({
436
+ done: 0,
437
+ failedIds: [],
438
+ cancelled: false,
439
+ error: new Error("network blip"),
440
+ }),
441
+ { exit: false, retryIds: [] },
442
+ );
443
+ });
444
+
445
+ test("failedIds wins over cancelled/error when both are present", () => {
446
+ assert.deepEqual(
447
+ resolveSelectionAfterDelete({
448
+ done: 10,
449
+ failedIds: ["x"],
450
+ cancelled: true,
451
+ error: new Error("boom"),
452
+ }),
453
+ { exit: false, retryIds: ["x"] },
454
+ );
455
+ });
456
+ });