@remit/drizzle-service 0.0.106 → 0.0.107

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.106",
3
+ "version": "0.0.107",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,7 +36,9 @@
36
36
  "uuid": "^13.0.2",
37
37
  "@remit/drizzle-sqlite-schema": "*",
38
38
  "better-sqlite3": "^12.11.1",
39
- "@types/better-sqlite3": "^7.6.13"
39
+ "@types/better-sqlite3": "^7.6.13",
40
+ "@remit/storage-service": "*",
41
+ "p-map": "^2.1.0"
40
42
  },
41
43
  "license": "MIT",
42
44
  "publishConfig": {
package/src/index.ts CHANGED
@@ -74,6 +74,11 @@ export {
74
74
  } from "./repos/message.js";
75
75
  export { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
76
76
  export { MessageLabelRepo } from "./repos/message-label.js";
77
+ export {
78
+ DEFAULT_MESSAGE_OBJECT_DELETE_CONCURRENCY,
79
+ type MessageObjectStore,
80
+ messageObjectsInMemory,
81
+ } from "./repos/message-objects.js";
77
82
  export { QuarantineRepo } from "./repos/quarantine.js";
78
83
  export { SyncWarningRepo } from "./repos/sync-warning.js";
79
84
  export { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
@@ -7,6 +7,7 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
7
7
  import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
8
8
  import { AccountSettingRepo } from "../repos/i4-account-setting.js";
9
9
  import type { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
10
+ import { messageObjectsInMemory } from "../repos/message-objects.js";
10
11
  import { createSqliteRepositories } from "../sqlite-repositories.js";
11
12
  import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
12
13
  import {
@@ -190,6 +191,7 @@ describe("addresses standing only on mail in Junk", () => {
190
191
  search: db,
191
192
  },
192
193
  BETTER_SQLITE3_LIMITS,
194
+ messageObjectsInMemory(),
193
195
  ).mailboxSpecialUse;
194
196
  accountSetting = new AccountSettingRepo(db as never);
195
197
  applyMigrationSet(sqlite, "app");
@@ -3,6 +3,7 @@ import { NotFoundError } from "../error.js";
3
3
  import { randomId } from "../id.js";
4
4
  import { composeSqliteRepositories } from "../sqlite-repositories.js";
5
5
  import { openD1TestStores } from "../test-d1.js";
6
+ import { messageObjectsInMemory } from "./message-objects.js";
6
7
 
7
8
  let close: (() => Promise<void>) | undefined;
8
9
 
@@ -10,7 +11,8 @@ accountRepositoryConformance({
10
11
  async createRepository() {
11
12
  const opened = await openD1TestStores();
12
13
  close = opened.close;
13
- return composeSqliteRepositories(opened.stores).account;
14
+ return composeSqliteRepositories(opened.stores, messageObjectsInMemory())
15
+ .account;
14
16
  },
15
17
  teardown: async () => {
16
18
  await close?.();
@@ -5,6 +5,7 @@ import { randomId } from "../id.js";
5
5
  import { accountTable } from "../schema.js";
6
6
  import { createSqliteRepositories } from "../sqlite-repositories.js";
7
7
  import { createSqliteTestDb } from "../test-db-sqlite.js";
8
+ import { messageObjectsInMemory } from "./message-objects.js";
8
9
 
9
10
  let close: (() => Promise<void>) | undefined;
10
11
 
@@ -21,6 +22,7 @@ accountRepositoryConformance({
21
22
  search: db,
22
23
  },
23
24
  BETTER_SQLITE3_LIMITS,
25
+ messageObjectsInMemory(),
24
26
  ).account;
25
27
  },
26
28
  teardown: async () => {
@@ -6,6 +6,7 @@ import type { RepositoryDb } from "../db.js";
6
6
  import { composeSqliteRepositories } from "../sqlite-repositories.js";
7
7
  import type { OpenTestStores } from "../test-stores.js";
8
8
  import { CalendarCollectionRepo } from "./calendar-collection.js";
9
+ import { messageObjectsInMemory } from "./message-objects.js";
9
10
 
10
11
  const ACCOUNT_CONFIG_ID = "account-config-default";
11
12
 
@@ -19,7 +20,10 @@ export const calendarDefaultSuite = (open: OpenTestStores): void => {
19
20
  const opened = await open();
20
21
  db = opened.stores.app.db;
21
22
  close = opened.close;
22
- store = composeSqliteRepositories(opened.stores).calendarStore;
23
+ store = composeSqliteRepositories(
24
+ opened.stores,
25
+ messageObjectsInMemory(),
26
+ ).calendarStore;
23
27
  });
24
28
 
25
29
  after(() => close());
@@ -18,6 +18,7 @@ import {
18
18
  } from "../schema.js";
19
19
  import { composeSqliteRepositories } from "../sqlite-repositories.js";
20
20
  import type { OpenTestStores } from "../test-stores.js";
21
+ import { messageObjectsInMemory } from "./message-objects.js";
21
22
 
22
23
  const ACCOUNT_CONFIG_ID = "account-config-1";
23
24
 
@@ -74,7 +75,10 @@ export const calendarPutSuite = (open: OpenTestStores): void => {
74
75
  const opened = await open();
75
76
  db = opened.stores.app.db;
76
77
  close = opened.close;
77
- store = composeSqliteRepositories(opened.stores).calendarStore;
78
+ store = composeSqliteRepositories(
79
+ opened.stores,
80
+ messageObjectsInMemory(),
81
+ ).calendarStore;
78
82
  const collection = await provisionDefaultCalendar(
79
83
  store,
80
84
  ACCOUNT_CONFIG_ID,
@@ -102,7 +102,7 @@ export const runDrizzleCascadeDelete = async (
102
102
  const items: SqliteBatchItem[] = [];
103
103
  const appItems: SqliteBatchItem[] = [];
104
104
  const messageIds = (grouped.get("Message") ?? []).map((k) => k.messageId);
105
- items.push(...deleteMessageSubtree(mail, messageIds));
105
+ items.push(...deleteMessageSubtree(mail, messageIds).statements);
106
106
 
107
107
  const threadMessageIds = (grouped.get("ThreadMessage") ?? []).map(
108
108
  (k) => k.threadMessageId,
@@ -8,6 +8,7 @@ import {
8
8
  import { createSqliteRepositories } from "../sqlite-repositories.js";
9
9
  import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
10
10
  import type { DrizzleEnvelopeRepository } from "./envelope.js";
11
+ import { messageObjectsInMemory } from "./message-objects.js";
11
12
 
12
13
  // Envelope repo on sqlite (RFC 036 D1): the onConflictDoUpdate upsert path and
13
14
  // the transactional body-part upsert both run on better-sqlite3 unchanged.
@@ -24,6 +25,7 @@ describe("DrizzleEnvelopeRepository (sqlite)", () => {
24
25
  repo = createSqliteRepositories(
25
26
  { app: db, mail: db, search: db },
26
27
  BETTER_SQLITE3_LIMITS,
28
+ messageObjectsInMemory(),
27
29
  ).envelope;
28
30
  });
29
31
 
@@ -5,6 +5,7 @@ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
5
5
  import { envelopeId as deriveEnvelopeId } from "../id.js";
6
6
  import { createSqliteRepositories } from "../sqlite-repositories.js";
7
7
  import type { DrizzleEnvelopeRepository } from "./envelope.js";
8
+ import { messageObjectsInMemory } from "./message-objects.js";
8
9
  import { createTestDb, type TestDb } from "./test-helpers.js";
9
10
 
10
11
  describe("DrizzleEnvelopeRepository", () => {
@@ -23,6 +24,7 @@ describe("DrizzleEnvelopeRepository", () => {
23
24
  repo = createSqliteRepositories(
24
25
  { app: db, mail: db, search: db },
25
26
  BETTER_SQLITE3_LIMITS,
27
+ messageObjectsInMemory(),
26
28
  ).envelope;
27
29
 
28
30
  await repo.upsertEnvelope({
@@ -7,6 +7,7 @@ import {
7
7
  } from "../sqlite-repositories.js";
8
8
  import type { OpenTestStores } from "../test-stores.js";
9
9
  import type { AccountConfigRepo } from "./i4-account-config.js";
10
+ import { messageObjectsInMemory } from "./message-objects.js";
10
11
 
11
12
  export const accountConfigRepoSuite = (open: OpenTestStores): void => {
12
13
  describe("AccountConfigRepo", () => {
@@ -17,7 +18,10 @@ export const accountConfigRepoSuite = (open: OpenTestStores): void => {
17
18
  before(async () => {
18
19
  const opened = await open();
19
20
  close = opened.close;
20
- repos = composeSqliteRepositories(opened.stores);
21
+ repos = composeSqliteRepositories(
22
+ opened.stores,
23
+ messageObjectsInMemory(),
24
+ );
21
25
  repo = repos.accountConfig;
22
26
  });
23
27
 
@@ -4,6 +4,7 @@ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
4
4
  import { createSqliteRepositories } from "../sqlite-repositories.js";
5
5
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
6
6
  import type { AccountRepo } from "./i4-account.js";
7
+ import { messageObjectsInMemory } from "./message-objects.js";
7
8
  import { mailboxCreated } from "./test-helpers.js";
8
9
 
9
10
  function makeAccountInput(accountConfigId: string) {
@@ -34,6 +35,7 @@ describe("AccountRepo", () => {
34
35
  search: db,
35
36
  },
36
37
  BETTER_SQLITE3_LIMITS,
38
+ messageObjectsInMemory(),
37
39
  ).account;
38
40
  });
39
41
 
@@ -175,6 +177,7 @@ describe("AccountRepo", () => {
175
177
  search: db,
176
178
  },
177
179
  BETTER_SQLITE3_LIMITS,
180
+ messageObjectsInMemory(),
178
181
  ).mailbox;
179
182
  mailboxCreated(
180
183
  await mailboxRepo.create({
@@ -6,6 +6,7 @@ import { createSqliteRepositories } from "../sqlite-repositories.js";
6
6
  import { createTestDb, type TestDb } from "../test-db.js";
7
7
  import { AddressRepo } from "./i4-address.js";
8
8
  import type { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
9
+ import { messageObjectsInMemory } from "./message-objects.js";
9
10
 
10
11
  const CONFIG = "cfg-1";
11
12
 
@@ -133,6 +134,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
133
134
  search: db,
134
135
  },
135
136
  BETTER_SQLITE3_LIMITS,
137
+ messageObjectsInMemory(),
136
138
  ).mailboxSpecialUse;
137
139
  });
138
140
 
@@ -18,6 +18,7 @@ import { createSqliteTestDb } from "../test-db-sqlite.js";
18
18
  import type { AccountSettingRepo } from "./i4-account-setting.js";
19
19
  import type { MailboxRepo } from "./i4-mailbox.js";
20
20
  import type { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
21
+ import { messageObjectsInMemory } from "./message-objects.js";
21
22
  import { mailboxCreated } from "./test-helpers.js";
22
23
 
23
24
  const makeMailboxInput = (
@@ -65,6 +66,7 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
65
66
  search: testDb.db,
66
67
  },
67
68
  BETTER_SQLITE3_LIMITS,
69
+ messageObjectsInMemory(),
68
70
  );
69
71
  repo = repos.mailboxSpecialUse;
70
72
  mailboxes = repos.mailbox;
@@ -13,6 +13,7 @@ import {
13
13
  shippedTableDdl,
14
14
  } from "../test-shipped-sqlite-schema.js";
15
15
  import type { MailboxRepo } from "./i4-mailbox.js";
16
+ import { messageObjectsInMemory } from "./message-objects.js";
16
17
  import { mailboxCreated } from "./test-helpers.js";
17
18
 
18
19
  function makeMailboxInput(accountId: string, fullPath = "INBOX") {
@@ -49,6 +50,7 @@ describe("MailboxRepo (sqlite)", () => {
49
50
  search: testDb.db,
50
51
  },
51
52
  BETTER_SQLITE3_LIMITS,
53
+ messageObjectsInMemory(),
52
54
  ).mailbox;
53
55
  });
54
56
 
@@ -222,6 +224,7 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
222
224
  search: db,
223
225
  },
224
226
  BETTER_SQLITE3_LIMITS,
227
+ messageObjectsInMemory(),
225
228
  ).mailbox;
226
229
  close = async () => {
227
230
  sqlite.close();
@@ -18,6 +18,7 @@ import { createSqliteRepositories } from "../sqlite-repositories.js";
18
18
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
19
19
  import type { MailboxRepo } from "./i4-mailbox.js";
20
20
  import { deleteMessageSubtree } from "./message.js";
21
+ import { messageObjectsInMemory } from "./message-objects.js";
21
22
  import { mailboxCreated } from "./test-helpers.js";
22
23
 
23
24
  /** Every state a row can carry, for a transition that decides against none. */
@@ -62,6 +63,7 @@ describe("MailboxRepo", () => {
62
63
  search: db,
63
64
  },
64
65
  BETTER_SQLITE3_LIMITS,
66
+ messageObjectsInMemory(),
65
67
  ).mailbox;
66
68
  });
67
69
 
@@ -554,6 +556,7 @@ describe("MailboxRepo", () => {
554
556
  search: db,
555
557
  },
556
558
  BETTER_SQLITE3_LIMITS,
559
+ messageObjectsInMemory(),
557
560
  ).message.create({
558
561
  messageId,
559
562
  mailboxId,
@@ -682,7 +685,7 @@ describe("MailboxRepo", () => {
682
685
 
683
686
  // The shape an interruption leaves: one message's rows already gone,
684
687
  // the mailbox row still there and still `deleting`.
685
- await syncBatch(db)(deleteMessageSubtree(db, [first]));
688
+ await syncBatch(db)(deleteMessageSubtree(db, [first]).statements);
686
689
  await repo.transition(accountId, mailbox.mailboxId, {
687
690
  from: EVERY_STATE,
688
691
  to: MailboxSyncStatus.deleting,
@@ -46,6 +46,7 @@ import { threadMessageTable } from "../schema/thread-message.js";
46
46
  import type { SearchIndex } from "../search/thread-message-search.js";
47
47
  import { inList } from "./in-list.js";
48
48
  import { deleteMessageSubtree } from "./message.js";
49
+ import type { MessageObjects } from "./message-objects.js";
49
50
 
50
51
  const base36Translator = shortUuid.createTranslator(
51
52
  shortUuid.constants.uuid25Base36,
@@ -155,6 +156,7 @@ export class MailboxRepo implements IMailboxRepository {
155
156
  constructor(
156
157
  store: SqliteStore<Record<string, unknown>>,
157
158
  private search: SearchIndex,
159
+ private messageObjects: MessageObjects,
158
160
  ) {
159
161
  this.db = store.db;
160
162
  this.batch = store.batch;
@@ -562,13 +564,16 @@ export class MailboxRepo implements IMailboxRepository {
562
564
 
563
565
  // The primitive the rest of the codebase deletes mail with: nine
564
566
  // per-message child tables. A bespoke table list would orphan them.
565
- await this.batch([
566
- ...deleteMessageSubtree(this.db, messageIds),
567
+ const subtree = deleteMessageSubtree(this.db, messageIds);
568
+ const [removed] = await this.batch([
569
+ subtree.removed,
570
+ ...subtree.statements,
567
571
  this.db
568
572
  .delete(threadMessageTable)
569
573
  .where(inList(threadMessageTable.messageId, messageIds)),
570
574
  ]);
571
575
  await this.search.sync(listings.map((row) => row.threadMessageId));
576
+ await this.messageObjects.reclaim(removed);
572
577
  }
573
578
 
574
579
  await this.db
@@ -4,6 +4,7 @@ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
4
4
  import { createSqliteRepositories } from "../sqlite-repositories.js";
5
5
  import { createTestDb, randomId } from "../test-db.js";
6
6
  import type { OutboxAttachmentRepo } from "./i4-outbox-attachment.js";
7
+ import { messageObjectsInMemory } from "./message-objects.js";
7
8
 
8
9
  const CAP = { maxTotalBytes: 1000, maxCount: 3, nowSeconds: 1_000_000 };
9
10
 
@@ -36,6 +37,7 @@ before(async () => {
36
37
  search: harness.db,
37
38
  },
38
39
  BETTER_SQLITE3_LIMITS,
40
+ messageObjectsInMemory(),
39
41
  ).outboxAttachment;
40
42
  });
41
43
 
@@ -0,0 +1,217 @@
1
+ import assert from "node:assert/strict";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { after, before, describe, test } from "node:test";
12
+ import type { StorageService } from "@remit/storage-service";
13
+ import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
14
+ import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
15
+ import {
16
+ envelopeId as deriveEnvelopeId,
17
+ rootBodyPartId as deriveRootBodyPartId,
18
+ } from "../id.js";
19
+ import {
20
+ createSqliteRepositories,
21
+ type SqliteRepositories,
22
+ } from "../sqlite-repositories.js";
23
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
24
+ import { mailboxCreated } from "./test-helpers.js";
25
+
26
+ const ACCOUNT_CONFIG_ID = "cfg-objects";
27
+
28
+ describe("message objects follow the last row that references them", () => {
29
+ let db: TestDb;
30
+ let close: () => Promise<void>;
31
+ let directory: string;
32
+ let storage: StorageService;
33
+ let failDeletesAfter = Number.POSITIVE_INFINITY;
34
+ let repos: SqliteRepositories;
35
+
36
+ before(async () => {
37
+ ({ db, close } = await createTestDb());
38
+ directory = mkdtempSync(join(tmpdir(), "message-objects-"));
39
+ storage = createFilesystemStorageService(directory);
40
+ repos = createSqliteRepositories(
41
+ { app: db, mail: db, search: db },
42
+ 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
+ },
55
+ );
56
+ });
57
+
58
+ after(async () => {
59
+ await close();
60
+ rmSync(directory, { recursive: true, force: true });
61
+ });
62
+
63
+ const folder = async (accountId: string, fullPath: string) =>
64
+ mailboxCreated(
65
+ await repos.mailbox.create({
66
+ accountId,
67
+ namespaceType: "personal",
68
+ namespacePrefix: "",
69
+ hierarchyDelimiter: "/",
70
+ fullPath,
71
+ uidValidity: 1,
72
+ uidNext: 1,
73
+ highestModseq: "0",
74
+ messageCount: 0,
75
+ unseenCount: 0,
76
+ deletedCount: 0,
77
+ totalSize: 0,
78
+ lastSyncUid: 0,
79
+ highWaterMarkUid: 0,
80
+ lastMessageSyncAt: 0,
81
+ }),
82
+ );
83
+
84
+ const message = async (
85
+ accountId: string,
86
+ mailboxId: string,
87
+ uid: number,
88
+ body: { from?: string } = {},
89
+ ) => {
90
+ const messageId = randomId();
91
+ const owner = body.from ?? messageId;
92
+ const stored = await storage.storeMessageBody({
93
+ accountConfigId: ACCOUNT_CONFIG_ID,
94
+ accountId,
95
+ messageId: owner,
96
+ content: Buffer.from(`body of ${owner}`),
97
+ });
98
+ await storage.storeBodyPart({
99
+ accountConfigId: ACCOUNT_CONFIG_ID,
100
+ accountId,
101
+ messageId,
102
+ partPath: "1",
103
+ content: Buffer.from("part"),
104
+ });
105
+ await repos.message.create({
106
+ messageId,
107
+ mailboxId,
108
+ uid,
109
+ sequenceNumber: uid,
110
+ rfc822Size: 10,
111
+ internalDate: 1700000000000,
112
+ envelopeId: deriveEnvelopeId(messageId),
113
+ rootBodyPartId: deriveRootBodyPartId(messageId),
114
+ bodyStorageKey: stored.uri,
115
+ });
116
+ return { messageId, accountId, bodyStorageKey: stored.uri };
117
+ };
118
+
119
+ const part = (row: { accountId: string; messageId: string }) =>
120
+ storage.retrieveBodyPart(
121
+ ACCOUNT_CONFIG_ID,
122
+ row.accountId,
123
+ row.messageId,
124
+ "1",
125
+ );
126
+
127
+ test("a copy sharing the source's body keeps it until the copy goes too", async () => {
128
+ const accountId = randomId();
129
+ const inbox = await folder(accountId, "INBOX");
130
+ const source = await message(accountId, inbox.mailboxId, 1);
131
+ const copy = await message(accountId, inbox.mailboxId, 2, {
132
+ from: source.messageId,
133
+ });
134
+ assert.equal(copy.bodyStorageKey, source.bodyStorageKey);
135
+
136
+ await repos.message.delete(source.messageId);
137
+
138
+ assert.ok(await storage.retrieve(copy.bodyStorageKey));
139
+ assert.ok(await part(copy));
140
+
141
+ await repos.message.delete(copy.messageId);
142
+
143
+ assert.equal(await storage.exists(copy.bodyStorageKey), false);
144
+ assert.equal(await part(copy), null);
145
+ assert.equal(await part(source), null);
146
+ });
147
+
148
+ test("a placement that no longer matches keeps the row and its objects", async () => {
149
+ const accountId = randomId();
150
+ const inbox = await folder(accountId, "INBOX");
151
+ const kept = await message(accountId, inbox.mailboxId, 1);
152
+
153
+ assert.equal(
154
+ await repos.message.deleteIfPlacement(kept.messageId, { uid: 2 }),
155
+ false,
156
+ );
157
+ assert.ok(await storage.retrieve(kept.bodyStorageKey));
158
+
159
+ assert.equal(
160
+ await repos.message.deleteIfPlacement(kept.messageId, { uid: 1 }),
161
+ true,
162
+ );
163
+ assert.equal(await storage.exists(kept.bodyStorageKey), false);
164
+ });
165
+
166
+ test("a body stored under the key shape without an account config removes its message prefix", async () => {
167
+ const accountId = randomId();
168
+ const inbox = await folder(accountId, "INBOX");
169
+ const messageId = randomId();
170
+ const legacy = join(
171
+ directory,
172
+ "accounts",
173
+ accountId,
174
+ "messages",
175
+ messageId,
176
+ );
177
+ mkdirSync(join(legacy, "parts"), { recursive: true });
178
+ writeFileSync(join(legacy, "body.eml"), "legacy");
179
+ writeFileSync(join(legacy, "parts", "1"), "part");
180
+ await repos.message.create({
181
+ messageId,
182
+ mailboxId: inbox.mailboxId,
183
+ uid: 1,
184
+ sequenceNumber: 1,
185
+ rfc822Size: 10,
186
+ internalDate: 1700000000000,
187
+ envelopeId: deriveEnvelopeId(messageId),
188
+ rootBodyPartId: deriveRootBodyPartId(messageId),
189
+ bodyStorageKey: `file://${join(legacy, "body.eml")}`,
190
+ });
191
+
192
+ await repos.message.delete(messageId);
193
+
194
+ assert.equal(existsSync(legacy), false);
195
+ });
196
+
197
+ test("a folder delete that fails part way has already removed the batches it finished", async () => {
198
+ const accountId = randomId();
199
+ const doomed = await folder(accountId, "Doomed");
200
+ const rows = [];
201
+ for (let uid = 1; uid <= 101; uid += 1) {
202
+ rows.push(await message(accountId, doomed.mailboxId, uid));
203
+ }
204
+ failDeletesAfter = 100;
205
+
206
+ await assert.rejects(
207
+ repos.mailbox.deleteMailboxWithMail(accountId, doomed.mailboxId),
208
+ /storage went away/,
209
+ );
210
+ failDeletesAfter = Number.POSITIVE_INFINITY;
211
+
212
+ const gone = await Promise.all(
213
+ rows.map((row) => storage.exists(row.bodyStorageKey)),
214
+ );
215
+ assert.equal(gone.filter((exists) => !exists).length, 100);
216
+ });
217
+ });
@@ -0,0 +1,76 @@
1
+ import {
2
+ createMockStorageService,
3
+ messageObjectPrefixOf,
4
+ type StorageService,
5
+ } from "@remit/storage-service";
6
+ import pMap from "p-map";
7
+ import type { RepositoryDb } from "../db.js";
8
+ import { messageTable } from "../schema/message-data.js";
9
+ import { inList } from "./in-list.js";
10
+
11
+ export const DEFAULT_MESSAGE_OBJECT_DELETE_CONCURRENCY = 8;
12
+
13
+ export interface MessageObjectStore {
14
+ storage: Pick<StorageService, "deletePrefix">;
15
+ messageObjectDeleteConcurrency: number;
16
+ }
17
+
18
+ export const messageObjectsInMemory = (): MessageObjectStore => ({
19
+ storage: createMockStorageService(),
20
+ messageObjectDeleteConcurrency: 1,
21
+ });
22
+
23
+ export interface RemovedMessageRow {
24
+ messageId: string;
25
+ bodyStorageKey: string | null;
26
+ }
27
+
28
+ interface Candidate {
29
+ prefix: string;
30
+ bodyStorageKey: string;
31
+ }
32
+
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
+ export class MessageObjects {
46
+ constructor(
47
+ private readonly db: Pick<RepositoryDb<Record<string, unknown>>, "select">,
48
+ private readonly store: MessageObjectStore,
49
+ ) {}
50
+
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
+ ];
59
+ const referenced = await this.db
60
+ .select({ bodyStorageKey: messageTable.bodyStorageKey })
61
+ .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
+ );
75
+ }
76
+ }
@@ -10,6 +10,7 @@ import { threadMessageTable } from "../schema/thread-message.js";
10
10
  import { createSqliteRepositories } from "../sqlite-repositories.js";
11
11
  import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
12
12
  import type { DrizzleMessageRepository } from "./message.js";
13
+ import { messageObjectsInMemory } from "./message-objects.js";
13
14
 
14
15
  const SCHEMA = { ...messageDataSchema, threadMessages: threadMessageTable };
15
16
 
@@ -47,6 +48,7 @@ describe("DrizzleMessageRepository (sqlite)", () => {
47
48
  repo = createSqliteRepositories(
48
49
  { app: db, mail: db, search: db },
49
50
  BETTER_SQLITE3_LIMITS,
51
+ messageObjectsInMemory(),
50
52
  ).message;
51
53
  });
52
54
 
@@ -8,6 +8,7 @@ import {
8
8
  import { createSqliteRepositories } from "../sqlite-repositories.js";
9
9
  import type { DrizzleEnvelopeRepository } from "./envelope.js";
10
10
  import type { DrizzleMessageRepository } from "./message.js";
11
+ import { messageObjectsInMemory } from "./message-objects.js";
11
12
  import { createTestDb, type TestDb } from "./test-helpers.js";
12
13
 
13
14
  describe("DrizzleMessageRepository", () => {
@@ -41,6 +42,7 @@ describe("DrizzleMessageRepository", () => {
41
42
  const repos = createSqliteRepositories(
42
43
  { app: db, mail: db, search: db },
43
44
  BETTER_SQLITE3_LIMITS,
45
+ messageObjectsInMemory(),
44
46
  );
45
47
  messageRepo = repos.message;
46
48
  envelopeRepo = repos.envelope;
@@ -55,6 +55,7 @@ import {
55
55
  toMessageReferenceItem,
56
56
  toRawMessageStorageItem,
57
57
  } from "./mappers.js";
58
+ import type { MessageObjects } from "./message-objects.js";
58
59
 
59
60
  type DB = RepositoryDb<Record<string, unknown>>;
60
61
 
@@ -112,22 +113,33 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
112
113
  };
113
114
  }
114
115
 
115
- export type SubtreeDb = Pick<RepositoryDb<Record<string, unknown>>, "delete">;
116
+ export type SubtreeDb = Pick<
117
+ RepositoryDb<Record<string, unknown>>,
118
+ "delete" | "select"
119
+ >;
116
120
 
117
121
  /**
118
122
  * The statements that delete a message and its whole per-message subtree by
119
123
  * message id: the child tables, then the message row. The caller runs them as
120
- * one batch so the subtree goes atomically; the last statement returns the
121
- * message ids it removed. `guard` rides every statement, so
122
- * a guard that reads the message row holds until the last statement removes it.
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.
123
128
  */
124
129
  export function deleteMessageSubtree(
125
130
  db: SubtreeDb,
126
131
  messageIds: string[],
127
132
  guard?: SQL,
128
- ): SqliteBatchItem[] {
129
- if (messageIds.length === 0) return [];
130
- return [
133
+ ) {
134
+ const removed = db
135
+ .select({
136
+ messageId: messageTable.messageId,
137
+ bodyStorageKey: messageTable.bodyStorageKey,
138
+ })
139
+ .from(messageTable)
140
+ .where(and(inList(messageTable.messageId, messageIds), guard));
141
+ if (messageIds.length === 0) return { removed, statements: [] };
142
+ const statements: SqliteBatchItem[] = [
131
143
  db
132
144
  .delete(messageFlagTable)
133
145
  .where(and(inList(messageFlagTable.messageId, messageIds), guard)),
@@ -162,9 +174,9 @@ export function deleteMessageSubtree(
162
174
  .where(and(inList(envelopeTable.messageId, messageIds), guard)),
163
175
  db
164
176
  .delete(messageTable)
165
- .where(and(inList(messageTable.messageId, messageIds), guard))
166
- .returning({ messageId: messageTable.messageId }),
177
+ .where(and(inList(messageTable.messageId, messageIds), guard)),
167
178
  ];
179
+ return { removed, statements };
168
180
  }
169
181
 
170
182
  /**
@@ -202,6 +214,7 @@ export class DrizzleMessageRepository implements IMessageRepository {
202
214
  constructor(
203
215
  store: SqliteStore<Record<string, unknown>>,
204
216
  private search: SearchIndex,
217
+ private messageObjects: MessageObjects,
205
218
  ) {
206
219
  this.db = store.db;
207
220
  this.batch = store.batch;
@@ -533,20 +546,27 @@ export class DrizzleMessageRepository implements IMessageRepository {
533
546
  .select({ threadMessageId: threadMessageTable.threadMessageId })
534
547
  .from(threadMessageTable)
535
548
  .where(eq(threadMessageTable.messageId, messageId));
536
- const results = await this.batch([
549
+ const subtree = deleteMessageSubtree(this.db, [messageId], placed);
550
+ const [removed] = await this.batch([
551
+ subtree.removed,
537
552
  this.db
538
553
  .delete(threadMessageTable)
539
554
  .where(and(eq(threadMessageTable.messageId, messageId), placed)),
540
- ...deleteMessageSubtree(this.db, [messageId], placed),
555
+ ...subtree.statements,
541
556
  ]);
542
557
  await this.search.sync(listings.map((row) => row.threadMessageId));
543
- const removed = results.at(-1);
544
- return Array.isArray(removed) && removed.length > 0;
558
+ await this.messageObjects.reclaim(removed);
559
+ return removed.length > 0;
545
560
  }
546
561
 
547
562
  async deleteMany(messageIds: string[]): Promise<void> {
548
563
  if (messageIds.length === 0) return;
549
- await this.batch(deleteMessageSubtree(this.db, messageIds));
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);
550
570
  }
551
571
 
552
572
  async listByMailbox(
@@ -16,6 +16,7 @@ import { messageSearchTextTable } from "../schema/message-data.js";
16
16
  import { threadMessageTable } from "../schema/thread-message.js";
17
17
  import { createSqliteRepositories } from "../sqlite-repositories.js";
18
18
  import { createSqliteTestDb } from "../test-db-sqlite.js";
19
+ import { messageObjectsInMemory } from "./message-objects.js";
19
20
  import type { DrizzleThreadMessageRepository } from "./thread-message.js";
20
21
 
21
22
  const ACCOUNT = "acct-seam";
@@ -68,6 +69,7 @@ describe("thread-message body and muted-sender predicates (sqlite)", () => {
68
69
  search: db,
69
70
  },
70
71
  BETTER_SQLITE3_LIMITS,
72
+ messageObjectsInMemory(),
71
73
  ).threadMessage;
72
74
  });
73
75
 
@@ -6,6 +6,7 @@ import { messageSearchTextTable } from "../schema/message-data.js";
6
6
  import { threadMessageTable } from "../schema/thread-message.js";
7
7
  import { createSqliteRepositories } from "../sqlite-repositories.js";
8
8
  import { createSqliteTestDb } from "../test-db-sqlite.js";
9
+ import { messageObjectsInMemory } from "./message-objects.js";
9
10
  import {
10
11
  type DrizzleThreadMessageRepository,
11
12
  THREAD_SEARCH_MAX_LIMIT,
@@ -67,6 +68,7 @@ describe("DrizzleThreadMessageRepository.listByFieldTerms (sqlite, #459)", () =>
67
68
  search: created.db,
68
69
  },
69
70
  BETTER_SQLITE3_LIMITS,
71
+ messageObjectsInMemory(),
70
72
  ).threadMessage;
71
73
 
72
74
  await repo.create(
@@ -13,6 +13,7 @@ import {
13
13
  LIVE_TOTALS,
14
14
  totalRows,
15
15
  } from "./category-fixture.js";
16
+ import { messageObjectsInMemory } from "./message-objects.js";
16
17
  import {
17
18
  type DrizzleThreadMessageRepository,
18
19
  THREAD_SEARCH_MAX_LIMIT,
@@ -68,6 +69,7 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
68
69
  search: db,
69
70
  },
70
71
  BETTER_SQLITE3_LIMITS,
72
+ messageObjectsInMemory(),
71
73
  ).threadMessage;
72
74
  });
73
75
 
@@ -10,6 +10,7 @@ import { messageSearchTextTable } from "../schema/message-data.js";
10
10
  import { threadMessageTable } from "../schema/thread-message.js";
11
11
  import { createSqliteRepositories } from "../sqlite-repositories.js";
12
12
  import { createSqliteTestDb } from "../test-db-sqlite.js";
13
+ import { messageObjectsInMemory } from "./message-objects.js";
13
14
  import {
14
15
  clampThreadSearchLimit,
15
16
  type DrizzleThreadMessageRepository,
@@ -102,6 +103,7 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
102
103
  search: harness.db,
103
104
  },
104
105
  BETTER_SQLITE3_LIMITS,
106
+ messageObjectsInMemory(),
105
107
  ).threadMessage;
106
108
  });
107
109
 
@@ -411,6 +413,7 @@ describe("DrizzleThreadMessageRepository.searchByDate", () => {
411
413
  search: harness.db,
412
414
  },
413
415
  BETTER_SQLITE3_LIMITS,
416
+ messageObjectsInMemory(),
414
417
  );
415
418
  repo = repositories.threadMessage;
416
419
  messages = repositories.message;
@@ -949,6 +952,7 @@ describe("DrizzleThreadMessageRepository — native text search", () => {
949
952
  search: harness.db,
950
953
  },
951
954
  BETTER_SQLITE3_LIMITS,
955
+ messageObjectsInMemory(),
952
956
  ).threadMessage;
953
957
  });
954
958
 
@@ -1128,6 +1132,7 @@ describe("DrizzleThreadMessageRepository — core CRUD", () => {
1128
1132
  search: harness.db,
1129
1133
  },
1130
1134
  BETTER_SQLITE3_LIMITS,
1135
+ messageObjectsInMemory(),
1131
1136
  ).threadMessage;
1132
1137
  });
1133
1138
 
@@ -6,6 +6,7 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
6
6
  import { BETTER_SQLITE3_LIMITS } from "../bound-parameters.js";
7
7
  import { syncStore } from "../databases.js";
8
8
  import type { Db } from "../db.js";
9
+ import { messageObjectsInMemory } from "../repos/message-objects.js";
9
10
  import { messageSearchTextTable } from "../schema/message-data.js";
10
11
  import { createSqliteRepositories } from "../sqlite-repositories.js";
11
12
  import { applyMigrationSet } from "../test-shipped-sqlite-schema.js";
@@ -62,6 +63,7 @@ describe("the thread-message search index in its own database", () => {
62
63
  search: search.db,
63
64
  },
64
65
  BETTER_SQLITE3_LIMITS,
66
+ messageObjectsInMemory(),
65
67
  ).threadMessage;
66
68
 
67
69
  const found = async (query: string): Promise<string[]> =>
@@ -31,20 +31,24 @@ import { LabelRepo } from "./repos/label.js";
31
31
  import { DrizzleMessageRepository } from "./repos/message.js";
32
32
  import { DrizzleMessageFlagRepository } from "./repos/message-flag.js";
33
33
  import { MessageLabelRepo } from "./repos/message-label.js";
34
+ import {
35
+ type MessageObjectStore,
36
+ MessageObjects,
37
+ } from "./repos/message-objects.js";
34
38
  import { QuarantineRepo } from "./repos/quarantine.js";
35
39
  import { SyncWarningRepo } from "./repos/sync-warning.js";
36
40
  import { DrizzleThreadMessageRepository } from "./repos/thread-message.js";
37
41
  import { SearchIndex } from "./search/thread-message-search.js";
38
42
 
39
- export const composeSqliteRepositories = ({
40
- app,
41
- mail,
42
- search,
43
- }: SqliteStores) => {
43
+ export const composeSqliteRepositories = (
44
+ { app, mail, search }: SqliteStores,
45
+ objects: MessageObjectStore,
46
+ ) => {
44
47
  const searchIndex = new SearchIndex(mail.db, search);
48
+ const messageObjects = new MessageObjects(mail.db, objects);
45
49
  const accountSetting = new AccountSettingRepo(app.db);
46
50
  const address = new AddressRepo(mail.db);
47
- const mailbox = new MailboxRepo(mail, searchIndex);
51
+ const mailbox = new MailboxRepo(mail, searchIndex, messageObjects);
48
52
  const account = new AccountRepo(app.db, mailbox);
49
53
  const calendarSuggestion = new CalendarSuggestionRepo(mail.db);
50
54
  return {
@@ -59,7 +63,7 @@ export const composeSqliteRepositories = ({
59
63
  accountSetting,
60
64
  ),
61
65
  mailboxLock: new MailboxLockRepo(mail.db),
62
- message: new DrizzleMessageRepository(mail, searchIndex),
66
+ message: new DrizzleMessageRepository(mail, searchIndex, messageObjects),
63
67
  messageFlag: new DrizzleMessageFlagRepository(mail.db),
64
68
  outboxMessage: new OutboxMessageRepo(mail.db),
65
69
  outboxAttachment: new OutboxAttachmentRepo(mail),
@@ -90,5 +94,6 @@ export type SqliteRepositories = ReturnType<typeof composeSqliteRepositories>;
90
94
  export const createSqliteRepositories = (
91
95
  databases: SqliteDatabases,
92
96
  limits: SqliteLimits,
97
+ objects: MessageObjectStore,
93
98
  ): SqliteRepositories =>
94
- composeSqliteRepositories(syncStores(databases, limits));
99
+ composeSqliteRepositories(syncStores(databases, limits), objects);