@remit/mailbox-service 0.0.41 → 0.0.43
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/body-sync-auto-read-writeonce.test.ts +219 -0
- package/src/body-sync.ts +27 -1
- package/src/list-id-backfill.test.ts +158 -1
- package/src/list-id-backfill.ts +32 -13
package/package.json
CHANGED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #499: `Address.flags.unsubscribed` auto-mark-read is a first-
|
|
3
|
+
* classification decision, like `Message.category` (#355) and the placement
|
|
4
|
+
* verdict (#383). Re-deriving it on the two shipped re-entrant paths —
|
|
5
|
+
* `fetchAndGetBody`'s `NoSuchKey` fallback and `syncBodies(..., force: true)`
|
|
6
|
+
* — undid a user who had deliberately marked such a message unread.
|
|
7
|
+
*
|
|
8
|
+
* The re-entrant fixtures below keep `flags.unsubscribed` set, so a regression
|
|
9
|
+
* that drops the guard shows up as a real `FlagQueueService.markAsRead`
|
|
10
|
+
* round-trip against a message the user owns, not as a fixture that happens to
|
|
11
|
+
* disagree with the flag.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { Readable } from "node:stream";
|
|
16
|
+
import { describe, it } from "node:test";
|
|
17
|
+
import type {
|
|
18
|
+
AddressItem,
|
|
19
|
+
IAddressRepository,
|
|
20
|
+
IEnvelopeRepository,
|
|
21
|
+
IMessageRepository,
|
|
22
|
+
IThreadMessageRepository,
|
|
23
|
+
MessageItem,
|
|
24
|
+
UpdateMessageInput,
|
|
25
|
+
} from "@remit/data-ports";
|
|
26
|
+
import type { StorageService } from "@remit/storage-service";
|
|
27
|
+
import { BodySyncService } from "./body-sync.js";
|
|
28
|
+
import type { FlagQueueService } from "./flag-queue.js";
|
|
29
|
+
import type { IImapConnection } from "./types.js";
|
|
30
|
+
|
|
31
|
+
const UNSUBSCRIBED_SENDER = "newsletter@example.com";
|
|
32
|
+
|
|
33
|
+
const PLAIN_EML = Buffer.from(
|
|
34
|
+
[
|
|
35
|
+
`From: Newsletter <${UNSUBSCRIBED_SENDER}>`,
|
|
36
|
+
"To: me@example.com",
|
|
37
|
+
"Subject: This week",
|
|
38
|
+
"List-Unsubscribe: <mailto:stop@example.com>",
|
|
39
|
+
"Content-Type: text/plain",
|
|
40
|
+
"",
|
|
41
|
+
"body",
|
|
42
|
+
].join("\r\n"),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
interface MarkReadCall {
|
|
46
|
+
accountConfigId: string;
|
|
47
|
+
messageId: string;
|
|
48
|
+
accountId: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Harness {
|
|
52
|
+
service: BodySyncService;
|
|
53
|
+
markReadCalls: MarkReadCall[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const buildHarness = (
|
|
57
|
+
message: Partial<MessageItem> & Pick<MessageItem, "messageId">,
|
|
58
|
+
retrieve: () => Promise<Buffer>,
|
|
59
|
+
): Harness => {
|
|
60
|
+
const markReadCalls: MarkReadCall[] = [];
|
|
61
|
+
|
|
62
|
+
const messageRow = {
|
|
63
|
+
uid: 1,
|
|
64
|
+
mailboxId: "mb-inbox",
|
|
65
|
+
...message,
|
|
66
|
+
} as unknown as MessageItem;
|
|
67
|
+
|
|
68
|
+
const messageService = {
|
|
69
|
+
get: async () => messageRow,
|
|
70
|
+
update: async (_messageId: string, input: UpdateMessageInput) => {
|
|
71
|
+
Object.assign(messageRow, input);
|
|
72
|
+
},
|
|
73
|
+
} as unknown as IMessageRepository;
|
|
74
|
+
|
|
75
|
+
const threadMessageService = {
|
|
76
|
+
findAllByMessageId: async () => [
|
|
77
|
+
{
|
|
78
|
+
threadMessageId: "tm-1",
|
|
79
|
+
messageId: message.messageId,
|
|
80
|
+
mailboxId: messageRow.mailboxId,
|
|
81
|
+
sentDate: 1,
|
|
82
|
+
isRead: false,
|
|
83
|
+
isDeleted: false,
|
|
84
|
+
hasStars: false,
|
|
85
|
+
hasAttachment: false,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
update: async () => {},
|
|
89
|
+
} as unknown as IThreadMessageRepository;
|
|
90
|
+
|
|
91
|
+
const storageService = {
|
|
92
|
+
retrieve,
|
|
93
|
+
storeMessageBody: async () => ({ uri: `s3://bodies/${message.messageId}` }),
|
|
94
|
+
storeMessageBodyStream: async () => ({
|
|
95
|
+
uri: `s3://bodies/${message.messageId}`,
|
|
96
|
+
}),
|
|
97
|
+
storeParsedBody: async () => {},
|
|
98
|
+
listBodyParts: async () => [],
|
|
99
|
+
} as unknown as StorageService;
|
|
100
|
+
|
|
101
|
+
const addressService = {
|
|
102
|
+
getAddress: async () =>
|
|
103
|
+
({
|
|
104
|
+
flags: { unsubscribed: { value: true, setAt: 1 } },
|
|
105
|
+
}) as unknown as AddressItem,
|
|
106
|
+
incrementInboundCount: async () => {},
|
|
107
|
+
} as unknown as IAddressRepository;
|
|
108
|
+
|
|
109
|
+
const envelopeService = {
|
|
110
|
+
listBodyParts: async () => [],
|
|
111
|
+
} as unknown as IEnvelopeRepository;
|
|
112
|
+
|
|
113
|
+
const flagQueueService = {
|
|
114
|
+
markAsRead: async (
|
|
115
|
+
accountConfigId: string,
|
|
116
|
+
messageId: string,
|
|
117
|
+
accountId: string,
|
|
118
|
+
) => {
|
|
119
|
+
markReadCalls.push({ accountConfigId, messageId, accountId });
|
|
120
|
+
},
|
|
121
|
+
} as unknown as FlagQueueService;
|
|
122
|
+
|
|
123
|
+
const service = new BodySyncService(
|
|
124
|
+
messageService,
|
|
125
|
+
storageService,
|
|
126
|
+
threadMessageService,
|
|
127
|
+
addressService,
|
|
128
|
+
envelopeService,
|
|
129
|
+
{ info: () => {}, error: () => {} },
|
|
130
|
+
undefined,
|
|
131
|
+
undefined,
|
|
132
|
+
undefined,
|
|
133
|
+
{ flagQueueService },
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { service, markReadCalls };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const noSuchKeyError = () =>
|
|
140
|
+
Object.assign(new Error("missing"), { name: "NoSuchKey" });
|
|
141
|
+
|
|
142
|
+
const bodyConnection = () =>
|
|
143
|
+
({
|
|
144
|
+
openBox: async () => {},
|
|
145
|
+
fetchMessageBody: async () => PLAIN_EML,
|
|
146
|
+
async *fetchMessageBodies(uids: number[]) {
|
|
147
|
+
for (const uid of uids) {
|
|
148
|
+
yield { uid, source: Readable.from([PLAIN_EML]) };
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
}) as unknown as IImapConnection;
|
|
152
|
+
|
|
153
|
+
describe("unsubscribed auto-mark-read is decided once (issue #499)", () => {
|
|
154
|
+
it("marks a message read on the pass that first classifies it", async () => {
|
|
155
|
+
const harness = buildHarness({ messageId: "m-1" }, async () => {
|
|
156
|
+
throw new Error("no body stored yet; must not retrieve");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await harness.service.fetchAndGetBody(
|
|
160
|
+
"m-1",
|
|
161
|
+
"acc-1",
|
|
162
|
+
"cfg-1",
|
|
163
|
+
"INBOX",
|
|
164
|
+
async () => bodyConnection(),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
assert.deepEqual(harness.markReadCalls, [
|
|
168
|
+
{ accountConfigId: "cfg-1", messageId: "m-1", accountId: "acc-1" },
|
|
169
|
+
]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("leaves a manually-unread message alone through the NoSuchKey IMAP re-fetch", async () => {
|
|
173
|
+
const harness = buildHarness(
|
|
174
|
+
{ messageId: "m-1", bodyStorageKey: "s3://bodies/m-1" },
|
|
175
|
+
async () => {
|
|
176
|
+
throw noSuchKeyError();
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
await harness.service.fetchAndGetBody(
|
|
181
|
+
"m-1",
|
|
182
|
+
"acc-1",
|
|
183
|
+
"cfg-1",
|
|
184
|
+
"INBOX",
|
|
185
|
+
async () => bodyConnection(),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
harness.markReadCalls,
|
|
190
|
+
[],
|
|
191
|
+
"a re-entrant pass must not re-apply auto-read over the user's unread",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("leaves a manually-unread message alone when syncBodies re-fetches with force", async () => {
|
|
196
|
+
const harness = buildHarness(
|
|
197
|
+
{ messageId: "m-1", bodyStorageKey: "s3://bodies/m-1" },
|
|
198
|
+
async () => {
|
|
199
|
+
throw new Error("force path must not retrieve from storage");
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const result = await harness.service.syncBodies(
|
|
204
|
+
["m-1"],
|
|
205
|
+
"acc-1",
|
|
206
|
+
"cfg-1",
|
|
207
|
+
"INBOX",
|
|
208
|
+
async () => bodyConnection(),
|
|
209
|
+
true,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
assert.deepEqual(result.syncedMessageIds, ["m-1"]);
|
|
213
|
+
assert.deepEqual(
|
|
214
|
+
harness.markReadCalls,
|
|
215
|
+
[],
|
|
216
|
+
"a forced re-sync must not re-apply auto-read over the user's unread",
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
package/src/body-sync.ts
CHANGED
|
@@ -200,6 +200,18 @@ const hasDecidedCategory = (
|
|
|
200
200
|
const hasDecidedPlacement = (placementDecidedAt: number | undefined): boolean =>
|
|
201
201
|
placementDecidedAt !== undefined;
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Issue #499: whether a completed body-sync pass has already classified this
|
|
205
|
+
* message. `bodyStorageKey` is written last, once every derivation of that pass
|
|
206
|
+
* has run, so its presence means the pass that first classified the message
|
|
207
|
+
* finished. Reads it exactly as `syncBodies`' own skip guard does. The same two
|
|
208
|
+
* re-entrant paths `hasDecidedCategory` and `hasDecidedPlacement` guard
|
|
209
|
+
* (`fetchAndGetBody`'s `NoSuchKey` fallback, `syncBodies(..., force: true)`) are
|
|
210
|
+
* the ones that reach the derivations again with it already set.
|
|
211
|
+
*/
|
|
212
|
+
const hasClassifiedBody = (bodyStorageKey: string | undefined): boolean =>
|
|
213
|
+
Boolean(bodyStorageKey);
|
|
214
|
+
|
|
203
215
|
/**
|
|
204
216
|
* Issue #398: `flags.autoArchive` is a filing preference relative to the Inbox.
|
|
205
217
|
* A `leave` verdict reaches it for two unrelated reasons and only one of them
|
|
@@ -869,6 +881,12 @@ export class BodySyncService {
|
|
|
869
881
|
});
|
|
870
882
|
}
|
|
871
883
|
|
|
884
|
+
// Read once for the two write-once guards below — the auto-read decision
|
|
885
|
+
// and the category carry-forward. Taken here rather than earlier because
|
|
886
|
+
// nothing between this point and the Message update writes either field;
|
|
887
|
+
// the moves above touch `mailboxId` only.
|
|
888
|
+
const existingMessage = await this.messageService.get(messageId);
|
|
889
|
+
|
|
872
890
|
// `flags.unsubscribed` (issue #302, RFC 039 Decision 3): auto-mark-read,
|
|
873
891
|
// reusing the same FlagQueueService.markAsRead round-trip a manual
|
|
874
892
|
// mark-as-read goes through — idempotent on a retry (flipFlag no-ops when
|
|
@@ -879,6 +897,7 @@ export class BodySyncService {
|
|
|
879
897
|
accountId,
|
|
880
898
|
accountConfigId,
|
|
881
899
|
parsed,
|
|
900
|
+
existingMessage.bodyStorageKey,
|
|
882
901
|
);
|
|
883
902
|
|
|
884
903
|
const moved = Boolean(resolved.move || filterMoved);
|
|
@@ -907,7 +926,6 @@ export class BodySyncService {
|
|
|
907
926
|
// re-entrant paths for placement: `computePlacement` already declined to
|
|
908
927
|
// recompute a verdict once this field is set, so it is only ever present
|
|
909
928
|
// here on the pass that first decided it.
|
|
910
|
-
const existingMessage = await this.messageService.get(messageId);
|
|
911
929
|
const finalCategory = hasDecidedCategory(existingMessage.category)
|
|
912
930
|
? existingMessage.category
|
|
913
931
|
: classification.category;
|
|
@@ -1284,14 +1302,22 @@ export class BodySyncService {
|
|
|
1284
1302
|
* separate expiry mechanism, and no caching of the decision beyond this
|
|
1285
1303
|
* per-message `Address` read. A no-op when body sync was built without an
|
|
1286
1304
|
* {@link UnsubscribeConfig} or the message carries no `From` address.
|
|
1305
|
+
*
|
|
1306
|
+
* Write-once per message (issue #499), like the `category` and placement
|
|
1307
|
+
* derivations alongside it: read state is applied on the pass that first
|
|
1308
|
+
* classifies a message and never re-decided. A user who marks such a message
|
|
1309
|
+
* unread afterwards owns it — a re-entrant pass over an already-classified
|
|
1310
|
+
* body would otherwise silently undo that.
|
|
1287
1311
|
*/
|
|
1288
1312
|
private async applyUnsubscribedAutoRead(
|
|
1289
1313
|
messageId: string,
|
|
1290
1314
|
accountId: string,
|
|
1291
1315
|
accountConfigId: string,
|
|
1292
1316
|
parsed: ParsedMail,
|
|
1317
|
+
storedBodyKey: string | undefined,
|
|
1293
1318
|
): Promise<void> {
|
|
1294
1319
|
if (!this.unsubscribeConfig) return;
|
|
1320
|
+
if (hasClassifiedBody(storedBodyKey)) return;
|
|
1295
1321
|
|
|
1296
1322
|
const fromEmail = extractPrimaryFromEmail(parsed);
|
|
1297
1323
|
if (!fromEmail) return;
|
|
@@ -24,6 +24,7 @@ import type { StorageService } from "@remit/storage-service";
|
|
|
24
24
|
import {
|
|
25
25
|
backfillListIds,
|
|
26
26
|
type ListIdBackfillCheckpoint,
|
|
27
|
+
type ListIdBackfillCheckpointStore,
|
|
27
28
|
type ListIdBackfillProgress,
|
|
28
29
|
} from "./list-id-backfill.js";
|
|
29
30
|
|
|
@@ -99,6 +100,22 @@ const message = (overrides: Partial<MessageItem>): MessageItem =>
|
|
|
99
100
|
...overrides,
|
|
100
101
|
}) as unknown as MessageItem;
|
|
101
102
|
|
|
103
|
+
const asAccount = (accountConfigId: string): AccountConfigItem =>
|
|
104
|
+
({ accountConfigId }) as unknown as AccountConfigItem;
|
|
105
|
+
|
|
106
|
+
const inMemoryCheckpointStore = (): ListIdBackfillCheckpointStore => {
|
|
107
|
+
let checkpoint: ListIdBackfillCheckpoint | undefined;
|
|
108
|
+
return {
|
|
109
|
+
load: async () => checkpoint,
|
|
110
|
+
save: async (next) => {
|
|
111
|
+
checkpoint = next;
|
|
112
|
+
},
|
|
113
|
+
clear: async () => {
|
|
114
|
+
checkpoint = undefined;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
102
119
|
interface Harness {
|
|
103
120
|
accountConfigService: Pick<IAccountConfigRepository, "listAll">;
|
|
104
121
|
threadMessageService: Pick<
|
|
@@ -351,6 +368,37 @@ describe("backfillListIds", () => {
|
|
|
351
368
|
assert.equal(harness.updates.length, 2);
|
|
352
369
|
});
|
|
353
370
|
|
|
371
|
+
it("scans accounts in account-id order, whatever order listAll returns", async () => {
|
|
372
|
+
const accountIds = ["acc-1", "acc-2", "acc-3"];
|
|
373
|
+
const rows = accountIds.map((accountConfigId) =>
|
|
374
|
+
row({
|
|
375
|
+
threadMessageId: `tm-${accountConfigId}`,
|
|
376
|
+
messageId: `m-${accountConfigId}`,
|
|
377
|
+
accountConfigId,
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
const harness = buildHarness({
|
|
381
|
+
accounts: ["acc-3", "acc-1", "acc-2"].map(asAccount),
|
|
382
|
+
rows,
|
|
383
|
+
messages: rows.map((r) =>
|
|
384
|
+
message({
|
|
385
|
+
messageId: r.messageId,
|
|
386
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
387
|
+
}),
|
|
388
|
+
),
|
|
389
|
+
});
|
|
390
|
+
const progress: ListIdBackfillProgress[] = [];
|
|
391
|
+
|
|
392
|
+
await backfillListIds(harness, {
|
|
393
|
+
onProgress: (p) => progress.push({ ...p }),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
assert.deepEqual(
|
|
397
|
+
progress.map((p) => p.accountConfigId),
|
|
398
|
+
accountIds,
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
354
402
|
it("checkpoints after each page and clears it on completion", async () => {
|
|
355
403
|
const rows = Array.from({ length: 3 }, (_, i) =>
|
|
356
404
|
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
@@ -380,6 +428,7 @@ describe("backfillListIds", () => {
|
|
|
380
428
|
});
|
|
381
429
|
|
|
382
430
|
assert.equal(saved.length, 2);
|
|
431
|
+
assert.equal(saved[0].accountConfigId, "acc-1");
|
|
383
432
|
assert.equal(saved[0].continuationToken, "2");
|
|
384
433
|
assert.equal(saved[1].continuationToken, undefined);
|
|
385
434
|
assert.equal(cleared, true);
|
|
@@ -400,7 +449,10 @@ describe("backfillListIds", () => {
|
|
|
400
449
|
const result = await backfillListIds(harness, {
|
|
401
450
|
batchSize: 2,
|
|
402
451
|
checkpointStore: {
|
|
403
|
-
load: async () => ({
|
|
452
|
+
load: async () => ({
|
|
453
|
+
accountConfigId: "acc-1",
|
|
454
|
+
continuationToken: "2",
|
|
455
|
+
}),
|
|
404
456
|
save: async () => {},
|
|
405
457
|
clear: async () => {},
|
|
406
458
|
},
|
|
@@ -413,4 +465,109 @@ describe("backfillListIds", () => {
|
|
|
413
465
|
["tm-2"],
|
|
414
466
|
);
|
|
415
467
|
});
|
|
468
|
+
|
|
469
|
+
it("resumes the account it was working through after an earlier account is removed", async () => {
|
|
470
|
+
const accountIds = ["acc-1", "acc-2", "acc-3"];
|
|
471
|
+
const rows = [
|
|
472
|
+
row({
|
|
473
|
+
threadMessageId: "tm-1",
|
|
474
|
+
messageId: "m-1",
|
|
475
|
+
accountConfigId: "acc-1",
|
|
476
|
+
}),
|
|
477
|
+
row({
|
|
478
|
+
threadMessageId: "tm-2a",
|
|
479
|
+
messageId: "m-2a",
|
|
480
|
+
accountConfigId: "acc-2",
|
|
481
|
+
}),
|
|
482
|
+
row({
|
|
483
|
+
threadMessageId: "tm-2b",
|
|
484
|
+
messageId: "m-2b",
|
|
485
|
+
accountConfigId: "acc-2",
|
|
486
|
+
}),
|
|
487
|
+
row({
|
|
488
|
+
threadMessageId: "tm-3",
|
|
489
|
+
messageId: "m-3",
|
|
490
|
+
accountConfigId: "acc-3",
|
|
491
|
+
}),
|
|
492
|
+
];
|
|
493
|
+
const messages = rows.map((r) =>
|
|
494
|
+
message({
|
|
495
|
+
messageId: r.messageId,
|
|
496
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
497
|
+
}),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const store = inMemoryCheckpointStore();
|
|
501
|
+
|
|
502
|
+
const interrupted = buildHarness({
|
|
503
|
+
accounts: accountIds.map(asAccount),
|
|
504
|
+
rows,
|
|
505
|
+
messages,
|
|
506
|
+
pageSize: 1,
|
|
507
|
+
});
|
|
508
|
+
const listByAccount = interrupted.threadMessageService.listByAccount;
|
|
509
|
+
interrupted.threadMessageService.listByAccount = async (
|
|
510
|
+
accountConfigId,
|
|
511
|
+
opts,
|
|
512
|
+
) => {
|
|
513
|
+
if (accountConfigId === "acc-2" && opts?.continuationToken) {
|
|
514
|
+
throw new Error("interrupted");
|
|
515
|
+
}
|
|
516
|
+
return listByAccount(accountConfigId, opts);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
await assert.rejects(
|
|
520
|
+
backfillListIds(interrupted, { batchSize: 1, checkpointStore: store }),
|
|
521
|
+
/interrupted/,
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
const resumed = buildHarness({
|
|
525
|
+
accounts: ["acc-2", "acc-3"].map(asAccount),
|
|
526
|
+
rows,
|
|
527
|
+
messages,
|
|
528
|
+
pageSize: 1,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const result = await backfillListIds(resumed, {
|
|
532
|
+
batchSize: 1,
|
|
533
|
+
checkpointStore: store,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
assert.deepEqual(
|
|
537
|
+
resumed.updates.map((u) => u.threadMessageId),
|
|
538
|
+
["tm-2b", "tm-3"],
|
|
539
|
+
);
|
|
540
|
+
assert.equal(result.backfilled, 2);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("restarts the pass when the checkpointed account no longer exists", async () => {
|
|
544
|
+
const rows = Array.from({ length: 3 }, (_, i) =>
|
|
545
|
+
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
546
|
+
);
|
|
547
|
+
const messages = rows.map((r) =>
|
|
548
|
+
message({
|
|
549
|
+
messageId: r.messageId,
|
|
550
|
+
bodyStorageKey: `s3://${r.messageId}`,
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
const harness = buildHarness({ rows, messages, pageSize: 2 });
|
|
554
|
+
|
|
555
|
+
const result = await backfillListIds(harness, {
|
|
556
|
+
batchSize: 2,
|
|
557
|
+
checkpointStore: {
|
|
558
|
+
load: async () => ({
|
|
559
|
+
accountConfigId: "acc-removed",
|
|
560
|
+
continuationToken: "2",
|
|
561
|
+
}),
|
|
562
|
+
save: async () => {},
|
|
563
|
+
clear: async () => {},
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
assert.equal(result.scanned, 3);
|
|
568
|
+
assert.deepEqual(
|
|
569
|
+
harness.updates.map((u) => u.threadMessageId),
|
|
570
|
+
["tm-0", "tm-1", "tm-2"],
|
|
571
|
+
);
|
|
572
|
+
});
|
|
416
573
|
});
|
package/src/list-id-backfill.ts
CHANGED
|
@@ -11,14 +11,14 @@ import { extractListId } from "./filters/list-id.js";
|
|
|
11
11
|
const DEFAULT_BATCH_SIZE = 200;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Where the full-corpus pass left off: the
|
|
15
|
-
*
|
|
16
|
-
* account
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* Where the full-corpus pass left off: the identity of the account it was
|
|
15
|
+
* working through, and the page cursor within that account. Resuming looks the
|
|
16
|
+
* account up by id and re-opens it at its saved cursor, rather than re-scanning
|
|
17
|
+
* the whole corpus from the top after an interruption. A checkpoint naming no
|
|
18
|
+
* configured account restarts the pass.
|
|
19
19
|
*/
|
|
20
20
|
export interface ListIdBackfillCheckpoint {
|
|
21
|
-
|
|
21
|
+
accountConfigId: string;
|
|
22
22
|
continuationToken?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -138,7 +138,8 @@ const deriveAndApplyListId = async (
|
|
|
138
138
|
* sync path will populate `listId` for it once the body lands.
|
|
139
139
|
*
|
|
140
140
|
* Chunked by `listByAccount`'s existing keyset pagination, one account at a
|
|
141
|
-
* time in
|
|
141
|
+
* time in account-id order, so an interrupted run and its resume walk the same
|
|
142
|
+
* sequence. A failure reading or parsing one message's
|
|
142
143
|
* stored body is contained to that message — logged, counted, and the pass
|
|
143
144
|
* continues — the same containment `BodySyncService`'s classification
|
|
144
145
|
* backfill uses for the same reason: one unreadable object must not strand
|
|
@@ -151,9 +152,29 @@ export const backfillListIds = async (
|
|
|
151
152
|
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
152
153
|
const { logger, checkpointStore } = options;
|
|
153
154
|
|
|
154
|
-
const accounts = await deps.accountConfigService.listAll()
|
|
155
|
+
const accounts = [...(await deps.accountConfigService.listAll())].sort(
|
|
156
|
+
(left, right) => left.accountConfigId.localeCompare(right.accountConfigId),
|
|
157
|
+
);
|
|
155
158
|
const startingCheckpoint = await checkpointStore?.load();
|
|
156
|
-
const
|
|
159
|
+
const checkpointedIndex = startingCheckpoint
|
|
160
|
+
? accounts.findIndex(
|
|
161
|
+
(account) =>
|
|
162
|
+
account.accountConfigId === startingCheckpoint.accountConfigId,
|
|
163
|
+
)
|
|
164
|
+
: -1;
|
|
165
|
+
|
|
166
|
+
if (startingCheckpoint && checkpointedIndex === -1) {
|
|
167
|
+
logger?.info(
|
|
168
|
+
{ accountConfigId: startingCheckpoint.accountConfigId },
|
|
169
|
+
"ListId backfill checkpoint names no configured account; restarting the pass",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const startIndex = checkpointedIndex === -1 ? 0 : checkpointedIndex;
|
|
174
|
+
const startContinuationToken =
|
|
175
|
+
checkpointedIndex === -1
|
|
176
|
+
? undefined
|
|
177
|
+
: startingCheckpoint?.continuationToken;
|
|
157
178
|
|
|
158
179
|
const totals = emptyTotals();
|
|
159
180
|
const failedThreadMessageIds: string[] = [];
|
|
@@ -165,9 +186,7 @@ export const backfillListIds = async (
|
|
|
165
186
|
) {
|
|
166
187
|
const account = accounts[accountIndex];
|
|
167
188
|
let continuationToken: string | undefined =
|
|
168
|
-
accountIndex === startIndex
|
|
169
|
-
? startingCheckpoint?.continuationToken
|
|
170
|
-
: undefined;
|
|
189
|
+
accountIndex === startIndex ? startContinuationToken : undefined;
|
|
171
190
|
|
|
172
191
|
do {
|
|
173
192
|
const page = await deps.threadMessageService.listByAccount(
|
|
@@ -231,7 +250,7 @@ export const backfillListIds = async (
|
|
|
231
250
|
|
|
232
251
|
continuationToken = page.continuationToken;
|
|
233
252
|
await checkpointStore?.save({
|
|
234
|
-
|
|
253
|
+
accountConfigId: account.accountConfigId,
|
|
235
254
|
continuationToken,
|
|
236
255
|
});
|
|
237
256
|
|