@remit/drizzle-service 0.0.106 → 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.
Files changed (31) hide show
  1. package/package.json +4 -2
  2. package/src/id.ts +9 -0
  3. package/src/index.ts +4 -0
  4. package/src/repair/junk-only-address.sqlite.test.ts +2 -0
  5. package/src/repos/account.conformance.d1.test.ts +3 -1
  6. package/src/repos/account.conformance.sqlite.test.ts +2 -0
  7. package/src/repos/calendar-default.suite.ts +5 -1
  8. package/src/repos/calendar-put.suite.ts +5 -1
  9. package/src/repos/cascade-delete.ts +1 -1
  10. package/src/repos/envelope.sqlite.test.ts +2 -0
  11. package/src/repos/envelope.test.ts +2 -0
  12. package/src/repos/envelope.ts +5 -3
  13. package/src/repos/i4-account-config.suite.ts +5 -1
  14. package/src/repos/i4-account.test.ts +3 -0
  15. package/src/repos/i4-address-junk-move.sqlite.test.ts +2 -0
  16. package/src/repos/i4-mailbox-special-use.sqlite.test.ts +2 -0
  17. package/src/repos/i4-mailbox.sqlite.test.ts +3 -0
  18. package/src/repos/i4-mailbox.test.ts +4 -3
  19. package/src/repos/i4-mailbox.ts +7 -28
  20. package/src/repos/i4-outbox-attachment.test.ts +2 -0
  21. package/src/repos/message-objects.sqlite.test.ts +271 -0
  22. package/src/repos/message-objects.ts +70 -0
  23. package/src/repos/message.sqlite.test.ts +11 -17
  24. package/src/repos/message.test.ts +4 -6
  25. package/src/repos/message.ts +341 -46
  26. package/src/repos/thread-message-body-muted.sqlite.test.ts +2 -0
  27. package/src/repos/thread-message-field-terms.sqlite.test.ts +2 -0
  28. package/src/repos/thread-message.sqlite.test.ts +2 -0
  29. package/src/repos/thread-message.test.ts +5 -0
  30. package/src/search/thread-message-search.sqlite.test.ts +2 -0
  31. package/src/sqlite-repositories.ts +18 -8
@@ -0,0 +1,271 @@
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
+ createSqliteRepositories,
17
+ type SqliteRepositories,
18
+ } from "../sqlite-repositories.js";
19
+ import { createTestDb, randomId, type TestDb } from "../test-db.js";
20
+ import { mailboxCreated } from "./test-helpers.js";
21
+
22
+ const ACCOUNT_CONFIG_ID = "cfg-objects";
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
+
35
+ describe("message objects follow the last row that references them", () => {
36
+ let db: TestDb;
37
+ let close: () => Promise<void>;
38
+ let directory: string;
39
+ let storage: StorageService;
40
+ let failDeletesAfter = Number.POSITIVE_INFINITY;
41
+ let repos: SqliteRepositories;
42
+ let deleteCalls: number[] = [];
43
+
44
+ before(async () => {
45
+ ({ db, close } = await createTestDb());
46
+ directory = mkdtempSync(join(tmpdir(), "message-objects-"));
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
+ };
58
+ repos = createSqliteRepositories(
59
+ { app: db, mail: db, search: db },
60
+ BETTER_SQLITE3_LIMITS,
61
+ { storage: counted },
62
+ );
63
+ });
64
+
65
+ after(async () => {
66
+ await close();
67
+ rmSync(directory, { recursive: true, force: true });
68
+ });
69
+
70
+ const folder = async (accountId: string, fullPath: string) =>
71
+ mailboxCreated(
72
+ await repos.mailbox.create({
73
+ accountId,
74
+ namespaceType: "personal",
75
+ namespacePrefix: "",
76
+ hierarchyDelimiter: "/",
77
+ fullPath,
78
+ uidValidity: 1,
79
+ uidNext: 1,
80
+ highestModseq: "0",
81
+ messageCount: 0,
82
+ unseenCount: 0,
83
+ deletedCount: 0,
84
+ totalSize: 0,
85
+ lastSyncUid: 0,
86
+ highWaterMarkUid: 0,
87
+ lastMessageSyncAt: 0,
88
+ }),
89
+ );
90
+
91
+ const message = async (
92
+ accountId: string,
93
+ mailboxId: string,
94
+ uid: number,
95
+ body: { from?: string } = {},
96
+ ) => {
97
+ const messageId = randomId();
98
+ const owner = body.from ?? messageId;
99
+ const stored = await storage.storeMessageBody({
100
+ accountConfigId: ACCOUNT_CONFIG_ID,
101
+ accountId,
102
+ messageId: owner,
103
+ content: Buffer.from(`body of ${owner}`),
104
+ });
105
+ await storage.storeBodyPart({
106
+ accountConfigId: ACCOUNT_CONFIG_ID,
107
+ accountId,
108
+ messageId,
109
+ partPath: "1",
110
+ content: Buffer.from("part"),
111
+ });
112
+ await repos.message.create({
113
+ messageId,
114
+ mailboxId,
115
+ uid,
116
+ sequenceNumber: uid,
117
+ rfc822Size: 10,
118
+ internalDate: 1700000000000,
119
+ bodyStorageKey: stored.uri,
120
+ });
121
+ await repos.envelope.upsertBodyParts(messageId, [bodyPart("1")]);
122
+ return { messageId, accountId, bodyStorageKey: stored.uri };
123
+ };
124
+
125
+ const part = (row: { accountId: string; messageId: string }) =>
126
+ storage.retrieveBodyPart(
127
+ ACCOUNT_CONFIG_ID,
128
+ row.accountId,
129
+ row.messageId,
130
+ "1",
131
+ );
132
+
133
+ test("a copy sharing the source's body keeps it until the copy goes too", async () => {
134
+ const accountId = randomId();
135
+ const inbox = await folder(accountId, "INBOX");
136
+ const source = await message(accountId, inbox.mailboxId, 1);
137
+ const copy = await message(accountId, inbox.mailboxId, 2, {
138
+ from: source.messageId,
139
+ });
140
+ assert.equal(copy.bodyStorageKey, source.bodyStorageKey);
141
+
142
+ await repos.message.delete(source.messageId);
143
+
144
+ assert.ok(await storage.retrieve(copy.bodyStorageKey));
145
+ assert.ok(await part(copy));
146
+
147
+ await repos.message.delete(copy.messageId);
148
+
149
+ assert.equal(await storage.exists(copy.bodyStorageKey), false);
150
+ assert.equal(await part(copy), null);
151
+ assert.equal(await part(source), null);
152
+ });
153
+
154
+ test("a placement that no longer matches keeps the row and its objects", async () => {
155
+ const accountId = randomId();
156
+ const inbox = await folder(accountId, "INBOX");
157
+ const kept = await message(accountId, inbox.mailboxId, 1);
158
+
159
+ assert.equal(
160
+ await repos.message.deleteIfPlacement(kept.messageId, { uid: 2 }),
161
+ false,
162
+ );
163
+ assert.ok(await storage.retrieve(kept.bodyStorageKey));
164
+
165
+ assert.equal(
166
+ await repos.message.deleteIfPlacement(kept.messageId, { uid: 1 }),
167
+ true,
168
+ );
169
+ assert.equal(await storage.exists(kept.bodyStorageKey), false);
170
+ });
171
+
172
+ test("a body stored under the key shape without an account config removes its message prefix", async () => {
173
+ const accountId = randomId();
174
+ const inbox = await folder(accountId, "INBOX");
175
+ const messageId = randomId();
176
+ const legacy = join(
177
+ directory,
178
+ "accounts",
179
+ accountId,
180
+ "messages",
181
+ messageId,
182
+ );
183
+ mkdirSync(join(legacy, "parts"), { recursive: true });
184
+ writeFileSync(join(legacy, "body.eml"), "legacy");
185
+ writeFileSync(join(legacy, "parts", "1"), "part");
186
+ await repos.message.create({
187
+ messageId,
188
+ mailboxId: inbox.mailboxId,
189
+ uid: 1,
190
+ sequenceNumber: 1,
191
+ rfc822Size: 10,
192
+ internalDate: 1700000000000,
193
+ bodyStorageKey: `file://${join(legacy, "body.eml")}`,
194
+ });
195
+ await repos.envelope.upsertBodyParts(messageId, [bodyPart("1")]);
196
+
197
+ await repos.message.delete(messageId);
198
+
199
+ assert.equal(existsSync(legacy), false);
200
+ });
201
+
202
+ test("a folder delete that fails part way has already removed the batches it finished", async () => {
203
+ const accountId = randomId();
204
+ const doomed = await folder(accountId, "Doomed");
205
+ const rows = [];
206
+ for (let uid = 1; uid <= 101; uid += 1) {
207
+ rows.push(await message(accountId, doomed.mailboxId, uid));
208
+ }
209
+ failDeletesAfter = 1;
210
+
211
+ await assert.rejects(
212
+ repos.mailbox.deleteMailboxWithMail(accountId, doomed.mailboxId),
213
+ /storage went away/,
214
+ );
215
+ failDeletesAfter = Number.POSITIVE_INFINITY;
216
+
217
+ const gone = await Promise.all(
218
+ rows.map((row) => storage.exists(row.bodyStorageKey)),
219
+ );
220
+ assert.equal(gone.filter((exists) => !exists).length, 100);
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
+ });
271
+ });
@@ -0,0 +1,70 @@
1
+ import {
2
+ createMockStorageService,
3
+ messageObjectKeysOf,
4
+ type StorageService,
5
+ } from "@remit/storage-service";
6
+ import type { RepositoryDb } from "../db.js";
7
+ import { messageTable } from "../schema/message-data.js";
8
+ import { inList } from "./in-list.js";
9
+
10
+ export interface MessageObjectStore {
11
+ storage: Pick<StorageService, "deleteKeys">;
12
+ }
13
+
14
+ export const messageObjectsInMemory = (): MessageObjectStore => ({
15
+ storage: createMockStorageService(),
16
+ });
17
+
18
+ export interface RemovedMessageRow {
19
+ messageId: string;
20
+ bodyStorageKey: string | null;
21
+ }
22
+
23
+ export interface RemovedBodyPartRow {
24
+ messageId: string;
25
+ partPath: string;
26
+ }
27
+
28
+ export class MessageObjects {
29
+ constructor(
30
+ private readonly db: Pick<RepositoryDb<Record<string, unknown>>, "select">,
31
+ private readonly store: MessageObjectStore,
32
+ ) {}
33
+
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;
58
+ const referenced = await this.db
59
+ .select({ bodyStorageKey: messageTable.bodyStorageKey })
60
+ .from(messageTable)
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
+ ]);
69
+ }
70
+ }
@@ -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
 
@@ -83,8 +85,6 @@ describe("DrizzleMessageRepository (sqlite)", () => {
83
85
  const created = await repo.create({
84
86
  ...BASE_INPUT,
85
87
  messageId: AUTO_MOVED_ID,
86
- envelopeId: deriveEnvelopeId(AUTO_MOVED_ID),
87
- rootBodyPartId: deriveRootBodyPartId(AUTO_MOVED_ID),
88
88
  movedByRemit: true,
89
89
  filterMove: filterMove as unknown as never,
90
90
  });
@@ -158,8 +158,6 @@ describe("DrizzleMessageRepository (sqlite)", () => {
158
158
  sequenceNumber: 2,
159
159
  rfc822Size: 1024,
160
160
  internalDate: NOW,
161
- envelopeId: deriveEnvelopeId(inboundId),
162
- rootBodyPartId: deriveRootBodyPartId(inboundId),
163
161
  });
164
162
 
165
163
  assert.equal(created, true);
@@ -180,8 +178,6 @@ describe("DrizzleMessageRepository (sqlite)", () => {
180
178
  ...BASE_INPUT,
181
179
  messageId: LOCK_ID,
182
180
  uid: 7,
183
- envelopeId: deriveEnvelopeId(LOCK_ID),
184
- rootBodyPartId: deriveRootBodyPartId(LOCK_ID),
185
181
  status: "moving" as const,
186
182
  syncStatus: "pending" as const,
187
183
  });
@@ -253,8 +249,6 @@ describe("DrizzleMessageRepository (sqlite)", () => {
253
249
  ...BASE_INPUT,
254
250
  messageId: GONE_ID,
255
251
  uid: 9,
256
- envelopeId: deriveEnvelopeId(GONE_ID),
257
- rootBodyPartId: deriveRootBodyPartId(GONE_ID),
258
252
  });
259
253
  });
260
254
 
@@ -317,19 +311,19 @@ describe("DrizzleMessageRepository (sqlite)", () => {
317
311
  );
318
312
  });
319
313
 
320
- test("copySearchText gives a copy the source's search text", async () => {
314
+ test("copyMessage gives a copy the source's search text", async () => {
321
315
  const source = "00000000-0000-0000-4444-000000000001";
322
316
  const copy = "00000000-0000-0000-4444-000000000002";
323
- for (const [messageId, uid] of [
324
- [source, 201],
325
- [copy, 202],
326
- ] as const) {
327
- await repo.create({ ...BASE_INPUT, messageId, uid });
328
- await repo.update(messageId, { bodyStorageKey: "file:///bodies/shared" });
329
- }
317
+ await repo.create({ ...BASE_INPUT, messageId: source, uid: 201 });
318
+ await repo.update(source, { bodyStorageKey: "file:///bodies/shared" });
330
319
  await repo.setSearchText(source, "the shared body");
331
320
 
332
- 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
+ });
333
327
 
334
328
  const missing = await repo.listMissingSearchText("", 100);
335
329
  assert.equal(
@@ -6,9 +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
- import { createTestDb, type TestDb } from "./test-helpers.js";
12
+ import { messageObjectsInMemory } from "./message-objects.js";
12
13
 
13
14
  describe("DrizzleMessageRepository", () => {
14
15
  let db: TestDb;
@@ -37,10 +38,11 @@ describe("DrizzleMessageRepository", () => {
37
38
  };
38
39
 
39
40
  before(async () => {
40
- ({ db, stop } = await createTestDb());
41
+ ({ db, close: stop } = await createTestDb());
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;
@@ -147,8 +149,6 @@ describe("DrizzleMessageRepository", () => {
147
149
  sequenceNumber: 1,
148
150
  rfc822Size: 512,
149
151
  internalDate: NOW,
150
- envelopeId: deriveEnvelopeId(MOVE_MESSAGE_ID),
151
- rootBodyPartId: deriveRootBodyPartId(MOVE_MESSAGE_ID),
152
152
  status: "active" as const,
153
153
  syncStatus: "synced" as const,
154
154
  });
@@ -262,8 +262,6 @@ describe("DrizzleMessageRepository", () => {
262
262
  sequenceNumber: 1,
263
263
  rfc822Size: 10,
264
264
  internalDate: NOW,
265
- envelopeId: DEL_ENVELOPE_ID,
266
- rootBodyPartId: DEL_ROOT_BODY_PART_ID,
267
265
  status: "active",
268
266
  syncStatus: "synced",
269
267
  });