@remit/web-client 0.0.24 → 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,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
+ });
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Chunked bulk-delete orchestration (issue #92).
3
+ *
4
+ * The bulk endpoint caps a call at 100 ids (`BulkMessageInput.messageIds`,
5
+ * `@maxItems(100)`); a delete over a search result can run to thousands. These
6
+ * helpers sequence the calls and tally what actually happened rather than what
7
+ * was requested, so a caller can always ask "what's still not deleted" instead
8
+ * of trusting the request it sent.
9
+ *
10
+ * Pure, framework-agnostic, and independently testable — no React, no fetch.
11
+ * `useEscalatedDelete.ts` supplies the real `DeleteBatch`/`FetchIdsPage`
12
+ * implementations (the generated SDK client) and owns the React state.
13
+ */
14
+
15
+ export const BULK_DELETE_CHUNK_SIZE = 100;
16
+
17
+ /**
18
+ * Resolves a promise to a discriminated result instead of throwing, so a
19
+ * caller can branch on infrastructure failure as a value — the app's
20
+ * try/catch rule requires a rethrow, and there is nothing to rethrow to here;
21
+ * the caller's job on failure is to stop the run and report what happened,
22
+ * not to keep propagating the same rejection past the point it's already
23
+ * been handled.
24
+ */
25
+ const attempt = async <T>(
26
+ promise: Promise<T>,
27
+ ): Promise<{ ok: true; value: T } | { ok: false; error: unknown }> =>
28
+ promise.then(
29
+ (value) => ({ ok: true as const, value }),
30
+ (error) => ({ ok: false as const, error }),
31
+ );
32
+
33
+ /** Split `ids` into chunks of at most `size`. Empty input yields no chunks. */
34
+ export const chunkIds = (
35
+ ids: readonly string[],
36
+ size = BULK_DELETE_CHUNK_SIZE,
37
+ ): string[][] => {
38
+ if (ids.length === 0) return [];
39
+ const chunks: string[][] = [];
40
+ for (let i = 0; i < ids.length; i += size) {
41
+ chunks.push(ids.slice(i, i + size));
42
+ }
43
+ return chunks;
44
+ };
45
+
46
+ export interface DeleteBatchResult {
47
+ successCount: number;
48
+ failureCount: number;
49
+ failedIds?: string[];
50
+ }
51
+
52
+ /** Sends one bulk-delete call for the given ids (≤100). */
53
+ export type DeleteBatch = (ids: string[]) => Promise<DeleteBatchResult>;
54
+
55
+ export interface BulkDeleteProgress {
56
+ /** Ids confirmed deleted so far. */
57
+ done: number;
58
+ /** The total the run was started against (may be an estimate for the
59
+ * predicate case — see `runPredicateDelete`). */
60
+ total: number;
61
+ }
62
+
63
+ export interface BulkDeleteOutcome {
64
+ /** Ids confirmed deleted (server-reported success, not merely "sent"). */
65
+ done: number;
66
+ /**
67
+ * Ids not confirmed deleted: explicit per-batch failures. Does NOT include
68
+ * ids from chunks the run never reached (cancelled or stopped by an
69
+ * error) — see the per-function doc for how each source handles that.
70
+ */
71
+ failedIds: string[];
72
+ cancelled: boolean;
73
+ /** Set when a batch call threw — an infrastructure failure, not a
74
+ * per-item failure. The run stops at the point it was raised. */
75
+ error?: unknown;
76
+ }
77
+
78
+ /**
79
+ * Bounded case: the full id list is known upfront (a materialized selection,
80
+ * or a "select all loaded" that grew past 100 rows). Chunked synchronously, so
81
+ * on cancellation or a thrown error every chunk not yet attempted — including
82
+ * the one in flight when an error was thrown — is folded into `failedIds`. The
83
+ * caller always gets back exactly the ids still not confirmed deleted, ready
84
+ * to retry as-is (deleting an already-trashed message is a safe no-op).
85
+ */
86
+ export const runChunkedDelete = async (
87
+ ids: readonly string[],
88
+ deleteBatch: DeleteBatch,
89
+ onProgress: (progress: BulkDeleteProgress) => void,
90
+ isCancelled: () => boolean,
91
+ ): Promise<BulkDeleteOutcome> => {
92
+ const chunks = chunkIds(ids);
93
+ const total = ids.length;
94
+ let done = 0;
95
+ const failedIds: string[] = [];
96
+
97
+ for (let i = 0; i < chunks.length; i++) {
98
+ if (isCancelled()) {
99
+ failedIds.push(...chunks.slice(i).flat());
100
+ return { done, failedIds, cancelled: true };
101
+ }
102
+ const chunk = chunks[i];
103
+ const attempted = await attempt(deleteBatch(chunk));
104
+ if (!attempted.ok) {
105
+ failedIds.push(...chunks.slice(i).flat());
106
+ onProgress({ done, total });
107
+ return { done, failedIds, cancelled: false, error: attempted.error };
108
+ }
109
+ const failedInChunk = new Set(attempted.value.failedIds ?? []);
110
+ done += chunk.length - failedInChunk.size;
111
+ failedIds.push(...chunk.filter((id) => failedInChunk.has(id)));
112
+ onProgress({ done, total });
113
+ }
114
+
115
+ return { done, failedIds, cancelled: false };
116
+ };
117
+
118
+ export interface FetchIdsPageResult {
119
+ ids: string[];
120
+ continuationToken?: string;
121
+ }
122
+
123
+ /** Fetches one page of matching message ids for the active predicate. */
124
+ export type FetchIdsPage = (
125
+ continuationToken: string | undefined,
126
+ ) => Promise<FetchIdsPageResult>;
127
+
128
+ /**
129
+ * Escalated case: the selection is a predicate (D2), not a materialized list.
130
+ * Each page is fetched immediately before the chunk it feeds is deleted — a
131
+ * page IS a chunk, sized to the same 100-id cap the write side enforces — so
132
+ * ids are never held in memory beyond the batch in flight.
133
+ *
134
+ * Cancelling or a thrown error simply stops paging: nothing is added to
135
+ * `failedIds` for the unreached remainder, because those ids were never
136
+ * fetched — there is nothing to hand back. A predicate resolves fresh on
137
+ * every run (D2), so resuming is re-invoking this same function with the same
138
+ * predicate: already-deleted matches drop out of the search on their own and
139
+ * are not re-sent.
140
+ */
141
+ export const runPredicateDelete = async (
142
+ fetchIdsPage: FetchIdsPage,
143
+ total: number,
144
+ deleteBatch: DeleteBatch,
145
+ onProgress: (progress: BulkDeleteProgress) => void,
146
+ isCancelled: () => boolean,
147
+ ): Promise<BulkDeleteOutcome> => {
148
+ let done = 0;
149
+ const failedIds: string[] = [];
150
+ let token: string | undefined;
151
+
152
+ do {
153
+ if (isCancelled()) {
154
+ return { done, failedIds, cancelled: true };
155
+ }
156
+
157
+ const fetched = await attempt(fetchIdsPage(token));
158
+ if (!fetched.ok) {
159
+ return { done, failedIds, cancelled: false, error: fetched.error };
160
+ }
161
+ const page = fetched.value;
162
+
163
+ if (page.ids.length > 0) {
164
+ const attempted = await attempt(deleteBatch(page.ids));
165
+ if (!attempted.ok) {
166
+ return { done, failedIds, cancelled: false, error: attempted.error };
167
+ }
168
+ const failedInPage = new Set(attempted.value.failedIds ?? []);
169
+ done += page.ids.length - failedInPage.size;
170
+ failedIds.push(...page.ids.filter((id) => failedInPage.has(id)));
171
+ onProgress({ done, total });
172
+ }
173
+
174
+ token = page.continuationToken;
175
+ } while (token);
176
+
177
+ return { done, failedIds, cancelled: false };
178
+ };
179
+
180
+ export interface CountMatchesResult {
181
+ total: number;
182
+ cancelled: boolean;
183
+ error?: unknown;
184
+ }
185
+
186
+ /**
187
+ * Pages the full predicate result set to find its exact total — the only way,
188
+ * short of a server-side total (out of scope, see issue #92), since search has
189
+ * no total-count field beyond a small capped-window estimate. Reports a
190
+ * running count via `onProgress` so a long count reads as progressing rather
191
+ * than hung, and checks `isCancelled` between pages so Stop takes effect
192
+ * within one page's latency.
193
+ */
194
+ export const countMatches = async (
195
+ fetchIdsPage: FetchIdsPage,
196
+ onProgress: (countSoFar: number) => void,
197
+ isCancelled: () => boolean,
198
+ ): Promise<CountMatchesResult> => {
199
+ let total = 0;
200
+ let token: string | undefined;
201
+
202
+ do {
203
+ if (isCancelled()) {
204
+ return { total, cancelled: true };
205
+ }
206
+ const fetched = await attempt(fetchIdsPage(token));
207
+ if (!fetched.ok) {
208
+ return { total, cancelled: false, error: fetched.error };
209
+ }
210
+ const page = fetched.value;
211
+ total += page.ids.length;
212
+ onProgress(total);
213
+ token = page.continuationToken;
214
+ } while (token);
215
+
216
+ return { total, cancelled: false };
217
+ };
218
+
219
+ export interface DeleteRunOutcome {
220
+ done: number;
221
+ failedIds: string[];
222
+ cancelled: boolean;
223
+ error?: unknown;
224
+ }
225
+
226
+ export interface SelectionAfterDelete {
227
+ /** Everything targeted was confirmed deleted — selection mode should exit. */
228
+ exit: boolean;
229
+ /** The bounded selection to leave in place, empty when exiting. */
230
+ retryIds: string[];
231
+ }
232
+
233
+ /**
234
+ * What a caller does with selection once a run ends, for any reason. Every id
235
+ * not confirmed deleted — an explicit failure, or a chunk the run never
236
+ * reached because it was stopped or errored — belongs in `retryIds`: it is
237
+ * exactly what Retry should resend, and it is what stays selected so the
238
+ * count on screen never claims more was deleted than actually was (#92
239
+ * requirement 8).
240
+ */
241
+ export const resolveSelectionAfterDelete = (
242
+ outcome: DeleteRunOutcome,
243
+ ): SelectionAfterDelete => {
244
+ if (outcome.failedIds.length > 0) {
245
+ return { exit: false, retryIds: outcome.failedIds };
246
+ }
247
+ if (outcome.cancelled || outcome.error) {
248
+ return { exit: false, retryIds: [] };
249
+ }
250
+ return { exit: true, retryIds: [] };
251
+ };