@remit/drizzle-service 0.0.98 → 0.0.100

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 (93) hide show
  1. package/package.json +1 -1
  2. package/src/batch.sqlite.test.ts +78 -0
  3. package/src/batch.ts +45 -0
  4. package/src/databases.ts +70 -0
  5. package/src/db.ts +1 -4
  6. package/src/generated-enum.test.ts +8 -0
  7. package/src/generated-id.test.ts +19 -0
  8. package/src/id.test.ts +0 -8
  9. package/src/id.ts +0 -6
  10. package/src/index.ts +16 -6
  11. package/src/repair/address-display-name.sqlite.test.ts +3 -5
  12. package/src/repair/address-display-name.ts +8 -3
  13. package/src/repair/junk-only-address.sqlite.test.ts +8 -36
  14. package/src/repair/stranded-sent-outbox.sqlite.test.ts +2 -9
  15. package/src/repos/account.conformance.sqlite.test.ts +1 -1
  16. package/src/repos/calendar-default.sqlite.test.ts +6 -6
  17. package/src/repos/calendar-put.sqlite.test.ts +40 -39
  18. package/src/repos/calendar-store.ts +273 -0
  19. package/src/repos/cascade-delete.sqlite.test.ts +7 -9
  20. package/src/repos/cascade-delete.test.ts +6 -13
  21. package/src/repos/cascade-delete.ts +127 -97
  22. package/src/repos/envelope.test.ts +1 -17
  23. package/src/repos/envelope.ts +83 -67
  24. package/src/repos/filter.test.ts +22 -2
  25. package/src/repos/filter.ts +1 -5
  26. package/src/repos/i4-account-config.test.ts +2 -2
  27. package/src/repos/i4-account-config.ts +5 -2
  28. package/src/repos/i4-account.test.ts +6 -2
  29. package/src/repos/i4-account.ts +12 -56
  30. package/src/repos/i4-address-junk-move.sqlite.test.ts +1 -1
  31. package/src/repos/i4-address-merge-flags.sqlite.test.ts +1 -2
  32. package/src/repos/i4-address.ts +58 -90
  33. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +6 -2
  34. package/src/repos/i4-mailbox-special-use.ts +9 -6
  35. package/src/repos/i4-mailbox.sqlite.test.ts +12 -14
  36. package/src/repos/i4-mailbox.test.ts +17 -62
  37. package/src/repos/i4-mailbox.ts +121 -94
  38. package/src/repos/i4-organize-job-request.ts +0 -4
  39. package/src/repos/i4-outbox-attachment.ts +62 -33
  40. package/src/repos/mappers.ts +0 -16
  41. package/src/repos/message.sqlite.test.ts +62 -102
  42. package/src/repos/message.test.ts +6 -167
  43. package/src/repos/message.ts +227 -218
  44. package/src/repos/sync-warning.ts +76 -0
  45. package/src/repos/thread-message-body-muted.sqlite.test.ts +5 -10
  46. package/src/repos/thread-message-field-terms.sqlite.test.ts +5 -1
  47. package/src/repos/thread-message.sqlite.test.ts +2 -1
  48. package/src/repos/thread-message.test.ts +17 -4
  49. package/src/repos/thread-message.ts +502 -285
  50. package/src/repos/thread-search-predicates.ts +24 -26
  51. package/src/schema/message-data.ts +4 -8
  52. package/src/schema/sync-warning.ts +3 -0
  53. package/src/schema-sets/app.ts +17 -0
  54. package/src/schema-sets/mail.ts +32 -0
  55. package/src/schema-sets/search.ts +5 -0
  56. package/src/schema-version-accounting.sqlite.test.ts +56 -43
  57. package/src/schema.ts +3 -4
  58. package/src/search/thread-message-search.sqlite.test.ts +131 -0
  59. package/src/search/thread-message-search.ts +271 -0
  60. package/src/sqlite-client.ts +5 -7
  61. package/src/sqlite-repositories.ts +75 -0
  62. package/src/test-db-sqlite.ts +5 -13
  63. package/src/test-shipped-sqlite-schema.ts +46 -45
  64. package/src/test-sqlite.ts +35 -11
  65. package/src/vps-migrations-drift.sqlite.test.ts +81 -15
  66. package/src/account-granted-scopes-migration.sqlite.test.ts +0 -66
  67. package/src/account-synced-services-migration.sqlite.test.ts +0 -56
  68. package/src/mailbox-path-dedupe.sqlite.test.ts +0 -462
  69. package/src/mailbox-sync-status-backfill.sqlite.test.ts +0 -118
  70. package/src/outbox-created-cleanup.sqlite.test.ts +0 -83
  71. package/src/repair/duplicate-mailbox-mail.ts +0 -98
  72. package/src/repair/thread-message-category-contract.ts +0 -505
  73. package/src/repair/thread-message-category.sqlite.test.ts +0 -100
  74. package/src/repair/thread-message-category.ts +0 -415
  75. package/src/repos/calendar-migrations.sqlite.test.ts +0 -345
  76. package/src/repos/calendar-unit-of-work.ts +0 -31
  77. package/src/repos/config-import-atomicity.sqlite.test.ts +0 -255
  78. package/src/repos/filter-anchor-transaction.test.ts +0 -136
  79. package/src/repos/filter-anchor-transaction.ts +0 -45
  80. package/src/repos/filter-anchor.test.ts +0 -131
  81. package/src/repos/filter-anchor.ts +0 -105
  82. package/src/repos/i4-mailbox-special-use.structure.test.ts +0 -27
  83. package/src/repos/message-placement-backfill.sqlite.test.ts +0 -190
  84. package/src/repos/search-index-shape.test.ts +0 -82
  85. package/src/repos/search-index-shape.ts +0 -56
  86. package/src/repos/serialized-writes.sqlite.test.ts +0 -199
  87. package/src/repos/unit-of-work.sqlite.test.ts +0 -138
  88. package/src/repos/unit-of-work.test.ts +0 -109
  89. package/src/repos/unit-of-work.ts +0 -31
  90. package/src/schema/outbox.ts +0 -46
  91. package/src/schema-full-sqlite.ts +0 -13
  92. package/src/tx.sqlite.test.ts +0 -83
  93. package/src/tx.ts +0 -233
@@ -1,12 +1,14 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
3
  import {
4
+ prepareCalendarObject,
4
5
  provisionDefaultCalendar,
5
6
  putCalendarObject,
6
7
  } from "@remit/calendar-service";
7
8
  import type {
8
- CalendarUnitOfWorkRepositories,
9
- ICalendarUnitOfWork,
9
+ CalendarRepositories,
10
+ CalendarWrite,
11
+ ICalendarStore,
10
12
  } from "@remit/data-ports";
11
13
  import type { Db } from "../db.js";
12
14
  import {
@@ -15,7 +17,7 @@ import {
15
17
  calendarTable,
16
18
  } from "../schema.js";
17
19
  import { createSqliteTestDb } from "../test-db-sqlite.js";
18
- import { DrizzleCalendarUnitOfWork } from "./calendar-unit-of-work.js";
20
+ import { DrizzleCalendarStore } from "./calendar-store.js";
19
21
 
20
22
  const ACCOUNT_CONFIG_ID = "account-config-1";
21
23
 
@@ -34,35 +36,37 @@ const RESOURCE = [
34
36
  ].join("\r\n");
35
37
 
36
38
  /**
37
- * Fails the third of the write set's three writes. The occurrence rows are
38
- * written last, so a failure there is the case that proves the boundary: the
39
- * object row and the sequence bump are already in the transaction when it
40
- * happens.
39
+ * Appends a write that fails to every commit: a resource put into a collection
40
+ * that does not exist, which the store refuses at its last statement. Every
41
+ * statement of the real write set has run by then, so a failure there is the
42
+ * case that proves the boundary.
41
43
  */
42
- class FailingIndexUnitOfWork implements ICalendarUnitOfWork {
43
- constructor(private inner: ICalendarUnitOfWork) {}
44
-
45
- transaction<T>(
46
- fn: (repos: CalendarUnitOfWorkRepositories) => Promise<T>,
47
- ): Promise<T> {
48
- return this.inner.transaction((repos) =>
49
- fn({
50
- ...repos,
51
- calendarEventIndex: {
52
- ...repos.calendarEventIndex,
53
- replaceForObject: () => {
54
- throw new Error("the index write failed");
55
- },
56
- },
57
- }),
44
+ class FailingLastWriteStore implements ICalendarStore {
45
+ constructor(private inner: ICalendarStore) {}
46
+
47
+ get repos(): CalendarRepositories {
48
+ return this.inner.repos;
49
+ }
50
+
51
+ async commit(writes: readonly CalendarWrite[]): Promise<void> {
52
+ const orphan = await prepareCalendarObject(
53
+ { timezone: "" },
54
+ {
55
+ accountConfigId: ACCOUNT_CONFIG_ID,
56
+ calendarId: "no-such-calendar",
57
+ resourceName: "orphan.ics",
58
+ icalData: RESOURCE,
59
+ },
58
60
  );
61
+ assert.ok(orphan.ok);
62
+ await this.inner.commit([...writes, orphan.value]);
59
63
  }
60
64
  }
61
65
 
62
66
  describe("the calendar write path against sqlite", () => {
63
67
  let db: Db<Record<string, unknown>>;
64
68
  let close: () => Promise<void>;
65
- let unitOfWork: ICalendarUnitOfWork;
69
+ let store: ICalendarStore;
66
70
  let calendarId: string;
67
71
 
68
72
  before(async () => {
@@ -73,11 +77,8 @@ describe("the calendar write path against sqlite", () => {
73
77
  });
74
78
  db = created.db as unknown as Db<Record<string, unknown>>;
75
79
  close = created.close;
76
- unitOfWork = new DrizzleCalendarUnitOfWork(db);
77
- const collection = await provisionDefaultCalendar(
78
- unitOfWork,
79
- ACCOUNT_CONFIG_ID,
80
- );
80
+ store = new DrizzleCalendarStore(db, db);
81
+ const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
81
82
  calendarId = collection.calendarId;
82
83
  });
83
84
 
@@ -87,7 +88,7 @@ describe("the calendar write path against sqlite", () => {
87
88
  const occurrences = () => db.select().from(calendarEventIndexTable);
88
89
 
89
90
  test("writes the object, its occurrences and the sequence bump together", async () => {
90
- const result = await putCalendarObject(unitOfWork, {
91
+ const result = await putCalendarObject(store, {
91
92
  accountConfigId: ACCOUNT_CONFIG_ID,
92
93
  calendarId,
93
94
  resourceName: "review.ics",
@@ -111,17 +112,17 @@ describe("the calendar write path against sqlite", () => {
111
112
  );
112
113
  });
113
114
 
114
- test("a failing index write leaves no object behind", async () => {
115
+ test("a failing write in the batch leaves no object behind", async () => {
115
116
  const before = await objects();
116
117
 
117
118
  await assert.rejects(
118
- putCalendarObject(new FailingIndexUnitOfWork(unitOfWork), {
119
+ putCalendarObject(new FailingLastWriteStore(store), {
119
120
  accountConfigId: ACCOUNT_CONFIG_ID,
120
121
  calendarId,
121
122
  resourceName: "doomed.ics",
122
123
  icalData: RESOURCE,
123
124
  }),
124
- /the index write failed/,
125
+ /NOT NULL constraint failed/,
125
126
  );
126
127
 
127
128
  const after = await objects();
@@ -132,17 +133,17 @@ describe("the calendar write path against sqlite", () => {
132
133
  );
133
134
  });
134
135
 
135
- test("a failing index write leaves the collection's sequence where it was", async () => {
136
+ test("a failing write in the batch leaves the collection's sequence where it was", async () => {
136
137
  const [before] = await db.select().from(calendarTable);
137
138
 
138
139
  await assert.rejects(
139
- putCalendarObject(new FailingIndexUnitOfWork(unitOfWork), {
140
+ putCalendarObject(new FailingLastWriteStore(store), {
140
141
  accountConfigId: ACCOUNT_CONFIG_ID,
141
142
  calendarId,
142
143
  resourceName: "doomed-again.ics",
143
144
  icalData: RESOURCE,
144
145
  }),
145
- /the index write failed/,
146
+ /NOT NULL constraint failed/,
146
147
  );
147
148
 
148
149
  const [after] = await db.select().from(calendarTable);
@@ -153,7 +154,7 @@ describe("the calendar write path against sqlite", () => {
153
154
  const before = await objects();
154
155
  const [collectionBefore] = await db.select().from(calendarTable);
155
156
 
156
- const result = await putCalendarObject(unitOfWork, {
157
+ const result = await putCalendarObject(store, {
157
158
  accountConfigId: ACCOUNT_CONFIG_ID,
158
159
  calendarId,
159
160
  resourceName: "backwards.ics",
@@ -178,7 +179,7 @@ describe("the calendar write path against sqlite", () => {
178
179
  });
179
180
 
180
181
  test("rewriting a resource replaces its occurrences rather than adding to them", async () => {
181
- const first = await putCalendarObject(unitOfWork, {
182
+ const first = await putCalendarObject(store, {
182
183
  accountConfigId: ACCOUNT_CONFIG_ID,
183
184
  calendarId,
184
185
  resourceName: "shrinking.ics",
@@ -186,7 +187,7 @@ describe("the calendar write path against sqlite", () => {
186
187
  });
187
188
  assert.ok(first.ok);
188
189
 
189
- const second = await putCalendarObject(unitOfWork, {
190
+ const second = await putCalendarObject(store, {
190
191
  accountConfigId: ACCOUNT_CONFIG_ID,
191
192
  calendarId,
192
193
  resourceName: "shrinking.ics",
@@ -0,0 +1,273 @@
1
+ import type {
2
+ CalendarRepositories,
3
+ CalendarWrite,
4
+ ICalendarStore,
5
+ } from "@remit/data-ports";
6
+ import { deriveCalendarObjectId } from "@remit/data-ports/id";
7
+ import { and, eq, exists, sql } from "drizzle-orm";
8
+ import { runBatch, type SqliteBatchItem } from "../batch.js";
9
+ import type { Db } from "../db.js";
10
+ import {
11
+ calendarEventIndexTable,
12
+ calendarFeedTokenTable,
13
+ calendarObjectTable,
14
+ calendarSuggestionTable,
15
+ calendarTable,
16
+ } from "../schema.js";
17
+ import { CalendarCollectionRepo } from "./calendar-collection.js";
18
+ import { CalendarEventIndexRepo } from "./calendar-event-index.js";
19
+ import { CalendarFeedTokenRepo } from "./calendar-feed-token.js";
20
+ import { CalendarObjectRepo } from "./calendar-object.js";
21
+ import { CalendarSuggestionRepo } from "./calendar-suggestion.js";
22
+
23
+ type DB = Db<Record<string, unknown>>;
24
+
25
+ export class DrizzleCalendarStore implements ICalendarStore {
26
+ readonly repos: CalendarRepositories;
27
+
28
+ constructor(
29
+ private db: DB,
30
+ private mail: DB,
31
+ ) {
32
+ this.repos = {
33
+ calendarCollection: new CalendarCollectionRepo(db),
34
+ calendarObject: new CalendarObjectRepo(db),
35
+ calendarEventIndex: new CalendarEventIndexRepo(db),
36
+ calendarSuggestion: new CalendarSuggestionRepo(mail),
37
+ calendarFeedToken: new CalendarFeedTokenRepo(db),
38
+ };
39
+ }
40
+
41
+ async commit(writes: readonly CalendarWrite[]): Promise<void> {
42
+ const settlements = writes.filter(
43
+ (write) => write.kind === "SettleSuggestion",
44
+ );
45
+ await runBatch(
46
+ this.db,
47
+ writes
48
+ .filter((write) => write.kind !== "SettleSuggestion")
49
+ .flatMap((write) => this.statements(write)),
50
+ );
51
+ await runBatch(
52
+ this.mail,
53
+ settlements.flatMap((write) => this.statements(write)),
54
+ );
55
+ }
56
+
57
+ private collection(accountConfigId: string, calendarId: string) {
58
+ return and(
59
+ eq(calendarTable.accountConfigId, accountConfigId),
60
+ eq(calendarTable.calendarId, calendarId),
61
+ );
62
+ }
63
+
64
+ /**
65
+ * The sequence the bump just wrote, read inside the batch. A collection that
66
+ * is gone yields NULL, which the NOT NULL column refuses, so the whole batch
67
+ * fails rather than storing an orphan.
68
+ */
69
+ private bumpedSequence(accountConfigId: string, calendarId: string) {
70
+ return sql<number>`(${this.db
71
+ .select({ syncSequence: calendarTable.syncSequence })
72
+ .from(calendarTable)
73
+ .where(this.collection(accountConfigId, calendarId))})`;
74
+ }
75
+
76
+ private bump(accountConfigId: string, calendarId: string): SqliteBatchItem {
77
+ return this.db
78
+ .update(calendarTable)
79
+ .set({
80
+ syncSequence: sql`${calendarTable.syncSequence} + 1`,
81
+ updatedAt: Date.now(),
82
+ })
83
+ .where(this.collection(accountConfigId, calendarId));
84
+ }
85
+
86
+ private clearOccurrences(
87
+ calendarId: string,
88
+ calendarObjectId: string,
89
+ ): SqliteBatchItem {
90
+ return this.db
91
+ .delete(calendarEventIndexTable)
92
+ .where(
93
+ and(
94
+ eq(calendarEventIndexTable.calendarId, calendarId),
95
+ eq(calendarEventIndexTable.calendarObjectId, calendarObjectId),
96
+ ),
97
+ );
98
+ }
99
+
100
+ private statements(write: CalendarWrite): SqliteBatchItem[] {
101
+ const now = Date.now();
102
+ switch (write.kind) {
103
+ case "PutObject": {
104
+ const { calendarId, resourceName } = write.object;
105
+ const calendarObjectId = deriveCalendarObjectId(
106
+ calendarId,
107
+ resourceName,
108
+ );
109
+ const syncSequence = this.bumpedSequence(
110
+ write.accountConfigId,
111
+ calendarId,
112
+ );
113
+ const items: SqliteBatchItem[] = [
114
+ this.bump(write.accountConfigId, calendarId),
115
+ this.db
116
+ .insert(calendarObjectTable)
117
+ .values({
118
+ ...write.object,
119
+ calendarObjectId,
120
+ syncSequence,
121
+ createdAt: now,
122
+ updatedAt: now,
123
+ })
124
+ .onConflictDoUpdate({
125
+ target: calendarObjectTable.calendarObjectId,
126
+ set: { ...write.object, syncSequence, updatedAt: now },
127
+ }),
128
+ this.clearOccurrences(calendarId, calendarObjectId),
129
+ ];
130
+ if (write.occurrences.length === 0) return items;
131
+ return [
132
+ ...items,
133
+ this.db.insert(calendarEventIndexTable).values(
134
+ write.occurrences.map((occurrence) => ({
135
+ ...occurrence,
136
+ calendarId,
137
+ calendarObjectId,
138
+ createdAt: now,
139
+ updatedAt: now,
140
+ })),
141
+ ),
142
+ ];
143
+ }
144
+ case "ReprojectObject": {
145
+ const { calendarId, resourceName } = write.object;
146
+ const calendarObjectId = deriveCalendarObjectId(
147
+ calendarId,
148
+ resourceName,
149
+ );
150
+ // Every statement reads the object it rewrites at the version the
151
+ // caller read, so a resource edited or deleted since writes nothing.
152
+ const asRead = and(
153
+ eq(calendarObjectTable.calendarObjectId, calendarObjectId),
154
+ eq(calendarObjectTable.etag, write.readEtag),
155
+ );
156
+ const unchanged = exists(
157
+ this.db
158
+ .select({ id: calendarObjectTable.calendarObjectId })
159
+ .from(calendarObjectTable)
160
+ .where(asRead),
161
+ );
162
+ const value = <T>(field: T, name: string) => sql<T>`${field}`.as(name);
163
+ return [
164
+ this.bump(write.accountConfigId, calendarId),
165
+ this.db
166
+ .update(calendarObjectTable)
167
+ .set({
168
+ ...write.object,
169
+ syncSequence: this.bumpedSequence(
170
+ write.accountConfigId,
171
+ calendarId,
172
+ ),
173
+ updatedAt: now,
174
+ })
175
+ .where(asRead),
176
+ this.db
177
+ .delete(calendarEventIndexTable)
178
+ .where(
179
+ and(
180
+ eq(calendarEventIndexTable.calendarId, calendarId),
181
+ eq(calendarEventIndexTable.calendarObjectId, calendarObjectId),
182
+ unchanged,
183
+ ),
184
+ ),
185
+ ...write.occurrences.map((occurrence) =>
186
+ this.db.insert(calendarEventIndexTable).select(
187
+ this.db
188
+ .select({
189
+ calendarId: value(calendarId, "calendar_id"),
190
+ calendarObjectId: value(
191
+ calendarObjectId,
192
+ "calendar_object_id",
193
+ ),
194
+ recurrenceId: value(occurrence.recurrenceId, "recurrence_id"),
195
+ startAt: value(occurrence.startAt, "start_at"),
196
+ endAt: value(occurrence.endAt, "end_at"),
197
+ allDay: value(occurrence.allDay ? 1 : 0, "all_day"),
198
+ summary: value(occurrence.summary, "summary"),
199
+ status: value(occurrence.status, "status"),
200
+ transparency: value(occurrence.transparency, "transparency"),
201
+ createdAt: value(now, "created_at"),
202
+ updatedAt: value(now, "updated_at"),
203
+ })
204
+ .from(calendarObjectTable)
205
+ .where(asRead),
206
+ ),
207
+ ),
208
+ ];
209
+ }
210
+ case "DeleteObject":
211
+ return [
212
+ this.clearOccurrences(write.calendarId, write.calendarObjectId),
213
+ this.db
214
+ .delete(calendarObjectTable)
215
+ .where(
216
+ and(
217
+ eq(calendarObjectTable.calendarId, write.calendarId),
218
+ eq(
219
+ calendarObjectTable.calendarObjectId,
220
+ write.calendarObjectId,
221
+ ),
222
+ ),
223
+ ),
224
+ this.bump(write.accountConfigId, write.calendarId),
225
+ ];
226
+ case "SettleSuggestion":
227
+ return [
228
+ this.mail
229
+ .update(calendarSuggestionTable)
230
+ .set({ ...write.settle, updatedAt: now })
231
+ .where(
232
+ and(
233
+ eq(
234
+ calendarSuggestionTable.accountConfigId,
235
+ write.accountConfigId,
236
+ ),
237
+ eq(calendarSuggestionTable.suggestionId, write.suggestionId),
238
+ ),
239
+ ),
240
+ ];
241
+ case "UpdateCollection":
242
+ return [
243
+ this.db
244
+ .update(calendarTable)
245
+ .set({ ...write.patch, updatedAt: now })
246
+ .where(this.collection(write.accountConfigId, write.calendarId)),
247
+ ];
248
+ case "DeleteCollection":
249
+ return [
250
+ this.db
251
+ .delete(calendarEventIndexTable)
252
+ .where(eq(calendarEventIndexTable.calendarId, write.calendarId)),
253
+ this.db
254
+ .delete(calendarObjectTable)
255
+ .where(eq(calendarObjectTable.calendarId, write.calendarId)),
256
+ this.db
257
+ .delete(calendarFeedTokenTable)
258
+ .where(
259
+ and(
260
+ eq(
261
+ calendarFeedTokenTable.accountConfigId,
262
+ write.accountConfigId,
263
+ ),
264
+ eq(calendarFeedTokenTable.calendarId, write.calendarId),
265
+ ),
266
+ ),
267
+ this.db
268
+ .delete(calendarTable)
269
+ .where(this.collection(write.accountConfigId, write.calendarId)),
270
+ ];
271
+ }
272
+ }
273
+ }
@@ -12,6 +12,7 @@ import {
12
12
  mailboxTable,
13
13
  messageTable,
14
14
  quarantineTable,
15
+ syncWarningTable,
15
16
  } from "../schema.js";
16
17
  import { createSqliteTestDb } from "../test-db-sqlite.js";
17
18
  import {
@@ -30,6 +31,7 @@ const CASCADE_SCHEMA = {
30
31
  account: accountTable,
31
32
  mailbox: mailboxTable,
32
33
  quarantine: quarantineTable,
34
+ syncWarning: syncWarningTable,
33
35
  };
34
36
 
35
37
  const log = { info: () => {} };
@@ -131,7 +133,11 @@ describe("createSqliteCascadeDeleter", () => {
131
133
  { entityType: "Account", key: { accountId: ACCOUNT_ID } },
132
134
  ];
133
135
 
134
- const deleter = await createSqliteCascadeDeleter(filename);
136
+ const deleter = await createSqliteCascadeDeleter({
137
+ app: filename,
138
+ mail: filename,
139
+ search: filename,
140
+ });
135
141
  await deleter(entities, log);
136
142
 
137
143
  const sqlite = new Database(filename);
@@ -150,14 +156,6 @@ describe("createSqliteCascadeDeleter", () => {
150
156
  );
151
157
  assert.equal(count("mailbox", "mailbox_id", MAILBOX_ID), 0, "mailbox gone");
152
158
  assert.equal(count("account", "account_id", ACCOUNT_ID), 0, "account gone");
153
-
154
- const outboxRows = (
155
- sqlite.prepare("SELECT COUNT(*) AS n FROM outbox").get() as { n: number }
156
- ).n;
157
- assert.ok(
158
- outboxRows >= 1,
159
- "a message-removed outbox row must be emitted for search-index cleanup",
160
- );
161
159
  sqlite.close();
162
160
  });
163
161
  });
@@ -15,7 +15,6 @@ import {
15
15
  messagePlacementMoveTable,
16
16
  messageTable,
17
17
  outboxMessageTable,
18
- outboxTable,
19
18
  quarantineTable,
20
19
  threadMessageTable,
21
20
  } from "../schema.js";
@@ -299,7 +298,11 @@ describe("runDrizzleCascadeDelete", () => {
299
298
  ];
300
299
 
301
300
  test("deletes every enumerated row across all tables", async () => {
302
- await runDrizzleCascadeDelete(db as never, entities, log);
301
+ await runDrizzleCascadeDelete(
302
+ { app: db, mail: db, search: db } as never,
303
+ entities,
304
+ log,
305
+ );
303
306
 
304
307
  assert.equal(
305
308
  (
@@ -431,21 +434,11 @@ describe("runDrizzleCascadeDelete", () => {
431
434
  );
432
435
  });
433
436
 
434
- test("emits a message.removed outbox row for search-index cleanup", async () => {
435
- const rows = await db
436
- .select()
437
- .from(outboxTable)
438
- .where(eq(outboxTable.messageId, MESSAGE_ID));
439
- assert.equal(rows.length, 1);
440
- assert.equal(rows[0].event, "message.removed");
441
- assert.equal(rows[0].processedAt, null);
442
- });
443
-
444
437
  test("rejects an unknown entity type without deleting", async () => {
445
438
  await assert.rejects(
446
439
  () =>
447
440
  runDrizzleCascadeDelete(
448
- db as never,
441
+ { app: db, mail: db, search: db } as never,
449
442
  [{ entityType: "Nonsense", key: { id: "x" } }],
450
443
  log,
451
444
  ),