@remit/account-worker 0.0.32 → 0.0.33

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.32",
3
+ "version": "0.0.33",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
package/src/cascade.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { RemitClient } from "@remit/backend/client";
1
2
  import type {
2
3
  IAccountConfigRepository,
3
4
  IAccountExportRequestRepository,
@@ -45,6 +46,26 @@ export interface CascadeServices {
45
46
  messageLabelService: IMessageLabelRepository;
46
47
  }
47
48
 
49
+ export const cascadeServicesOf = (client: RemitClient): CascadeServices => ({
50
+ accountConfigService: client.accountConfig,
51
+ accountService: client.account,
52
+ addressService: client.address,
53
+ mailboxService: client.mailbox,
54
+ messageService: client.message,
55
+ messageFlagService: client.messageFlag,
56
+ envelopeService: client.envelope,
57
+ outboxMessageService: client.outboxMessage,
58
+ threadMessageService: client.threadMessage,
59
+ mailboxLockService: client.mailboxLock,
60
+ messagePlacementMoveService: client.placementMove,
61
+ messageFlagPushService: client.flagPush,
62
+ accountExportRequestService: client.accountExportRequest,
63
+ accountSettingService: client.accountSetting,
64
+ filterService: client.filter,
65
+ labelService: client.label,
66
+ messageLabelService: client.messageLabel,
67
+ });
68
+
48
69
  export interface CascadeResult {
49
70
  entities: CascadeEntity[];
50
71
  messageIds: string[];
package/src/config.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  import type { StorageService } from "@remit/storage-service";
8
8
  import { createStorageService } from "@remit/storage-service/s3";
9
9
  import { env } from "expect-env";
10
- import type { CascadeServices } from "./cascade.js";
10
+ import { type CascadeServices, cascadeServicesOf } from "./cascade.js";
11
11
  import type {
12
12
  AccountDataPurgeFinalizeEvent,
13
13
  AccountFinalizeEvent,
@@ -49,25 +49,7 @@ let cascadeServicesPromise: Promise<CascadeServices> | null = null;
49
49
  // backend-specific wiring here.
50
50
  export const getCascadeServices = (): Promise<CascadeServices> => {
51
51
  if (!cascadeServicesPromise) {
52
- cascadeServicesPromise = getClient().then((remitClient) => ({
53
- accountConfigService: remitClient.accountConfig,
54
- accountService: remitClient.account,
55
- addressService: remitClient.address,
56
- mailboxService: remitClient.mailbox,
57
- messageService: remitClient.message,
58
- messageFlagService: remitClient.messageFlag,
59
- envelopeService: remitClient.envelope,
60
- outboxMessageService: remitClient.outboxMessage,
61
- threadMessageService: remitClient.threadMessage,
62
- mailboxLockService: remitClient.mailboxLock,
63
- messagePlacementMoveService: remitClient.placementMove,
64
- messageFlagPushService: remitClient.flagPush,
65
- accountExportRequestService: remitClient.accountExportRequest,
66
- accountSettingService: remitClient.accountSetting,
67
- filterService: remitClient.filter,
68
- labelService: remitClient.label,
69
- messageLabelService: remitClient.messageLabel,
70
- }));
52
+ cascadeServicesPromise = getClient().then(cascadeServicesOf);
71
53
  }
72
54
  return cascadeServicesPromise;
73
55
  };
@@ -1,58 +1,6 @@
1
- import type { Logger } from "@remit/logger";
2
- import type { CascadeEntity } from "./cascade.js";
1
+ import type { DeletionCapabilities } from "./deletion-port.js";
3
2
 
4
- /**
5
- * The four steps of the deletion cascade that vary by deployment. Everything
6
- * else — enumerating the AccountConfig's rows, the FIFO purge orchestration, the
7
- * queue sends — is backend-neutral and lives in the handlers unchanged.
8
- *
9
- * Two composition roots build this:
10
- *
11
- * - The DynamoDB composition (AWS custody stack): Cognito global sign-out,
12
- * CloudFront invalidation, raw-S3 prefix delete, DynamoDB `BatchWriteItem`
13
- * cascade. Byte-for-byte the pre-split behavior. It imports the AWS SDK /
14
- * ElectroDB graph and lives outside this shared, open-core module; it is
15
- * injected here through `setDeletionCapabilities` so that graph never reaches
16
- * the relational tree.
17
- * - `compose-relational.ts` (self-host stack, RFC 035/036): no-op sign-out
18
- * (deleting the AccountConfig severs the session's data resolution; there is
19
- * no CDN to sign out of), no-op invalidation (Caddy proxies `/content/*`
20
- * straight to the backend with no cache), filesystem prefix delete, and the
21
- * Drizzle cascade over SQLite.
22
- */
23
- export interface DeletionCapabilities {
24
- /**
25
- * Validate every precondition the destructive steps depend on, BEFORE any row
26
- * or object is deleted. AWS checks both `CONTENT_DISTRIBUTION_ID` and
27
- * `S3_STORAGE_BUCKET_NAME` up front so a missing var fails loud instead of
28
- * destroying rows in the cascade and only then erroring at the storage step.
29
- * Relational is a no-op — its steps carry no such deploy-time configuration.
30
- */
31
- assertReady(log: Logger): Promise<void>;
32
- /**
33
- * Invalidate the deleted user's active auth sessions. AWS: Cognito
34
- * `AdminUserGlobalSignOut`. Relational: no-op.
35
- */
36
- signOut(userId: string, log: Logger): Promise<void>;
37
- /**
38
- * Purge any CDN/edge cache under a tenant/account content prefix
39
- * (`accountConfigId` or `accountConfigId/accountId`). AWS: CloudFront
40
- * invalidation of `/content/accounts/{prefix}/*`. Relational: no-op.
41
- */
42
- invalidateContent(contentPrefix: string, log: Logger): Promise<void>;
43
- /**
44
- * Delete every stored object under a storage key prefix
45
- * (`accounts/{accountConfigId}/…`). AWS: raw-S3 list+delete. Relational:
46
- * recursive filesystem removal under the local storage root.
47
- */
48
- deleteStoragePrefix(keyPrefix: string, log: Logger): Promise<void>;
49
- /**
50
- * Remove the enumerated rows in dependency order (children → parents). AWS:
51
- * DynamoDB `BatchWriteItem`. Relational: a Drizzle transaction. Excludes the AccountConfig row, which the caller deletes last
52
- * through its repository as the cascade-in-progress marker.
53
- */
54
- cascadeDelete(entities: CascadeEntity[], log: Logger): Promise<void>;
55
- }
3
+ export type { DeletionCapabilities };
56
4
 
57
5
  let injectedCapabilities: DeletionCapabilities | null = null;
58
6
 
@@ -0,0 +1,55 @@
1
+ import type { Logger } from "@remit/logger";
2
+ import type { CascadeEntity } from "./cascade.js";
3
+
4
+ /**
5
+ * The four steps of the deletion cascade that vary by deployment. Everything
6
+ * else — enumerating the AccountConfig's rows, the FIFO purge orchestration, the
7
+ * queue sends — is backend-neutral and lives in the handlers unchanged.
8
+ *
9
+ * Two composition roots build this:
10
+ *
11
+ * - The DynamoDB composition (AWS custody stack): Cognito global sign-out,
12
+ * CloudFront invalidation, raw-S3 prefix delete, DynamoDB `BatchWriteItem`
13
+ * cascade. Byte-for-byte the pre-split behavior. It imports the AWS SDK /
14
+ * ElectroDB graph and lives outside this shared, open-core module; it is
15
+ * injected here through `setDeletionCapabilities` so that graph never reaches
16
+ * the relational tree.
17
+ * - `compose-relational.ts` (self-host stack, RFC 035/036): no-op sign-out
18
+ * (deleting the AccountConfig severs the session's data resolution; there is
19
+ * no CDN to sign out of), no-op invalidation (Caddy proxies `/content/*`
20
+ * straight to the backend with no cache), filesystem prefix delete, and the
21
+ * Drizzle cascade over SQLite.
22
+ */
23
+ export interface DeletionCapabilities {
24
+ /**
25
+ * Validate every precondition the destructive steps depend on, BEFORE any row
26
+ * or object is deleted. AWS checks both `CONTENT_DISTRIBUTION_ID` and
27
+ * `S3_STORAGE_BUCKET_NAME` up front so a missing var fails loud instead of
28
+ * destroying rows in the cascade and only then erroring at the storage step.
29
+ * Relational is a no-op — its steps carry no such deploy-time configuration.
30
+ */
31
+ assertReady(log: Logger): Promise<void>;
32
+ /**
33
+ * Invalidate the deleted user's active auth sessions. AWS: Cognito
34
+ * `AdminUserGlobalSignOut`. Relational: no-op.
35
+ */
36
+ signOut(userId: string, log: Logger): Promise<void>;
37
+ /**
38
+ * Purge any CDN/edge cache under a tenant/account content prefix
39
+ * (`accountConfigId` or `accountConfigId/accountId`). AWS: CloudFront
40
+ * invalidation of `/content/accounts/{prefix}/*`. Relational: no-op.
41
+ */
42
+ invalidateContent(contentPrefix: string, log: Logger): Promise<void>;
43
+ /**
44
+ * Delete every stored object under a storage key prefix
45
+ * (`accounts/{accountConfigId}/…`). AWS: raw-S3 list+delete. Relational:
46
+ * recursive filesystem removal under the local storage root.
47
+ */
48
+ deleteStoragePrefix(keyPrefix: string, log: Logger): Promise<void>;
49
+ /**
50
+ * Remove the enumerated rows in dependency order (children → parents). AWS:
51
+ * DynamoDB `BatchWriteItem`. Relational: a Drizzle transaction. Excludes the AccountConfig row, which the caller deletes last
52
+ * through its repository as the cascade-in-progress marker.
53
+ */
54
+ cascadeDelete(entities: CascadeEntity[], log: Logger): Promise<void>;
55
+ }
@@ -9,7 +9,6 @@ import type { SQSEvent, SQSHandler } from "aws-lambda";
9
9
  import {
10
10
  type CascadeEntity,
11
11
  type CascadeServices,
12
- collectMessageChildEntities,
13
12
  enumerateCascadeEntities,
14
13
  } from "../cascade.js";
15
14
  import { getCascadeServices } from "../config.js";
@@ -18,10 +17,10 @@ import {
18
17
  getDeletionCapabilities,
19
18
  } from "../deletion-capabilities.js";
20
19
  import type {
21
- AccountDataPurgeFinalizeEvent,
22
20
  AccountDeleteFinalizeEvent,
23
21
  AccountFinalizeEvent,
24
22
  } from "../events.js";
23
+ import { processAccountDataPurgeFinalize } from "./account-purge-finalize.js";
25
24
 
26
25
  installNodeLogger();
27
26
 
@@ -124,154 +123,6 @@ export const processAccountFinalize = async (
124
123
  log.info({ accountConfigId }, "AccountConfig deleted; cascade complete");
125
124
  };
126
125
 
127
- /**
128
- * Destructive phase of the per-account purge. Consumes the FIFO stream the
129
- * fanout producer emits (#1069), one message group per account so the messages
130
- * arrive strictly in order: every `subtrees` batch, then exactly one
131
- * `container` leftover. There is NO self-re-enqueue — the producer enqueues all
132
- * the work; this worker only ever consumes.
133
- *
134
- * Idempotency: the cascade on a missing key is a no-op, so a redelivered batch
135
- * (or container) no-ops on already-gone rows.
136
- */
137
- export const processAccountDataPurgeFinalize = async (
138
- event: AccountDataPurgeFinalizeEvent,
139
- log: Logger,
140
- deps: ProcessFinalizeDeps = {},
141
- ): Promise<void> => {
142
- if (event.kind === "subtrees") {
143
- await processPurgeSubtrees(event, log, deps);
144
- return;
145
- }
146
- await processPurgeContainer(event, log, deps);
147
- };
148
-
149
- /**
150
- * Delete a batch of message subtrees. For each `{ threadMessageId, messageId }`
151
- * the manifest row is deleted UNCONDITIONALLY — there are ghost ThreadMessages
152
- * whose Message is already gone — and the Message + its 9 child entities are
153
- * deleted only when `describe()` resolves. A `describe()` 404 means the subtree
154
- * is already gone: skip the children, still drop the manifest row.
155
- */
156
- const processPurgeSubtrees = async (
157
- event: Extract<AccountDataPurgeFinalizeEvent, { kind: "subtrees" }>,
158
- log: Logger,
159
- deps: ProcessFinalizeDeps,
160
- ): Promise<void> => {
161
- const { accountId, accountConfigId, items } = event;
162
- const { capabilities, services } = await resolveDeps(deps);
163
-
164
- const entities: CascadeEntity[] = [];
165
- for (const { threadMessageId, messageId } of items) {
166
- entities.push({
167
- entityType: "ThreadMessage",
168
- key: { accountConfigId, threadMessageId },
169
- });
170
- try {
171
- const messageData = await services.messageService.describe(messageId);
172
- entities.push({ entityType: "Message", key: { messageId } });
173
- collectMessageChildEntities(entities, messageData);
174
- } catch (error) {
175
- if ((error as { name?: string })?.name === "NotFoundError") {
176
- log.info(
177
- { accountConfigId, accountId, messageId },
178
- "Message subtree already gone — deleting manifest row only",
179
- );
180
- continue;
181
- }
182
- throw error;
183
- }
184
- }
185
-
186
- await capabilities.cascadeDelete(entities, log);
187
- log.info(
188
- { accountConfigId, accountId, count: items.length },
189
- "Per-account purge subtree batch deleted",
190
- );
191
- };
192
-
193
- /**
194
- * The container leftover — processed last in the FIFO group, after every subtree
195
- * delete. Deletes the account-keyed container rows (mailboxes, outbox, locks),
196
- * the storage prefix `accounts/{cfg}/{acct}/`, and invalidates the account's
197
- * cached content. Ordering is invalidate-before-storage, storage-after-cascade.
198
- * The tenant-shared Address rows, the AccountConfig, sibling accounts, and the
199
- * soft-deleted account row (the purge-in-progress marker) are all kept.
200
- */
201
- const processPurgeContainer = async (
202
- event: Extract<AccountDataPurgeFinalizeEvent, { kind: "container" }>,
203
- log: Logger,
204
- deps: ProcessFinalizeDeps,
205
- ): Promise<void> => {
206
- const { accountId, accountConfigId } = event;
207
- const { capabilities, services } = await resolveDeps(deps);
208
-
209
- // The account row is kept as the purge-in-progress marker. Its absence means
210
- // the container leftover already ran — no-op on redelivery.
211
- let accountDescription: Awaited<
212
- ReturnType<CascadeServices["accountService"]["describe"]>
213
- >;
214
- try {
215
- accountDescription = await services.accountService.describe(accountId);
216
- } catch (error) {
217
- if ((error as { name?: string })?.name === "NotFoundError") {
218
- log.info(
219
- { accountConfigId, accountId },
220
- "Per-account purge already complete (account row gone) — no-op",
221
- );
222
- return;
223
- }
224
- throw error;
225
- }
226
-
227
- // Validate every deploy-time precondition BEFORE the first destructive step.
228
- // Runs after the account-existence guard so a redelivery whose account row is
229
- // already gone still returns cleanly without needing the config present.
230
- await capabilities.assertReady(log);
231
-
232
- // Step 1: content invalidation — first, so cached body parts cannot leak
233
- // after the underlying objects are gone.
234
- log.info(
235
- { accountConfigId, accountId },
236
- "Invalidating cached content for purged account",
237
- );
238
- await capabilities.invalidateContent(`${accountConfigId}/${accountId}`, log);
239
-
240
- // Step 2: container rows — mailboxes, outbox, locks. Address is
241
- // tenant-shared and never deleted on a per-account purge.
242
- const entities: CascadeEntity[] = [];
243
- for (const mailbox of accountDescription.mailbox) {
244
- entities.push({
245
- entityType: "Mailbox",
246
- key: { mailboxId: mailbox.mailboxId },
247
- });
248
- }
249
- const outboxMessages =
250
- await services.outboxMessageService.listByAccount(accountId);
251
- for (const outbox of outboxMessages.items) {
252
- entities.push({
253
- entityType: "OutboxMessage",
254
- key: { outboxMessageId: outbox.outboxMessageId },
255
- });
256
- }
257
- const locks = await services.mailboxLockService.listByAccount(accountId);
258
- for (const lock of locks) {
259
- entities.push({
260
- entityType: "MailboxLock",
261
- key: { mailboxId: lock.mailboxId, eventName: lock.eventName },
262
- });
263
- }
264
- await capabilities.cascadeDelete(entities, log);
265
-
266
- // Step 3: storage prefix cleanup scoped to this account only.
267
- await capabilities.deleteStoragePrefix(
268
- `accounts/${accountConfigId}/${accountId}/`,
269
- log,
270
- );
271
-
272
- log.info({ accountConfigId, accountId }, "Per-account data purge complete");
273
- };
274
-
275
126
  // ---------- SQS handler ----------
276
127
 
277
128
  const log = createLogger();
@@ -292,7 +143,9 @@ export const handler: SQSHandler = withTelemetry(async (event: SQSEvent) => {
292
143
  const start = Date.now();
293
144
  const work =
294
145
  finalizeEvent.type === "FinalizeAccountDataPurge"
295
- ? processAccountDataPurgeFinalize(finalizeEvent, log)
146
+ ? resolveDeps({}).then((deps) =>
147
+ processAccountDataPurgeFinalize(finalizeEvent, log, deps),
148
+ )
296
149
  : processAccountFinalize(finalizeEvent, log);
297
150
 
298
151
  const failed = await work
@@ -0,0 +1,155 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { RemitClient } from "@remit/backend/client";
4
+ import { recordingQueue } from "@remit/data-ports/recording-queue";
5
+ import type { CascadeEntity } from "../cascade.js";
6
+ import type { DeletionCapabilities } from "../deletion-port.js";
7
+ import { processAccountPurgeEvent } from "../process.js";
8
+
9
+ const notFound = (): Error =>
10
+ Object.assign(new Error("gone"), { name: "NotFoundError" });
11
+
12
+ const messageDescription = {
13
+ messageFlag: [{ messageFlagId: "flag-1" }],
14
+ envelope: [{ envelopeId: "env-1" }],
15
+ messageReference: [],
16
+ envelopeAddress: [],
17
+ bodyPart: [],
18
+ bodyPartParameter: [],
19
+ rawMessageStorage: [],
20
+ bodyPartStorage: [],
21
+ };
22
+
23
+ const fakeClient = (accountGone = false): RemitClient =>
24
+ ({
25
+ account: {
26
+ describe: async () => {
27
+ if (accountGone) throw notFound();
28
+ return { mailbox: [{ mailboxId: "mbx-1" }] };
29
+ },
30
+ },
31
+ message: {
32
+ describe: async (messageId: string) => {
33
+ if (messageId === "msg-gone") throw notFound();
34
+ return messageDescription;
35
+ },
36
+ },
37
+ outboxMessage: {
38
+ listByAccount: async () => ({ items: [{ outboxMessageId: "out-1" }] }),
39
+ },
40
+ mailboxLock: {
41
+ listByAccount: async () => [
42
+ { mailboxId: "mbx-1", eventName: "SYNC_MESSAGES" },
43
+ ],
44
+ },
45
+ }) as unknown as RemitClient;
46
+
47
+ interface Recorded {
48
+ readonly capabilities: DeletionCapabilities;
49
+ readonly deleted: CascadeEntity[];
50
+ readonly prefixes: string[];
51
+ readonly invalidated: string[];
52
+ }
53
+
54
+ const recordingCapabilities = (): Recorded => {
55
+ const deleted: CascadeEntity[] = [];
56
+ const prefixes: string[] = [];
57
+ const invalidated: string[] = [];
58
+ return {
59
+ deleted,
60
+ prefixes,
61
+ invalidated,
62
+ capabilities: {
63
+ assertReady: async () => {},
64
+ signOut: async () => {},
65
+ invalidateContent: async (prefix) => {
66
+ invalidated.push(prefix);
67
+ },
68
+ deleteStoragePrefix: async (prefix) => {
69
+ prefixes.push(prefix);
70
+ },
71
+ cascadeDelete: async (entities) => {
72
+ deleted.push(...entities);
73
+ },
74
+ },
75
+ };
76
+ };
77
+
78
+ const typesOf = (entities: CascadeEntity[]): string[] =>
79
+ entities.map((entity) => entity.entityType);
80
+
81
+ describe("processAccountPurgeEvent", () => {
82
+ it("deletes each message subtree and the manifest row of a message already gone", async () => {
83
+ const recorded = recordingCapabilities();
84
+ await processAccountPurgeEvent(
85
+ {
86
+ type: "FinalizeAccountDataPurge",
87
+ kind: "subtrees",
88
+ accountId: "acc-1",
89
+ accountConfigId: "cfg-1",
90
+ items: [
91
+ { threadMessageId: "tm-1", messageId: "msg-1" },
92
+ { threadMessageId: "tm-2", messageId: "msg-gone" },
93
+ ],
94
+ },
95
+ {
96
+ client: fakeClient(),
97
+ purgeQueue: recordingQueue().grouped,
98
+ capabilities: recorded.capabilities,
99
+ },
100
+ );
101
+
102
+ assert.deepEqual(typesOf(recorded.deleted), [
103
+ "ThreadMessage",
104
+ "Message",
105
+ "MessageFlag",
106
+ "Envelope",
107
+ "ThreadMessage",
108
+ ]);
109
+ });
110
+
111
+ it("deletes the account's mailboxes, outbox and locks, then its stored objects", async () => {
112
+ const recorded = recordingCapabilities();
113
+ await processAccountPurgeEvent(
114
+ {
115
+ type: "FinalizeAccountDataPurge",
116
+ kind: "container",
117
+ accountId: "acc-1",
118
+ accountConfigId: "cfg-1",
119
+ },
120
+ {
121
+ client: fakeClient(),
122
+ purgeQueue: recordingQueue().grouped,
123
+ capabilities: recorded.capabilities,
124
+ },
125
+ );
126
+
127
+ assert.deepEqual(typesOf(recorded.deleted), [
128
+ "Mailbox",
129
+ "OutboxMessage",
130
+ "MailboxLock",
131
+ ]);
132
+ assert.deepEqual(recorded.invalidated, ["cfg-1/acc-1"]);
133
+ assert.deepEqual(recorded.prefixes, ["accounts/cfg-1/acc-1/"]);
134
+ });
135
+
136
+ it("does nothing for a container whose account row is gone", async () => {
137
+ const recorded = recordingCapabilities();
138
+ await processAccountPurgeEvent(
139
+ {
140
+ type: "FinalizeAccountDataPurge",
141
+ kind: "container",
142
+ accountId: "acc-1",
143
+ accountConfigId: "cfg-1",
144
+ },
145
+ {
146
+ client: fakeClient(true),
147
+ purgeQueue: recordingQueue().grouped,
148
+ capabilities: recorded.capabilities,
149
+ },
150
+ );
151
+
152
+ assert.deepEqual(recorded.deleted, []);
153
+ assert.deepEqual(recorded.prefixes, []);
154
+ });
155
+ });
@@ -0,0 +1,161 @@
1
+ import type { Logger } from "@remit/logger";
2
+ import {
3
+ type CascadeEntity,
4
+ type CascadeServices,
5
+ collectMessageChildEntities,
6
+ } from "../cascade.js";
7
+ import type { DeletionCapabilities } from "../deletion-port.js";
8
+ import type { AccountDataPurgeFinalizeEvent } from "../events.js";
9
+
10
+ export interface PurgeFinalizeDeps {
11
+ capabilities: DeletionCapabilities;
12
+ services: CascadeServices;
13
+ }
14
+
15
+ /**
16
+ * Destructive phase of the per-account purge. Consumes the FIFO stream the
17
+ * fanout producer emits (#1069), one message group per account so the messages
18
+ * arrive strictly in order: every `subtrees` batch, then exactly one
19
+ * `container` leftover. There is NO self-re-enqueue — the producer enqueues all
20
+ * the work; this worker only ever consumes.
21
+ *
22
+ * Idempotency: the cascade on a missing key is a no-op, so a redelivered batch
23
+ * (or container) no-ops on already-gone rows.
24
+ */
25
+ export const processAccountDataPurgeFinalize = async (
26
+ event: AccountDataPurgeFinalizeEvent,
27
+ log: Logger,
28
+ deps: PurgeFinalizeDeps,
29
+ ): Promise<void> => {
30
+ if (event.kind === "subtrees") {
31
+ await processPurgeSubtrees(event, log, deps);
32
+ return;
33
+ }
34
+ await processPurgeContainer(event, log, deps);
35
+ };
36
+
37
+ /**
38
+ * Delete a batch of message subtrees. For each `{ threadMessageId, messageId }`
39
+ * the manifest row is deleted UNCONDITIONALLY — there are ghost ThreadMessages
40
+ * whose Message is already gone — and the Message + its 9 child entities are
41
+ * deleted only when `describe()` resolves. A `describe()` 404 means the subtree
42
+ * is already gone: skip the children, still drop the manifest row.
43
+ */
44
+ const processPurgeSubtrees = async (
45
+ event: Extract<AccountDataPurgeFinalizeEvent, { kind: "subtrees" }>,
46
+ log: Logger,
47
+ deps: PurgeFinalizeDeps,
48
+ ): Promise<void> => {
49
+ const { accountId, accountConfigId, items } = event;
50
+ const { capabilities, services } = deps;
51
+
52
+ const entities: CascadeEntity[] = [];
53
+ for (const { threadMessageId, messageId } of items) {
54
+ entities.push({
55
+ entityType: "ThreadMessage",
56
+ key: { accountConfigId, threadMessageId },
57
+ });
58
+ try {
59
+ const messageData = await services.messageService.describe(messageId);
60
+ entities.push({ entityType: "Message", key: { messageId } });
61
+ collectMessageChildEntities(entities, messageData);
62
+ } catch (error) {
63
+ if ((error as { name?: string })?.name === "NotFoundError") {
64
+ log.info(
65
+ { accountConfigId, accountId, messageId },
66
+ "Message subtree already gone — deleting manifest row only",
67
+ );
68
+ continue;
69
+ }
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ await capabilities.cascadeDelete(entities, log);
75
+ log.info(
76
+ { accountConfigId, accountId, count: items.length },
77
+ "Per-account purge subtree batch deleted",
78
+ );
79
+ };
80
+
81
+ /**
82
+ * The container leftover — processed last in the FIFO group, after every subtree
83
+ * delete. Deletes the account-keyed container rows (mailboxes, outbox, locks),
84
+ * the storage prefix `accounts/{cfg}/{acct}/`, and invalidates the account's
85
+ * cached content. Ordering is invalidate-before-storage, storage-after-cascade.
86
+ * The tenant-shared Address rows, the AccountConfig, sibling accounts, and the
87
+ * soft-deleted account row (the purge-in-progress marker) are all kept.
88
+ */
89
+ const processPurgeContainer = async (
90
+ event: Extract<AccountDataPurgeFinalizeEvent, { kind: "container" }>,
91
+ log: Logger,
92
+ deps: PurgeFinalizeDeps,
93
+ ): Promise<void> => {
94
+ const { accountId, accountConfigId } = event;
95
+ const { capabilities, services } = deps;
96
+
97
+ // The account row is kept as the purge-in-progress marker. Its absence means
98
+ // the container leftover already ran — no-op on redelivery.
99
+ let accountDescription: Awaited<
100
+ ReturnType<CascadeServices["accountService"]["describe"]>
101
+ >;
102
+ try {
103
+ accountDescription = await services.accountService.describe(accountId);
104
+ } catch (error) {
105
+ if ((error as { name?: string })?.name === "NotFoundError") {
106
+ log.info(
107
+ { accountConfigId, accountId },
108
+ "Per-account purge already complete (account row gone) — no-op",
109
+ );
110
+ return;
111
+ }
112
+ throw error;
113
+ }
114
+
115
+ // Validate every deploy-time precondition BEFORE the first destructive step.
116
+ // Runs after the account-existence guard so a redelivery whose account row is
117
+ // already gone still returns cleanly without needing the config present.
118
+ await capabilities.assertReady(log);
119
+
120
+ // Step 1: content invalidation — first, so cached body parts cannot leak
121
+ // after the underlying objects are gone.
122
+ log.info(
123
+ { accountConfigId, accountId },
124
+ "Invalidating cached content for purged account",
125
+ );
126
+ await capabilities.invalidateContent(`${accountConfigId}/${accountId}`, log);
127
+
128
+ // Step 2: container rows — mailboxes, outbox, locks. Address is
129
+ // tenant-shared and never deleted on a per-account purge.
130
+ const entities: CascadeEntity[] = [];
131
+ for (const mailbox of accountDescription.mailbox) {
132
+ entities.push({
133
+ entityType: "Mailbox",
134
+ key: { mailboxId: mailbox.mailboxId },
135
+ });
136
+ }
137
+ const outboxMessages =
138
+ await services.outboxMessageService.listByAccount(accountId);
139
+ for (const outbox of outboxMessages.items) {
140
+ entities.push({
141
+ entityType: "OutboxMessage",
142
+ key: { outboxMessageId: outbox.outboxMessageId },
143
+ });
144
+ }
145
+ const locks = await services.mailboxLockService.listByAccount(accountId);
146
+ for (const lock of locks) {
147
+ entities.push({
148
+ entityType: "MailboxLock",
149
+ key: { mailboxId: lock.mailboxId, eventName: lock.eventName },
150
+ });
151
+ }
152
+ await capabilities.cascadeDelete(entities, log);
153
+
154
+ // Step 3: storage prefix cleanup scoped to this account only.
155
+ await capabilities.deleteStoragePrefix(
156
+ `accounts/${accountConfigId}/${accountId}/`,
157
+ log,
158
+ );
159
+
160
+ log.info({ accountConfigId, accountId }, "Per-account data purge complete");
161
+ };
@@ -1,7 +1,6 @@
1
1
  import type { GroupedQueueProducer } from "@remit/data-ports";
2
2
  import type { Logger } from "@remit/logger";
3
3
  import type { CascadeServices } from "../cascade.js";
4
- import { getCascadeServices } from "../config.js";
5
4
  import type {
6
5
  AccountDataPurgeEvent,
7
6
  AccountDataPurgeFinalizeEvent,
@@ -10,7 +9,7 @@ import type {
10
9
 
11
10
  export interface ProcessPurgeFanoutDeps {
12
11
  queue: GroupedQueueProducer<AccountDataPurgeFinalizeEvent>;
13
- services?: CascadeServices;
12
+ services: CascadeServices;
14
13
  }
15
14
 
16
15
  /**
@@ -81,7 +80,7 @@ export const processAccountDataPurge = async (
81
80
  deps: ProcessPurgeFanoutDeps,
82
81
  ): Promise<void> => {
83
82
  const { accountId, accountConfigId } = event;
84
- const services = deps.services ?? (await getCascadeServices());
83
+ const { services } = deps;
85
84
 
86
85
  let mailboxIds: Set<string>;
87
86
  try {
package/src/process.ts CHANGED
@@ -1,18 +1,39 @@
1
1
  import type { RemitClient } from "@remit/backend/client";
2
+ import type { GroupedQueueProducer } from "@remit/data-ports";
2
3
  import { createLogger } from "@remit/logger";
4
+ import { cascadeServicesOf } from "./cascade.js";
5
+ import type { DeletionCapabilities } from "./deletion-port.js";
3
6
  import type {
7
+ AccountDataPurgeEvent,
8
+ AccountDataPurgeFinalizeEvent,
4
9
  OrganizeJobEvent,
5
10
  SenderCategoryBackApplyEvent,
6
11
  } from "./events.js";
12
+ import { processAccountDataPurge } from "./handlers/account-purge.js";
13
+ import { processAccountDataPurgeFinalize } from "./handlers/account-purge-finalize.js";
7
14
  import { processOrganizeJob } from "./handlers/organize-job.js";
8
15
  import { processSenderCategoryBackApply } from "./handlers/sender-category-backapply.js";
9
16
 
17
+ export type { CascadeEntity } from "./cascade.js";
18
+ export type { DeletionCapabilities } from "./deletion-port.js";
19
+ export type { AccountDataPurgeEvent, AccountDataPurgeFinalizeEvent };
20
+
10
21
  export type ClientFanoutEvent = OrganizeJobEvent | SenderCategoryBackApplyEvent;
11
22
 
12
23
  export interface AccountFanoutContext {
13
24
  client: RemitClient<"messageManagement">;
14
25
  }
15
26
 
27
+ export type AccountPurgeEvent =
28
+ | AccountDataPurgeEvent
29
+ | AccountDataPurgeFinalizeEvent;
30
+
31
+ export interface AccountPurgeContext {
32
+ client: RemitClient;
33
+ purgeQueue: GroupedQueueProducer<AccountDataPurgeFinalizeEvent>;
34
+ capabilities: DeletionCapabilities;
35
+ }
36
+
16
37
  const log = createLogger();
17
38
 
18
39
  export const processAccountFanoutEvent = (
@@ -26,3 +47,22 @@ export const processAccountFanoutEvent = (
26
47
  return processSenderCategoryBackApply(event, log, { client: ctx.client });
27
48
  }
28
49
  };
50
+
51
+ export const processAccountPurgeEvent = (
52
+ event: AccountPurgeEvent,
53
+ ctx: AccountPurgeContext,
54
+ ): Promise<void> => {
55
+ const services = cascadeServicesOf(ctx.client);
56
+ switch (event.type) {
57
+ case "AccountDataPurge":
58
+ return processAccountDataPurge(event, log, {
59
+ services,
60
+ queue: ctx.purgeQueue,
61
+ });
62
+ case "FinalizeAccountDataPurge":
63
+ return processAccountDataPurgeFinalize(event, log, {
64
+ services,
65
+ capabilities: ctx.capabilities,
66
+ });
67
+ }
68
+ };