@remit/drizzle-service 0.0.99 → 0.0.101

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 +46 -0
  4. package/src/databases.ts +70 -0
  5. package/src/db.ts +10 -9
  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 +32 -106
  42. package/src/repos/message.test.ts +6 -167
  43. package/src/repos/message.ts +216 -228
  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,5 +1,4 @@
1
1
  import type {
2
- BodyPartContentItem,
3
2
  BodyPartItem,
4
3
  BodyPartParameterItem,
5
4
  BodyPartStorageItem,
@@ -9,7 +8,6 @@ import type {
9
8
  RawMessageStorageItem,
10
9
  } from "@remit/data-ports";
11
10
  import type {
12
- bodyPartContentTable,
13
11
  bodyPartParameterTable,
14
12
  bodyPartStorageTable,
15
13
  bodyPartTable,
@@ -155,17 +153,3 @@ export function toBodyPartStorageItem(
155
153
  ...(row.dedupHash !== null ? { dedupHash: row.dedupHash } : {}),
156
154
  };
157
155
  }
158
-
159
- export function toBodyPartContentItem(
160
- row: typeof bodyPartContentTable.$inferSelect,
161
- ): BodyPartContentItem {
162
- return {
163
- bodyPartContentId: row.bodyPartContentId,
164
- messageId: row.messageId,
165
- bodyPartId: row.bodyPartId,
166
- content: row.content,
167
- contentLength: row.contentLength,
168
- createdAt: row.createdAt,
169
- updatedAt: row.updatedAt,
170
- };
171
- }
@@ -1,25 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { after, before, describe, test } from "node:test";
3
- import { and, eq, isNull } from "drizzle-orm";
4
3
  import {
5
4
  envelopeId as deriveEnvelopeId,
6
5
  rootBodyPartId as deriveRootBodyPartId,
7
6
  } from "../id.js";
8
- import {
9
- type MessageDataSchema,
10
- messageDataSchema,
11
- outboxTable,
12
- } from "../schema/message-data.js";
7
+ import { messageDataSchema } from "../schema/message-data.js";
8
+ import { threadMessageTable } from "../schema/thread-message.js";
9
+ import { SearchIndex } from "../search/thread-message-search.js";
13
10
  import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
14
11
  import { DrizzleMessageRepository } from "./message.js";
15
12
 
13
+ const SCHEMA = { ...messageDataSchema, threadMessages: threadMessageTable };
14
+
16
15
  // Runs the ported message repo against a real better-sqlite3 database
17
- // (RFC 036 D1). Exercises the SQLite-specific seams: the SAVEPOINT-bracketed
18
- // transaction, the sqlite outbox table (text-json payload), the unique-
19
- // violation → CreateFailedConflictError mapping, and the JSON/boolean columns.
16
+ // (RFC 036 D1). Exercises the SQLite-specific seams: the batched delete, the
17
+ // unique-violation → CreateFailedConflictError mapping, and the JSON/boolean columns.
20
18
 
21
19
  describe("DrizzleMessageRepository (sqlite)", () => {
22
- let db: SqliteTestDb<MessageDataSchema>;
20
+ let db: SqliteTestDb<typeof SCHEMA>;
23
21
  let close: () => Promise<void>;
24
22
  let repo: DrizzleMessageRepository;
25
23
 
@@ -44,29 +42,19 @@ describe("DrizzleMessageRepository (sqlite)", () => {
44
42
  };
45
43
 
46
44
  before(async () => {
47
- ({ db, close } = await createSqliteTestDb(messageDataSchema));
48
- repo = new DrizzleMessageRepository(db);
45
+ ({ db, close } = await createSqliteTestDb(SCHEMA));
46
+ repo = new DrizzleMessageRepository(db, new SearchIndex(db, db));
49
47
  });
50
48
 
51
49
  after(async () => {
52
50
  await close();
53
51
  });
54
52
 
55
- test("create returns a MessageItem and writes no outbox row", async () => {
53
+ test("create returns a MessageItem", async () => {
56
54
  const item = await repo.create(BASE_INPUT);
57
55
  assert.equal(item.messageId, MESSAGE_ID);
58
56
  assert.equal(item.status, "active");
59
57
  assert.equal(item.hasListUnsubscribe, true);
60
-
61
- const rows = await db
62
- .select()
63
- .from(outboxTable)
64
- .where(eq(outboxTable.messageId, MESSAGE_ID));
65
- assert.equal(
66
- rows.length,
67
- 0,
68
- "a message with no body yet has nothing to index",
69
- );
70
58
  });
71
59
 
72
60
  test("boolean and json columns round-trip", async () => {
@@ -122,26 +110,11 @@ describe("DrizzleMessageRepository (sqlite)", () => {
122
110
  assert.equal(afterUpdate.placementVerdict?.action, "MoveToInbox");
123
111
  });
124
112
 
125
- test("duplicate messageId throws CreateFailedConflictError and appends no outbox row", async () => {
126
- const before = await db
127
- .select()
128
- .from(outboxTable)
129
- .where(eq(outboxTable.messageId, MESSAGE_ID));
130
-
113
+ test("duplicate messageId throws CreateFailedConflictError", async () => {
131
114
  await assert.rejects(
132
115
  () => repo.create(BASE_INPUT),
133
116
  (err: Error) => err.name === "CreateFailedConflictError",
134
117
  );
135
-
136
- const afterRows = await db
137
- .select()
138
- .from(outboxTable)
139
- .where(eq(outboxTable.messageId, MESSAGE_ID));
140
- assert.equal(
141
- afterRows.length,
142
- before.length,
143
- "rolled-back create must not append an outbox row",
144
- );
145
118
  });
146
119
 
147
120
  test("upsertWithStatus reports created=false for an existing row", async () => {
@@ -153,6 +126,25 @@ describe("DrizzleMessageRepository (sqlite)", () => {
153
126
  // The state every inbound sync row is born in, and the one the re-point gate
154
127
  // in mailbox-service has to accept (#1096): the sync path supplies no
155
128
  // syncStatus and nothing later promotes what the repository writes here.
129
+ test("two concurrent saves of one message both succeed, one of them creating it", async () => {
130
+ const messageId = "00000000-0000-0000-1111-000000000099";
131
+ const input = {
132
+ ...BASE_INPUT,
133
+ messageId,
134
+ envelopeId: deriveEnvelopeId(messageId),
135
+ rootBodyPartId: deriveRootBodyPartId(messageId),
136
+ };
137
+
138
+ const saves = await Promise.all([
139
+ repo.upsertWithStatus(input),
140
+ repo.upsertWithStatus(input),
141
+ ]);
142
+
143
+ assert.deepEqual(saves.map((save) => save.created).sort(), [false, true]);
144
+ assert.equal(saves[0]?.item.messageId, messageId);
145
+ assert.equal(saves[1]?.item.messageId, messageId);
146
+ });
147
+
156
148
  test("upsertWithStatus writes pending when the caller names no syncStatus", async () => {
157
149
  const inboundId = "00000000-0000-0000-2222-000000000009";
158
150
  const { item, created } = await repo.upsertWithStatus({
@@ -283,74 +275,8 @@ describe("DrizzleMessageRepository (sqlite)", () => {
283
275
  });
284
276
  });
285
277
 
286
- test("requestReembed queues a forced re-index for each message that still exists", async () => {
287
- const movedRows = () =>
288
- db
289
- .select()
290
- .from(outboxTable)
291
- .where(
292
- and(
293
- eq(outboxTable.messageId, MESSAGE_ID),
294
- eq(outboxTable.event, "message.moved"),
295
- isNull(outboxTable.processedAt),
296
- ),
297
- );
298
- const pendingBefore = (await movedRows()).length;
299
-
300
- const queued = await repo.requestReembed([
301
- MESSAGE_ID,
302
- "00000000-0000-0000-2222-0000000000fe",
303
- ]);
304
-
305
- assert.deepEqual(queued, { queued: 1, alreadyQueued: 0 });
306
- assert.equal((await movedRows()).length, pendingBefore + 1);
307
- const orphan = await db
308
- .select()
309
- .from(outboxTable)
310
- .where(eq(outboxTable.messageId, "00000000-0000-0000-2222-0000000000fe"));
311
- assert.equal(orphan.length, 0);
312
- });
313
-
314
- test("requestReembed skips a message whose forced re-index is still pending", async () => {
315
- const pendingBefore = await db
316
- .select()
317
- .from(outboxTable)
318
- .where(
319
- and(
320
- eq(outboxTable.messageId, MESSAGE_ID),
321
- eq(outboxTable.event, "message.moved"),
322
- isNull(outboxTable.processedAt),
323
- ),
324
- );
325
- assert.ok(pendingBefore.length > 0);
326
-
327
- const again = await repo.requestReembed([MESSAGE_ID]);
328
-
329
- assert.deepEqual(again, { queued: 0, alreadyQueued: 1 });
330
- const pendingAfter = await db
331
- .select()
332
- .from(outboxTable)
333
- .where(
334
- and(
335
- eq(outboxTable.messageId, MESSAGE_ID),
336
- eq(outboxTable.event, "message.moved"),
337
- isNull(outboxTable.processedAt),
338
- ),
339
- );
340
- assert.equal(pendingAfter.length, pendingBefore.length);
341
- });
342
-
343
- test("delete removes the message and appends a removal outbox row", async () => {
278
+ test("delete removes the message", async () => {
344
279
  await repo.delete(MESSAGE_ID);
345
280
  await assert.rejects(() => repo.get(MESSAGE_ID));
346
-
347
- const rows = await db
348
- .select()
349
- .from(outboxTable)
350
- .where(eq(outboxTable.messageId, MESSAGE_ID));
351
- assert.ok(
352
- rows.some((r) => r.event === "message.removed"),
353
- "delete must append a message.removed outbox row",
354
- );
355
281
  });
356
282
  });
@@ -4,6 +4,7 @@ import {
4
4
  envelopeId as deriveEnvelopeId,
5
5
  rootBodyPartId as deriveRootBodyPartId,
6
6
  } from "../id.js";
7
+ import { SearchIndex } from "../search/thread-message-search.js";
7
8
  import { DrizzleEnvelopeRepository } from "./envelope.js";
8
9
  import { DrizzleMessageRepository } from "./message.js";
9
10
  import { createTestDb, type TestDb } from "./test-helpers.js";
@@ -36,7 +37,7 @@ describe("DrizzleMessageRepository", () => {
36
37
 
37
38
  before(async () => {
38
39
  ({ db, stop } = await createTestDb());
39
- messageRepo = new DrizzleMessageRepository(db);
40
+ messageRepo = new DrizzleMessageRepository(db, new SearchIndex(db, db));
40
41
  envelopeRepo = new DrizzleEnvelopeRepository(db);
41
42
  });
42
43
 
@@ -56,25 +57,7 @@ describe("DrizzleMessageRepository", () => {
56
57
  assert.ok(typeof item.updatedAt === "number");
57
58
  });
58
59
 
59
- test("writes no outbox row — there is nothing to index before the body", async () => {
60
- const { outboxTable } = await import("../schema/message-data.js");
61
- const { eq } = await import("drizzle-orm");
62
- const rows = await db
63
- .select()
64
- .from(outboxTable)
65
- .where(eq(outboxTable.messageId, MESSAGE_ID));
66
- assert.equal(rows.length, 0);
67
- });
68
-
69
- test("duplicate messageId throws CreateFailedConflictError and writes no extra outbox row", async () => {
70
- const { outboxTable } = await import("../schema/message-data.js");
71
- const { eq } = await import("drizzle-orm");
72
-
73
- const before = await db
74
- .select()
75
- .from(outboxTable)
76
- .where(eq(outboxTable.messageId, MESSAGE_ID));
77
-
60
+ test("duplicate messageId throws CreateFailedConflictError", async () => {
78
61
  await assert.rejects(
79
62
  () => messageRepo.create(BASE_MESSAGE_INPUT),
80
63
  (err: Error) => {
@@ -82,16 +65,6 @@ describe("DrizzleMessageRepository", () => {
82
65
  return true;
83
66
  },
84
67
  );
85
-
86
- const after = await db
87
- .select()
88
- .from(outboxTable)
89
- .where(eq(outboxTable.messageId, MESSAGE_ID));
90
- assert.equal(
91
- after.length,
92
- before.length,
93
- "rolled-back create must not add an outbox row",
94
- );
95
68
  });
96
69
  });
97
70
 
@@ -119,23 +92,6 @@ describe("DrizzleMessageRepository", () => {
119
92
  });
120
93
  });
121
94
 
122
- test("returns all 10 MessageDescription members", async () => {
123
- const desc = await messageRepo.describe(MESSAGE_ID);
124
- const keys = Object.keys(desc).sort();
125
- assert.deepStrictEqual(keys, [
126
- "bodyPart",
127
- "bodyPartContent",
128
- "bodyPartParameter",
129
- "bodyPartStorage",
130
- "envelope",
131
- "envelopeAddress",
132
- "message",
133
- "messageFlag",
134
- "messageReference",
135
- "rawMessageStorage",
136
- ]);
137
- });
138
-
139
95
  test("message array has 1 row with correct fields", async () => {
140
96
  const desc = await messageRepo.describe(MESSAGE_ID);
141
97
  assert.equal(desc.message.length, 1);
@@ -160,7 +116,6 @@ describe("DrizzleMessageRepository", () => {
160
116
  assert.equal(desc.bodyPartParameter.length, 0);
161
117
  assert.equal(desc.rawMessageStorage.length, 0);
162
118
  assert.equal(desc.bodyPartStorage.length, 0);
163
- assert.equal(desc.bodyPartContent.length, 0);
164
119
  });
165
120
 
166
121
  test("throws NotFoundError for unknown messageId", async () => {
@@ -235,22 +190,7 @@ describe("DrizzleMessageRepository", () => {
235
190
  );
236
191
  });
237
192
 
238
- test("updateForMove writes no move reindex event (completion-only)", async () => {
239
- const { outboxTable } = await import("../schema/message-data.js");
240
- const { and, eq } = await import("drizzle-orm");
241
- const rows = await db
242
- .select()
243
- .from(outboxTable)
244
- .where(
245
- and(
246
- eq(outboxTable.messageId, MOVE_MESSAGE_ID),
247
- eq(outboxTable.event, "message.moved"),
248
- ),
249
- );
250
- assert.equal(rows.length, 0);
251
- });
252
-
253
- test("updateUid completes the move and emits a move reindex event", async () => {
193
+ test("updateUid completes the move", async () => {
254
194
  const completed = await messageRepo.updateUid(
255
195
  MOVE_MESSAGE_ID,
256
196
  99,
@@ -265,21 +205,6 @@ describe("DrizzleMessageRepository", () => {
265
205
  assert.equal(read.uid, 99);
266
206
  assert.equal(read.status, "active");
267
207
  assert.equal(read.syncStatus, "synced");
268
-
269
- const { outboxTable } = await import("../schema/message-data.js");
270
- const { and, eq } = await import("drizzle-orm");
271
- const rows = await db
272
- .select()
273
- .from(outboxTable)
274
- .where(
275
- and(
276
- eq(outboxTable.messageId, MOVE_MESSAGE_ID),
277
- eq(outboxTable.event, "message.moved"),
278
- ),
279
- );
280
- assert.equal(rows.length, 1, "one move reindex event was enqueued");
281
- assert.deepStrictEqual(rows[0].payload, { messageId: MOVE_MESSAGE_ID });
282
- assert.equal(rows[0].processedAt, null, "event starts undrained");
283
208
  });
284
209
 
285
210
  test("updateUid drops the source uid and keeps the folder Undo restores to", async () => {
@@ -304,7 +229,7 @@ describe("DrizzleMessageRepository", () => {
304
229
  );
305
230
  });
306
231
 
307
- test("updateUid throws NotFoundError and rolls back its reindex event", async () => {
232
+ test("updateUid throws NotFoundError for an unknown messageId", async () => {
308
233
  const UNKNOWN = "00000000-0000-0000-9999-000000000002";
309
234
  await assert.rejects(
310
235
  () => messageRepo.updateUid(UNKNOWN, 1, DEST_MAILBOX_ID),
@@ -313,83 +238,10 @@ describe("DrizzleMessageRepository", () => {
313
238
  return true;
314
239
  },
315
240
  );
316
-
317
- const { outboxTable } = await import("../schema/message-data.js");
318
- const { eq } = await import("drizzle-orm");
319
- const rows = await db
320
- .select()
321
- .from(outboxTable)
322
- .where(eq(outboxTable.messageId, UNKNOWN));
323
- assert.equal(rows.length, 0, "rolled-back move writes no outbox row");
324
241
  });
325
242
  });
326
243
 
327
- describe("update — appends a body_synced outbox row per body write", () => {
328
- const SYNC_MESSAGE_ID = "00000000-0000-0000-3333-000000000001";
329
- const SYNC_MAILBOX_ID = "00000000-0000-0000-3333-000000000002";
330
-
331
- const bodySyncedRows = async () => {
332
- const { outboxTable } = await import("../schema/message-data.js");
333
- const { and, eq } = await import("drizzle-orm");
334
- return db
335
- .select()
336
- .from(outboxTable)
337
- .where(
338
- and(
339
- eq(outboxTable.messageId, SYNC_MESSAGE_ID),
340
- eq(outboxTable.event, "message.body_synced"),
341
- ),
342
- );
343
- };
344
-
345
- before(async () => {
346
- await messageRepo.create({
347
- messageId: SYNC_MESSAGE_ID,
348
- mailboxId: SYNC_MAILBOX_ID,
349
- uid: 11,
350
- sequenceNumber: 1,
351
- rfc822Size: 256,
352
- internalDate: NOW,
353
- envelopeId: deriveEnvelopeId(SYNC_MESSAGE_ID),
354
- rootBodyPartId: deriveRootBodyPartId(SYNC_MESSAGE_ID),
355
- status: "active" as const,
356
- syncStatus: "synced" as const,
357
- });
358
- });
359
-
360
- test("a body write appends one body_synced event", async () => {
361
- assert.equal((await bodySyncedRows()).length, 0, "none before sync");
362
- await messageRepo.update(SYNC_MESSAGE_ID, {
363
- bodyStorageKey: "s3://bucket/body-a.eml",
364
- });
365
- const rows = await bodySyncedRows();
366
- assert.equal(rows.length, 1, "one row for one body write");
367
- assert.deepStrictEqual(rows[0].payload, { messageId: SYNC_MESSAGE_ID });
368
- assert.equal(rows[0].processedAt, null, "event starts undrained");
369
- });
370
-
371
- test("each body write appends its own row (append-only, no suppression)", async () => {
372
- await messageRepo.update(SYNC_MESSAGE_ID, {
373
- bodyStorageKey: "s3://bucket/body-b.eml",
374
- });
375
- await messageRepo.update(SYNC_MESSAGE_ID, {
376
- bodyStorageKey: "s3://bucket/body-c.eml",
377
- });
378
- assert.equal(
379
- (await bodySyncedRows()).length,
380
- 3,
381
- "three body writes leave three rows — coalescing is the worker's job",
382
- );
383
- });
384
-
385
- test("a classification-only update (no bodyStorageKey) appends nothing", async () => {
386
- const before = (await bodySyncedRows()).length;
387
- await messageRepo.update(SYNC_MESSAGE_ID, { category: "personal" });
388
- assert.equal((await bodySyncedRows()).length, before);
389
- });
390
- });
391
-
392
- describe("deleteMany — subtree delete + message.removed outbox", () => {
244
+ describe("deleteMany — subtree delete", () => {
393
245
  const DEL_MESSAGE_ID = "00000000-0000-0000-4444-000000000001";
394
246
  const DEL_MAILBOX_ID = "00000000-0000-0000-4444-000000000002";
395
247
  const DEL_ENVELOPE_ID = deriveEnvelopeId(DEL_MESSAGE_ID);
@@ -480,19 +332,6 @@ describe("DrizzleMessageRepository", () => {
480
332
  );
481
333
  });
482
334
 
483
- test("leaves exactly one undrained message.removed outbox row", async () => {
484
- const { outboxTable } = await import("../schema/message-data.js");
485
- const { eq } = await import("drizzle-orm");
486
- const rows = await db
487
- .select()
488
- .from(outboxTable)
489
- .where(eq(outboxTable.messageId, DEL_MESSAGE_ID));
490
- assert.equal(rows.length, 1, "prior CDC rows are cleared");
491
- assert.equal(rows[0].event, "message.removed");
492
- assert.equal(rows[0].processedAt, null, "removal starts undrained");
493
- assert.deepStrictEqual(rows[0].payload, { messageId: DEL_MESSAGE_ID });
494
- });
495
-
496
335
  test("deleteMany([]) is a no-op", async () => {
497
336
  await messageRepo.deleteMany([]);
498
337
  });