@remit/web-client 0.0.25 → 0.0.27

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,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
+ };
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ describeSearchScope,
5
+ escalatedStatusLabel,
6
+ escalationActionLabel,
7
+ } from "./escalation-label.js";
8
+
9
+ describe("describeSearchScope", () => {
10
+ test("free text query wins and is quoted", () => {
11
+ assert.equal(describeSearchScope({ query: "npm" }), 'matching "npm"');
12
+ });
13
+
14
+ test("falls back to a from: filter when there's no free text", () => {
15
+ assert.equal(
16
+ describeSearchScope({ from: "billing@example.com" }),
17
+ 'from "billing@example.com"',
18
+ );
19
+ });
20
+
21
+ test("free text wins over a from: filter when both are set", () => {
22
+ assert.equal(
23
+ describeSearchScope({ query: "npm", from: "noreply@npmjs.com" }),
24
+ 'matching "npm"',
25
+ );
26
+ });
27
+
28
+ test("neither present falls back to a generic scope rather than throwing", () => {
29
+ assert.equal(describeSearchScope({}), "matching your search");
30
+ });
31
+ });
32
+
33
+ describe("escalationActionLabel", () => {
34
+ test("names the scope, never a bare Select all", () => {
35
+ assert.equal(
36
+ escalationActionLabel({ query: "npm" }),
37
+ 'Select all matching "npm"',
38
+ );
39
+ });
40
+ });
41
+
42
+ describe("escalatedStatusLabel", () => {
43
+ test("thousands-separates the total and names the scope", () => {
44
+ assert.equal(
45
+ escalatedStatusLabel({ query: "npm" }, 3412),
46
+ 'All 3,412 matching "npm" selected',
47
+ );
48
+ });
49
+ });
@@ -0,0 +1,32 @@
1
+ import { formatNumber } from "./format";
2
+
3
+ /** The subset of the search predicate that names the scope in escalation copy. */
4
+ export interface EscalationScopeQuery {
5
+ query?: string;
6
+ from?: string;
7
+ }
8
+
9
+ /**
10
+ * Names what an escalated selection covers, for the notice text and the
11
+ * confirm dialog — never a bare "Select all" (issue #92 requirement 4). Free
12
+ * text wins when present since it's what the user actually typed; `from:`
13
+ * alone still names a scope. Neither present is unreachable in practice (the
14
+ * escalation control only ever renders for an active search), but the
15
+ * fallback keeps the label honest instead of throwing.
16
+ */
17
+ export const describeSearchScope = (query: EscalationScopeQuery): string => {
18
+ if (query.query) return `matching "${query.query}"`;
19
+ if (query.from) return `from "${query.from}"`;
20
+ return "matching your search";
21
+ };
22
+
23
+ /** "Select all matching "npm"" — the escalation notice's action label. */
24
+ export const escalationActionLabel = (query: EscalationScopeQuery): string =>
25
+ `Select all ${describeSearchScope(query)}`;
26
+
27
+ /** "All 3,412 matching "npm" selected" — the bar's status label once escalated. */
28
+ export const escalatedStatusLabel = (
29
+ query: EscalationScopeQuery,
30
+ total: number,
31
+ ): string =>
32
+ `All ${formatNumber(total)} ${describeSearchScope(query)} selected`;
@@ -112,4 +112,11 @@ describe("formatDeleteToTrashTitle", () => {
112
112
  "Move 0 messages to Trash?",
113
113
  );
114
114
  });
115
+
116
+ test("thousands-separates an escalated-selection-scale count", () => {
117
+ assert.strictEqual(
118
+ formatDeleteToTrashTitle(3412),
119
+ "Move 3,412 messages to Trash?",
120
+ );
121
+ });
115
122
  });
package/src/lib/format.ts CHANGED
@@ -193,6 +193,10 @@ export const formatList = (
193
193
  /**
194
194
  * Confirmation title for the move-to-Trash delete flow. Reflects that delete
195
195
  * moves messages to Trash (not a permanent delete) and pluralizes on count.
196
+ * Thousands-separated: at escalated-selection scale this is exactly the digit
197
+ * count someone checks against what they meant to select.
196
198
  */
197
199
  export const formatDeleteToTrashTitle = (count: number): string =>
198
- count === 1 ? "Move 1 message to Trash?" : `Move ${count} messages to Trash?`;
200
+ count === 1
201
+ ? "Move 1 message to Trash?"
202
+ : `Move ${formatNumber(count)} messages to Trash?`;