@remit/account-worker 0.0.33 → 0.0.35

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,305 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { finished } from "node:stream/promises";
6
+ import { after, before, describe, it } from "node:test";
7
+ import { MailboxCursorState } from "@remit/domain-enums";
8
+ import {
9
+ accountTable,
10
+ BETTER_SQLITE3_LIMITS,
11
+ composeSqliteRepositories,
12
+ mailboxTable,
13
+ messageObjectsInMemory,
14
+ messageTable,
15
+ openSqliteDatabases,
16
+ syncStores,
17
+ } from "@remit/drizzle-service";
18
+ import { createShippedSqliteFiles } from "@remit/drizzle-service/test-sqlite";
19
+ import { noopLogger } from "@remit/logger";
20
+ import {
21
+ buildExportArchiveKey,
22
+ type StorageService,
23
+ } from "@remit/storage-service";
24
+ import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
25
+ import { cascadeServicesOf } from "../cascade.js";
26
+ import { processAccountExport } from "./account-export.js";
27
+
28
+ const NOW = 1700000000000;
29
+ const MESSAGE_COUNT = 3;
30
+
31
+ const exists = (path: string): Promise<boolean> =>
32
+ stat(path).then(
33
+ () => true,
34
+ () => false,
35
+ );
36
+
37
+ interface Tracked {
38
+ readonly storage: StorageService;
39
+ readonly maxOpen: () => number;
40
+ }
41
+
42
+ const trackingOpenBodies = (
43
+ storage: StorageService,
44
+ onFirstRead: () => Promise<void>,
45
+ ): Tracked => {
46
+ let open = 0;
47
+ let max = 0;
48
+ let reads = 0;
49
+ return {
50
+ maxOpen: () => max,
51
+ storage: {
52
+ ...storage,
53
+ retrieveMessageBodyStream: async (
54
+ accountConfigId,
55
+ accountId,
56
+ messageId,
57
+ ) => {
58
+ reads += 1;
59
+ if (reads === 1) await onFirstRead();
60
+ const body = await storage.retrieveMessageBodyStream(
61
+ accountConfigId,
62
+ accountId,
63
+ messageId,
64
+ );
65
+ if (body === null) return null;
66
+ open += 1;
67
+ max = Math.max(max, open);
68
+ body.once("close", () => {
69
+ open -= 1;
70
+ });
71
+ return body;
72
+ },
73
+ },
74
+ };
75
+ };
76
+
77
+ describe("processAccountExport over SQLite repositories and filesystem storage", () => {
78
+ let directory: string;
79
+ let close: () => Promise<void>;
80
+ let repositories: ReturnType<typeof composeSqliteRepositories>;
81
+ let databases: Awaited<ReturnType<typeof openSqliteDatabases>>;
82
+ let storage: StorageService;
83
+
84
+ const seedTenant = async (): Promise<{
85
+ accountConfigId: string;
86
+ accountExportRequestId: string;
87
+ }> => {
88
+ const accountConfig = await repositories.accountConfig.create({
89
+ userId: "user000000000000000000001",
90
+ });
91
+ const { accountConfigId } = accountConfig;
92
+ const accountId = `${accountConfigId}-acc`;
93
+ const mailboxId = `${accountConfigId}-mbx`;
94
+ await databases.app.insert(accountTable).values({
95
+ accountId,
96
+ accountConfigId,
97
+ username: "u",
98
+ email: "u@example.com",
99
+ authType: "password",
100
+ syncedServices: ["Mail"],
101
+ imapHost: "imap.example.com",
102
+ imapPort: 993,
103
+ imapTls: true,
104
+ imapStartTls: false,
105
+ smtpPort: 587,
106
+ isActive: true,
107
+ connectionState: "authenticated",
108
+ createdAt: NOW,
109
+ updatedAt: NOW,
110
+ });
111
+ await databases.mail.insert(mailboxTable).values({
112
+ mailboxId,
113
+ accountId,
114
+ namespaceType: "personal",
115
+ namespacePrefix: "",
116
+ hierarchyDelimiter: "/",
117
+ fullPath: "INBOX",
118
+ uidValidity: 1,
119
+ uidNext: 1,
120
+ highestModseq: "0",
121
+ messageCount: 0,
122
+ unseenCount: 0,
123
+ deletedCount: 0,
124
+ totalSize: 0,
125
+ lastSyncUid: 0,
126
+ highWaterMarkUid: 0,
127
+ lastMessageSyncAt: NOW,
128
+ cursorState: MailboxCursorState.normal,
129
+ createdAt: NOW,
130
+ updatedAt: NOW,
131
+ });
132
+ for (let uid = 1; uid <= MESSAGE_COUNT; uid += 1) {
133
+ const messageId = `${accountConfigId}-msg-${uid}`;
134
+ await databases.mail.insert(messageTable).values({
135
+ messageId,
136
+ mailboxId,
137
+ uid,
138
+ sequenceNumber: uid,
139
+ rfc822Size: 10,
140
+ internalDate: NOW,
141
+ envelopeId: `${messageId}-env`,
142
+ rootBodyPartId: `${messageId}-root`,
143
+ status: "active",
144
+ syncStatus: "synced",
145
+ category: "uncategorized",
146
+ createdAt: NOW,
147
+ updatedAt: NOW,
148
+ });
149
+ await storage.storeMessageBody({
150
+ accountConfigId,
151
+ accountId,
152
+ messageId,
153
+ content: Buffer.from(`Subject: message ${uid}\r\n\r\nbody`),
154
+ });
155
+ }
156
+ const request = await repositories.accountExportRequest.create({
157
+ accountConfigId,
158
+ userId: accountConfig.userId,
159
+ state: "Pending",
160
+ });
161
+ return {
162
+ accountConfigId,
163
+ accountExportRequestId: request.accountExportRequestId,
164
+ };
165
+ };
166
+
167
+ before(async () => {
168
+ directory = await mkdtemp(join(tmpdir(), "remit-account-export-"));
169
+ databases = await openSqliteDatabases(createShippedSqliteFiles(directory));
170
+ close = databases.close;
171
+ repositories = composeSqliteRepositories(
172
+ syncStores(databases, BETTER_SQLITE3_LIMITS),
173
+ messageObjectsInMemory(),
174
+ );
175
+ storage = createFilesystemStorageService(join(directory, "storage"));
176
+ });
177
+
178
+ after(async () => {
179
+ await close();
180
+ });
181
+
182
+ it("archives every body while holding one open at a time", async () => {
183
+ const event = { type: "AccountExport" as const, ...(await seedTenant()) };
184
+ const tracked = trackingOpenBodies(storage, async () => {});
185
+
186
+ await processAccountExport(event, noopLogger, {
187
+ services: cascadeServicesOf(repositories),
188
+ storageService: tracked.storage,
189
+ });
190
+
191
+ const request = await repositories.accountExportRequest.get(
192
+ event.accountExportRequestId,
193
+ );
194
+ assert.equal(request.state, "Ready");
195
+ assert.equal(tracked.maxOpen(), 1);
196
+ assert.equal(
197
+ await exists(
198
+ join(
199
+ directory,
200
+ "storage",
201
+ buildExportArchiveKey(
202
+ event.accountConfigId,
203
+ event.accountExportRequestId,
204
+ ),
205
+ ),
206
+ ),
207
+ true,
208
+ );
209
+ });
210
+
211
+ it("leaves no archive when the account config is deleted while it runs", async () => {
212
+ const event = { type: "AccountExport" as const, ...(await seedTenant()) };
213
+ const tracked = trackingOpenBodies(storage, async () => {
214
+ await repositories.accountConfig.update(event.accountConfigId, {
215
+ state: "deleting",
216
+ deletedAt: Date.now(),
217
+ });
218
+ });
219
+
220
+ await processAccountExport(event, noopLogger, {
221
+ services: cascadeServicesOf(repositories),
222
+ storageService: tracked.storage,
223
+ });
224
+
225
+ const request = await repositories.accountExportRequest.get(
226
+ event.accountExportRequestId,
227
+ );
228
+ assert.notEqual(request.state, "Ready");
229
+ assert.equal(
230
+ await exists(
231
+ join(
232
+ directory,
233
+ "storage",
234
+ buildExportArchiveKey(
235
+ event.accountConfigId,
236
+ event.accountExportRequestId,
237
+ ),
238
+ ),
239
+ ),
240
+ false,
241
+ );
242
+ });
243
+
244
+ it("skips a message with no stored body and still completes", async () => {
245
+ const event = { type: "AccountExport" as const, ...(await seedTenant()) };
246
+ const consulted: string[] = [];
247
+ const skipped = `${event.accountConfigId}-msg-2`;
248
+
249
+ await processAccountExport(event, noopLogger, {
250
+ services: cascadeServicesOf(repositories),
251
+ storageService: {
252
+ ...storage,
253
+ retrieveMessageBodyStream: async (
254
+ accountConfigId,
255
+ accountId,
256
+ messageId,
257
+ ) => {
258
+ consulted.push(messageId);
259
+ return messageId === skipped
260
+ ? null
261
+ : storage.retrieveMessageBodyStream(
262
+ accountConfigId,
263
+ accountId,
264
+ messageId,
265
+ );
266
+ },
267
+ },
268
+ });
269
+
270
+ assert.equal(consulted.length, MESSAGE_COUNT);
271
+ const request = await repositories.accountExportRequest.get(
272
+ event.accountExportRequestId,
273
+ );
274
+ assert.equal(request.state, "Ready");
275
+ });
276
+
277
+ it("marks the request Failed and rethrows when the upload fails", async () => {
278
+ const event = { type: "AccountExport" as const, ...(await seedTenant()) };
279
+
280
+ await assert.rejects(
281
+ processAccountExport(event, noopLogger, {
282
+ services: cascadeServicesOf(repositories),
283
+ storageService: {
284
+ ...storage,
285
+ storeExportArchiveStream: async (
286
+ _accountConfigId,
287
+ _exportRequestId,
288
+ body,
289
+ ) => {
290
+ body.resume();
291
+ await finished(body);
292
+ throw new Error("storage refused the archive");
293
+ },
294
+ },
295
+ }),
296
+ /storage refused the archive/,
297
+ );
298
+
299
+ const request = await repositories.accountExportRequest.get(
300
+ event.accountExportRequestId,
301
+ );
302
+ assert.equal(request.state, "Failed");
303
+ assert.equal(request.errorMessage, "storage refused the archive");
304
+ });
305
+ });
@@ -1,9 +1,9 @@
1
+ import { once } from "node:events";
1
2
  import { PassThrough } from "node:stream";
2
3
  import type { Logger } from "@remit/logger";
3
4
  import type { StorageService } from "@remit/storage-service";
4
5
  import { ZipArchive } from "archiver";
5
6
  import type { CascadeServices } from "../cascade.js";
6
- import { getCascadeServices, getStorageService } from "../config.js";
7
7
  import type { AccountExportEvent } from "../events.js";
8
8
 
9
9
  const THIRTY_DAYS_MILLIS = 30 * 24 * 60 * 60 * 1000;
@@ -13,11 +13,6 @@ export interface ProcessAccountExportDeps {
13
13
  storageService: StorageService;
14
14
  }
15
15
 
16
- const defaultDeps = async (): Promise<ProcessAccountExportDeps> => ({
17
- services: await getCascadeServices(),
18
- storageService: getStorageService(),
19
- });
20
-
21
16
  const appendMessageBodies = async (
22
17
  archive: ZipArchive,
23
18
  accountConfigId: string,
@@ -52,9 +47,13 @@ const appendMessageBodies = async (
52
47
  continue;
53
48
  }
54
49
 
50
+ // One body in flight at a time, so an export holds one message in
51
+ // memory and one open storage read, not the whole account.
52
+ const written = once(archive, "entry");
55
53
  archive.append(body, {
56
54
  name: `accounts/${account.accountId}/${mailbox.fullPath ?? mailbox.mailboxId}/${message.messageId}.eml`,
57
55
  });
56
+ await written;
58
57
  appended += 1;
59
58
  }
60
59
  }
@@ -63,15 +62,31 @@ const appendMessageBodies = async (
63
62
  return appended;
64
63
  };
65
64
 
65
+ const accountConfigLive = async (
66
+ services: CascadeServices,
67
+ accountConfigId: string,
68
+ ): Promise<boolean> => {
69
+ const [accountConfig] = await services.accountConfigService.get([
70
+ accountConfigId,
71
+ ]);
72
+ return accountConfig !== undefined && accountConfig.deletedAt === undefined;
73
+ };
74
+
66
75
  export const processAccountExport = async (
67
76
  event: AccountExportEvent,
68
77
  log: Logger,
69
- deps?: ProcessAccountExportDeps,
78
+ { services, storageService }: ProcessAccountExportDeps,
70
79
  ): Promise<void> => {
71
80
  const { accountConfigId, accountExportRequestId } = event;
72
- const { services, storageService } = deps ?? (await defaultDeps());
73
81
  const { accountExportRequestService } = services;
74
82
 
83
+ if (!(await accountConfigLive(services, accountConfigId))) {
84
+ log.info(
85
+ { accountConfigId, accountExportRequestId },
86
+ "Account config is being deleted; export skipped",
87
+ );
88
+ return;
89
+ }
75
90
  await accountExportRequestService.get(accountExportRequestId);
76
91
  await accountExportRequestService.update(accountExportRequestId, {
77
92
  state: "Processing",
@@ -110,6 +125,20 @@ export const processAccountExport = async (
110
125
  await archiveSettled;
111
126
  const objectKey = await uploadDone;
112
127
 
128
+ // Delete-me sets deletedAt before it queues the erase, so a config that
129
+ // is gone or deleting now means this archive would outlive the account.
130
+ if (!(await accountConfigLive(services, accountConfigId))) {
131
+ await storageService.deleteExportArchive(
132
+ accountConfigId,
133
+ accountExportRequestId,
134
+ );
135
+ log.info(
136
+ { accountConfigId, accountExportRequestId },
137
+ "Account config deleted during export; archive removed",
138
+ );
139
+ return;
140
+ }
141
+
113
142
  const expiresAt = Date.now() + THIRTY_DAYS_MILLIS;
114
143
 
115
144
  await accountExportRequestService.update(accountExportRequestId, {
@@ -8,12 +8,13 @@ import { processAccountFanout } from "./account-fanout.js";
8
8
 
9
9
  const buildServices = (): CascadeServices =>
10
10
  ({
11
- accountConfigService: { get: async () => ({ userId: "user-1" }) },
11
+ accountConfigService: { get: async () => [{ userId: "user-1" }] },
12
12
  }) as unknown as CascadeServices;
13
13
 
14
14
  const deleteEvent: AccountDeleteEvent = {
15
15
  type: "AccountDelete",
16
16
  accountConfigId: "cfg-1",
17
+ userId: "user-1",
17
18
  };
18
19
 
19
20
  describe("processAccountFanout on account delete", () => {
@@ -10,18 +10,16 @@ import type { CascadeServices } from "../cascade.js";
10
10
  import {
11
11
  type FanoutQueues,
12
12
  getCascadeServices,
13
+ getStorageService,
13
14
  sqsFanoutQueues,
14
15
  } from "../config.js";
15
16
  import {
16
17
  type DeletionCapabilities,
17
18
  getDeletionCapabilities,
18
19
  } from "../deletion-capabilities.js";
19
- import type {
20
- AccountDeleteEvent,
21
- AccountFanoutEvent,
22
- AccountFinalizeEvent,
23
- } from "../events.js";
20
+ import type { AccountFanoutEvent } from "../events.js";
24
21
  import { processAccountFanoutEvent } from "../process.js";
22
+ import { processAccountDelete } from "./account-delete.js";
25
23
  import { processAccountExport } from "./account-export.js";
26
24
  import { processAccountDataPurge } from "./account-purge.js";
27
25
 
@@ -49,7 +47,10 @@ export const processAccountFanout = async (
49
47
  }
50
48
 
51
49
  if (event.type === "AccountExport") {
52
- await processAccountExport(event, log);
50
+ await processAccountExport(event, log, {
51
+ services,
52
+ storageService: getStorageService(),
53
+ });
53
54
  return;
54
55
  }
55
56
 
@@ -61,31 +62,12 @@ export const processAccountFanout = async (
61
62
  return;
62
63
  }
63
64
 
64
- await processAccountDelete(event, log, services, deps);
65
- };
66
-
67
- const processAccountDelete = async (
68
- event: AccountDeleteEvent,
69
- log: Logger,
70
- services: CascadeServices,
71
- deps: ProcessAccountFanoutDeps,
72
- ): Promise<void> => {
73
- const { accountConfigId } = event;
74
- const signOut = deps.signOut ?? (await getDeletionCapabilities()).signOut;
75
- const { accountFinalize } = deps.queues;
76
-
77
- const accountConfig =
78
- await services.accountConfigService.get(accountConfigId);
79
- const userId = accountConfig.userId;
80
-
81
- await signOut(userId, log);
82
-
83
- const finalizeEvent: AccountFinalizeEvent = {
84
- type: "FinalizeAccountDelete",
85
- accountConfigId,
86
- };
87
- await accountFinalize.send({ body: finalizeEvent });
88
- log.info({ accountConfigId }, "Enqueued finalize event");
65
+ await processAccountDelete(event, log, {
66
+ services,
67
+ signOut: deps.signOut ?? (await getDeletionCapabilities()).signOut,
68
+ enqueueFinalize: (finalizeEvent) =>
69
+ deps.queues.accountFinalize.send({ body: finalizeEvent }),
70
+ });
89
71
  };
90
72
 
91
73
  const log = createLogger();
@@ -6,11 +6,7 @@ import {
6
6
  } from "@remit/logger-lambda/metrics";
7
7
  import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
8
8
  import type { SQSEvent, SQSHandler } from "aws-lambda";
9
- import {
10
- type CascadeEntity,
11
- type CascadeServices,
12
- enumerateCascadeEntities,
13
- } from "../cascade.js";
9
+ import type { CascadeServices } from "../cascade.js";
14
10
  import { getCascadeServices } from "../config.js";
15
11
  import {
16
12
  type DeletionCapabilities,
@@ -20,6 +16,7 @@ import type {
20
16
  AccountDeleteFinalizeEvent,
21
17
  AccountFinalizeEvent,
22
18
  } from "../events.js";
19
+ import { finalizeAccountDelete } from "./account-delete.js";
23
20
  import { processAccountDataPurgeFinalize } from "./account-purge-finalize.js";
24
21
 
25
22
  installNodeLogger();
@@ -39,89 +36,11 @@ const resolveDeps = async (
39
36
  services: deps.cascadeServices ?? (await getCascadeServices()),
40
37
  });
41
38
 
42
- /**
43
- * GDPR hard-delete: every row tied to the deleted AccountConfig is removed and
44
- * every stored object under `accounts/{accountConfigId}/` is purged. The
45
- * AccountConfig row itself is the LAST delete so that a mid-cascade replay still
46
- * sees the cascade-in-progress flag (`deletedAt` set, `isActive=false`, written
47
- * API-side) and re-runs cleanly. After a successful run nothing tied to the
48
- * AccountConfig persists.
49
- *
50
- * Step order (non-negotiable per #320):
51
- * 1. Content invalidation — runs FIRST so cached body parts cannot leak after
52
- * the underlying objects are gone (a no-op where no CDN fronts content).
53
- * 2. Relational/DDB cascade delete in dependency order (children → parents).
54
- * 3. Storage prefix cleanup `accounts/{accountConfigId}/`.
55
- * 4. AccountConfig delete.
56
- *
57
- * Idempotency: every step is replay-safe. The cascade on a missing key is a
58
- * no-op, storage prefix delete on missing keys is a no-op, and invalidation is
59
- * always idempotent. No explicit "already deleted" pre-checks — they make
60
- * replays racier, not safer. Errors propagate so SQS retries the message;
61
- * eventually the DLQ alarm fires and an operator inspects the failure.
62
- */
63
39
  export const processAccountFinalize = async (
64
40
  event: AccountDeleteFinalizeEvent,
65
41
  log: Logger,
66
42
  deps: ProcessFinalizeDeps = {},
67
- ): Promise<void> => {
68
- const { accountConfigId } = event;
69
- const { capabilities, services } = await resolveDeps(deps);
70
-
71
- // Validate every deploy-time precondition BEFORE the first destructive step,
72
- // so a missing var fails loud instead of deleting rows in the cascade and
73
- // only then erroring at the storage step.
74
- await capabilities.assertReady(log);
75
-
76
- // Step 1: content invalidation — first, so cached body parts cannot leak
77
- // after the underlying objects are gone.
78
- log.info(
79
- { accountConfigId },
80
- "Invalidating cached content for erased account",
81
- );
82
- await capabilities.invalidateContent(accountConfigId, log);
83
-
84
- // Step 2: cascade delete (children → parents). The AccountConfig row is
85
- // excluded from the cascade plan and removed last, after storage.
86
- //
87
- // SQS is at-least-once: a successful cascade can be redelivered. After a
88
- // clean run the AccountConfig row is gone, so `describe()` throws
89
- // `NotFoundError` — that's the success signal, not a failure. Treat it as
90
- // "cascade already complete" and return cleanly. Any other error propagates
91
- // so SQS retries → DLQ.
92
- let entities: CascadeEntity[];
93
- try {
94
- const enumeration = await enumerateCascadeEntities(
95
- accountConfigId,
96
- services,
97
- log,
98
- );
99
- entities = enumeration.entities;
100
- } catch (error) {
101
- if ((error as { name?: string })?.name === "NotFoundError") {
102
- log.info(
103
- { accountConfigId },
104
- "Cascade already complete (AccountConfig not found on replay) — no-op",
105
- );
106
- return;
107
- }
108
- throw error;
109
- }
110
- const cascadeEntities = entities.filter(
111
- (e) => e.entityType !== "AccountConfig",
112
- );
113
- await capabilities.cascadeDelete(cascadeEntities, log);
114
-
115
- // Step 3: storage prefix cleanup. Runs AFTER the cascade so a mid-cascade
116
- // replay always re-runs the (idempotent) storage step.
117
- await capabilities.deleteStoragePrefix(`accounts/${accountConfigId}/`, log);
118
-
119
- // Step 4: AccountConfig delete — the last write. The presence of an
120
- // AccountConfig row with `deletedAt` set is the cascade-in-progress flag;
121
- // removing the row is the only signal the cascade fully finished.
122
- await services.accountConfigService.delete(accountConfigId);
123
- log.info({ accountConfigId }, "AccountConfig deleted; cascade complete");
124
- };
43
+ ): Promise<void> => finalizeAccountDelete(event, log, await resolveDeps(deps));
125
44
 
126
45
  // ---------- SQS handler ----------
127
46
 
@@ -71,6 +71,9 @@ const recordingCapabilities = (): Recorded => {
71
71
  cascadeDelete: async (entities) => {
72
72
  deleted.push(...entities);
73
73
  },
74
+ eraseAccountConfig: async () => {
75
+ throw new Error("a per-account purge never erases the account config");
76
+ },
74
77
  },
75
78
  };
76
79
  };
@@ -13,6 +13,7 @@ import {
13
13
  BETTER_SQLITE3_LIMITS,
14
14
  createSqliteRepositories,
15
15
  type DrizzleThreadMessageRepository,
16
+ messageObjectsInMemory,
16
17
  } from "@remit/drizzle-service";
17
18
  import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
18
19
  import { noopLogger } from "@remit/logger";
@@ -193,6 +194,7 @@ const world = async (filters: FilterItem[]): Promise<World> => {
193
194
  search: store.db,
194
195
  },
195
196
  BETTER_SQLITE3_LIMITS,
197
+ messageObjectsInMemory(),
196
198
  ).threadMessage;
197
199
  const { inbox, archive } = await seedThreads(threadMessage);
198
200
  const updates: Update[] = [];