@remit/imap-worker 0.0.1

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.
Files changed (54) hide show
  1. package/README.md +100 -0
  2. package/build.mjs +17 -0
  3. package/package.json +50 -0
  4. package/src/account-check.test.ts +122 -0
  5. package/src/account-check.ts +88 -0
  6. package/src/body-sync-gate.test.ts +185 -0
  7. package/src/body-sync-gate.ts +86 -0
  8. package/src/cli.ts +211 -0
  9. package/src/connection-scope.test.ts +266 -0
  10. package/src/connection-scope.ts +335 -0
  11. package/src/e2e-processor-shim.ts +248 -0
  12. package/src/emit.test.ts +44 -0
  13. package/src/emit.ts +142 -0
  14. package/src/events.ts +221 -0
  15. package/src/handlers/append-sent-message.ts +163 -0
  16. package/src/handlers/delete-account-objects.test.ts +81 -0
  17. package/src/handlers/delete-account-objects.ts +116 -0
  18. package/src/handlers/empty-trash.ts +136 -0
  19. package/src/handlers/flag-push.test.ts +25 -0
  20. package/src/handlers/flag-push.ts +224 -0
  21. package/src/handlers/mailbox-management.ts +266 -0
  22. package/src/handlers/mailbox-sync-order.test.ts +93 -0
  23. package/src/handlers/mailbox-sync-order.ts +65 -0
  24. package/src/handlers/message-copy.ts +219 -0
  25. package/src/handlers/message-delete.test.ts +176 -0
  26. package/src/handlers/message-delete.ts +283 -0
  27. package/src/handlers/message-move.test.ts +168 -0
  28. package/src/handlers/message-move.ts +298 -0
  29. package/src/handlers/placement-move-push.test.ts +234 -0
  30. package/src/handlers/placement-move-push.ts +434 -0
  31. package/src/handlers/sync-mailboxes.ts +241 -0
  32. package/src/handlers/sync-message-body.test.ts +375 -0
  33. package/src/handlers/sync-message-body.ts +337 -0
  34. package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
  35. package/src/handlers/sync-messages.test.ts +204 -0
  36. package/src/handlers/sync-messages.ts +412 -0
  37. package/src/handlers/sync-reserved-host.test.ts +97 -0
  38. package/src/index.test.ts +22 -0
  39. package/src/index.ts +70 -0
  40. package/src/poller.ts +49 -0
  41. package/src/processor.test.ts +58 -0
  42. package/src/processor.ts +66 -0
  43. package/src/scheduler/config.test.ts +40 -0
  44. package/src/scheduler/config.ts +52 -0
  45. package/src/scheduler/decide-due.test.ts +44 -0
  46. package/src/scheduler/decide-due.ts +26 -0
  47. package/src/scheduler/handler.ts +52 -0
  48. package/src/scheduler/local-runner.ts +76 -0
  49. package/src/scheduler/run-tick.test.ts +248 -0
  50. package/src/scheduler/run-tick.ts +141 -0
  51. package/src/with-oauth-lifecycle-deps.ts +62 -0
  52. package/src/with-oauth-lifecycle.test.ts +227 -0
  53. package/src/with-oauth-lifecycle.ts +125 -0
  54. package/tsconfig.json +8 -0
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Connection scope utility for managing IMAP connections across event processing.
3
+ *
4
+ * Creates a lazily-connected, cached connection that can be shared across
5
+ * multiple operations during a single event's lifetime.
6
+ *
7
+ * On top of the per-event scope, this module keeps a MODULE-scoped pool of warm
8
+ * connections keyed by accountId so a warm Lambda container reuses live IMAP
9
+ * connections across invocations instead of paying TCP+TLS+LOGIN+SELECT every
10
+ * time (see #605).
11
+ */
12
+
13
+ import {
14
+ createConnection,
15
+ createConnectionWithCredentials,
16
+ type IImapConnection,
17
+ type ImapConnectionConfig,
18
+ type MailCredentials,
19
+ } from "@remit/mailbox-service";
20
+
21
+ export interface ConnectionScope {
22
+ /**
23
+ * Get the connection, connecting lazily if not already connected.
24
+ * Returns the same connection instance on subsequent calls.
25
+ */
26
+ getConnection: () => Promise<IImapConnection>;
27
+
28
+ /**
29
+ * Disconnect the connection if it was ever connected.
30
+ * Safe to call multiple times.
31
+ */
32
+ disconnect: () => Promise<void>;
33
+ }
34
+
35
+ /**
36
+ * Create a connection scope that manages a single IMAP connection's lifecycle.
37
+ *
38
+ * The connection is created lazily on first call to getConnection() and
39
+ * reused for all subsequent calls. Call disconnect() when done to clean up.
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * const scope = createConnectionScope(config);
44
+ *
45
+ * await doWork(scope.getConnection)
46
+ * .finally(() => scope.disconnect());
47
+ * ```
48
+ */
49
+ export const createConnectionScope = (
50
+ config: ImapConnectionConfig,
51
+ ): ConnectionScope => {
52
+ let connection: IImapConnection | null = null;
53
+ let connectPromise: Promise<IImapConnection> | null = null;
54
+
55
+ const getConnection = async (): Promise<IImapConnection> => {
56
+ if (connectPromise) {
57
+ return connectPromise;
58
+ }
59
+
60
+ const conn = createConnection(config);
61
+ connection = conn;
62
+ connectPromise = conn.connect().then(() => conn);
63
+
64
+ return connectPromise;
65
+ };
66
+
67
+ const disconnect = async (): Promise<void> => {
68
+ if (connection) {
69
+ await connection.disconnect();
70
+ connection = null;
71
+ connectPromise = null;
72
+ }
73
+ };
74
+
75
+ return { getConnection, disconnect };
76
+ };
77
+
78
+ /**
79
+ * Create a connection scope from account credentials using a password.
80
+ */
81
+ export const createConnectionScopeFromAccount = (
82
+ account: {
83
+ username: string;
84
+ imapHost: string;
85
+ imapPort: number;
86
+ imapTls: boolean;
87
+ },
88
+ password: string,
89
+ ): ConnectionScope => {
90
+ return createConnectionScope({
91
+ user: account.username,
92
+ credentials: { kind: "password", password },
93
+ host: account.imapHost,
94
+ port: account.imapPort,
95
+ tls: account.imapTls,
96
+ });
97
+ };
98
+
99
+ /**
100
+ * Create a connection scope from account data and a MailCredentials union.
101
+ * Use this for all handlers that support both password and OAuth accounts.
102
+ */
103
+ export const createConnectionScopeWithCredentials = (
104
+ account: {
105
+ username: string;
106
+ imapHost: string;
107
+ imapPort: number;
108
+ imapTls: boolean;
109
+ },
110
+ credentials: MailCredentials,
111
+ ): ConnectionScope => {
112
+ let connection: IImapConnection | null = null;
113
+ let connectPromise: Promise<IImapConnection> | null = null;
114
+
115
+ const getConnection = async (): Promise<IImapConnection> => {
116
+ if (connectPromise) {
117
+ return connectPromise;
118
+ }
119
+
120
+ const conn = createConnectionWithCredentials(account, credentials);
121
+ connection = conn;
122
+ connectPromise = conn.connect().then(() => conn);
123
+
124
+ return connectPromise;
125
+ };
126
+
127
+ const disconnect = async (): Promise<void> => {
128
+ if (connection) {
129
+ await connection.disconnect();
130
+ connection = null;
131
+ connectPromise = null;
132
+ }
133
+ };
134
+
135
+ return { getConnection, disconnect };
136
+ };
137
+
138
+ /**
139
+ * Number of warm connections to hold per account in a single container.
140
+ *
141
+ * Default 2 (Hostnet-conservative). imapflow runs one command at a time, so a
142
+ * single cached connection cannot be shared by concurrent invocations of the
143
+ * same account in one container (the worker runs handlers under p-map). The
144
+ * pool lets concurrent same-account invocations each borrow a live connection
145
+ * while still reusing them across invocations.
146
+ *
147
+ * CAVEAT: warm reuse only helps WITHIN one container. Lambda may run many
148
+ * containers (each with its own pool) and recycle them, so this cuts login
149
+ * churn but does NOT by itself bound the connections an account opens against
150
+ * the provider — the queue concurrency cap (#610) does that.
151
+ */
152
+ const connectionsPerAccount = (() => {
153
+ const raw = Number(process.env.CONNECTIONS_PER_ACCOUNT);
154
+ return Number.isInteger(raw) && raw > 0 ? raw : 2;
155
+ })();
156
+
157
+ /**
158
+ * A pooled, lazily-connected connection plus its liveness/borrow bookkeeping.
159
+ */
160
+ interface PooledConnection {
161
+ scope: ConnectionScope;
162
+ /** The established connection once getConnection() has resolved. */
163
+ connection: IImapConnection | null;
164
+ /** Borrowed by an in-flight invocation; must not be lent out concurrently. */
165
+ busy: boolean;
166
+ }
167
+
168
+ /** accountId -> warm pool. Module scope so it survives across invocations. */
169
+ const warmPools = new Map<string, PooledConnection[]>();
170
+
171
+ /**
172
+ * A connection borrowed from the warm pool for the duration of one invocation.
173
+ */
174
+ export interface BorrowedConnection {
175
+ /** Get the (possibly cached, liveness-checked) live connection. */
176
+ getConnection: () => Promise<IImapConnection>;
177
+ /**
178
+ * Return the connection to the pool. Pooled connections stay connected for
179
+ * reuse; overflow connections (created when the pool is saturated) are
180
+ * disconnected so the pool never grows past connectionsPerAccount.
181
+ */
182
+ release: () => Promise<void>;
183
+ }
184
+
185
+ /**
186
+ * A live connection passes liveness when the underlying socket is still
187
+ * authenticated. imapflow flips `isConnected` to false on its `close`/`error`
188
+ * events, which fire when the provider drops an idle socket — the dominant
189
+ * warm-container failure mode. A connection that fails this check is replaced.
190
+ */
191
+ const isLive = (entry: PooledConnection): boolean =>
192
+ entry.connection?.isConnected ?? false;
193
+
194
+ const disconnectQuietly = async (entry: PooledConnection): Promise<void> => {
195
+ try {
196
+ await entry.scope.disconnect();
197
+ } catch {
198
+ // A dead connection may already be gone; reclaiming it must never throw.
199
+ }
200
+ };
201
+
202
+ /**
203
+ * Borrow a warm connection for one invocation, keyed by accountId.
204
+ *
205
+ * Reuses a free, live pooled connection when one exists (no reconnect). A free
206
+ * connection that has gone dead is disconnected and replaced. When every pooled
207
+ * connection is busy and the pool is full, an overflow connection is created and
208
+ * torn down on release so the steady-state pool size stays at
209
+ * connectionsPerAccount.
210
+ *
211
+ * The caller MUST call release() (e.g. in a finally) so the connection returns
212
+ * to the pool; releasing does NOT disconnect a pooled connection.
213
+ */
214
+ export const borrowWarmConnection = (
215
+ accountId: string,
216
+ createScope: () => ConnectionScope,
217
+ ): BorrowedConnection => {
218
+ const pool = warmPools.get(accountId) ?? [];
219
+ if (!warmPools.has(accountId)) {
220
+ warmPools.set(accountId, pool);
221
+ }
222
+
223
+ let entry: PooledConnection | undefined;
224
+ let isOverflow = false;
225
+
226
+ const claimFreeLiveEntry = (): PooledConnection | undefined => {
227
+ for (const candidate of pool) {
228
+ if (candidate.busy) {
229
+ continue;
230
+ }
231
+ if (isLive(candidate)) {
232
+ candidate.busy = true;
233
+ return candidate;
234
+ }
235
+ }
236
+ return undefined;
237
+ };
238
+
239
+ const claimDeadOrEmptyEntry = (): PooledConnection | undefined => {
240
+ for (const candidate of pool) {
241
+ if (!candidate.busy && !isLive(candidate)) {
242
+ // Claim synchronously BEFORE the awaited disconnect in the recycle
243
+ // branch, mirroring claimFreeLiveEntry. Without this, a concurrent
244
+ // same-account borrow could claim the same dead entry during that
245
+ // await and two invocations would share one imapflow connection.
246
+ candidate.busy = true;
247
+ return candidate;
248
+ }
249
+ }
250
+ return undefined;
251
+ };
252
+
253
+ const getConnection = async (): Promise<IImapConnection> => {
254
+ if (entry) {
255
+ const conn = await entry.scope.getConnection();
256
+ entry.connection = conn;
257
+ return conn;
258
+ }
259
+
260
+ const reused = claimFreeLiveEntry();
261
+ if (reused) {
262
+ entry = reused;
263
+ const conn = await reused.scope.getConnection();
264
+ reused.connection = conn;
265
+ return conn;
266
+ }
267
+
268
+ // No free live connection: reuse a dead/never-connected slot if one is
269
+ // free, otherwise grow the pool, otherwise go overflow.
270
+ // claimDeadOrEmptyEntry already set busy=true synchronously, so the
271
+ // recycle slot is fenced off across the awaited disconnect below.
272
+ const recyclable = claimDeadOrEmptyEntry();
273
+ if (recyclable) {
274
+ await disconnectQuietly(recyclable);
275
+ recyclable.scope = createScope();
276
+ recyclable.connection = null;
277
+ entry = recyclable;
278
+ } else if (pool.length < connectionsPerAccount) {
279
+ const fresh: PooledConnection = {
280
+ scope: createScope(),
281
+ connection: null,
282
+ busy: true,
283
+ };
284
+ pool.push(fresh);
285
+ entry = fresh;
286
+ } else {
287
+ isOverflow = true;
288
+ entry = { scope: createScope(), connection: null, busy: true };
289
+ }
290
+
291
+ const conn = await entry.scope.getConnection();
292
+ entry.connection = conn;
293
+ return conn;
294
+ };
295
+
296
+ const release = async (): Promise<void> => {
297
+ if (!entry) {
298
+ return;
299
+ }
300
+ if (isOverflow) {
301
+ await disconnectQuietly(entry);
302
+ entry = undefined;
303
+ return;
304
+ }
305
+ entry.busy = false;
306
+ entry = undefined;
307
+ };
308
+
309
+ return { getConnection, release };
310
+ };
311
+
312
+ /**
313
+ * Test-only: disconnect and drop an account's warm pool so a test never leaks a
314
+ * live connection across cases. Not part of the production lifecycle — in
315
+ * steady state dead entries are recycled lazily on the next borrow.
316
+ */
317
+ export const __evictWarmConnectionsForTest = async (
318
+ accountId: string,
319
+ ): Promise<void> => {
320
+ const pool = warmPools.get(accountId);
321
+ if (!pool) {
322
+ return;
323
+ }
324
+ warmPools.delete(accountId);
325
+ await Promise.all(pool.map(disconnectQuietly));
326
+ };
327
+
328
+ /** Test-only: clear all warm pools without disconnecting (fixtures own teardown). */
329
+ export const __resetWarmPoolsForTest = (): void => {
330
+ warmPools.clear();
331
+ };
332
+
333
+ /** Test-only: inspect the live pool size for an account. */
334
+ export const __warmPoolSizeForTest = (accountId: string): number =>
335
+ warmPools.get(accountId)?.length ?? 0;
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ import cluster from "node:cluster";
3
+ import {
4
+ DeleteMessageCommand,
5
+ ReceiveMessageCommand,
6
+ SQSClient,
7
+ } from "@aws-sdk/client-sqs";
8
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
9
+ import { createLogger } from "@remit/logger-lambda";
10
+ import { handler as searchIndexHandler } from "@remit/search-index-worker";
11
+ import { resolveSqsCredentials } from "@remit/sqs-client";
12
+ import type {
13
+ Context,
14
+ SQSBatchResponse,
15
+ SQSEvent,
16
+ SQSHandler,
17
+ } from "aws-lambda";
18
+ import { env } from "expect-env";
19
+ import { handler } from "./index.js";
20
+
21
+ /**
22
+ * E2E-only queue drainer.
23
+ *
24
+ * Production binds the Lambda `handler` (`src/index.ts`) to each queue via an
25
+ * SQS event-source mapping. The e2e/CI stack runs on ElasticMQ, which has no
26
+ * event-source mapping, so this process supplies that missing piece: it
27
+ * long-polls each queue, wraps every received batch in an `SQSEvent`, and
28
+ * invokes the exact production `handler`. It then honours the returned
29
+ * `batchItemFailures` the way the SQS service would — deleting the messages
30
+ * that succeeded and leaving the failures un-deleted so their visibility
31
+ * timeout lapses and SQS redelivers them. No processing or failure logic lives
32
+ * here; the prod handler owns all of it.
33
+ *
34
+ * This is a test harness, not production code. If it crashes the e2e suite
35
+ * fails loudly, which is the desired signal — there is no crash net.
36
+ */
37
+
38
+ // The search-index queue is drained by the production search-index-worker
39
+ // handler instead of the imap handler. It is optional: when its URL is unset
40
+ // (e.g. the e2e stack), the queue is simply not polled.
41
+ const searchIndexQueueUrl = process.env.SQS_QUEUE_URL_SEARCH_INDEX;
42
+
43
+ // Collect all unique queue URLs to poll. Every required queue URL crashes at
44
+ // init via expect-env instead of silently dropping queues.
45
+ const queueUrls = [
46
+ ...new Set([
47
+ // FIFO queues for sync operations
48
+ env.SQS_QUEUE_URL_MAILBOXES,
49
+ env.SQS_QUEUE_URL_MESSAGES,
50
+ env.SQS_QUEUE_URL_FLAGS,
51
+ // Standard body queue (#612) + management queues
52
+ env.SQS_QUEUE_URL_BODY,
53
+ env.SQS_QUEUE_URL_MAILBOX_MGMT,
54
+ env.SQS_QUEUE_URL_MESSAGE_MGMT,
55
+ // Standard queue for local search indexing (optional)
56
+ ...(searchIndexQueueUrl ? [searchIndexQueueUrl] : []),
57
+ ]),
58
+ ];
59
+
60
+ if (cluster.isPrimary) {
61
+ // Primary process: fork a worker for each queue
62
+ const log = createLogger();
63
+ log.info({ queueUrls, workerCount: queueUrls.length }, "Primary started");
64
+
65
+ // Track which queue each worker handles for restart
66
+ const workerQueues = new Map<number, string>();
67
+
68
+ // Fork a worker for each queue
69
+ for (const queueUrl of queueUrls) {
70
+ const worker = cluster.fork({ WORKER_QUEUE_URL: queueUrl });
71
+ workerQueues.set(worker.id, queueUrl);
72
+ const queueName = new URL(queueUrl).pathname.split("/").pop();
73
+ log.info({ workerId: worker.id, queueName }, "Forked worker for queue");
74
+ }
75
+
76
+ // Restart workers that crash
77
+ cluster.on("exit", (worker, code, signal) => {
78
+ const queueUrl = workerQueues.get(worker.id);
79
+ const queueName = queueUrl
80
+ ? new URL(queueUrl).pathname.split("/").pop()
81
+ : "unknown";
82
+
83
+ if (signal) {
84
+ log.info({ workerId: worker.id, queueName, signal }, "Worker killed");
85
+ } else if (code !== 0) {
86
+ log.error(
87
+ { workerId: worker.id, queueName, code },
88
+ "Worker crashed, exiting primary",
89
+ );
90
+ process.exit(1);
91
+ } else {
92
+ log.info({ workerId: worker.id, queueName }, "Worker exited cleanly");
93
+ workerQueues.delete(worker.id);
94
+ }
95
+ });
96
+
97
+ // Graceful shutdown: signal all workers
98
+ const shutdown = () => {
99
+ log.info("Primary received shutdown signal, stopping workers...");
100
+ for (const worker of Object.values(cluster.workers ?? {})) {
101
+ worker?.process.kill("SIGTERM");
102
+ }
103
+ };
104
+
105
+ process.on("SIGINT", shutdown);
106
+ process.on("SIGTERM", shutdown);
107
+ } else {
108
+ // Worker process: poll a single queue
109
+ const queueUrl = env.WORKER_QUEUE_URL;
110
+ const queueName = new URL(queueUrl).pathname.split("/").pop();
111
+ const log = createLogger().child({ queue: queueName });
112
+
113
+ // The search-index queue carries DynamoDB-stream-shaped events and is owned
114
+ // by the search-index-worker handler; every other queue is an imap operation.
115
+ const activeHandler: SQSHandler =
116
+ queueUrl === searchIndexQueueUrl ? searchIndexHandler : handler;
117
+
118
+ const maxMessages = 10; // SQS API limit
119
+
120
+ const isLocal = queueUrl.startsWith("http://localhost");
121
+ const sqs = new SQSClient({
122
+ endpoint: isLocal ? new URL(queueUrl).origin : undefined,
123
+ ...(isLocal && { protocol: AwsQueryProtocol }),
124
+ credentials: resolveSqsCredentials(),
125
+ });
126
+
127
+ let isShuttingDown = false;
128
+
129
+ process.on("SIGINT", () => {
130
+ log.info("Worker received SIGINT, shutting down...");
131
+ isShuttingDown = true;
132
+ });
133
+
134
+ process.on("SIGTERM", () => {
135
+ log.info("Worker received SIGTERM, shutting down...");
136
+ isShuttingDown = true;
137
+ });
138
+
139
+ // Minimal Lambda Context: `withTelemetry` only reads `functionName` and adds
140
+ // it to the logger; the prod handler never touches the rest.
141
+ const lambdaContext = {
142
+ functionName: `e2e-imap-worker-${queueName}`,
143
+ } as Context;
144
+
145
+ const pollQueue = async (): Promise<void> => {
146
+ log.info({ maxMessages }, "Worker started, polling...");
147
+
148
+ let consecutiveEmptyPolls = 0;
149
+
150
+ while (!isShuttingDown) {
151
+ // Use short polling when we just processed messages (likely more waiting)
152
+ // Use long polling after empty polls to reduce API calls
153
+ const waitTime = consecutiveEmptyPolls > 0 ? 20 : 0;
154
+
155
+ const response = await sqs.send(
156
+ new ReceiveMessageCommand({
157
+ QueueUrl: queueUrl,
158
+ MaxNumberOfMessages: maxMessages,
159
+ WaitTimeSeconds: waitTime,
160
+ VisibilityTimeout: 300,
161
+ MessageSystemAttributeNames: ["ApproximateReceiveCount"],
162
+ }),
163
+ );
164
+
165
+ if (!response.Messages || response.Messages.length === 0) {
166
+ consecutiveEmptyPolls++;
167
+ continue;
168
+ }
169
+
170
+ consecutiveEmptyPolls = 0;
171
+
172
+ const messages = response.Messages.flatMap((m) =>
173
+ m.Body && m.ReceiptHandle && m.MessageId
174
+ ? [
175
+ {
176
+ messageId: m.MessageId,
177
+ receiptHandle: m.ReceiptHandle,
178
+ body: m.Body,
179
+ receiveCount: m.Attributes?.ApproximateReceiveCount ?? "1",
180
+ },
181
+ ]
182
+ : [],
183
+ );
184
+
185
+ if (messages.length === 0) {
186
+ continue;
187
+ }
188
+
189
+ log.info({ count: messages.length }, "Invoking handler for batch");
190
+
191
+ const event: SQSEvent = {
192
+ Records: messages.map((m) => ({
193
+ messageId: m.messageId,
194
+ receiptHandle: m.receiptHandle,
195
+ body: m.body,
196
+ attributes: {
197
+ ApproximateReceiveCount: m.receiveCount,
198
+ SentTimestamp: "0",
199
+ SenderId: "e2e",
200
+ ApproximateFirstReceiveTimestamp: "0",
201
+ },
202
+ messageAttributes: {},
203
+ md5OfBody: "",
204
+ eventSource: "aws:sqs",
205
+ eventSourceARN: queueUrl,
206
+ awsRegion: "local",
207
+ })),
208
+ };
209
+
210
+ const result = (await activeHandler(event, lambdaContext, () => {})) as
211
+ | SQSBatchResponse
212
+ | undefined;
213
+
214
+ const failedIds = new Set(
215
+ (result?.batchItemFailures ?? []).map((f) => f.itemIdentifier),
216
+ );
217
+
218
+ // Mirror SQS partial-batch-failure semantics: delete the messages the
219
+ // handler reported as succeeded; leave failures un-deleted so their
220
+ // visibility timeout lapses and SQS redelivers them (and eventually
221
+ // dead-letters once maxReceiveCount is hit).
222
+ const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
223
+
224
+ for (const message of succeeded) {
225
+ await sqs.send(
226
+ new DeleteMessageCommand({
227
+ QueueUrl: queueUrl,
228
+ ReceiptHandle: message.receiptHandle,
229
+ }),
230
+ );
231
+ }
232
+
233
+ log.info(
234
+ { deleted: succeeded.length, leftForRedelivery: failedIds.size },
235
+ "Batch processed",
236
+ );
237
+ }
238
+
239
+ log.info("Worker polling stopped");
240
+ };
241
+
242
+ pollQueue()
243
+ .then(() => process.exit(0))
244
+ .catch((error) => {
245
+ log.error({ error }, "Worker error");
246
+ process.exit(1);
247
+ });
248
+ }
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { getDeduplicationId } from "./emit.js";
4
+ import type { SyncMessagesEvent } from "./events.js";
5
+
6
+ type SyncMessagesInput = Omit<SyncMessagesEvent, "eventId" | "timestamp">;
7
+
8
+ const syncMessages = (over: Partial<SyncMessagesInput>): SyncMessagesInput => ({
9
+ type: "SYNC_MESSAGES",
10
+ accountId: "acc-1",
11
+ mailboxId: "mbx-1",
12
+ ...over,
13
+ });
14
+
15
+ describe("getDeduplicationId for SYNC_MESSAGES continuation", () => {
16
+ it("gives an initial (cursor-less) sync a stable id so concurrent fresh syncs dedup", () => {
17
+ assert.strictEqual(
18
+ getDeduplicationId(syncMessages({})),
19
+ "SYNC_MESSAGES:mbx-1",
20
+ );
21
+ });
22
+
23
+ it("gives each continuation batch a distinct id so FIFO does not drop batches 2..N", () => {
24
+ // Regression guard: a constant dedup id per mailbox let SQS FIFO reject
25
+ // every continuation within the 5-minute window, capping a sync at one
26
+ // batch (~200 messages). Folding the batch's remaining count into the id
27
+ // keeps sequential batches distinct.
28
+ const batch2 = getDeduplicationId(syncMessages({ resumeCursor: 800 }));
29
+ const batch3 = getDeduplicationId(syncMessages({ resumeCursor: 600 }));
30
+
31
+ assert.strictEqual(batch2, "SYNC_MESSAGES:mbx-1:800");
32
+ assert.strictEqual(batch3, "SYNC_MESSAGES:mbx-1:600");
33
+ assert.notStrictEqual(batch2, batch3);
34
+ });
35
+
36
+ it("separates a continuation from the initial event of the same mailbox", () => {
37
+ const initial = getDeduplicationId(syncMessages({}));
38
+ const continuation = getDeduplicationId(
39
+ syncMessages({ resumeCursor: 800 }),
40
+ );
41
+
42
+ assert.notStrictEqual(initial, continuation);
43
+ });
44
+ });