@remit/account-worker 0.0.7 → 0.0.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/account-worker",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { afterEach, describe, it } from "node:test";
3
3
  import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
4
- import { getSqsClient } from "./config.js";
4
+ import { getImapWorkerQueueUrl, getSqsClient } from "./config.js";
5
5
 
6
6
  // The queue URL a self-hosted compose stack hands the account worker
7
7
  // (deploy/vps/remit.env.template). It is a plain http:// URL that is not
@@ -35,3 +35,25 @@ describe("getSqsClient", () => {
35
35
  assert.notEqual(getSqsClient(composeQueueUrl), getSqsClient(awsQueueUrl));
36
36
  });
37
37
  });
38
+
39
+ describe("getImapWorkerQueueUrl", () => {
40
+ // The imap-worker stop queue is optional: the self-host compose stacks do not
41
+ // provision it, and reading it through expect-env would throw and abort the
42
+ // deletion fanout before it ever reached the finalize step. It must yield
43
+ // undefined when unset so the fanout can skip the (no-op) stop signal.
44
+ const prev = process.env.SQS_QUEUE_URL_IMAP_WORKER;
45
+ afterEach(() => {
46
+ if (prev === undefined) delete process.env.SQS_QUEUE_URL_IMAP_WORKER;
47
+ else process.env.SQS_QUEUE_URL_IMAP_WORKER = prev;
48
+ });
49
+
50
+ it("returns undefined when the var is unset instead of throwing", () => {
51
+ delete process.env.SQS_QUEUE_URL_IMAP_WORKER;
52
+ assert.equal(getImapWorkerQueueUrl(), undefined);
53
+ });
54
+
55
+ it("returns the configured value when set", () => {
56
+ process.env.SQS_QUEUE_URL_IMAP_WORKER = "http://queue/imap-worker";
57
+ assert.equal(getImapWorkerQueueUrl(), "http://queue/imap-worker");
58
+ });
59
+ });
package/src/config.ts CHANGED
@@ -28,8 +28,14 @@ export const getSqsClient = (queueUrl: string): SQSClient => {
28
28
  // sites.
29
29
  export const getSearchIndexQueueUrl = (): string =>
30
30
  env.SQS_QUEUE_URL_SEARCH_INDEX;
31
- export const getImapWorkerQueueUrl = (): string =>
32
- env.SQS_QUEUE_URL_IMAP_WORKER;
31
+ // Optional, and read through `process.env` rather than expect-env so an unset
32
+ // var yields undefined instead of throwing. IMAP_WORKER_STOP is a no-op
33
+ // acknowledgement in the cascade contract — the account tombstone fence is what
34
+ // actually halts the worker — so a deployment that never provisions a dedicated
35
+ // imap-worker stop queue (the self-host compose stacks) skips the signal rather
36
+ // than failing the whole fanout. AWS sets the var and keeps sending it.
37
+ export const getImapWorkerQueueUrl = (): string | undefined =>
38
+ process.env.SQS_QUEUE_URL_IMAP_WORKER;
33
39
  export const getAccountFinalizeQueueUrl = (): string =>
34
40
  env.SQS_QUEUE_URL_ACCOUNT_FINALIZE;
35
41
  export const getAccountPurgeDeleteQueueUrl = (): string =>
@@ -0,0 +1,119 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import type { SQSClient } from "@aws-sdk/client-sqs";
4
+ import type { Logger } from "@remit/logger-lambda";
5
+ import type { CascadeServices } from "../cascade.js";
6
+ import type { AccountDeleteEvent } from "../events.js";
7
+ import { processAccountFanout } from "./account-fanout.js";
8
+
9
+ const noopLog = {
10
+ info: () => {},
11
+ warn: () => {},
12
+ error: () => {},
13
+ debug: () => {},
14
+ fatal: () => {},
15
+ trace: () => {},
16
+ child: () => noopLog,
17
+ } as unknown as Logger;
18
+
19
+ // A CascadeServices fake with one account and no data: enough for
20
+ // enumerateCascadeEntities to yield a single Account row (so the stop-signal
21
+ // loop has one accountId to iterate) without standing up a database.
22
+ const buildServices = (): CascadeServices =>
23
+ ({
24
+ accountConfigService: {
25
+ get: async () => ({ userId: "user-1" }),
26
+ describe: async () => ({
27
+ account: [{ accountId: "acc-1" }],
28
+ address: [],
29
+ }),
30
+ },
31
+ accountService: {
32
+ describe: async () => ({ mailbox: [] }),
33
+ },
34
+ messageService: {
35
+ listAllByMailbox: async () => [],
36
+ describe: async () => ({}),
37
+ },
38
+ outboxMessageService: { listByAccount: async () => ({ items: [] }) },
39
+ mailboxLockService: { listByAccount: async () => [] },
40
+ messagePlacementMoveService: { listByAccountId: async () => [] },
41
+ messageFlagPushService: { listByAccountId: async () => [] },
42
+ threadMessageService: { listAllByAccount: async () => [] },
43
+ accountSettingService: { listByAccountConfig: async () => [] },
44
+ filterService: { listByAccountConfig: async () => [] },
45
+ filterAnchorService: { get: async () => null },
46
+ labelService: { listByAccountConfig: async () => [] },
47
+ messageLabelService: { listByLabelId: async () => [] },
48
+ }) as unknown as CascadeServices;
49
+
50
+ interface SentMessage {
51
+ queueUrl: string | undefined;
52
+ body: { type?: string; accountId?: string };
53
+ }
54
+
55
+ const recordingSqs = (sent: SentMessage[]): SQSClient =>
56
+ ({
57
+ send: async (command: {
58
+ input: { QueueUrl?: string; MessageBody?: string };
59
+ }) => {
60
+ sent.push({
61
+ queueUrl: command.input.QueueUrl,
62
+ body: JSON.parse(command.input.MessageBody ?? "{}"),
63
+ });
64
+ return {};
65
+ },
66
+ }) as unknown as SQSClient;
67
+
68
+ const deleteEvent: AccountDeleteEvent = {
69
+ type: "AccountDelete",
70
+ accountConfigId: "cfg-1",
71
+ };
72
+
73
+ describe("processAccountFanout — imap-worker stop signal on account delete", () => {
74
+ const prev = process.env.SQS_QUEUE_URL_IMAP_WORKER;
75
+ afterEach(() => {
76
+ if (prev === undefined) delete process.env.SQS_QUEUE_URL_IMAP_WORKER;
77
+ else process.env.SQS_QUEUE_URL_IMAP_WORKER = prev;
78
+ });
79
+
80
+ it("skips the stop signal when no imap-worker queue is configured, still enqueuing finalize", async () => {
81
+ delete process.env.SQS_QUEUE_URL_IMAP_WORKER;
82
+ const sent: SentMessage[] = [];
83
+
84
+ await processAccountFanout(deleteEvent, noopLog, {
85
+ services: buildServices(),
86
+ sqs: recordingSqs(sent),
87
+ signOut: async () => {},
88
+ accountFinalizeQueueUrl: "http://queue/finalize",
89
+ });
90
+
91
+ const stops = sent.filter((m) => m.body.type === "IMAP_WORKER_STOP");
92
+ const finalize = sent.filter(
93
+ (m) => m.body.type === "FinalizeAccountDelete",
94
+ );
95
+ assert.equal(stops.length, 0, "no stop signal without a configured queue");
96
+ assert.equal(finalize.length, 1, "finalize is still enqueued");
97
+ });
98
+
99
+ it("enqueues one stop signal per account when a queue is configured", async () => {
100
+ const sent: SentMessage[] = [];
101
+
102
+ await processAccountFanout(deleteEvent, noopLog, {
103
+ services: buildServices(),
104
+ sqs: recordingSqs(sent),
105
+ signOut: async () => {},
106
+ imapWorkerQueueUrl: "http://queue/imap",
107
+ accountFinalizeQueueUrl: "http://queue/finalize",
108
+ });
109
+
110
+ const stops = sent.filter((m) => m.body.type === "IMAP_WORKER_STOP");
111
+ assert.equal(stops.length, 1, "one stop signal for the single account");
112
+ assert.equal(stops[0]?.queueUrl, "http://queue/imap");
113
+ assert.equal(stops[0]?.body.accountId, "acc-1");
114
+ assert.equal(
115
+ sent.filter((m) => m.body.type === "FinalizeAccountDelete").length,
116
+ 1,
117
+ );
118
+ });
119
+ });
@@ -98,22 +98,33 @@ const processAccountDelete = async (
98
98
  .filter((e) => e.entityType === "Account")
99
99
  .map((e) => e.key.accountId);
100
100
 
101
- for (const accountId of accountIds) {
102
- await sqsFor(imapWorkerQueueUrl).send(
103
- new SendMessageCommand({
104
- QueueUrl: imapWorkerQueueUrl,
105
- MessageBody: JSON.stringify({
106
- type: "IMAP_WORKER_STOP",
107
- accountConfigId,
108
- accountId,
101
+ // IMAP_WORKER_STOP is a no-op acknowledgement — the account tombstone fence
102
+ // (isActive=false + deletedAt) is what actually halts the worker. Deployments
103
+ // that provision a dedicated imap-worker stop queue get the signal; the
104
+ // self-host stacks, which do not, skip it and let the fence do the work.
105
+ if (imapWorkerQueueUrl) {
106
+ for (const accountId of accountIds) {
107
+ await sqsFor(imapWorkerQueueUrl).send(
108
+ new SendMessageCommand({
109
+ QueueUrl: imapWorkerQueueUrl,
110
+ MessageBody: JSON.stringify({
111
+ type: "IMAP_WORKER_STOP",
112
+ accountConfigId,
113
+ accountId,
114
+ }),
109
115
  }),
110
- }),
116
+ );
117
+ }
118
+ log.info(
119
+ { accountConfigId, count: accountIds.length },
120
+ "Enqueued imap-worker stop signals",
121
+ );
122
+ } else {
123
+ log.info(
124
+ { accountConfigId, count: accountIds.length },
125
+ "No imap-worker stop queue configured; skipping stop signals (tombstone fence halts the worker)",
111
126
  );
112
127
  }
113
- log.info(
114
- { accountConfigId, count: accountIds.length },
115
- "Enqueued imap-worker stop signals",
116
- );
117
128
 
118
129
  await signOut(userId, log);
119
130
 
package/src/poller.ts CHANGED
@@ -9,11 +9,14 @@ import { fanoutHandler, finalizeHandler } from "./index.js";
9
9
  * CI (see AGENTS.md worker roster notes). This is the standalone
10
10
  * production entrypoint for the dedicated image.
11
11
  *
12
- * The fanout worker's Cognito sign-out and the finalize worker's CloudFront
13
- * invalidation are AWS-only calls with no portable counterpart yet on a
14
- * non-AWS deployment those specific steps of the deletion cascade will
15
- * error if account deletion is exercised. That is a pre-existing gap in
16
- * the application code, not something this packaging change fixes.
12
+ * The deployment-specific steps of the cascade — sign-out, content
13
+ * invalidation, storage cleanup, and the row cascade resolve through the
14
+ * deletion capabilities seam (deletion-capabilities.ts): AWS runs Cognito
15
+ * global sign-out and CloudFront invalidation; the relational self-host
16
+ * backends run the no-op counterparts and a filesystem/Drizzle cascade. The
17
+ * imap-worker stop signal is a no-op acknowledgement whose queue is optional,
18
+ * skipped where it is not provisioned. Account deletion completes on every
19
+ * deployment flavor.
17
20
  */
18
21
  const log = createLogger();
19
22