@remit/web-client 0.0.43 → 0.0.45

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.
@@ -1,15 +1,15 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
3
  import {
4
- BULK_DELETE_CHUNK_SIZE,
4
+ BULK_ACTION_CHUNK_SIZE,
5
5
  chunkIds,
6
6
  countMatches,
7
7
  type FetchIdsPageResult,
8
8
  honestProgress,
9
- resolveSelectionAfterDelete,
10
- runChunkedDelete,
11
- runPredicateDelete,
12
- } from "./bulk-delete.js";
9
+ resolveSelectionAfterRun,
10
+ runChunkedAction,
11
+ runPredicateAction,
12
+ } from "./bulk-actions.js";
13
13
 
14
14
  const ids = (count: number, prefix = "m"): string[] =>
15
15
  Array.from({ length: count }, (_, i) => `${prefix}${i}`);
@@ -20,20 +20,20 @@ describe("chunkIds", () => {
20
20
  });
21
21
 
22
22
  test("exactly one chunk's worth stays a single chunk", () => {
23
- const got = chunkIds(ids(BULK_DELETE_CHUNK_SIZE));
23
+ const got = chunkIds(ids(BULK_ACTION_CHUNK_SIZE));
24
24
  assert.equal(got.length, 1);
25
- assert.equal(got[0].length, BULK_DELETE_CHUNK_SIZE);
25
+ assert.equal(got[0].length, BULK_ACTION_CHUNK_SIZE);
26
26
  });
27
27
 
28
28
  test("one over the boundary spills a second chunk of one", () => {
29
- const got = chunkIds(ids(BULK_DELETE_CHUNK_SIZE + 1));
29
+ const got = chunkIds(ids(BULK_ACTION_CHUNK_SIZE + 1));
30
30
  assert.equal(got.length, 2);
31
- assert.equal(got[0].length, BULK_DELETE_CHUNK_SIZE);
31
+ assert.equal(got[0].length, BULK_ACTION_CHUNK_SIZE);
32
32
  assert.equal(got[1].length, 1);
33
33
  });
34
34
 
35
35
  test("preserves order across chunk boundaries", () => {
36
- const input = ids(BULK_DELETE_CHUNK_SIZE + 5);
36
+ const input = ids(BULK_ACTION_CHUNK_SIZE + 5);
37
37
  const got = chunkIds(input).flat();
38
38
  assert.deepEqual(got, input);
39
39
  });
@@ -47,13 +47,13 @@ describe("chunkIds", () => {
47
47
  });
48
48
  });
49
49
 
50
- describe("runChunkedDelete", () => {
50
+ describe("runChunkedAction", () => {
51
51
  const neverCancelled = () => false;
52
52
  const noopProgress = () => undefined;
53
53
 
54
54
  test("zero ids does nothing and reports done=0", async () => {
55
55
  const calls: string[][] = [];
56
- const outcome = await runChunkedDelete(
56
+ const outcome = await runChunkedAction(
57
57
  [],
58
58
  async (chunk) => {
59
59
  calls.push(chunk);
@@ -71,9 +71,9 @@ describe("runChunkedDelete", () => {
71
71
  });
72
72
 
73
73
  test("sequences one call per 100-id chunk, in order", async () => {
74
- const input = ids(BULK_DELETE_CHUNK_SIZE + 1);
74
+ const input = ids(BULK_ACTION_CHUNK_SIZE + 1);
75
75
  const calls: string[][] = [];
76
- const outcome = await runChunkedDelete(
76
+ const outcome = await runChunkedAction(
77
77
  input,
78
78
  async (chunk) => {
79
79
  calls.push(chunk);
@@ -83,7 +83,7 @@ describe("runChunkedDelete", () => {
83
83
  neverCancelled,
84
84
  );
85
85
  assert.equal(calls.length, 2);
86
- assert.equal(calls[0].length, BULK_DELETE_CHUNK_SIZE);
86
+ assert.equal(calls[0].length, BULK_ACTION_CHUNK_SIZE);
87
87
  assert.equal(calls[1].length, 1);
88
88
  assert.equal(outcome.done, input.length);
89
89
  assert.deepEqual(outcome.failedIds, []);
@@ -91,7 +91,7 @@ describe("runChunkedDelete", () => {
91
91
 
92
92
  test("a returned batch counts every id in it as accepted", async () => {
93
93
  const input = ids(5);
94
- const outcome = await runChunkedDelete(
94
+ const outcome = await runChunkedAction(
95
95
  input,
96
96
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
97
97
  noopProgress,
@@ -102,10 +102,10 @@ describe("runChunkedDelete", () => {
102
102
  });
103
103
 
104
104
  test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
105
- const input = ids(BULK_DELETE_CHUNK_SIZE * 3);
105
+ const input = ids(BULK_ACTION_CHUNK_SIZE * 3);
106
106
  let calls = 0;
107
107
  let cancelled = false;
108
- const outcome = await runChunkedDelete(
108
+ const outcome = await runChunkedAction(
109
109
  input,
110
110
  async (chunk) => {
111
111
  calls++;
@@ -117,15 +117,15 @@ describe("runChunkedDelete", () => {
117
117
  );
118
118
  assert.equal(outcome.cancelled, true);
119
119
  assert.equal(calls, 1);
120
- assert.equal(outcome.done, BULK_DELETE_CHUNK_SIZE);
120
+ assert.equal(outcome.done, BULK_ACTION_CHUNK_SIZE);
121
121
  // The two chunks never attempted come back as not-yet-deleted.
122
- assert.equal(outcome.failedIds.length, BULK_DELETE_CHUNK_SIZE * 2);
122
+ assert.equal(outcome.failedIds.length, BULK_ACTION_CHUNK_SIZE * 2);
123
123
  });
124
124
 
125
125
  test("an infra failure mid-run stops the run and reports the error", async () => {
126
- const input = ids(BULK_DELETE_CHUNK_SIZE * 2);
126
+ const input = ids(BULK_ACTION_CHUNK_SIZE * 2);
127
127
  const boom = new Error("network blip");
128
- const outcome = await runChunkedDelete(
128
+ const outcome = await runChunkedAction(
129
129
  input,
130
130
  async () => {
131
131
  throw boom;
@@ -139,22 +139,22 @@ describe("runChunkedDelete", () => {
139
139
  });
140
140
 
141
141
  test("reports progress after each chunk", async () => {
142
- const input = ids(BULK_DELETE_CHUNK_SIZE + 1);
142
+ const input = ids(BULK_ACTION_CHUNK_SIZE + 1);
143
143
  const progressCalls: { done: number; total: number }[] = [];
144
- await runChunkedDelete(
144
+ await runChunkedAction(
145
145
  input,
146
146
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
147
147
  (p) => progressCalls.push(p),
148
148
  neverCancelled,
149
149
  );
150
150
  assert.deepEqual(progressCalls, [
151
- { done: BULK_DELETE_CHUNK_SIZE, total: input.length },
151
+ { done: BULK_ACTION_CHUNK_SIZE, total: input.length },
152
152
  { done: input.length, total: input.length },
153
153
  ]);
154
154
  });
155
155
  });
156
156
 
157
- describe("runPredicateDelete", () => {
157
+ describe("runPredicateAction", () => {
158
158
  const neverCancelled = () => false;
159
159
  const noopProgress = () => undefined;
160
160
 
@@ -170,7 +170,7 @@ describe("runPredicateDelete", () => {
170
170
 
171
171
  test("zero matches deletes nothing", async () => {
172
172
  const fetch = pagedFetcher([{ ids: [] }]);
173
- const outcome = await runPredicateDelete(
173
+ const outcome = await runPredicateAction(
174
174
  fetch,
175
175
  0,
176
176
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
@@ -181,11 +181,11 @@ describe("runPredicateDelete", () => {
181
181
  });
182
182
 
183
183
  test("exactly 100 matches — a page size's worth — resolves in a single page with no continuation", async () => {
184
- const fetch = pagedFetcher([{ ids: ids(BULK_DELETE_CHUNK_SIZE) }]);
184
+ const fetch = pagedFetcher([{ ids: ids(BULK_ACTION_CHUNK_SIZE) }]);
185
185
  const deleteCalls: string[][] = [];
186
- const outcome = await runPredicateDelete(
186
+ const outcome = await runPredicateAction(
187
187
  fetch,
188
- BULK_DELETE_CHUNK_SIZE,
188
+ BULK_ACTION_CHUNK_SIZE,
189
189
  async (chunk) => {
190
190
  deleteCalls.push(chunk);
191
191
  return { successCount: chunk.length, failureCount: 0 };
@@ -194,7 +194,7 @@ describe("runPredicateDelete", () => {
194
194
  neverCancelled,
195
195
  );
196
196
  assert.equal(deleteCalls.length, 1);
197
- assert.equal(outcome.done, BULK_DELETE_CHUNK_SIZE);
197
+ assert.equal(outcome.done, BULK_ACTION_CHUNK_SIZE);
198
198
  });
199
199
 
200
200
  test("pages until the continuation token is exhausted, one delete call per page", async () => {
@@ -203,7 +203,7 @@ describe("runPredicateDelete", () => {
203
203
  { ids: ids(50, "b") },
204
204
  ]);
205
205
  const deleteCalls: string[][] = [];
206
- const outcome = await runPredicateDelete(
206
+ const outcome = await runPredicateAction(
207
207
  fetch,
208
208
  150,
209
209
  async (chunk) => {
@@ -226,7 +226,7 @@ describe("runPredicateDelete", () => {
226
226
  { ids: ["new-c", "d"] }, // "b" is gone (filed away), "new-c" just arrived
227
227
  ]);
228
228
  const deleteCalls: string[][] = [];
229
- const outcome = await runPredicateDelete(
229
+ const outcome = await runPredicateAction(
230
230
  fetch,
231
231
  4,
232
232
  async (chunk) => {
@@ -250,7 +250,7 @@ describe("runPredicateDelete", () => {
250
250
  fetchCalls++;
251
251
  return { ids: ["a", "b"], continuationToken: "more" };
252
252
  };
253
- const outcome = await runPredicateDelete(
253
+ const outcome = await runPredicateAction(
254
254
  fetch,
255
255
  1000,
256
256
  async (chunk) => {
@@ -271,7 +271,7 @@ describe("runPredicateDelete", () => {
271
271
  const fetch = async (): Promise<FetchIdsPageResult> => {
272
272
  throw boom;
273
273
  };
274
- const outcome = await runPredicateDelete(
274
+ const outcome = await runPredicateAction(
275
275
  fetch,
276
276
  100,
277
277
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
@@ -285,7 +285,7 @@ describe("runPredicateDelete", () => {
285
285
  test("an infra failure from the delete call itself stops the run and reports the error", async () => {
286
286
  const boom = new Error("500");
287
287
  const fetch = pagedFetcher([{ ids: ["a"] }]);
288
- const outcome = await runPredicateDelete(
288
+ const outcome = await runPredicateAction(
289
289
  fetch,
290
290
  1,
291
291
  async () => {
@@ -355,10 +355,10 @@ describe("countMatches", () => {
355
355
  });
356
356
  });
357
357
 
358
- describe("resolveSelectionAfterDelete", () => {
358
+ describe("resolveSelectionAfterRun", () => {
359
359
  test("a clean run with nothing failed exits selection mode", () => {
360
360
  assert.deepEqual(
361
- resolveSelectionAfterDelete({
361
+ resolveSelectionAfterRun({
362
362
  done: 3412,
363
363
  failedIds: [],
364
364
  cancelled: false,
@@ -369,7 +369,7 @@ describe("resolveSelectionAfterDelete", () => {
369
369
 
370
370
  test("unreached ids stay selected for a precise retry, even alongside a clean stop", () => {
371
371
  assert.deepEqual(
372
- resolveSelectionAfterDelete({
372
+ resolveSelectionAfterRun({
373
373
  done: 3072,
374
374
  failedIds: ["a", "b"],
375
375
  cancelled: false,
@@ -380,7 +380,7 @@ describe("resolveSelectionAfterDelete", () => {
380
380
 
381
381
  test("a clean cancel with nothing yet confirmed failed leaves nothing to retry, but does not exit", () => {
382
382
  assert.deepEqual(
383
- resolveSelectionAfterDelete({
383
+ resolveSelectionAfterRun({
384
384
  done: 100,
385
385
  failedIds: [],
386
386
  cancelled: true,
@@ -391,7 +391,7 @@ describe("resolveSelectionAfterDelete", () => {
391
391
 
392
392
  test("an infra failure with nothing left unreached still keeps selection mode open", () => {
393
393
  assert.deepEqual(
394
- resolveSelectionAfterDelete({
394
+ resolveSelectionAfterRun({
395
395
  done: 0,
396
396
  failedIds: [],
397
397
  cancelled: false,
@@ -403,7 +403,7 @@ describe("resolveSelectionAfterDelete", () => {
403
403
 
404
404
  test("failedIds wins over cancelled/error when both are present", () => {
405
405
  assert.deepEqual(
406
- resolveSelectionAfterDelete({
406
+ resolveSelectionAfterRun({
407
407
  done: 10,
408
408
  failedIds: ["x"],
409
409
  cancelled: true,
@@ -415,7 +415,7 @@ describe("resolveSelectionAfterDelete", () => {
415
415
  });
416
416
 
417
417
  describe("honestProgress", () => {
418
- // Regression for #109: `countMatches` and `runPredicateDelete` page the
418
+ // Regression for #109: `countMatches` and `runPredicateAction` page the
419
419
  // same predicate independently, so the delete can outrun the frozen
420
420
  // `total` it started with when the result set grows in between.
421
421
  test("leaves an on-track progress reading untouched", () => {
@@ -1,25 +1,26 @@
1
1
  /**
2
- * Chunked bulk-delete orchestration (issue #92).
2
+ * Chunked bulk-action orchestration (issues #92, #114).
3
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
4
+ * The bulk endpoints cap a call at 100 ids (`BulkMessageInput.messageIds`,
5
+ * `@maxItems(100)`); an action over a search result can run to thousands. These
6
6
  * helpers sequence the calls and tally which ids the run actually reached, so a
7
- * caller can always ask "what's still not deleted" instead of trusting the
8
- * request it sent.
7
+ * caller can always ask "what's still not done" instead of trusting the request
8
+ * it sent. Delete, move and mark-read all take this path — the action itself is
9
+ * the `ApplyBatch` the caller supplies.
9
10
  *
10
- * The endpoint enqueues the IMAP delete and returns; it does not apply it. So a
11
- * returned call means every id in it was accepted for deletion, not that the
12
- * mail server removed it — there is no per-id success/failure in the response
13
- * to read. The only failure this layer can observe is a thrown call: an
14
- * infrastructure failure (auth, the write, or the enqueue) that takes out the
15
- * whole batch and stops the run.
11
+ * The endpoints enqueue the IMAP write and return; they do not apply it. So a
12
+ * returned call means every id in it was accepted, not that the mail server
13
+ * applied it — there is no per-id success/failure in the response to read. The
14
+ * only failure this layer can observe is a thrown call: an infrastructure
15
+ * failure (auth, the write, or the enqueue) that takes out the whole batch and
16
+ * stops the run.
16
17
  *
17
18
  * Pure, framework-agnostic, and independently testable — no React, no fetch.
18
- * `useEscalatedDelete.ts` supplies the real `DeleteBatch`/`FetchIdsPage`
19
+ * `useEscalatedActions.ts` supplies the real `ApplyBatch`/`FetchIdsPage`
19
20
  * implementations (the generated SDK client) and owns the React state.
20
21
  */
21
22
 
22
- export const BULK_DELETE_CHUNK_SIZE = 100;
23
+ export const BULK_ACTION_CHUNK_SIZE = 100;
23
24
 
24
25
  /**
25
26
  * Resolves a promise to a discriminated result instead of throwing, so a
@@ -40,7 +41,7 @@ const attempt = async <T>(
40
41
  /** Split `ids` into chunks of at most `size`. Empty input yields no chunks. */
41
42
  export const chunkIds = (
42
43
  ids: readonly string[],
43
- size = BULK_DELETE_CHUNK_SIZE,
44
+ size = BULK_ACTION_CHUNK_SIZE,
44
45
  ): string[][] => {
45
46
  if (ids.length === 0) return [];
46
47
  const chunks: string[][] = [];
@@ -50,30 +51,30 @@ export const chunkIds = (
50
51
  return chunks;
51
52
  };
52
53
 
53
- export interface DeleteBatchResult {
54
+ export interface BatchResult {
54
55
  successCount: number;
55
56
  failureCount: number;
56
57
  }
57
58
 
58
- /** Sends one bulk-delete call for the given ids (≤100). */
59
- export type DeleteBatch = (ids: string[]) => Promise<DeleteBatchResult>;
59
+ /** Applies the action to one batch of ids (≤100) with a single bulk call. */
60
+ export type ApplyBatch = (ids: string[]) => Promise<BatchResult>;
60
61
 
61
- export interface BulkDeleteProgress {
62
- /** Ids confirmed deleted so far. */
62
+ export interface BulkActionProgress {
63
+ /** Ids the action has been applied to so far. */
63
64
  done: number;
64
65
  /** The total the run was started against (may be an estimate for the
65
- * predicate case — see `runPredicateDelete`). */
66
+ * predicate case — see `runPredicateAction`). */
66
67
  total: number;
67
68
  }
68
69
 
69
- export interface BulkDeleteOutcome {
70
- /** Ids confirmed deleted (server-reported success, not merely "sent"). */
70
+ export interface BulkActionOutcome {
71
+ /** Ids the action was applied to (server-accepted, not merely "sent"). */
71
72
  done: number;
72
73
  /**
73
- * Ids the run did not reach and are therefore not confirmed deleted: on
74
+ * Ids the run did not reach, so the action was never applied to them: on
74
75
  * cancellation or a thrown error, the chunks the bounded run never attempted
75
- * (see `runChunkedDelete`). Empty in the predicate case, which re-resolves on
76
- * every run rather than handing back a remainder (see `runPredicateDelete`).
76
+ * (see `runChunkedAction`). Empty in the predicate case, which re-resolves on
77
+ * every run rather than handing back a remainder (see `runPredicateAction`).
77
78
  * There is no per-id failure source: a returned batch call counts every id in
78
79
  * it as accepted (see the module header).
79
80
  */
@@ -89,15 +90,16 @@ export interface BulkDeleteOutcome {
89
90
  * or a "select all loaded" that grew past 100 rows). Chunked synchronously, so
90
91
  * on cancellation or a thrown error every chunk not yet attempted — including
91
92
  * the one in flight when an error was thrown — is folded into `failedIds`. The
92
- * caller always gets back exactly the ids still not confirmed deleted, ready
93
- * to retry as-is (deleting an already-trashed message is a safe no-op).
93
+ * caller always gets back exactly the ids the action never reached, ready to
94
+ * retry as-is: every action here is idempotent (re-trashing, re-moving or
95
+ * re-marking a message it already applied to is a no-op).
94
96
  */
95
- export const runChunkedDelete = async (
97
+ export const runChunkedAction = async (
96
98
  ids: readonly string[],
97
- deleteBatch: DeleteBatch,
98
- onProgress: (progress: BulkDeleteProgress) => void,
99
+ applyBatch: ApplyBatch,
100
+ onProgress: (progress: BulkActionProgress) => void,
99
101
  isCancelled: () => boolean,
100
- ): Promise<BulkDeleteOutcome> => {
102
+ ): Promise<BulkActionOutcome> => {
101
103
  const chunks = chunkIds(ids);
102
104
  const total = ids.length;
103
105
  let done = 0;
@@ -109,7 +111,7 @@ export const runChunkedDelete = async (
109
111
  return { done, failedIds, cancelled: true };
110
112
  }
111
113
  const chunk = chunks[i];
112
- const attempted = await attempt(deleteBatch(chunk));
114
+ const attempted = await attempt(applyBatch(chunk));
113
115
  if (!attempted.ok) {
114
116
  failedIds.push(...chunks.slice(i).flat());
115
117
  onProgress({ done, total });
@@ -134,7 +136,7 @@ export type FetchIdsPage = (
134
136
 
135
137
  /**
136
138
  * Escalated case: the selection is a predicate (D2), not a materialized list.
137
- * Each page is fetched immediately before the chunk it feeds is deleted — a
139
+ * Each page is fetched immediately before the chunk it feeds is acted on — a
138
140
  * page IS a chunk, sized to the same 100-id cap the write side enforces — so
139
141
  * ids are never held in memory beyond the batch in flight.
140
142
  *
@@ -142,16 +144,15 @@ export type FetchIdsPage = (
142
144
  * `failedIds` for the unreached remainder, because those ids were never
143
145
  * fetched — there is nothing to hand back. A predicate resolves fresh on
144
146
  * every run (D2), so resuming is re-invoking this same function with the same
145
- * predicate: already-deleted matches drop out of the search on their own and
146
- * are not re-sent.
147
+ * predicate.
147
148
  */
148
- export const runPredicateDelete = async (
149
+ export const runPredicateAction = async (
149
150
  fetchIdsPage: FetchIdsPage,
150
151
  total: number,
151
- deleteBatch: DeleteBatch,
152
- onProgress: (progress: BulkDeleteProgress) => void,
152
+ applyBatch: ApplyBatch,
153
+ onProgress: (progress: BulkActionProgress) => void,
153
154
  isCancelled: () => boolean,
154
- ): Promise<BulkDeleteOutcome> => {
155
+ ): Promise<BulkActionOutcome> => {
155
156
  let done = 0;
156
157
  const failedIds: string[] = [];
157
158
  let token: string | undefined;
@@ -168,7 +169,7 @@ export const runPredicateDelete = async (
168
169
  const page = fetched.value;
169
170
 
170
171
  if (page.ids.length > 0) {
171
- const attempted = await attempt(deleteBatch(page.ids));
172
+ const attempted = await attempt(applyBatch(page.ids));
172
173
  if (!attempted.ok) {
173
174
  return { done, failedIds, cancelled: false, error: attempted.error };
174
175
  }
@@ -184,8 +185,8 @@ export const runPredicateDelete = async (
184
185
 
185
186
  /**
186
187
  * Corrects a progress reading so its `total` can never read as less than
187
- * `done` (#109). `runPredicateDelete`'s `total` is `countMatches`'s frozen
188
- * page-through, taken before the delete re-pages the same predicate a second,
188
+ * `done` (#109). `runPredicateAction`'s `total` is `countMatches`'s frozen
189
+ * page-through, taken before the run re-pages the same predicate a second,
189
190
  * independent time; if more matches arrived in between, `done` can overtake
190
191
  * it mid-run and a raw "Deleting 1,340 of 1,284" would follow. Widening the
191
192
  * denominator to match keeps the bar's ratio sane (never past 100%) without
@@ -194,8 +195,8 @@ export const runPredicateDelete = async (
194
195
  * second count taken any later is no less stale than the first).
195
196
  */
196
197
  export const honestProgress = (
197
- progress: BulkDeleteProgress,
198
- ): BulkDeleteProgress => ({
198
+ progress: BulkActionProgress,
199
+ ): BulkActionProgress => ({
199
200
  done: progress.done,
200
201
  total: Math.max(progress.total, progress.done),
201
202
  });
@@ -239,15 +240,15 @@ export const countMatches = async (
239
240
  return { total, cancelled: false };
240
241
  };
241
242
 
242
- export interface DeleteRunOutcome {
243
+ export interface BulkRunOutcome {
243
244
  done: number;
244
245
  failedIds: string[];
245
246
  cancelled: boolean;
246
247
  error?: unknown;
247
248
  }
248
249
 
249
- export interface SelectionAfterDelete {
250
- /** Everything targeted was confirmed deleted — selection mode should exit. */
250
+ export interface SelectionAfterRun {
251
+ /** The action reached everything targeted — selection mode should exit. */
251
252
  exit: boolean;
252
253
  /** The bounded selection to leave in place, empty when exiting. */
253
254
  retryIds: string[];
@@ -255,14 +256,14 @@ export interface SelectionAfterDelete {
255
256
 
256
257
  /**
257
258
  * What a caller does with selection once a run ends, for any reason. Every id
258
- * not confirmed deleted — a chunk the bounded run never reached because it was
259
+ * the action never reached — a chunk the bounded run skipped because it was
259
260
  * stopped or errored — belongs in `retryIds`: it is exactly what Retry should
260
261
  * resend, and it is what stays selected so the count on screen never claims
261
- * more was deleted than actually was (#92 requirement 8).
262
+ * more was done than actually was (#92 requirement 8).
262
263
  */
263
- export const resolveSelectionAfterDelete = (
264
- outcome: DeleteRunOutcome,
265
- ): SelectionAfterDelete => {
264
+ export const resolveSelectionAfterRun = (
265
+ outcome: BulkRunOutcome,
266
+ ): SelectionAfterRun => {
266
267
  if (outcome.failedIds.length > 0) {
267
268
  return { exit: false, retryIds: outcome.failedIds };
268
269
  }
@@ -5,7 +5,15 @@ import {
5
5
  threadOperationsSearchThreadsQueryKey,
6
6
  unifiedThreadOperationsListAllThreadsQueryKey,
7
7
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
- import { threadListCacheKeys } from "./thread-list-cache.js";
8
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
+ import { QueryClient } from "@tanstack/react-query";
10
+ import {
11
+ invalidateThreadListQueries,
12
+ patchThreadListQueries,
13
+ restoreThreadListQueries,
14
+ snapshotThreadListQueries,
15
+ threadListCacheKeys,
16
+ } from "./thread-list-cache.js";
9
17
 
10
18
  describe("threadListCacheKeys", () => {
11
19
  test("resolves each mailbox's list and search caches plus the unified one", () => {
@@ -42,3 +50,98 @@ describe("threadListCacheKeys", () => {
42
50
  assert.strictEqual(keys.length, 3);
43
51
  });
44
52
  });
53
+
54
+ const thread = (
55
+ overrides: Partial<RemitImapThreadMessageResponse> & { messageId: string },
56
+ ): RemitImapThreadMessageResponse =>
57
+ ({
58
+ threadId: "t1",
59
+ threadMessageId: `tm-${overrides.messageId}`,
60
+ mailboxId: "mb1",
61
+ subject: "s",
62
+ fromName: "n",
63
+ fromEmail: "e",
64
+ sentDate: "2025-01-01T00:00:00Z",
65
+ snippet: "",
66
+ hasAttachment: false,
67
+ hasStars: false,
68
+ isRead: false,
69
+ ...overrides,
70
+ }) as RemitImapThreadMessageResponse;
71
+
72
+ /** The single-shot page the daily brief and Flagged cache under the unified key. */
73
+ const briefKey = unifiedThreadOperationsListAllThreadsQueryKey();
74
+
75
+ /** The brief's search variant caches under the same key plus query options. */
76
+ const briefSearchKey = unifiedThreadOperationsListAllThreadsQueryKey({
77
+ query: { query: "invoice", limit: 50 },
78
+ });
79
+
80
+ /** The infinite shape a mailbox list caches. */
81
+ const mailboxKey = threadOperationsListThreadsQueryKey({
82
+ path: { mailboxId: "mb1" },
83
+ query: { order: "desc" },
84
+ });
85
+
86
+ const seed = () => {
87
+ const queryClient = new QueryClient();
88
+ queryClient.setQueryData(briefKey, { items: [thread({ messageId: "m1" })] });
89
+ queryClient.setQueryData(briefSearchKey, {
90
+ items: [thread({ messageId: "m1" })],
91
+ });
92
+ queryClient.setQueryData(mailboxKey, {
93
+ pages: [{ items: [thread({ messageId: "m1" })] }],
94
+ pageParams: [undefined],
95
+ });
96
+ return queryClient;
97
+ };
98
+
99
+ const briefItems = (queryClient: QueryClient, key: readonly unknown[]) =>
100
+ queryClient.getQueryData<{ items: RemitImapThreadMessageResponse[] }>(key)
101
+ ?.items ?? [];
102
+
103
+ describe("mutating from the daily brief", () => {
104
+ test("patches the unified listing the brief reads, not only the mailbox list", () => {
105
+ const queryClient = seed();
106
+ patchThreadListQueries(queryClient, threadListCacheKeys(["mb1"]), (items) =>
107
+ items.map((item) => ({ ...item, isRead: true })),
108
+ );
109
+ assert.strictEqual(briefItems(queryClient, briefKey)[0].isRead, true);
110
+ assert.strictEqual(briefItems(queryClient, briefSearchKey)[0].isRead, true);
111
+ const pages = queryClient.getQueryData<{
112
+ pages: Array<{ items: RemitImapThreadMessageResponse[] }>;
113
+ }>(mailboxKey);
114
+ assert.strictEqual(pages?.pages[0].items[0].isRead, true);
115
+ });
116
+
117
+ test("removes a deleted row from the brief's page", () => {
118
+ const queryClient = seed();
119
+ patchThreadListQueries(queryClient, threadListCacheKeys(["mb1"]), (items) =>
120
+ items.filter((item) => item.messageId !== "m1"),
121
+ );
122
+ assert.deepStrictEqual(briefItems(queryClient, briefKey), []);
123
+ });
124
+
125
+ test("restores the brief's page when the mutation fails", () => {
126
+ const queryClient = seed();
127
+ const prefixes = threadListCacheKeys(["mb1"]);
128
+ const snapshot = snapshotThreadListQueries(queryClient, prefixes);
129
+ patchThreadListQueries(queryClient, prefixes, () => []);
130
+ restoreThreadListQueries(queryClient, snapshot);
131
+ assert.strictEqual(briefItems(queryClient, briefKey).length, 1);
132
+ assert.strictEqual(briefItems(queryClient, briefSearchKey).length, 1);
133
+ });
134
+
135
+ test("invalidates the brief's listing so the next read refetches", () => {
136
+ const queryClient = seed();
137
+ invalidateThreadListQueries(queryClient, threadListCacheKeys(["mb1"]));
138
+ assert.strictEqual(
139
+ queryClient.getQueryState(briefKey)?.isInvalidated,
140
+ true,
141
+ );
142
+ assert.strictEqual(
143
+ queryClient.getQueryState(mailboxKey)?.isInvalidated,
144
+ true,
145
+ );
146
+ });
147
+ });