@remit/drizzle-service 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 (82) hide show
  1. package/drizzle.config.ts +15 -0
  2. package/package.json +52 -0
  3. package/src/db.ts +13 -0
  4. package/src/dialect.ts +13 -0
  5. package/src/error.ts +37 -0
  6. package/src/id.ts +50 -0
  7. package/src/index.ts +62 -0
  8. package/src/pagination.ts +29 -0
  9. package/src/repos/cascade-delete.sqlite.test.ts +159 -0
  10. package/src/repos/cascade-delete.test.ts +423 -0
  11. package/src/repos/cascade-delete.ts +219 -0
  12. package/src/repos/envelope.sqlite.test.ts +94 -0
  13. package/src/repos/envelope.test.ts +225 -0
  14. package/src/repos/envelope.ts +342 -0
  15. package/src/repos/filter-anchor.test.ts +131 -0
  16. package/src/repos/filter-anchor.ts +105 -0
  17. package/src/repos/filter.test.ts +279 -0
  18. package/src/repos/filter.ts +253 -0
  19. package/src/repos/i4-account-config.test.ts +105 -0
  20. package/src/repos/i4-account-config.ts +257 -0
  21. package/src/repos/i4-account-export-request.ts +141 -0
  22. package/src/repos/i4-account-setting.test.ts +94 -0
  23. package/src/repos/i4-account-setting.ts +92 -0
  24. package/src/repos/i4-account.test.ts +223 -0
  25. package/src/repos/i4-account.ts +380 -0
  26. package/src/repos/i4-address-wellknown.ts +31 -0
  27. package/src/repos/i4-address.test.ts +358 -0
  28. package/src/repos/i4-address.ts +613 -0
  29. package/src/repos/i4-mailbox-lock.test.ts +368 -0
  30. package/src/repos/i4-mailbox-lock.ts +140 -0
  31. package/src/repos/i4-mailbox-special-use.ts +165 -0
  32. package/src/repos/i4-mailbox.test.ts +188 -0
  33. package/src/repos/i4-mailbox.ts +347 -0
  34. package/src/repos/i4-message-flag-push.test.ts +189 -0
  35. package/src/repos/i4-message-flag-push.ts +173 -0
  36. package/src/repos/i4-message-placement-move.test.ts +135 -0
  37. package/src/repos/i4-message-placement-move.ts +144 -0
  38. package/src/repos/i4-organize-job-request.ts +142 -0
  39. package/src/repos/i4-outbox-message.test.ts +298 -0
  40. package/src/repos/i4-outbox-message.ts +299 -0
  41. package/src/repos/label.conformance.sqlite.test.ts +23 -0
  42. package/src/repos/label.conformance.test.ts +19 -0
  43. package/src/repos/label.ts +125 -0
  44. package/src/repos/mappers.ts +171 -0
  45. package/src/repos/message-flag.ts +162 -0
  46. package/src/repos/message-label.test.ts +110 -0
  47. package/src/repos/message-label.ts +96 -0
  48. package/src/repos/message.sqlite.test.ts +118 -0
  49. package/src/repos/message.test.ts +488 -0
  50. package/src/repos/message.ts +558 -0
  51. package/src/repos/serialized-writes.sqlite.test.ts +199 -0
  52. package/src/repos/test-helpers.ts +222 -0
  53. package/src/repos/thread-message.sqlite.test.ts +198 -0
  54. package/src/repos/thread-message.test.ts +744 -0
  55. package/src/repos/thread-message.ts +832 -0
  56. package/src/repos/thread-search-predicates.ts +79 -0
  57. package/src/repos/unit-of-work.sqlite.test.ts +138 -0
  58. package/src/repos/unit-of-work.test.ts +105 -0
  59. package/src/repos/unit-of-work.ts +31 -0
  60. package/src/schema/active-entities.ts +19 -0
  61. package/src/schema/i4-account-config.ts +4 -0
  62. package/src/schema/i4-account-export-request.ts +3 -0
  63. package/src/schema/i4-account-setting.ts +3 -0
  64. package/src/schema/i4-address.ts +3 -0
  65. package/src/schema/i4-mailbox-lock.ts +3 -0
  66. package/src/schema/i4-mailbox.ts +4 -0
  67. package/src/schema/i4-message-flag-push.ts +3 -0
  68. package/src/schema/i4-message-placement-move.ts +3 -0
  69. package/src/schema/i4-organize-job-request.ts +1 -0
  70. package/src/schema/i4-outbox-message.ts +3 -0
  71. package/src/schema/message-data.ts +44 -0
  72. package/src/schema/outbox.ts +74 -0
  73. package/src/schema/thread-message.ts +3 -0
  74. package/src/schema-full-sqlite.ts +11 -0
  75. package/src/schema-full.ts +16 -0
  76. package/src/schema.ts +28 -0
  77. package/src/sqlite-client.ts +52 -0
  78. package/src/test-db-sqlite.ts +75 -0
  79. package/src/test-db.ts +76 -0
  80. package/src/tx.ts +208 -0
  81. package/src/vps-migrations-drift.test.ts +34 -0
  82. package/tsconfig.json +8 -0
@@ -0,0 +1,423 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { MailboxCursorState } from "@remit/domain-enums";
4
+ import { eq } from "drizzle-orm";
5
+ import {
6
+ accountSettingTable,
7
+ accountTable,
8
+ addressTable,
9
+ bodyPartTable,
10
+ envelopeTable,
11
+ mailboxLockTable,
12
+ mailboxTable,
13
+ messageFlagPushTable,
14
+ messageFlagTable,
15
+ messagePlacementMoveTable,
16
+ messageTable,
17
+ outboxMessageTable,
18
+ outboxTable,
19
+ threadMessageTable,
20
+ } from "../schema.js";
21
+ import { createTestDb, type TestDb } from "../test-db.js";
22
+ import {
23
+ type CascadeEntity,
24
+ runDrizzleCascadeDelete,
25
+ } from "./cascade-delete.js";
26
+
27
+ const log = { info: () => {} };
28
+
29
+ const NOW = 1700000000000;
30
+ const ACCOUNT_CONFIG_ID = "cfg-cascade-1";
31
+ const ACCOUNT_ID = "acc-cascade-1";
32
+ const MAILBOX_ID = "mbx-cascade-1";
33
+ const MESSAGE_ID = "msg-cascade-1";
34
+ const THREAD_MESSAGE_ID = "tm-cascade-1";
35
+ const OUTBOX_MESSAGE_ID = "outbox-cascade-1";
36
+ const ACCOUNT_SETTING_ID = "setting-cascade-1";
37
+ const ADDRESS_ID = "address-cascade-1";
38
+ const ENVELOPE_ID = "00000000-0000-4000-8000-000000000001";
39
+ const ROOT_BODY_PART_ID = "00000000-0000-4000-8000-000000000002";
40
+ const MESSAGE_FLAG_ID = "00000000-0000-4000-8000-000000000003";
41
+ const BODY_PART_ID = "00000000-0000-4000-8000-000000000004";
42
+ const OTHER_MAILBOX_ID = "mbx-cascade-2";
43
+
44
+ describe("runDrizzleCascadeDelete", () => {
45
+ let db: TestDb;
46
+ let close: () => Promise<void>;
47
+
48
+ before(async () => {
49
+ ({ db, close } = await createTestDb());
50
+
51
+ await db.insert(accountTable).values({
52
+ accountId: ACCOUNT_ID,
53
+ accountConfigId: ACCOUNT_CONFIG_ID,
54
+ username: "u",
55
+ email: "u@example.com",
56
+ authType: "password",
57
+ imapHost: "imap.example.com",
58
+ imapPort: 993,
59
+ imapTls: true,
60
+ imapStartTls: false,
61
+ smtpPort: 587,
62
+ isActive: true,
63
+ connectionState: "authenticated",
64
+ createdAt: NOW,
65
+ updatedAt: NOW,
66
+ });
67
+ await db.insert(mailboxTable).values({
68
+ mailboxId: MAILBOX_ID,
69
+ accountId: ACCOUNT_ID,
70
+ namespaceType: "personal",
71
+ namespacePrefix: "",
72
+ hierarchyDelimiter: "/",
73
+ fullPath: "INBOX",
74
+ uidValidity: 1,
75
+ uidNext: 1,
76
+ highestModseq: 0,
77
+ messageCount: 0,
78
+ unseenCount: 0,
79
+ deletedCount: 0,
80
+ totalSize: 0,
81
+ lastSyncUid: 0,
82
+ highWaterMarkUid: 0,
83
+ lastMessageSyncAt: NOW,
84
+ cursorState: MailboxCursorState.normal,
85
+ createdAt: NOW,
86
+ updatedAt: NOW,
87
+ });
88
+ await db.insert(messageTable).values({
89
+ messageId: MESSAGE_ID,
90
+ mailboxId: MAILBOX_ID,
91
+ uid: 1,
92
+ sequenceNumber: 1,
93
+ rfc822Size: 10,
94
+ internalDate: NOW,
95
+ envelopeId: ENVELOPE_ID,
96
+ rootBodyPartId: ROOT_BODY_PART_ID,
97
+ status: "active",
98
+ syncStatus: "synced",
99
+ category: "uncategorized",
100
+ createdAt: NOW,
101
+ updatedAt: NOW,
102
+ });
103
+ await db.insert(envelopeTable).values({
104
+ envelopeId: ENVELOPE_ID,
105
+ messageId: MESSAGE_ID,
106
+ dateValue: NOW,
107
+ dateRaw: "Mon, 14 Nov 2023 08:00:00 +0000",
108
+ createdAt: NOW,
109
+ updatedAt: NOW,
110
+ });
111
+ await db.insert(messageFlagTable).values({
112
+ messageFlagId: MESSAGE_FLAG_ID,
113
+ messageId: MESSAGE_ID,
114
+ flagName: "\\Seen",
115
+ setAt: NOW,
116
+ createdAt: NOW,
117
+ updatedAt: NOW,
118
+ });
119
+ await db.insert(bodyPartTable).values({
120
+ bodyPartId: BODY_PART_ID,
121
+ messageId: MESSAGE_ID,
122
+ partPath: "1",
123
+ mediaType: "TEXT",
124
+ mediaSubtype: "plain",
125
+ transferEncoding: "7BIT",
126
+ sizeOctets: 4,
127
+ isMultipart: false,
128
+ createdAt: NOW,
129
+ updatedAt: NOW,
130
+ });
131
+ await db.insert(threadMessageTable).values({
132
+ threadMessageId: THREAD_MESSAGE_ID,
133
+ accountConfigId: ACCOUNT_CONFIG_ID,
134
+ threadId: "thread-1",
135
+ messageId: MESSAGE_ID,
136
+ mailboxId: MAILBOX_ID,
137
+ uid: 1,
138
+ referenceOrder: 0,
139
+ internalDate: NOW,
140
+ sentDate: NOW,
141
+ isRead: false,
142
+ hasAttachment: false,
143
+ star: "none",
144
+ hasStars: false,
145
+ isDeleted: false,
146
+ category: "uncategorized",
147
+ createdAt: NOW,
148
+ updatedAt: NOW,
149
+ });
150
+ await db.insert(outboxMessageTable).values({
151
+ outboxMessageId: OUTBOX_MESSAGE_ID,
152
+ accountId: ACCOUNT_ID,
153
+ accountConfigId: ACCOUNT_CONFIG_ID,
154
+ fromAddress: "u@example.com",
155
+ toAddresses: ["b@example.com"],
156
+ ccAddresses: [],
157
+ bccAddresses: [],
158
+ messageIdValue: "<x@example.com>",
159
+ references: [],
160
+ status: "queued",
161
+ createdAt: NOW,
162
+ updatedAt: NOW,
163
+ });
164
+ await db.insert(mailboxLockTable).values({
165
+ mailboxId: MAILBOX_ID,
166
+ eventName: "SYNC_MESSAGES",
167
+ accountId: ACCOUNT_ID,
168
+ lockId: "lock-1",
169
+ acquiredAt: NOW,
170
+ lockedBy: "worker-1",
171
+ ttl: NOW + 60000,
172
+ });
173
+ await db.insert(accountSettingTable).values({
174
+ accountSettingId: ACCOUNT_SETTING_ID,
175
+ accountConfigId: ACCOUNT_CONFIG_ID,
176
+ name: "Theme",
177
+ value: { mode: "dark" },
178
+ createdAt: NOW,
179
+ updatedAt: NOW,
180
+ });
181
+ await db.insert(addressTable).values({
182
+ addressId: ADDRESS_ID,
183
+ accountConfigId: ACCOUNT_CONFIG_ID,
184
+ localPart: "u",
185
+ domain: "example.com",
186
+ normalizedEmail: "u@example.com",
187
+ normalizedCompound: "u u@example.com",
188
+ flags: {},
189
+ inboundCount: 0,
190
+ outboundCount: 0,
191
+ replyCount: 0,
192
+ lastInboundAt: NOW,
193
+ lastReplyAt: NOW,
194
+ createdAt: NOW,
195
+ updatedAt: NOW,
196
+ });
197
+ await db.insert(messagePlacementMoveTable).values({
198
+ messageId: MESSAGE_ID,
199
+ accountId: ACCOUNT_ID,
200
+ accountConfigId: ACCOUNT_CONFIG_ID,
201
+ sourceMailboxId: MAILBOX_ID,
202
+ destinationMailboxId: OTHER_MAILBOX_ID,
203
+ state: "pending",
204
+ createdAt: NOW,
205
+ updatedAt: NOW,
206
+ });
207
+ await db.insert(messageFlagPushTable).values([
208
+ {
209
+ messageId: MESSAGE_ID,
210
+ flagName: "\\Seen",
211
+ accountId: ACCOUNT_ID,
212
+ accountConfigId: ACCOUNT_CONFIG_ID,
213
+ mailboxId: MAILBOX_ID,
214
+ operation: "add",
215
+ state: "pending",
216
+ createdAt: NOW,
217
+ updatedAt: NOW,
218
+ },
219
+ {
220
+ messageId: MESSAGE_ID,
221
+ flagName: "\\Flagged",
222
+ accountId: ACCOUNT_ID,
223
+ accountConfigId: ACCOUNT_CONFIG_ID,
224
+ mailboxId: MAILBOX_ID,
225
+ operation: "remove",
226
+ state: "pending",
227
+ createdAt: NOW,
228
+ updatedAt: NOW,
229
+ },
230
+ ]);
231
+ });
232
+
233
+ after(async () => {
234
+ await close();
235
+ });
236
+
237
+ const entities: CascadeEntity[] = [
238
+ // Message + its enumerated children — the children are removed by message id.
239
+ { entityType: "Message", key: { messageId: MESSAGE_ID } },
240
+ { entityType: "MessageFlag", key: { messageFlagId: MESSAGE_FLAG_ID } },
241
+ { entityType: "Envelope", key: { envelopeId: ENVELOPE_ID } },
242
+ { entityType: "BodyPart", key: { bodyPartId: BODY_PART_ID } },
243
+ {
244
+ entityType: "ThreadMessage",
245
+ key: {
246
+ accountConfigId: ACCOUNT_CONFIG_ID,
247
+ threadMessageId: THREAD_MESSAGE_ID,
248
+ },
249
+ },
250
+ { entityType: "Mailbox", key: { mailboxId: MAILBOX_ID } },
251
+ {
252
+ entityType: "OutboxMessage",
253
+ key: { outboxMessageId: OUTBOX_MESSAGE_ID },
254
+ },
255
+ {
256
+ entityType: "MailboxLock",
257
+ key: { mailboxId: MAILBOX_ID, eventName: "SYNC_MESSAGES" },
258
+ },
259
+ {
260
+ entityType: "AccountSetting",
261
+ key: { accountSettingId: ACCOUNT_SETTING_ID },
262
+ },
263
+ { entityType: "Address", key: { addressId: ADDRESS_ID } },
264
+ { entityType: "Account", key: { accountId: ACCOUNT_ID } },
265
+ {
266
+ entityType: "MessagePlacementMove",
267
+ key: { messageId: MESSAGE_ID },
268
+ },
269
+ {
270
+ entityType: "MessageFlagPush",
271
+ key: { messageId: MESSAGE_ID, flagName: "\\Seen" },
272
+ },
273
+ {
274
+ entityType: "MessageFlagPush",
275
+ key: { messageId: MESSAGE_ID, flagName: "\\Flagged" },
276
+ },
277
+ ];
278
+
279
+ test("deletes every enumerated row across all tables", async () => {
280
+ await runDrizzleCascadeDelete(db as never, entities, log);
281
+
282
+ assert.equal(
283
+ (
284
+ await db
285
+ .select()
286
+ .from(messageTable)
287
+ .where(eq(messageTable.messageId, MESSAGE_ID))
288
+ ).length,
289
+ 0,
290
+ );
291
+ assert.equal(
292
+ (
293
+ await db
294
+ .select()
295
+ .from(messageFlagTable)
296
+ .where(eq(messageFlagTable.messageId, MESSAGE_ID))
297
+ ).length,
298
+ 0,
299
+ );
300
+ assert.equal(
301
+ (
302
+ await db
303
+ .select()
304
+ .from(bodyPartTable)
305
+ .where(eq(bodyPartTable.messageId, MESSAGE_ID))
306
+ ).length,
307
+ 0,
308
+ );
309
+ assert.equal(
310
+ (
311
+ await db
312
+ .select()
313
+ .from(envelopeTable)
314
+ .where(eq(envelopeTable.messageId, MESSAGE_ID))
315
+ ).length,
316
+ 0,
317
+ );
318
+ assert.equal(
319
+ (
320
+ await db
321
+ .select()
322
+ .from(threadMessageTable)
323
+ .where(eq(threadMessageTable.threadMessageId, THREAD_MESSAGE_ID))
324
+ ).length,
325
+ 0,
326
+ );
327
+ assert.equal(
328
+ (
329
+ await db
330
+ .select()
331
+ .from(mailboxTable)
332
+ .where(eq(mailboxTable.mailboxId, MAILBOX_ID))
333
+ ).length,
334
+ 0,
335
+ );
336
+ assert.equal(
337
+ (
338
+ await db
339
+ .select()
340
+ .from(outboxMessageTable)
341
+ .where(eq(outboxMessageTable.outboxMessageId, OUTBOX_MESSAGE_ID))
342
+ ).length,
343
+ 0,
344
+ );
345
+ assert.equal(
346
+ (
347
+ await db
348
+ .select()
349
+ .from(mailboxLockTable)
350
+ .where(eq(mailboxLockTable.mailboxId, MAILBOX_ID))
351
+ ).length,
352
+ 0,
353
+ );
354
+ assert.equal(
355
+ (
356
+ await db
357
+ .select()
358
+ .from(accountSettingTable)
359
+ .where(eq(accountSettingTable.accountSettingId, ACCOUNT_SETTING_ID))
360
+ ).length,
361
+ 0,
362
+ );
363
+ assert.equal(
364
+ (
365
+ await db
366
+ .select()
367
+ .from(addressTable)
368
+ .where(eq(addressTable.addressId, ADDRESS_ID))
369
+ ).length,
370
+ 0,
371
+ );
372
+ assert.equal(
373
+ (
374
+ await db
375
+ .select()
376
+ .from(accountTable)
377
+ .where(eq(accountTable.accountId, ACCOUNT_ID))
378
+ ).length,
379
+ 0,
380
+ );
381
+ assert.equal(
382
+ (
383
+ await db
384
+ .select()
385
+ .from(messagePlacementMoveTable)
386
+ .where(eq(messagePlacementMoveTable.messageId, MESSAGE_ID))
387
+ ).length,
388
+ 0,
389
+ );
390
+ assert.equal(
391
+ (
392
+ await db
393
+ .select()
394
+ .from(messageFlagPushTable)
395
+ .where(eq(messageFlagPushTable.messageId, MESSAGE_ID))
396
+ ).length,
397
+ 0,
398
+ "both the Seen and Flagged markers (composite key) must be deleted",
399
+ );
400
+ });
401
+
402
+ test("emits a message.removed outbox row for search-index cleanup", async () => {
403
+ const rows = await db
404
+ .select()
405
+ .from(outboxTable)
406
+ .where(eq(outboxTable.messageId, MESSAGE_ID));
407
+ assert.equal(rows.length, 1);
408
+ assert.equal(rows[0].event, "message.removed");
409
+ assert.equal(rows[0].processedAt, null);
410
+ });
411
+
412
+ test("rejects an unknown entity type without deleting", async () => {
413
+ await assert.rejects(
414
+ () =>
415
+ runDrizzleCascadeDelete(
416
+ db as never,
417
+ [{ entityType: "Nonsense", key: { id: "x" } }],
418
+ log,
419
+ ),
420
+ /Unknown entity type in cascade: Nonsense/,
421
+ );
422
+ });
423
+ });
@@ -0,0 +1,219 @@
1
+ import { and, eq, inArray, or, type SQL } from "drizzle-orm";
2
+ import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { accountTable } from "../schema/i4-account-config.js";
4
+ import { accountSettingTable } from "../schema/i4-account-setting.js";
5
+ import { addressTable } from "../schema/i4-address.js";
6
+ import { mailboxTable } from "../schema/i4-mailbox.js";
7
+ import { mailboxLockTable } from "../schema/i4-mailbox-lock.js";
8
+ import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
9
+ import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
10
+ import { outboxMessageTable } from "../schema/i4-outbox-message.js";
11
+ import { messageDataSchema } from "../schema/message-data.js";
12
+ import { threadMessageTable } from "../schema/thread-message.js";
13
+ import { createSqliteDatabase } from "../sqlite-client.js";
14
+ import { runInTransaction } from "../tx.js";
15
+ import { deleteMessageSubtree, type SubtreeDb } from "./message.js";
16
+
17
+ export interface CascadeEntity {
18
+ entityType: string;
19
+ key: Record<string, string>;
20
+ }
21
+
22
+ export interface CascadeDeleteLogger {
23
+ info(obj: Record<string, unknown>, msg: string): void;
24
+ }
25
+
26
+ type CascadeDb = NodePgDatabase<Record<string, unknown>>;
27
+
28
+ /**
29
+ * Per-message entity types. Their rows are removed with the message subtree by
30
+ * message id, so their individually-enumerated keys are not deleted one by one —
31
+ * the DynamoDB cascade enumerates them only because BatchWriteItem needs each
32
+ * full key; Postgres deletes them set-wise by message id.
33
+ */
34
+ const MESSAGE_CHILD_TYPES = new Set([
35
+ "MessageFlag",
36
+ "MessageReference",
37
+ "EnvelopeAddress",
38
+ "Envelope",
39
+ "BodyPart",
40
+ "BodyPartParameter",
41
+ "BodyPartStorage",
42
+ "BodyPartContent",
43
+ "RawMessageStorage",
44
+ ]);
45
+
46
+ const KEYED_TYPES = new Set([
47
+ "Message",
48
+ "ThreadMessage",
49
+ "Mailbox",
50
+ "OutboxMessage",
51
+ "MailboxLock",
52
+ "AccountSetting",
53
+ "Address",
54
+ "Account",
55
+ "MessagePlacementMove",
56
+ "MessageFlagPush",
57
+ ]);
58
+
59
+ const groupByType = (
60
+ entities: CascadeEntity[],
61
+ ): Map<string, Record<string, string>[]> => {
62
+ const grouped = new Map<string, Record<string, string>[]>();
63
+ for (const entity of entities) {
64
+ const list = grouped.get(entity.entityType) ?? [];
65
+ list.push(entity.key);
66
+ grouped.set(entity.entityType, list);
67
+ }
68
+ return grouped;
69
+ };
70
+
71
+ /**
72
+ * Postgres equivalent of the DynamoDB BatchWriteItem cascade: delete every
73
+ * enumerated row for an account/tenant in one transaction. Message subtrees
74
+ * (message + nine child tables) are deleted set-wise by message id and emit a
75
+ * `message.removed` outbox row each for search-index cleanup; the remaining
76
+ * entity types are deleted by their enumerated primary keys. AccountConfig is
77
+ * never deleted here — the caller removes it last through its repository, the
78
+ * cascade-in-progress marker.
79
+ */
80
+ export const runDrizzleCascadeDelete = async (
81
+ db: CascadeDb,
82
+ entities: CascadeEntity[],
83
+ log: CascadeDeleteLogger,
84
+ ): Promise<void> => {
85
+ const grouped = groupByType(entities);
86
+
87
+ for (const entityType of grouped.keys()) {
88
+ if (MESSAGE_CHILD_TYPES.has(entityType)) continue;
89
+ if (!KEYED_TYPES.has(entityType)) {
90
+ throw new Error(`Unknown entity type in cascade: ${entityType}`);
91
+ }
92
+ }
93
+
94
+ await runInTransaction(db, async (tx) => {
95
+ const messageIds = (grouped.get("Message") ?? []).map((k) => k.messageId);
96
+ await deleteMessageSubtree(tx as unknown as SubtreeDb, messageIds);
97
+
98
+ const threadMessageIds = (grouped.get("ThreadMessage") ?? []).map(
99
+ (k) => k.threadMessageId,
100
+ );
101
+ if (threadMessageIds.length > 0) {
102
+ await tx
103
+ .delete(threadMessageTable)
104
+ .where(inArray(threadMessageTable.threadMessageId, threadMessageIds));
105
+ }
106
+
107
+ const mailboxIds = (grouped.get("Mailbox") ?? []).map((k) => k.mailboxId);
108
+ if (mailboxIds.length > 0) {
109
+ await tx
110
+ .delete(mailboxTable)
111
+ .where(inArray(mailboxTable.mailboxId, mailboxIds));
112
+ }
113
+
114
+ const outboxMessageIds = (grouped.get("OutboxMessage") ?? []).map(
115
+ (k) => k.outboxMessageId,
116
+ );
117
+ if (outboxMessageIds.length > 0) {
118
+ await tx
119
+ .delete(outboxMessageTable)
120
+ .where(inArray(outboxMessageTable.outboxMessageId, outboxMessageIds));
121
+ }
122
+
123
+ const lockKeys = grouped.get("MailboxLock") ?? [];
124
+ if (lockKeys.length > 0) {
125
+ const conditions: SQL[] = lockKeys.map((k) =>
126
+ and(
127
+ eq(mailboxLockTable.mailboxId, k.mailboxId),
128
+ eq(mailboxLockTable.eventName, k.eventName),
129
+ ),
130
+ ) as SQL[];
131
+ await tx.delete(mailboxLockTable).where(or(...conditions));
132
+ }
133
+
134
+ const accountSettingIds = (grouped.get("AccountSetting") ?? []).map(
135
+ (k) => k.accountSettingId,
136
+ );
137
+ if (accountSettingIds.length > 0) {
138
+ await tx
139
+ .delete(accountSettingTable)
140
+ .where(
141
+ inArray(accountSettingTable.accountSettingId, accountSettingIds),
142
+ );
143
+ }
144
+
145
+ const addressIds = (grouped.get("Address") ?? []).map((k) => k.addressId);
146
+ if (addressIds.length > 0) {
147
+ await tx
148
+ .delete(addressTable)
149
+ .where(inArray(addressTable.addressId, addressIds));
150
+ }
151
+
152
+ const accountIds = (grouped.get("Account") ?? []).map((k) => k.accountId);
153
+ if (accountIds.length > 0) {
154
+ await tx
155
+ .delete(accountTable)
156
+ .where(inArray(accountTable.accountId, accountIds));
157
+ }
158
+
159
+ const placementMoveMessageIds = (
160
+ grouped.get("MessagePlacementMove") ?? []
161
+ ).map((k) => k.messageId);
162
+ if (placementMoveMessageIds.length > 0) {
163
+ await tx
164
+ .delete(messagePlacementMoveTable)
165
+ .where(
166
+ inArray(messagePlacementMoveTable.messageId, placementMoveMessageIds),
167
+ );
168
+ }
169
+
170
+ // Composite key (messageId, flagName) — a message can carry an
171
+ // independent pending read-state marker and pending star marker at once
172
+ // (issue #1273), so a single-column inArray cannot target one row.
173
+ const flagPushKeys = grouped.get("MessageFlagPush") ?? [];
174
+ if (flagPushKeys.length > 0) {
175
+ const conditions: SQL[] = flagPushKeys.map((k) =>
176
+ and(
177
+ eq(messageFlagPushTable.messageId, k.messageId),
178
+ eq(messageFlagPushTable.flagName, k.flagName),
179
+ ),
180
+ ) as SQL[];
181
+ await tx.delete(messageFlagPushTable).where(or(...conditions));
182
+ }
183
+ });
184
+
185
+ log.info({ entityCount: entities.length }, "Drizzle cascade delete complete");
186
+ };
187
+
188
+ export type CascadeDeleter = (
189
+ entities: CascadeEntity[],
190
+ log: CascadeDeleteLogger,
191
+ ) => Promise<void>;
192
+
193
+ /**
194
+ * Bind a cascade deleter to one Postgres connection. The account worker builds
195
+ * this once at module load on the `DATA_BACKEND=postgres` path and reuses it
196
+ * across invocations; the underlying pool is shared with no per-purge setup.
197
+ */
198
+ export const createCascadeDeleter = (connectionUrl: string): CascadeDeleter => {
199
+ const db = drizzle(connectionUrl) as CascadeDb;
200
+ return (entities, log) => runDrizzleCascadeDelete(db, entities, log);
201
+ };
202
+
203
+ /**
204
+ * SQLite twin of {@link createCascadeDeleter} (RFC 036). Opens the shared SQLite
205
+ * file through the same serialized connection every writer uses and binds a
206
+ * cascade deleter to it. `runDrizzleCascadeDelete` deletes through the
207
+ * dialect-selected entity tables (`active-entities`, SQLite on this backend), so
208
+ * the same enumerated `CascadeEntity[]` drives an identical cascade over SQLite.
209
+ * Async because opening the SQLite handle loads the native binding lazily.
210
+ */
211
+ export const createSqliteCascadeDeleter = async (
212
+ dbPath: string,
213
+ ): Promise<CascadeDeleter> => {
214
+ const { db } = await createSqliteDatabase(messageDataSchema, {
215
+ filename: dbPath,
216
+ });
217
+ const cascadeDb = db as unknown as CascadeDb;
218
+ return (entities, log) => runDrizzleCascadeDelete(cascadeDb, entities, log);
219
+ };