@remit/drizzle-service 0.0.107 → 0.0.108

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.108",
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";
@@ -21,7 +21,7 @@ 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,
@@ -229,8 +229,10 @@ export class DrizzleEnvelopeRepository implements IEnvelopeRepository {
229
229
  .where(eq(messageReferenceTable.messageId, messageId));
230
230
  const rows = references.map((reference) => ({
231
231
  ...reference,
232
- messageReferenceId: deterministicBase36Id(
233
- `msgref:${messageId}:${reference.referenceType}:${reference.referenceOrder}`,
232
+ messageReferenceId: deriveMessageReferenceId(
233
+ messageId,
234
+ reference.referenceType,
235
+ reference.referenceOrder,
234
236
  ),
235
237
  messageId,
236
238
  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)
@@ -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,
@@ -149,8 +149,6 @@ describe("DrizzleMessageRepository", () => {
149
149
  sequenceNumber: 1,
150
150
  rfc822Size: 512,
151
151
  internalDate: NOW,
152
- envelopeId: deriveEnvelopeId(MOVE_MESSAGE_ID),
153
- rootBodyPartId: deriveRootBodyPartId(MOVE_MESSAGE_ID),
154
152
  status: "active" as const,
155
153
  syncStatus: "synced" as const,
156
154
  });
@@ -264,8 +262,6 @@ describe("DrizzleMessageRepository", () => {
264
262
  sequenceNumber: 1,
265
263
  rfc822Size: 10,
266
264
  internalDate: NOW,
267
- envelopeId: DEL_ENVELOPE_ID,
268
- rootBodyPartId: DEL_ROOT_BODY_PART_ID,
269
265
  status: "active",
270
266
  syncStatus: "synced",
271
267
  });
@@ -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,7 +28,11 @@ 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";
@@ -45,7 +51,7 @@ import {
45
51
  } from "../schema/message-data.js";
46
52
  import { threadMessageTable } from "../schema/thread-message.js";
47
53
  import type { SearchIndex } from "../search/thread-message-search.js";
48
- import { inList } from "./in-list.js";
54
+ import { inList, pairInList } from "./in-list.js";
49
55
  import {
50
56
  toBodyPartItem,
51
57
  toBodyPartParameterItem,
@@ -118,18 +124,23 @@ export type SubtreeDb = Pick<
118
124
  "delete" | "select"
119
125
  >;
120
126
 
127
+ export type SubtreeGuard = (messageId: Column) => SQL;
128
+
129
+ export const MAIL_DELETE_BATCH_SIZE = 100;
130
+
121
131
  /**
122
132
  * The statements that delete a message and its whole per-message subtree by
123
133
  * 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.
134
+ * one batch so the subtree goes atomically. `removed` and `removedParts` read
135
+ * the message and body-part rows the batch deletes; they go first, so they see
136
+ * them under the same guard. `guard` rides every statement on that
137
+ * statement's own `messageId` column, so a guard that reads the message row
138
+ * holds until the last statement removes it.
128
139
  */
129
140
  export function deleteMessageSubtree(
130
141
  db: SubtreeDb,
131
- messageIds: string[],
132
- guard?: SQL,
142
+ messageIds: readonly string[],
143
+ guard?: SubtreeGuard,
133
144
  ) {
134
145
  const removed = db
135
146
  .select({
@@ -137,46 +148,118 @@ export function deleteMessageSubtree(
137
148
  bodyStorageKey: messageTable.bodyStorageKey,
138
149
  })
139
150
  .from(messageTable)
140
- .where(and(inList(messageTable.messageId, messageIds), guard));
141
- if (messageIds.length === 0) return { removed, statements: [] };
151
+ .where(
152
+ and(
153
+ inList(messageTable.messageId, messageIds),
154
+ guard?.(messageTable.messageId),
155
+ ),
156
+ );
157
+ const removedParts = db
158
+ .select({
159
+ messageId: bodyPartTable.messageId,
160
+ partPath: bodyPartTable.partPath,
161
+ })
162
+ .from(bodyPartTable)
163
+ .where(
164
+ and(
165
+ inList(bodyPartTable.messageId, messageIds),
166
+ guard?.(bodyPartTable.messageId),
167
+ ),
168
+ );
169
+ if (messageIds.length === 0) {
170
+ return { removed, removedParts, statements: [] };
171
+ }
142
172
  const statements: SqliteBatchItem[] = [
143
173
  db
144
174
  .delete(messageFlagTable)
145
- .where(and(inList(messageFlagTable.messageId, messageIds), guard)),
175
+ .where(
176
+ and(
177
+ inList(messageFlagTable.messageId, messageIds),
178
+ guard?.(messageFlagTable.messageId),
179
+ ),
180
+ ),
146
181
  db
147
182
  .delete(messageReferenceTable)
148
- .where(and(inList(messageReferenceTable.messageId, messageIds), guard)),
183
+ .where(
184
+ and(
185
+ inList(messageReferenceTable.messageId, messageIds),
186
+ guard?.(messageReferenceTable.messageId),
187
+ ),
188
+ ),
149
189
  db
150
190
  .delete(envelopeAddressTable)
151
- .where(and(inList(envelopeAddressTable.messageId, messageIds), guard)),
191
+ .where(
192
+ and(
193
+ inList(envelopeAddressTable.messageId, messageIds),
194
+ guard?.(envelopeAddressTable.messageId),
195
+ ),
196
+ ),
152
197
  db
153
198
  .delete(bodyPartParameterTable)
154
- .where(and(inList(bodyPartParameterTable.messageId, messageIds), guard)),
199
+ .where(
200
+ and(
201
+ inList(bodyPartParameterTable.messageId, messageIds),
202
+ guard?.(bodyPartParameterTable.messageId),
203
+ ),
204
+ ),
155
205
  db
156
206
  .delete(bodyPartStorageTable)
157
- .where(and(inList(bodyPartStorageTable.messageId, messageIds), guard)),
207
+ .where(
208
+ and(
209
+ inList(bodyPartStorageTable.messageId, messageIds),
210
+ guard?.(bodyPartStorageTable.messageId),
211
+ ),
212
+ ),
158
213
  db
159
214
  .delete(rawMessageStorageTable)
160
- .where(and(inList(rawMessageStorageTable.messageId, messageIds), guard)),
215
+ .where(
216
+ and(
217
+ inList(rawMessageStorageTable.messageId, messageIds),
218
+ guard?.(rawMessageStorageTable.messageId),
219
+ ),
220
+ ),
161
221
  db
162
222
  .delete(bodyPartTable)
163
- .where(and(inList(bodyPartTable.messageId, messageIds), guard)),
223
+ .where(
224
+ and(
225
+ inList(bodyPartTable.messageId, messageIds),
226
+ guard?.(bodyPartTable.messageId),
227
+ ),
228
+ ),
164
229
  db
165
230
  .delete(bodyStructureRefetchTable)
166
231
  .where(
167
- and(inList(bodyStructureRefetchTable.messageId, messageIds), guard),
232
+ and(
233
+ inList(bodyStructureRefetchTable.messageId, messageIds),
234
+ guard?.(bodyStructureRefetchTable.messageId),
235
+ ),
168
236
  ),
169
237
  db
170
238
  .delete(messageSearchTextTable)
171
- .where(and(inList(messageSearchTextTable.messageId, messageIds), guard)),
239
+ .where(
240
+ and(
241
+ inList(messageSearchTextTable.messageId, messageIds),
242
+ guard?.(messageSearchTextTable.messageId),
243
+ ),
244
+ ),
172
245
  db
173
246
  .delete(envelopeTable)
174
- .where(and(inList(envelopeTable.messageId, messageIds), guard)),
247
+ .where(
248
+ and(
249
+ inList(envelopeTable.messageId, messageIds),
250
+ guard?.(envelopeTable.messageId),
251
+ ),
252
+ ),
175
253
  db
176
254
  .delete(messageTable)
177
- .where(and(inList(messageTable.messageId, messageIds), guard)),
255
+ .where(
256
+ and(
257
+ inList(messageTable.messageId, messageIds),
258
+ guard?.(messageTable.messageId),
259
+ ),
260
+ ),
178
261
  ];
179
- return { removed, statements };
262
+ return { removed, removedParts, statements };
180
263
  }
181
264
 
182
265
  /**
@@ -210,6 +293,7 @@ const placementTerms = (expected: PlacementPredicate): SQL[] => {
210
293
  export class DrizzleMessageRepository implements IMessageRepository {
211
294
  private readonly db: DB;
212
295
  private readonly batch: WriteBatch;
296
+ private readonly maxBoundParameters: number;
213
297
 
214
298
  constructor(
215
299
  store: SqliteStore<Record<string, unknown>>,
@@ -218,6 +302,7 @@ export class DrizzleMessageRepository implements IMessageRepository {
218
302
  ) {
219
303
  this.db = store.db;
220
304
  this.batch = store.batch;
305
+ this.maxBoundParameters = store.maxBoundParameters;
221
306
  }
222
307
 
223
308
  private rowOf(input: CreateMessageInput) {
@@ -472,16 +557,172 @@ export class DrizzleMessageRepository implements IMessageRepository {
472
557
  });
473
558
  }
474
559
 
475
- async copySearchText(
560
+ async copyMessage(
476
561
  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);
562
+ input: CreateMessageInput,
563
+ ): Promise<{ item: MessageItem; created: boolean }> {
564
+ const targetMessageId = input.messageId;
565
+ const [searchTexts, envelopes, references, addresses, parts, parameters] =
566
+ await Promise.all([
567
+ this.db
568
+ .select({ text: messageSearchTextTable.text })
569
+ .from(messageSearchTextTable)
570
+ .where(eq(messageSearchTextTable.messageId, sourceMessageId)),
571
+ this.db
572
+ .select()
573
+ .from(envelopeTable)
574
+ .where(eq(envelopeTable.messageId, sourceMessageId)),
575
+ this.db
576
+ .select()
577
+ .from(messageReferenceTable)
578
+ .where(eq(messageReferenceTable.messageId, sourceMessageId)),
579
+ this.db
580
+ .select()
581
+ .from(envelopeAddressTable)
582
+ .where(eq(envelopeAddressTable.messageId, sourceMessageId)),
583
+ this.db
584
+ .select()
585
+ .from(bodyPartTable)
586
+ .where(eq(bodyPartTable.messageId, sourceMessageId)),
587
+ this.db
588
+ .select()
589
+ .from(bodyPartParameterTable)
590
+ .where(eq(bodyPartParameterTable.messageId, sourceMessageId)),
591
+ ]);
592
+
593
+ const now = Date.now();
594
+ const envelopeId = deriveEnvelopeId(targetMessageId);
595
+ const copiedPartIds = new Map(
596
+ parts.map((part) => [
597
+ part.bodyPartId,
598
+ deriveBodyPartId(targetMessageId, part.partPath),
599
+ ]),
600
+ );
601
+ const partPaths = new Map(
602
+ parts.map((part) => [part.bodyPartId, part.partPath]),
603
+ );
604
+
605
+ const searchTextRows = searchTexts.map((row) => ({
606
+ messageId: targetMessageId,
607
+ text: row.text,
608
+ createdAt: now,
609
+ updatedAt: now,
610
+ }));
611
+ const envelopeRows = envelopes.map((row) => ({
612
+ ...row,
613
+ envelopeId,
614
+ messageId: targetMessageId,
615
+ createdAt: now,
616
+ updatedAt: now,
617
+ }));
618
+ const referenceRows = references.map((row) => ({
619
+ ...row,
620
+ messageReferenceId: deriveMessageReferenceId(
621
+ targetMessageId,
622
+ row.referenceType,
623
+ row.referenceOrder,
624
+ ),
625
+ messageId: targetMessageId,
626
+ envelopeId,
627
+ createdAt: now,
628
+ updatedAt: now,
629
+ }));
630
+ const addressRows = addresses.map((row) => ({
631
+ ...row,
632
+ envelopeAddressId: deriveEnvelopeAddressId(
633
+ targetMessageId,
634
+ row.addressRole,
635
+ row.addressOrder,
636
+ ),
637
+ messageId: targetMessageId,
638
+ createdAt: now,
639
+ updatedAt: now,
640
+ }));
641
+ const partRows = parts.map((row) => ({
642
+ ...row,
643
+ bodyPartId: deriveBodyPartId(targetMessageId, row.partPath),
644
+ messageId: targetMessageId,
645
+ parentBodyPartId:
646
+ row.parentBodyPartId === null
647
+ ? null
648
+ : (copiedPartIds.get(row.parentBodyPartId) ?? null),
649
+ createdAt: now,
650
+ updatedAt: now,
651
+ }));
652
+ const parameterRows = parameters.flatMap((row) => {
653
+ const partPath = partPaths.get(row.bodyPartId);
654
+ if (partPath === undefined) return [];
655
+ return [
656
+ {
657
+ ...row,
658
+ bodyPartParameterId: deriveBodyPartParameterId(
659
+ targetMessageId,
660
+ partPath,
661
+ row.parameterName,
662
+ ),
663
+ messageId: targetMessageId,
664
+ bodyPartId: deriveBodyPartId(targetMessageId, partPath),
665
+ createdAt: now,
666
+ updatedAt: now,
667
+ },
668
+ ];
669
+ });
670
+
671
+ const insertCopy = this.db
672
+ .insert(messageTable)
673
+ .values(this.rowOf(input))
674
+ .onConflictDoNothing({ target: messageTable.messageId })
675
+ .returning();
676
+ const clones: SqliteBatchItem[] = [
677
+ ...searchTextRows.map((row) =>
678
+ this.db
679
+ .insert(messageSearchTextTable)
680
+ .values(row)
681
+ .onConflictDoNothing({ target: messageSearchTextTable.messageId }),
682
+ ),
683
+ ...chunkRows(envelopeTable, envelopeRows, this.maxBoundParameters).map(
684
+ (slice) =>
685
+ this.db.insert(envelopeTable).values(slice).onConflictDoNothing(),
686
+ ),
687
+ ...chunkRows(
688
+ messageReferenceTable,
689
+ referenceRows,
690
+ this.maxBoundParameters,
691
+ ).map((slice) =>
692
+ this.db
693
+ .insert(messageReferenceTable)
694
+ .values(slice)
695
+ .onConflictDoNothing(),
696
+ ),
697
+ ...chunkRows(
698
+ envelopeAddressTable,
699
+ addressRows,
700
+ this.maxBoundParameters,
701
+ ).map((slice) =>
702
+ this.db
703
+ .insert(envelopeAddressTable)
704
+ .values(slice)
705
+ .onConflictDoNothing(),
706
+ ),
707
+ ...chunkRows(bodyPartTable, partRows, this.maxBoundParameters).map(
708
+ (slice) =>
709
+ this.db.insert(bodyPartTable).values(slice).onConflictDoNothing(),
710
+ ),
711
+ ...chunkRows(
712
+ bodyPartParameterTable,
713
+ parameterRows,
714
+ this.maxBoundParameters,
715
+ ).map((slice) =>
716
+ this.db
717
+ .insert(bodyPartParameterTable)
718
+ .values(slice)
719
+ .onConflictDoNothing(),
720
+ ),
721
+ ];
722
+ const [inserted] = await this.batch([insertCopy, ...clones]);
723
+ const [row] = inserted;
724
+ if (row) return { item: toMessageItem(row), created: true };
725
+ return { item: await this.get(targetMessageId), created: false };
485
726
  }
486
727
 
487
728
  async syncSearch(messageIds: string[]): Promise<void> {
@@ -542,31 +783,65 @@ export class DrizzleMessageRepository implements IMessageRepository {
542
783
  ),
543
784
  ),
544
785
  );
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;
786
+ return (await this.removeMessages([messageId], () => placed)) > 0;
787
+ }
788
+
789
+ async deleteManyIfPlacement(
790
+ mailboxId: string,
791
+ placements: ReadonlyArray<{ messageId: string; uid: number }>,
792
+ ): Promise<void> {
793
+ const placed = this.db
794
+ .select({ messageId: messageTable.messageId })
795
+ .from(messageTable)
796
+ .where(
797
+ and(
798
+ eq(messageTable.mailboxId, mailboxId),
799
+ pairInList(
800
+ messageTable.messageId,
801
+ messageTable.uid,
802
+ placements.map(({ messageId, uid }) => [messageId, uid] as const),
803
+ ),
804
+ ),
805
+ );
806
+ await this.removeMessages(
807
+ placements.map((placement) => placement.messageId),
808
+ (column) => inArray(column, placed),
809
+ );
560
810
  }
561
811
 
562
812
  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);
813
+ await this.removeMessages(messageIds);
814
+ }
815
+
816
+ private async removeMessages(
817
+ messageIds: readonly string[],
818
+ guard?: SubtreeGuard,
819
+ ): Promise<number> {
820
+ let removedCount = 0;
821
+ for (const batch of chunk(messageIds, MAIL_DELETE_BATCH_SIZE)) {
822
+ const listings = await this.db
823
+ .select({ threadMessageId: threadMessageTable.threadMessageId })
824
+ .from(threadMessageTable)
825
+ .where(inList(threadMessageTable.messageId, batch));
826
+ const subtree = deleteMessageSubtree(this.db, batch, guard);
827
+ const [removed, removedParts] = await this.batch([
828
+ subtree.removed,
829
+ subtree.removedParts,
830
+ this.db
831
+ .delete(threadMessageTable)
832
+ .where(
833
+ and(
834
+ inList(threadMessageTable.messageId, batch),
835
+ guard?.(threadMessageTable.messageId),
836
+ ),
837
+ ),
838
+ ...subtree.statements,
839
+ ]);
840
+ await this.search.sync(listings.map((row) => row.threadMessageId));
841
+ await this.messageObjects.reclaim(removed, removedParts);
842
+ removedCount += removed.length;
843
+ }
844
+ return removedCount;
570
845
  }
571
846
 
572
847
  async listByMailbox(
@@ -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),