@remit/drizzle-service 0.0.107 → 0.0.109

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.107",
3
+ "version": "0.0.109",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/id.ts CHANGED
@@ -42,3 +42,12 @@ export const envelopeAddressId = (
42
42
  order: number,
43
43
  ): string =>
44
44
  generateDeterministicId(`envelopeaddress:${messageId}:${role}:${order}`);
45
+
46
+ export const messageReferenceId = (
47
+ messageId: string,
48
+ referenceType: string,
49
+ referenceOrder: number,
50
+ ): string =>
51
+ deterministicBase36Id(
52
+ `msgref:${messageId}:${referenceType}:${referenceOrder}`,
53
+ );
package/src/index.ts CHANGED
@@ -75,7 +75,6 @@ export {
75
75
  export { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
76
76
  export { MessageLabelRepo } from "./repos/message-label.js";
77
77
  export {
78
- DEFAULT_MESSAGE_OBJECT_DELETE_CONCURRENCY,
79
78
  type MessageObjectStore,
80
79
  messageObjectsInMemory,
81
80
  } from "./repos/message-objects.js";
@@ -43,7 +43,6 @@ const MESSAGE_CHILD_TYPES = new Set([
43
43
  "Envelope",
44
44
  "BodyPart",
45
45
  "BodyPartParameter",
46
- "BodyPartStorage",
47
46
  "RawMessageStorage",
48
47
  ]);
49
48
 
@@ -61,7 +61,6 @@ describe("DrizzleEnvelopeRepository", () => {
61
61
  assert.equal(data.bodyPart.length, 0);
62
62
  assert.equal(data.bodyPartParameter.length, 0);
63
63
  assert.equal(data.rawMessageStorage.length, 0);
64
- assert.equal(data.bodyPartStorage.length, 0);
65
64
  });
66
65
 
67
66
  test("throws NotFoundError for unknown messageId", async () => {
@@ -21,11 +21,10 @@ import {
21
21
  bodyPartId as deriveBodyPartId,
22
22
  bodyPartParameterId as deriveBodyPartParameterId,
23
23
  envelopeId as deriveEnvelopeId,
24
- deterministicBase36Id,
24
+ messageReferenceId as deriveMessageReferenceId,
25
25
  } from "../id.js";
26
26
  import {
27
27
  bodyPartParameterTable,
28
- bodyPartStorageTable,
29
28
  bodyPartTable,
30
29
  bodyStructureRefetchTable,
31
30
  envelopeAddressTable,
@@ -38,7 +37,6 @@ import { inList } from "./in-list.js";
38
37
  import {
39
38
  toBodyPartItem,
40
39
  toBodyPartParameterItem,
41
- toBodyPartStorageItem,
42
40
  toEnvelopeAddressItem,
43
41
  toEnvelopeItem,
44
42
  toMessageReferenceItem,
@@ -74,7 +72,6 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
74
72
  bodyParts,
75
73
  bodyPartParameters,
76
74
  rawMessageStorages,
77
- bodyPartStorages,
78
75
  ] = await Promise.all([
79
76
  this.db
80
77
  .select()
@@ -100,10 +97,6 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
100
97
  .select()
101
98
  .from(rawMessageStorageTable)
102
99
  .where(eq(rawMessageStorageTable.messageId, messageId)),
103
- this.db
104
- .select()
105
- .from(bodyPartStorageTable)
106
- .where(eq(bodyPartStorageTable.messageId, messageId)),
107
100
  ]);
108
101
 
109
102
  if (envelopes.length === 0) {
@@ -117,7 +110,6 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
117
110
  bodyPart: bodyParts.map(toBodyPartItem),
118
111
  bodyPartParameter: bodyPartParameters.map(toBodyPartParameterItem),
119
112
  rawMessageStorage: rawMessageStorages.map(toRawMessageStorageItem),
120
- bodyPartStorage: bodyPartStorages.map(toBodyPartStorageItem),
121
113
  };
122
114
  }
123
115
 
@@ -229,8 +221,10 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
229
221
  .where(eq(messageReferenceTable.messageId, messageId));
230
222
  const rows = references.map((reference) => ({
231
223
  ...reference,
232
- messageReferenceId: deterministicBase36Id(
233
- `msgref:${messageId}:${reference.referenceType}:${reference.referenceOrder}`,
224
+ messageReferenceId: deriveMessageReferenceId(
225
+ messageId,
226
+ reference.referenceType,
227
+ reference.referenceOrder,
234
228
  ),
235
229
  messageId,
236
230
  envelopeId,
@@ -564,8 +564,6 @@ describe("MailboxRepo", () => {
564
564
  sequenceNumber: uid,
565
565
  rfc822Size: 10,
566
566
  internalDate: 1700000000000,
567
- envelopeId: deriveEnvelopeId(messageId),
568
- rootBodyPartId: deriveRootBodyPartId(messageId),
569
567
  });
570
568
  await db.insert(threadMessageTable).values({
571
569
  threadMessageId: randomId(),
@@ -42,11 +42,8 @@ import { messageFlagPushTable } from "../schema/i4-message-flag-push.js";
42
42
  import { messagePlacementMoveTable } from "../schema/i4-message-placement-move.js";
43
43
  import { messageTable } from "../schema/message-data.js";
44
44
  import { syncWarningTable } from "../schema/sync-warning.js";
45
- import { threadMessageTable } from "../schema/thread-message.js";
46
- import type { SearchIndex } from "../search/thread-message-search.js";
47
45
  import { inList } from "./in-list.js";
48
- import { deleteMessageSubtree } from "./message.js";
49
- import type { MessageObjects } from "./message-objects.js";
46
+ import type { DrizzleMessageRepository } from "./message.js";
50
47
 
51
48
  const base36Translator = shortUuid.createTranslator(
52
49
  shortUuid.constants.uuid25Base36,
@@ -61,7 +58,6 @@ type DB = RepositoryDb<Record<string, unknown>>;
61
58
  * the file's write lock, so the bound is what keeps a large folder's delete from
62
59
  * parking every other writer behind it (D8).
63
60
  */
64
- const MAIL_DELETE_BATCH_SIZE = 100;
65
61
 
66
62
  /**
67
63
  * The folders under `path`. A flat namespace nests nothing: with no delimiter
@@ -155,8 +151,7 @@ export class MailboxRepo implements IMailboxRepository {
155
151
 
156
152
  constructor(
157
153
  store: SqliteStore<Record<string, unknown>>,
158
- private search: SearchIndex,
159
- private messageObjects: MessageObjects,
154
+ private messages: Pick<DrizzleMessageRepository, "deleteMany">,
160
155
  ) {
161
156
  this.db = store.db;
162
157
  this.batch = store.batch;
@@ -549,32 +544,11 @@ export class MailboxRepo implements IMailboxRepository {
549
544
  // Ordered, batched and resumable rather than one transaction (D8). The
550
545
  // caller keeps the row `deleting` until the last commit, so an interrupted
551
546
  // run re-enters here and continues against whatever is left.
552
- for (;;) {
553
- const rows = await this.db
554
- .select({ messageId: messageTable.messageId })
555
- .from(messageTable)
556
- .where(eq(messageTable.mailboxId, mailboxId))
557
- .limit(MAIL_DELETE_BATCH_SIZE);
558
- if (rows.length === 0) break;
559
- const messageIds = rows.map((row) => row.messageId);
560
- const listings = await this.db
561
- .select({ threadMessageId: threadMessageTable.threadMessageId })
562
- .from(threadMessageTable)
563
- .where(inList(threadMessageTable.messageId, messageIds));
564
-
565
- // The primitive the rest of the codebase deletes mail with: nine
566
- // per-message child tables. A bespoke table list would orphan them.
567
- const subtree = deleteMessageSubtree(this.db, messageIds);
568
- const [removed] = await this.batch([
569
- subtree.removed,
570
- ...subtree.statements,
571
- this.db
572
- .delete(threadMessageTable)
573
- .where(inList(threadMessageTable.messageId, messageIds)),
574
- ]);
575
- await this.search.sync(listings.map((row) => row.threadMessageId));
576
- await this.messageObjects.reclaim(removed);
577
- }
547
+ const rows = await this.db
548
+ .select({ messageId: messageTable.messageId })
549
+ .from(messageTable)
550
+ .where(eq(messageTable.mailboxId, mailboxId));
551
+ await this.messages.deleteMany(rows.map((row) => row.messageId));
578
552
 
579
553
  await this.db
580
554
  .delete(mailboxSpecialUseTable)
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  BodyPartItem,
3
3
  BodyPartParameterItem,
4
- BodyPartStorageItem,
5
4
  EnvelopeAddressItem,
6
5
  EnvelopeItem,
7
6
  MessageReferenceItem,
@@ -9,7 +8,6 @@ import type {
9
8
  } from "@remit/data-ports";
10
9
  import type {
11
10
  bodyPartParameterTable,
12
- bodyPartStorageTable,
13
11
  bodyPartTable,
14
12
  envelopeAddressTable,
15
13
  envelopeTable,
@@ -132,24 +130,3 @@ export function toRawMessageStorageItem(
132
130
  ...(row.expiresAt !== null ? { expiresAt: row.expiresAt } : {}),
133
131
  };
134
132
  }
135
-
136
- export function toBodyPartStorageItem(
137
- row: typeof bodyPartStorageTable.$inferSelect,
138
- ): BodyPartStorageItem {
139
- return {
140
- bodyPartStorageId: row.bodyPartStorageId,
141
- messageId: row.messageId,
142
- bodyPartId: row.bodyPartId,
143
- storageType: row.storageType,
144
- storageLocation: row.storageLocation,
145
- storageKey: row.storageKey,
146
- decodedSizeBytes: row.decodedSizeBytes,
147
- checksumSha256: row.checksumSha256,
148
- contentEncoding: row.contentEncoding,
149
- isDeduped: row.isDeduped,
150
- storedAt: row.storedAt,
151
- createdAt: row.createdAt,
152
- updatedAt: row.updatedAt,
153
- ...(row.dedupHash !== null ? { dedupHash: row.dedupHash } : {}),
154
- };
155
- }
@@ -12,10 +12,6 @@ import { after, before, describe, test } from "node:test";
12
12
  import type { StorageService } from "@remit/storage-service";
13
13
  import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
14
14
  import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
15
- import {
16
- envelopeId as deriveEnvelopeId,
17
- rootBodyPartId as deriveRootBodyPartId,
18
- } from "../id.js";
19
15
  import {
20
16
  createSqliteRepositories,
21
17
  type SqliteRepositories,
@@ -25,6 +21,17 @@ import { mailboxCreated } from "./test-helpers.js";
25
21
 
26
22
  const ACCOUNT_CONFIG_ID = "cfg-objects";
27
23
 
24
+ const bodyPart = (partPath: string) => ({
25
+ partPath,
26
+ parentPartPath: null,
27
+ mediaType: "TEXT" as const,
28
+ mediaSubtype: "plain",
29
+ transferEncoding: "7BIT" as const,
30
+ sizeOctets: 4,
31
+ isMultipart: false,
32
+ parameters: [],
33
+ });
34
+
28
35
  describe("message objects follow the last row that references them", () => {
29
36
  let db: TestDb;
30
37
  let close: () => Promise<void>;
@@ -32,26 +39,26 @@ describe("message objects follow the last row that references them", () => {
32
39
  let storage: StorageService;
33
40
  let failDeletesAfter = Number.POSITIVE_INFINITY;
34
41
  let repos: SqliteRepositories;
42
+ let deleteCalls: number[] = [];
35
43
 
36
44
  before(async () => {
37
45
  ({ db, close } = await createTestDb());
38
46
  directory = mkdtempSync(join(tmpdir(), "message-objects-"));
39
47
  storage = createFilesystemStorageService(directory);
48
+ const counted: Pick<StorageService, "deleteKeys"> = {
49
+ deleteKeys: (keys) => {
50
+ deleteCalls.push(keys.length);
51
+ if (failDeletesAfter <= 0) {
52
+ return Promise.reject(new Error("storage went away"));
53
+ }
54
+ failDeletesAfter -= 1;
55
+ return storage.deleteKeys(keys);
56
+ },
57
+ };
40
58
  repos = createSqliteRepositories(
41
59
  { app: db, mail: db, search: db },
42
60
  BETTER_SQLITE3_LIMITS,
43
- {
44
- storage: {
45
- deletePrefix: (prefix) => {
46
- if (failDeletesAfter <= 0) {
47
- return Promise.reject(new Error("storage went away"));
48
- }
49
- failDeletesAfter -= 1;
50
- return storage.deletePrefix(prefix);
51
- },
52
- },
53
- messageObjectDeleteConcurrency: 1,
54
- },
61
+ { storage: counted },
55
62
  );
56
63
  });
57
64
 
@@ -109,10 +116,9 @@ describe("message objects follow the last row that references them", () => {
109
116
  sequenceNumber: uid,
110
117
  rfc822Size: 10,
111
118
  internalDate: 1700000000000,
112
- envelopeId: deriveEnvelopeId(messageId),
113
- rootBodyPartId: deriveRootBodyPartId(messageId),
114
119
  bodyStorageKey: stored.uri,
115
120
  });
121
+ await repos.envelope.upsertBodyParts(messageId, [bodyPart("1")]);
116
122
  return { messageId, accountId, bodyStorageKey: stored.uri };
117
123
  };
118
124
 
@@ -184,10 +190,9 @@ describe("message objects follow the last row that references them", () => {
184
190
  sequenceNumber: 1,
185
191
  rfc822Size: 10,
186
192
  internalDate: 1700000000000,
187
- envelopeId: deriveEnvelopeId(messageId),
188
- rootBodyPartId: deriveRootBodyPartId(messageId),
189
193
  bodyStorageKey: `file://${join(legacy, "body.eml")}`,
190
194
  });
195
+ await repos.envelope.upsertBodyParts(messageId, [bodyPart("1")]);
191
196
 
192
197
  await repos.message.delete(messageId);
193
198
 
@@ -201,7 +206,7 @@ describe("message objects follow the last row that references them", () => {
201
206
  for (let uid = 1; uid <= 101; uid += 1) {
202
207
  rows.push(await message(accountId, doomed.mailboxId, uid));
203
208
  }
204
- failDeletesAfter = 100;
209
+ failDeletesAfter = 1;
205
210
 
206
211
  await assert.rejects(
207
212
  repos.mailbox.deleteMailboxWithMail(accountId, doomed.mailboxId),
@@ -214,4 +219,53 @@ describe("message objects follow the last row that references them", () => {
214
219
  );
215
220
  assert.equal(gone.filter((exists) => !exists).length, 100);
216
221
  });
222
+
223
+ const gone = async (
224
+ rows: readonly {
225
+ accountId: string;
226
+ messageId: string;
227
+ bodyStorageKey: string;
228
+ }[],
229
+ ) => {
230
+ for (const row of rows) {
231
+ assert.equal(await storage.exists(row.bodyStorageKey), false);
232
+ assert.equal(await part(row), null);
233
+ }
234
+ };
235
+
236
+ test("deleting many messages makes one storage call per batch and keeps a body a copy shares", async () => {
237
+ const accountId = randomId();
238
+ const inbox = await folder(accountId, "INBOX");
239
+ const rows = [];
240
+ for (let uid = 1; uid <= 300; uid += 1) {
241
+ rows.push(await message(accountId, inbox.mailboxId, uid));
242
+ }
243
+ const [shared, ...unshared] = rows;
244
+ const copy = await message(accountId, inbox.mailboxId, 301, {
245
+ from: shared.messageId,
246
+ });
247
+ deleteCalls = [];
248
+
249
+ await repos.message.deleteMany(rows.map((row) => row.messageId));
250
+
251
+ assert.equal(deleteCalls.length, 3);
252
+ assert.ok(await storage.retrieve(copy.bodyStorageKey));
253
+ assert.ok(await part(copy));
254
+ await gone(unshared);
255
+ });
256
+
257
+ test("a folder delete makes one storage call per batch and removes every object", async () => {
258
+ const accountId = randomId();
259
+ const doomed = await folder(accountId, "Archive");
260
+ const rows = [];
261
+ for (let uid = 1; uid <= 250; uid += 1) {
262
+ rows.push(await message(accountId, doomed.mailboxId, uid));
263
+ }
264
+ deleteCalls = [];
265
+
266
+ await repos.mailbox.deleteMailboxWithMail(accountId, doomed.mailboxId);
267
+
268
+ assert.equal(deleteCalls.length, 3);
269
+ await gone(rows);
270
+ });
217
271
  });
@@ -1,23 +1,18 @@
1
1
  import {
2
2
  createMockStorageService,
3
- messageObjectPrefixOf,
3
+ messageObjectKeysOf,
4
4
  type StorageService,
5
5
  } from "@remit/storage-service";
6
- import pMap from "p-map";
7
6
  import type { RepositoryDb } from "../db.js";
8
7
  import { messageTable } from "../schema/message-data.js";
9
8
  import { inList } from "./in-list.js";
10
9
 
11
- export const DEFAULT_MESSAGE_OBJECT_DELETE_CONCURRENCY = 8;
12
-
13
10
  export interface MessageObjectStore {
14
- storage: Pick<StorageService, "deletePrefix">;
15
- messageObjectDeleteConcurrency: number;
11
+ storage: Pick<StorageService, "deleteKeys">;
16
12
  }
17
13
 
18
14
  export const messageObjectsInMemory = (): MessageObjectStore => ({
19
15
  storage: createMockStorageService(),
20
- messageObjectDeleteConcurrency: 1,
21
16
  });
22
17
 
23
18
  export interface RemovedMessageRow {
@@ -25,52 +20,51 @@ export interface RemovedMessageRow {
25
20
  bodyStorageKey: string | null;
26
21
  }
27
22
 
28
- interface Candidate {
29
- prefix: string;
30
- bodyStorageKey: string;
23
+ export interface RemovedBodyPartRow {
24
+ messageId: string;
25
+ partPath: string;
31
26
  }
32
27
 
33
- const candidatesOf = (row: RemovedMessageRow): Candidate[] => {
34
- const { messageId, bodyStorageKey } = row;
35
- if (bodyStorageKey === null) return [];
36
- const ownerPrefix = messageObjectPrefixOf(bodyStorageKey);
37
- const ownPrefix = ownerPrefix.replace(/[^/]+\/$/, `${messageId}/`);
38
- const owner = { prefix: ownerPrefix, bodyStorageKey };
39
- if (ownPrefix === ownerPrefix) return [owner];
40
- const at = bodyStorageKey.lastIndexOf(ownerPrefix);
41
- const ownBodyStorageKey = `${bodyStorageKey.slice(0, at)}${ownPrefix}${bodyStorageKey.slice(at + ownerPrefix.length)}`;
42
- return [owner, { prefix: ownPrefix, bodyStorageKey: ownBodyStorageKey }];
43
- };
44
-
45
28
  export class MessageObjects {
46
29
  constructor(
47
30
  private readonly db: Pick<RepositoryDb<Record<string, unknown>>, "select">,
48
31
  private readonly store: MessageObjectStore,
49
32
  ) {}
50
33
 
51
- async reclaim(removed: readonly RemovedMessageRow[]): Promise<void> {
52
- const candidates = new Map(
53
- removed.flatMap(candidatesOf).map((c) => [c.prefix, c]),
54
- );
55
- if (candidates.size === 0) return;
56
- const keys = [
57
- ...new Set([...candidates.values()].map((c) => c.bodyStorageKey)),
58
- ];
34
+ async reclaim(
35
+ removed: readonly RemovedMessageRow[],
36
+ removedParts: readonly RemovedBodyPartRow[],
37
+ ): Promise<void> {
38
+ const partPathsOf = new Map<string, string[]>();
39
+ for (const { messageId, partPath } of removedParts) {
40
+ partPathsOf.set(messageId, [
41
+ ...(partPathsOf.get(messageId) ?? []),
42
+ partPath,
43
+ ]);
44
+ }
45
+ const own: string[] = [];
46
+ const sharedBy = new Map<string, string[]>();
47
+ for (const { messageId, bodyStorageKey } of removed) {
48
+ if (bodyStorageKey === null) continue;
49
+ const keys = messageObjectKeysOf(
50
+ bodyStorageKey,
51
+ messageId,
52
+ partPathsOf.get(messageId) ?? [],
53
+ );
54
+ own.push(...keys.own);
55
+ sharedBy.set(bodyStorageKey, keys.shared);
56
+ }
57
+ if (sharedBy.size === 0) return;
59
58
  const referenced = await this.db
60
59
  .select({ bodyStorageKey: messageTable.bodyStorageKey })
61
60
  .from(messageTable)
62
- .where(inList(messageTable.bodyStorageKey, keys));
63
- const stillReferenced = new Set(
64
- referenced.flatMap((row) =>
65
- row.bodyStorageKey === null ? [] : [row.bodyStorageKey],
66
- ),
67
- );
68
- await pMap(
69
- [...candidates.values()].filter(
70
- (c) => !stillReferenced.has(c.bodyStorageKey),
71
- ),
72
- (c) => this.store.storage.deletePrefix(c.prefix),
73
- { concurrency: this.store.messageObjectDeleteConcurrency },
74
- );
61
+ .where(inList(messageTable.bodyStorageKey, [...sharedBy.keys()]));
62
+ for (const { bodyStorageKey } of referenced) {
63
+ if (bodyStorageKey !== null) sharedBy.delete(bodyStorageKey);
64
+ }
65
+ await this.store.storage.deleteKeys([
66
+ ...own,
67
+ ...[...sharedBy.values()].flat(),
68
+ ]);
75
69
  }
76
70
  }
@@ -85,8 +85,6 @@ describe("DrizzleMessageRepository (sqlite)", () => {
85
85
  const created = await repo.create({
86
86
  ...BASE_INPUT,
87
87
  messageId: AUTO_MOVED_ID,
88
- envelopeId: deriveEnvelopeId(AUTO_MOVED_ID),
89
- rootBodyPartId: deriveRootBodyPartId(AUTO_MOVED_ID),
90
88
  movedByRemit: true,
91
89
  filterMove: filterMove as unknown as never,
92
90
  });
@@ -160,8 +158,6 @@ describe("DrizzleMessageRepository (sqlite)", () => {
160
158
  sequenceNumber: 2,
161
159
  rfc822Size: 1024,
162
160
  internalDate: NOW,
163
- envelopeId: deriveEnvelopeId(inboundId),
164
- rootBodyPartId: deriveRootBodyPartId(inboundId),
165
161
  });
166
162
 
167
163
  assert.equal(created, true);
@@ -182,8 +178,6 @@ describe("DrizzleMessageRepository (sqlite)", () => {
182
178
  ...BASE_INPUT,
183
179
  messageId: LOCK_ID,
184
180
  uid: 7,
185
- envelopeId: deriveEnvelopeId(LOCK_ID),
186
- rootBodyPartId: deriveRootBodyPartId(LOCK_ID),
187
181
  status: "moving" as const,
188
182
  syncStatus: "pending" as const,
189
183
  });
@@ -255,8 +249,6 @@ describe("DrizzleMessageRepository (sqlite)", () => {
255
249
  ...BASE_INPUT,
256
250
  messageId: GONE_ID,
257
251
  uid: 9,
258
- envelopeId: deriveEnvelopeId(GONE_ID),
259
- rootBodyPartId: deriveRootBodyPartId(GONE_ID),
260
252
  });
261
253
  });
262
254
 
@@ -319,19 +311,19 @@ describe("DrizzleMessageRepository (sqlite)", () => {
319
311
  );
320
312
  });
321
313
 
322
- test("copySearchText gives a copy the source's search text", async () => {
314
+ test("copyMessage gives a copy the source's search text", async () => {
323
315
  const source = "00000000-0000-0000-4444-000000000001";
324
316
  const copy = "00000000-0000-0000-4444-000000000002";
325
- for (const [messageId, uid] of [
326
- [source, 201],
327
- [copy, 202],
328
- ] as const) {
329
- await repo.create({ ...BASE_INPUT, messageId, uid });
330
- await repo.update(messageId, { bodyStorageKey: "file:///bodies/shared" });
331
- }
317
+ await repo.create({ ...BASE_INPUT, messageId: source, uid: 201 });
318
+ await repo.update(source, { bodyStorageKey: "file:///bodies/shared" });
332
319
  await repo.setSearchText(source, "the shared body");
333
320
 
334
- await repo.copySearchText(source, copy);
321
+ await repo.copyMessage(source, {
322
+ ...BASE_INPUT,
323
+ messageId: copy,
324
+ uid: 202,
325
+ bodyStorageKey: "file:///bodies/shared",
326
+ });
335
327
 
336
328
  const missing = await repo.listMissingSearchText("", 100);
337
329
  assert.equal(
@@ -6,10 +6,10 @@ import {
6
6
  rootBodyPartId as deriveRootBodyPartId,
7
7
  } from "../id.js";
8
8
  import { createSqliteRepositories } from "../sqlite-repositories.js";
9
+ import { createTestDb, type TestDb } from "../test-db.js";
9
10
  import type { DrizzleEnvelopeRepository } from "./envelope.js";
10
11
  import type { DrizzleMessageRepository } from "./message.js";
11
12
  import { messageObjectsInMemory } from "./message-objects.js";
12
- import { createTestDb, type TestDb } from "./test-helpers.js";
13
13
 
14
14
  describe("DrizzleMessageRepository", () => {
15
15
  let db: TestDb;
@@ -38,7 +38,7 @@ describe("DrizzleMessageRepository", () => {
38
38
  };
39
39
 
40
40
  before(async () => {
41
- ({ db, stop } = await createTestDb());
41
+ ({ db, close: stop } = await createTestDb());
42
42
  const repos = createSqliteRepositories(
43
43
  { app: db, mail: db, search: db },
44
44
  BETTER_SQLITE3_LIMITS,
@@ -122,7 +122,6 @@ describe("DrizzleMessageRepository", () => {
122
122
  assert.equal(desc.bodyPart.length, 0);
123
123
  assert.equal(desc.bodyPartParameter.length, 0);
124
124
  assert.equal(desc.rawMessageStorage.length, 0);
125
- assert.equal(desc.bodyPartStorage.length, 0);
126
125
  });
127
126
 
128
127
  test("throws NotFoundError for unknown messageId", async () => {
@@ -149,8 +148,6 @@ describe("DrizzleMessageRepository", () => {
149
148
  sequenceNumber: 1,
150
149
  rfc822Size: 512,
151
150
  internalDate: NOW,
152
- envelopeId: deriveEnvelopeId(MOVE_MESSAGE_ID),
153
- rootBodyPartId: deriveRootBodyPartId(MOVE_MESSAGE_ID),
154
151
  status: "active" as const,
155
152
  syncStatus: "synced" as const,
156
153
  });
@@ -264,8 +261,6 @@ describe("DrizzleMessageRepository", () => {
264
261
  sequenceNumber: 1,
265
262
  rfc822Size: 10,
266
263
  internalDate: NOW,
267
- envelopeId: DEL_ENVELOPE_ID,
268
- rootBodyPartId: DEL_ROOT_BODY_PART_ID,
269
264
  status: "active",
270
265
  syncStatus: "synced",
271
266
  });
@@ -8,6 +8,7 @@ import type {
8
8
  import {
9
9
  and,
10
10
  asc,
11
+ type Column,
11
12
  eq,
12
13
  exists,
13
14
  gt,
@@ -19,6 +20,7 @@ import {
19
20
  type SQL,
20
21
  } from "drizzle-orm";
21
22
  import type { SqliteBatchItem, WriteBatch } from "../batch.js";
23
+ import { chunk, chunkRows } from "../bound-parameters.js";
22
24
  import type { RepositoryDb, SqliteStore } from "../db.js";
23
25
  import {
24
26
  CreateFailedConflictError,
@@ -26,13 +28,16 @@ import {
26
28
  NotFoundError,
27
29
  } from "../error.js";
28
30
  import {
31
+ bodyPartId as deriveBodyPartId,
32
+ bodyPartParameterId as deriveBodyPartParameterId,
33
+ envelopeAddressId as deriveEnvelopeAddressId,
29
34
  envelopeId as deriveEnvelopeId,
35
+ messageReferenceId as deriveMessageReferenceId,
30
36
  rootBodyPartId as deriveRootBodyPartId,
31
37
  } from "../id.js";
32
38
  import { decodeToken, resultList } from "../pagination.js";
33
39
  import {
34
40
  bodyPartParameterTable,
35
- bodyPartStorageTable,
36
41
  bodyPartTable,
37
42
  bodyStructureRefetchTable,
38
43
  envelopeAddressTable,
@@ -45,11 +50,10 @@ import {
45
50
  } from "../schema/message-data.js";
46
51
  import { threadMessageTable } from "../schema/thread-message.js";
47
52
  import type { SearchIndex } from "../search/thread-message-search.js";
48
- import { inList } from "./in-list.js";
53
+ import { inList, pairInList } from "./in-list.js";
49
54
  import {
50
55
  toBodyPartItem,
51
56
  toBodyPartParameterItem,
52
- toBodyPartStorageItem,
53
57
  toEnvelopeAddressItem,
54
58
  toEnvelopeItem,
55
59
  toMessageReferenceItem,
@@ -118,18 +122,23 @@ export type SubtreeDb = Pick<
118
122
  "delete" | "select"
119
123
  >;
120
124
 
125
+ export type SubtreeGuard = (messageId: Column) => SQL;
126
+
127
+ export const MAIL_DELETE_BATCH_SIZE = 100;
128
+
121
129
  /**
122
130
  * The statements that delete a message and its whole per-message subtree by
123
131
  * message id: the child tables, then the message row. The caller runs them as
124
- * one batch so the subtree goes atomically. `removed` reads the rows the batch
125
- * deletes; it goes first, so it sees them under the same guard. `guard` rides
126
- * every statement, so a guard that reads the message row holds until the last
127
- * statement removes it.
132
+ * one batch so the subtree goes atomically. `removed` and `removedParts` read
133
+ * the message and body-part rows the batch deletes; they go first, so they see
134
+ * them under the same guard. `guard` rides every statement on that
135
+ * statement's own `messageId` column, so a guard that reads the message row
136
+ * holds until the last statement removes it.
128
137
  */
129
138
  export function deleteMessageSubtree(
130
139
  db: SubtreeDb,
131
- messageIds: string[],
132
- guard?: SQL,
140
+ messageIds: readonly string[],
141
+ guard?: SubtreeGuard,
133
142
  ) {
134
143
  const removed = db
135
144
  .select({
@@ -137,46 +146,110 @@ export function deleteMessageSubtree(
137
146
  bodyStorageKey: messageTable.bodyStorageKey,
138
147
  })
139
148
  .from(messageTable)
140
- .where(and(inList(messageTable.messageId, messageIds), guard));
141
- if (messageIds.length === 0) return { removed, statements: [] };
149
+ .where(
150
+ and(
151
+ inList(messageTable.messageId, messageIds),
152
+ guard?.(messageTable.messageId),
153
+ ),
154
+ );
155
+ const removedParts = db
156
+ .select({
157
+ messageId: bodyPartTable.messageId,
158
+ partPath: bodyPartTable.partPath,
159
+ })
160
+ .from(bodyPartTable)
161
+ .where(
162
+ and(
163
+ inList(bodyPartTable.messageId, messageIds),
164
+ guard?.(bodyPartTable.messageId),
165
+ ),
166
+ );
167
+ if (messageIds.length === 0) {
168
+ return { removed, removedParts, statements: [] };
169
+ }
142
170
  const statements: SqliteBatchItem[] = [
143
171
  db
144
172
  .delete(messageFlagTable)
145
- .where(and(inList(messageFlagTable.messageId, messageIds), guard)),
173
+ .where(
174
+ and(
175
+ inList(messageFlagTable.messageId, messageIds),
176
+ guard?.(messageFlagTable.messageId),
177
+ ),
178
+ ),
146
179
  db
147
180
  .delete(messageReferenceTable)
148
- .where(and(inList(messageReferenceTable.messageId, messageIds), guard)),
181
+ .where(
182
+ and(
183
+ inList(messageReferenceTable.messageId, messageIds),
184
+ guard?.(messageReferenceTable.messageId),
185
+ ),
186
+ ),
149
187
  db
150
188
  .delete(envelopeAddressTable)
151
- .where(and(inList(envelopeAddressTable.messageId, messageIds), guard)),
189
+ .where(
190
+ and(
191
+ inList(envelopeAddressTable.messageId, messageIds),
192
+ guard?.(envelopeAddressTable.messageId),
193
+ ),
194
+ ),
152
195
  db
153
196
  .delete(bodyPartParameterTable)
154
- .where(and(inList(bodyPartParameterTable.messageId, messageIds), guard)),
155
- db
156
- .delete(bodyPartStorageTable)
157
- .where(and(inList(bodyPartStorageTable.messageId, messageIds), guard)),
197
+ .where(
198
+ and(
199
+ inList(bodyPartParameterTable.messageId, messageIds),
200
+ guard?.(bodyPartParameterTable.messageId),
201
+ ),
202
+ ),
158
203
  db
159
204
  .delete(rawMessageStorageTable)
160
- .where(and(inList(rawMessageStorageTable.messageId, messageIds), guard)),
205
+ .where(
206
+ and(
207
+ inList(rawMessageStorageTable.messageId, messageIds),
208
+ guard?.(rawMessageStorageTable.messageId),
209
+ ),
210
+ ),
161
211
  db
162
212
  .delete(bodyPartTable)
163
- .where(and(inList(bodyPartTable.messageId, messageIds), guard)),
213
+ .where(
214
+ and(
215
+ inList(bodyPartTable.messageId, messageIds),
216
+ guard?.(bodyPartTable.messageId),
217
+ ),
218
+ ),
164
219
  db
165
220
  .delete(bodyStructureRefetchTable)
166
221
  .where(
167
- and(inList(bodyStructureRefetchTable.messageId, messageIds), guard),
222
+ and(
223
+ inList(bodyStructureRefetchTable.messageId, messageIds),
224
+ guard?.(bodyStructureRefetchTable.messageId),
225
+ ),
168
226
  ),
169
227
  db
170
228
  .delete(messageSearchTextTable)
171
- .where(and(inList(messageSearchTextTable.messageId, messageIds), guard)),
229
+ .where(
230
+ and(
231
+ inList(messageSearchTextTable.messageId, messageIds),
232
+ guard?.(messageSearchTextTable.messageId),
233
+ ),
234
+ ),
172
235
  db
173
236
  .delete(envelopeTable)
174
- .where(and(inList(envelopeTable.messageId, messageIds), guard)),
237
+ .where(
238
+ and(
239
+ inList(envelopeTable.messageId, messageIds),
240
+ guard?.(envelopeTable.messageId),
241
+ ),
242
+ ),
175
243
  db
176
244
  .delete(messageTable)
177
- .where(and(inList(messageTable.messageId, messageIds), guard)),
245
+ .where(
246
+ and(
247
+ inList(messageTable.messageId, messageIds),
248
+ guard?.(messageTable.messageId),
249
+ ),
250
+ ),
178
251
  ];
179
- return { removed, statements };
252
+ return { removed, removedParts, statements };
180
253
  }
181
254
 
182
255
  /**
@@ -210,6 +283,7 @@ const placementTerms = (expected: PlacementPredicate): SQL[] => {
210
283
  export class DrizzleMessageRepository implements IMessageRepository {
211
284
  private readonly db: DB;
212
285
  private readonly batch: WriteBatch;
286
+ private readonly maxBoundParameters: number;
213
287
 
214
288
  constructor(
215
289
  store: SqliteStore<Record<string, unknown>>,
@@ -218,6 +292,7 @@ export class DrizzleMessageRepository implements IMessageRepository {
218
292
  ) {
219
293
  this.db = store.db;
220
294
  this.batch = store.batch;
295
+ this.maxBoundParameters = store.maxBoundParameters;
221
296
  }
222
297
 
223
298
  private rowOf(input: CreateMessageInput) {
@@ -322,7 +397,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
322
397
  bodyParts,
323
398
  bodyPartParameters,
324
399
  rawMessageStorages,
325
- bodyPartStorages,
326
400
  ] = await Promise.all([
327
401
  this.db
328
402
  .select()
@@ -356,10 +430,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
356
430
  .select()
357
431
  .from(rawMessageStorageTable)
358
432
  .where(eq(rawMessageStorageTable.messageId, messageId)),
359
- this.db
360
- .select()
361
- .from(bodyPartStorageTable)
362
- .where(eq(bodyPartStorageTable.messageId, messageId)),
363
433
  ]);
364
434
 
365
435
  if (messages.length === 0) {
@@ -382,7 +452,6 @@ export class DrizzleMessageRepository implements IMessageRepository {
382
452
  bodyPart: bodyParts.map(toBodyPartItem),
383
453
  bodyPartParameter: bodyPartParameters.map(toBodyPartParameterItem),
384
454
  rawMessageStorage: rawMessageStorages.map(toRawMessageStorageItem),
385
- bodyPartStorage: bodyPartStorages.map(toBodyPartStorageItem),
386
455
  };
387
456
  }
388
457
 
@@ -472,16 +541,172 @@ export class DrizzleMessageRepository implements IMessageRepository {
472
541
  });
473
542
  }
474
543
 
475
- async copySearchText(
544
+ async copyMessage(
476
545
  sourceMessageId: string,
477
- targetMessageId: string,
478
- ): Promise<void> {
479
- const [source] = await this.db
480
- .select({ text: messageSearchTextTable.text })
481
- .from(messageSearchTextTable)
482
- .where(eq(messageSearchTextTable.messageId, sourceMessageId));
483
- if (!source) return;
484
- await this.setSearchText(targetMessageId, source.text);
546
+ input: CreateMessageInput,
547
+ ): Promise<{ item: MessageItem; created: boolean }> {
548
+ const targetMessageId = input.messageId;
549
+ const [searchTexts, envelopes, references, addresses, parts, parameters] =
550
+ await Promise.all([
551
+ this.db
552
+ .select({ text: messageSearchTextTable.text })
553
+ .from(messageSearchTextTable)
554
+ .where(eq(messageSearchTextTable.messageId, sourceMessageId)),
555
+ this.db
556
+ .select()
557
+ .from(envelopeTable)
558
+ .where(eq(envelopeTable.messageId, sourceMessageId)),
559
+ this.db
560
+ .select()
561
+ .from(messageReferenceTable)
562
+ .where(eq(messageReferenceTable.messageId, sourceMessageId)),
563
+ this.db
564
+ .select()
565
+ .from(envelopeAddressTable)
566
+ .where(eq(envelopeAddressTable.messageId, sourceMessageId)),
567
+ this.db
568
+ .select()
569
+ .from(bodyPartTable)
570
+ .where(eq(bodyPartTable.messageId, sourceMessageId)),
571
+ this.db
572
+ .select()
573
+ .from(bodyPartParameterTable)
574
+ .where(eq(bodyPartParameterTable.messageId, sourceMessageId)),
575
+ ]);
576
+
577
+ const now = Date.now();
578
+ const envelopeId = deriveEnvelopeId(targetMessageId);
579
+ const copiedPartIds = new Map(
580
+ parts.map((part) => [
581
+ part.bodyPartId,
582
+ deriveBodyPartId(targetMessageId, part.partPath),
583
+ ]),
584
+ );
585
+ const partPaths = new Map(
586
+ parts.map((part) => [part.bodyPartId, part.partPath]),
587
+ );
588
+
589
+ const searchTextRows = searchTexts.map((row) => ({
590
+ messageId: targetMessageId,
591
+ text: row.text,
592
+ createdAt: now,
593
+ updatedAt: now,
594
+ }));
595
+ const envelopeRows = envelopes.map((row) => ({
596
+ ...row,
597
+ envelopeId,
598
+ messageId: targetMessageId,
599
+ createdAt: now,
600
+ updatedAt: now,
601
+ }));
602
+ const referenceRows = references.map((row) => ({
603
+ ...row,
604
+ messageReferenceId: deriveMessageReferenceId(
605
+ targetMessageId,
606
+ row.referenceType,
607
+ row.referenceOrder,
608
+ ),
609
+ messageId: targetMessageId,
610
+ envelopeId,
611
+ createdAt: now,
612
+ updatedAt: now,
613
+ }));
614
+ const addressRows = addresses.map((row) => ({
615
+ ...row,
616
+ envelopeAddressId: deriveEnvelopeAddressId(
617
+ targetMessageId,
618
+ row.addressRole,
619
+ row.addressOrder,
620
+ ),
621
+ messageId: targetMessageId,
622
+ createdAt: now,
623
+ updatedAt: now,
624
+ }));
625
+ const partRows = parts.map((row) => ({
626
+ ...row,
627
+ bodyPartId: deriveBodyPartId(targetMessageId, row.partPath),
628
+ messageId: targetMessageId,
629
+ parentBodyPartId:
630
+ row.parentBodyPartId === null
631
+ ? null
632
+ : (copiedPartIds.get(row.parentBodyPartId) ?? null),
633
+ createdAt: now,
634
+ updatedAt: now,
635
+ }));
636
+ const parameterRows = parameters.flatMap((row) => {
637
+ const partPath = partPaths.get(row.bodyPartId);
638
+ if (partPath === undefined) return [];
639
+ return [
640
+ {
641
+ ...row,
642
+ bodyPartParameterId: deriveBodyPartParameterId(
643
+ targetMessageId,
644
+ partPath,
645
+ row.parameterName,
646
+ ),
647
+ messageId: targetMessageId,
648
+ bodyPartId: deriveBodyPartId(targetMessageId, partPath),
649
+ createdAt: now,
650
+ updatedAt: now,
651
+ },
652
+ ];
653
+ });
654
+
655
+ const insertCopy = this.db
656
+ .insert(messageTable)
657
+ .values(this.rowOf(input))
658
+ .onConflictDoNothing({ target: messageTable.messageId })
659
+ .returning();
660
+ const clones: SqliteBatchItem[] = [
661
+ ...searchTextRows.map((row) =>
662
+ this.db
663
+ .insert(messageSearchTextTable)
664
+ .values(row)
665
+ .onConflictDoNothing({ target: messageSearchTextTable.messageId }),
666
+ ),
667
+ ...chunkRows(envelopeTable, envelopeRows, this.maxBoundParameters).map(
668
+ (slice) =>
669
+ this.db.insert(envelopeTable).values(slice).onConflictDoNothing(),
670
+ ),
671
+ ...chunkRows(
672
+ messageReferenceTable,
673
+ referenceRows,
674
+ this.maxBoundParameters,
675
+ ).map((slice) =>
676
+ this.db
677
+ .insert(messageReferenceTable)
678
+ .values(slice)
679
+ .onConflictDoNothing(),
680
+ ),
681
+ ...chunkRows(
682
+ envelopeAddressTable,
683
+ addressRows,
684
+ this.maxBoundParameters,
685
+ ).map((slice) =>
686
+ this.db
687
+ .insert(envelopeAddressTable)
688
+ .values(slice)
689
+ .onConflictDoNothing(),
690
+ ),
691
+ ...chunkRows(bodyPartTable, partRows, this.maxBoundParameters).map(
692
+ (slice) =>
693
+ this.db.insert(bodyPartTable).values(slice).onConflictDoNothing(),
694
+ ),
695
+ ...chunkRows(
696
+ bodyPartParameterTable,
697
+ parameterRows,
698
+ this.maxBoundParameters,
699
+ ).map((slice) =>
700
+ this.db
701
+ .insert(bodyPartParameterTable)
702
+ .values(slice)
703
+ .onConflictDoNothing(),
704
+ ),
705
+ ];
706
+ const [inserted] = await this.batch([insertCopy, ...clones]);
707
+ const [row] = inserted;
708
+ if (row) return { item: toMessageItem(row), created: true };
709
+ return { item: await this.get(targetMessageId), created: false };
485
710
  }
486
711
 
487
712
  async syncSearch(messageIds: string[]): Promise<void> {
@@ -542,31 +767,65 @@ export class DrizzleMessageRepository implements IMessageRepository {
542
767
  ),
543
768
  ),
544
769
  );
545
- const listings = await this.db
546
- .select({ threadMessageId: threadMessageTable.threadMessageId })
547
- .from(threadMessageTable)
548
- .where(eq(threadMessageTable.messageId, messageId));
549
- const subtree = deleteMessageSubtree(this.db, [messageId], placed);
550
- const [removed] = await this.batch([
551
- subtree.removed,
552
- this.db
553
- .delete(threadMessageTable)
554
- .where(and(eq(threadMessageTable.messageId, messageId), placed)),
555
- ...subtree.statements,
556
- ]);
557
- await this.search.sync(listings.map((row) => row.threadMessageId));
558
- await this.messageObjects.reclaim(removed);
559
- return removed.length > 0;
770
+ return (await this.removeMessages([messageId], () => placed)) > 0;
771
+ }
772
+
773
+ async deleteManyIfPlacement(
774
+ mailboxId: string,
775
+ placements: ReadonlyArray<{ messageId: string; uid: number }>,
776
+ ): Promise<void> {
777
+ const placed = this.db
778
+ .select({ messageId: messageTable.messageId })
779
+ .from(messageTable)
780
+ .where(
781
+ and(
782
+ eq(messageTable.mailboxId, mailboxId),
783
+ pairInList(
784
+ messageTable.messageId,
785
+ messageTable.uid,
786
+ placements.map(({ messageId, uid }) => [messageId, uid] as const),
787
+ ),
788
+ ),
789
+ );
790
+ await this.removeMessages(
791
+ placements.map((placement) => placement.messageId),
792
+ (column) => inArray(column, placed),
793
+ );
560
794
  }
561
795
 
562
796
  async deleteMany(messageIds: string[]): Promise<void> {
563
- if (messageIds.length === 0) return;
564
- const subtree = deleteMessageSubtree(this.db, messageIds);
565
- const [removed] = await this.batch([
566
- subtree.removed,
567
- ...subtree.statements,
568
- ]);
569
- await this.messageObjects.reclaim(removed);
797
+ await this.removeMessages(messageIds);
798
+ }
799
+
800
+ private async removeMessages(
801
+ messageIds: readonly string[],
802
+ guard?: SubtreeGuard,
803
+ ): Promise<number> {
804
+ let removedCount = 0;
805
+ for (const batch of chunk(messageIds, MAIL_DELETE_BATCH_SIZE)) {
806
+ const listings = await this.db
807
+ .select({ threadMessageId: threadMessageTable.threadMessageId })
808
+ .from(threadMessageTable)
809
+ .where(inList(threadMessageTable.messageId, batch));
810
+ const subtree = deleteMessageSubtree(this.db, batch, guard);
811
+ const [removed, removedParts] = await this.batch([
812
+ subtree.removed,
813
+ subtree.removedParts,
814
+ this.db
815
+ .delete(threadMessageTable)
816
+ .where(
817
+ and(
818
+ inList(threadMessageTable.messageId, batch),
819
+ guard?.(threadMessageTable.messageId),
820
+ ),
821
+ ),
822
+ ...subtree.statements,
823
+ ]);
824
+ await this.search.sync(listings.map((row) => row.threadMessageId));
825
+ await this.messageObjects.reclaim(removed, removedParts);
826
+ removedCount += removed.length;
827
+ }
828
+ return removedCount;
570
829
  }
571
830
 
572
831
  async listByMailbox(
@@ -1,7 +1,6 @@
1
1
  import * as entities from "@remit/drizzle-sqlite-schema";
2
2
 
3
3
  const bodyPartParameterTable = entities.bodyPartParameters;
4
- const bodyPartStorageTable = entities.bodyPartStorages;
5
4
  const bodyPartTable = entities.bodyParts;
6
5
  const bodyStructureRefetchTable = entities.bodyStructureRefetches;
7
6
  const envelopeAddressTable = entities.envelopeAddresses;
@@ -14,7 +13,6 @@ const rawMessageStorageTable = entities.rawMessageStorages;
14
13
 
15
14
  export {
16
15
  bodyPartParameterTable,
17
- bodyPartStorageTable,
18
16
  bodyPartTable,
19
17
  bodyStructureRefetchTable,
20
18
  envelopeAddressTable,
@@ -33,7 +31,6 @@ export const messageDataSchema = {
33
31
  bodyPart: bodyPartTable,
34
32
  bodyPartParameter: bodyPartParameterTable,
35
33
  rawMessageStorage: rawMessageStorageTable,
36
- bodyPartStorage: bodyPartStorageTable,
37
34
  bodyStructureRefetch: bodyStructureRefetchTable,
38
35
  message: messageTable,
39
36
  messageFlag: messageFlagTable,
@@ -4,7 +4,6 @@
4
4
  export {
5
5
  addresses,
6
6
  bodyPartParameters,
7
- bodyPartStorages,
8
7
  bodyParts,
9
8
  bodyStructureRefetches,
10
9
  calendarSuggestions,
@@ -48,7 +48,12 @@ export const composeSqliteRepositories = (
48
48
  const messageObjects = new MessageObjects(mail.db, objects);
49
49
  const accountSetting = new AccountSettingRepo(app.db);
50
50
  const address = new AddressRepo(mail.db);
51
- const mailbox = new MailboxRepo(mail, searchIndex, messageObjects);
51
+ const message = new DrizzleMessageRepository(
52
+ mail,
53
+ searchIndex,
54
+ messageObjects,
55
+ );
56
+ const mailbox = new MailboxRepo(mail, message);
52
57
  const account = new AccountRepo(app.db, mailbox);
53
58
  const calendarSuggestion = new CalendarSuggestionRepo(mail.db);
54
59
  return {
@@ -63,7 +68,7 @@ export const composeSqliteRepositories = (
63
68
  accountSetting,
64
69
  ),
65
70
  mailboxLock: new MailboxLockRepo(mail.db),
66
- message: new DrizzleMessageRepository(mail, searchIndex, messageObjects),
71
+ message,
67
72
  messageFlag: new DrizzleMessageFlagRepository(mail.db),
68
73
  outboxMessage: new OutboxMessageRepo(mail.db),
69
74
  outboxAttachment: new OutboxAttachmentRepo(mail),