@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.
- package/package.json +1 -1
- package/src/components/mail/MailboxPane.tsx +1 -1
- package/src/components/mail/MessageActionMenu.tsx +24 -63
- package/src/components/mail/MessageList.selection.test.ts +41 -0
- package/src/components/mail/MessageList.tsx +122 -63
- package/src/components/mail/MobileMessageBar.tsx +8 -10
- package/src/hooks/useEscalatedActions.test.ts +36 -0
- package/src/hooks/{useEscalatedDelete.ts → useEscalatedActions.ts} +135 -74
- package/src/hooks/useMoveMessages.ts +8 -4
- package/src/hooks/useSelection.test.ts +1 -1
- package/src/lib/bulk-action-copy.test.ts +108 -0
- package/src/lib/bulk-action-copy.ts +85 -0
- package/src/lib/{bulk-delete.test.ts → bulk-actions.test.ts} +43 -43
- package/src/lib/{bulk-delete.ts → bulk-actions.ts} +53 -52
- package/src/lib/thread-list-cache.test.ts +104 -1
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
BULK_ACTION_CHUNK_SIZE,
|
|
5
5
|
chunkIds,
|
|
6
6
|
countMatches,
|
|
7
7
|
type FetchIdsPageResult,
|
|
8
8
|
honestProgress,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "./bulk-
|
|
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(
|
|
23
|
+
const got = chunkIds(ids(BULK_ACTION_CHUNK_SIZE));
|
|
24
24
|
assert.equal(got.length, 1);
|
|
25
|
-
assert.equal(got[0].length,
|
|
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(
|
|
29
|
+
const got = chunkIds(ids(BULK_ACTION_CHUNK_SIZE + 1));
|
|
30
30
|
assert.equal(got.length, 2);
|
|
31
|
-
assert.equal(got[0].length,
|
|
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(
|
|
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("
|
|
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
|
|
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(
|
|
74
|
+
const input = ids(BULK_ACTION_CHUNK_SIZE + 1);
|
|
75
75
|
const calls: string[][] = [];
|
|
76
|
-
const outcome = await
|
|
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,
|
|
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
|
|
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(
|
|
105
|
+
const input = ids(BULK_ACTION_CHUNK_SIZE * 3);
|
|
106
106
|
let calls = 0;
|
|
107
107
|
let cancelled = false;
|
|
108
|
-
const outcome = await
|
|
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,
|
|
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,
|
|
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(
|
|
126
|
+
const input = ids(BULK_ACTION_CHUNK_SIZE * 2);
|
|
127
127
|
const boom = new Error("network blip");
|
|
128
|
-
const outcome = await
|
|
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(
|
|
142
|
+
const input = ids(BULK_ACTION_CHUNK_SIZE + 1);
|
|
143
143
|
const progressCalls: { done: number; total: number }[] = [];
|
|
144
|
-
await
|
|
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:
|
|
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("
|
|
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
|
|
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(
|
|
184
|
+
const fetch = pagedFetcher([{ ids: ids(BULK_ACTION_CHUNK_SIZE) }]);
|
|
185
185
|
const deleteCalls: string[][] = [];
|
|
186
|
-
const outcome = await
|
|
186
|
+
const outcome = await runPredicateAction(
|
|
187
187
|
fetch,
|
|
188
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
358
|
+
describe("resolveSelectionAfterRun", () => {
|
|
359
359
|
test("a clean run with nothing failed exits selection mode", () => {
|
|
360
360
|
assert.deepEqual(
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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-
|
|
2
|
+
* Chunked bulk-action orchestration (issues #92, #114).
|
|
3
3
|
*
|
|
4
|
-
* The bulk
|
|
5
|
-
* `@maxItems(100)`);
|
|
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
|
|
8
|
-
*
|
|
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
|
|
11
|
-
* returned call means every id in it was accepted
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
* `
|
|
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
|
|
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 =
|
|
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
|
|
54
|
+
export interface BatchResult {
|
|
54
55
|
successCount: number;
|
|
55
56
|
failureCount: number;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
/**
|
|
59
|
-
export type
|
|
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
|
|
62
|
-
/** Ids
|
|
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 `
|
|
66
|
+
* predicate case — see `runPredicateAction`). */
|
|
66
67
|
total: number;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
export interface
|
|
70
|
-
/** Ids
|
|
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
|
|
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 `
|
|
76
|
-
* every run rather than handing back a remainder (see `
|
|
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
|
|
93
|
-
*
|
|
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
|
|
97
|
+
export const runChunkedAction = async (
|
|
96
98
|
ids: readonly string[],
|
|
97
|
-
|
|
98
|
-
onProgress: (progress:
|
|
99
|
+
applyBatch: ApplyBatch,
|
|
100
|
+
onProgress: (progress: BulkActionProgress) => void,
|
|
99
101
|
isCancelled: () => boolean,
|
|
100
|
-
): Promise<
|
|
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(
|
|
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
|
|
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
|
|
146
|
-
* are not re-sent.
|
|
147
|
+
* predicate.
|
|
147
148
|
*/
|
|
148
|
-
export const
|
|
149
|
+
export const runPredicateAction = async (
|
|
149
150
|
fetchIdsPage: FetchIdsPage,
|
|
150
151
|
total: number,
|
|
151
|
-
|
|
152
|
-
onProgress: (progress:
|
|
152
|
+
applyBatch: ApplyBatch,
|
|
153
|
+
onProgress: (progress: BulkActionProgress) => void,
|
|
153
154
|
isCancelled: () => boolean,
|
|
154
|
-
): Promise<
|
|
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(
|
|
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). `
|
|
188
|
-
* page-through, taken before the
|
|
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:
|
|
198
|
-
):
|
|
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
|
|
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
|
|
250
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
262
|
+
* more was done than actually was (#92 requirement 8).
|
|
262
263
|
*/
|
|
263
|
-
export const
|
|
264
|
-
outcome:
|
|
265
|
-
):
|
|
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 {
|
|
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
|
+
});
|