@remit/drizzle-service 0.0.87 → 0.0.88
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 +1 -1
- package/src/mailbox-path-dedupe.sqlite.test.ts +436 -0
- package/src/repair/duplicate-mailbox-mail.ts +98 -0
- package/src/repos/i4-account.test.ts +20 -17
- package/src/repos/i4-mailbox-special-use.sqlite.test.ts +67 -38
- package/src/repos/i4-mailbox.sqlite.test.ts +84 -21
- package/src/repos/i4-mailbox.test.ts +92 -39
- package/src/repos/i4-mailbox.ts +14 -3
- package/src/repos/test-helpers.ts +8 -0
package/package.json
CHANGED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import * as entities from "@remit/drizzle-sqlite-schema";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { asc, eq } from "drizzle-orm";
|
|
6
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
7
|
+
import type { Db } from "./db.js";
|
|
8
|
+
import {
|
|
9
|
+
PARKED_MAILBOX_ID,
|
|
10
|
+
sweepDuplicateMailboxMail,
|
|
11
|
+
} from "./repair/duplicate-mailbox-mail.js";
|
|
12
|
+
import { MESSAGE_REMOVED_EVENT } from "./repos/message.js";
|
|
13
|
+
import { outboxTable } from "./schema/outbox.js";
|
|
14
|
+
import {
|
|
15
|
+
applyMigration,
|
|
16
|
+
migrationJournal,
|
|
17
|
+
} from "./test-shipped-sqlite-schema.js";
|
|
18
|
+
|
|
19
|
+
const DEDUPE = "0030_mailbox_path_dedupe";
|
|
20
|
+
const UNIQUE = "0031_mailbox_path_unique";
|
|
21
|
+
const ACCOUNT = "acct";
|
|
22
|
+
const CONFIG = "cfg";
|
|
23
|
+
|
|
24
|
+
type Handle = { sqlite: Database.Database; db: Db<Record<string, unknown>> };
|
|
25
|
+
|
|
26
|
+
const atPredecessor = (): Handle => {
|
|
27
|
+
const sqlite = new Database(":memory:");
|
|
28
|
+
for (const entry of migrationJournal()) {
|
|
29
|
+
if (entry.tag === DEDUPE) {
|
|
30
|
+
return {
|
|
31
|
+
sqlite,
|
|
32
|
+
db: drizzle(sqlite) as unknown as Db<Record<string, unknown>>,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
applyMigration(sqlite, entry.tag);
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`${DEDUPE} is not in the journal`);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const seedMailbox = async (
|
|
41
|
+
{ db }: Handle,
|
|
42
|
+
mailboxId: string,
|
|
43
|
+
fullPath: string,
|
|
44
|
+
createdAt: number,
|
|
45
|
+
over: Partial<typeof entities.mailboxes.$inferInsert> = {},
|
|
46
|
+
): Promise<void> => {
|
|
47
|
+
await db.insert(entities.mailboxes).values({
|
|
48
|
+
mailboxId,
|
|
49
|
+
accountId: ACCOUNT,
|
|
50
|
+
namespacePrefix: "",
|
|
51
|
+
hierarchyDelimiter: "/",
|
|
52
|
+
fullPath,
|
|
53
|
+
uidValidity: 1,
|
|
54
|
+
uidNext: 1,
|
|
55
|
+
highestModseq: "0",
|
|
56
|
+
messageCount: 0,
|
|
57
|
+
unseenCount: 0,
|
|
58
|
+
deletedCount: 0,
|
|
59
|
+
totalSize: 0,
|
|
60
|
+
lastSyncUid: 0,
|
|
61
|
+
highWaterMarkUid: 0,
|
|
62
|
+
lastMessageSyncAt: 0,
|
|
63
|
+
createdAt,
|
|
64
|
+
updatedAt: createdAt,
|
|
65
|
+
...over,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const seedMessage = async (
|
|
70
|
+
{ db }: Handle,
|
|
71
|
+
messageId: string,
|
|
72
|
+
mailboxId: string,
|
|
73
|
+
uid = 1,
|
|
74
|
+
): Promise<void> => {
|
|
75
|
+
await db.insert(entities.messages).values({
|
|
76
|
+
messageId,
|
|
77
|
+
mailboxId,
|
|
78
|
+
uid,
|
|
79
|
+
sequenceNumber: uid,
|
|
80
|
+
rfc822Size: 1,
|
|
81
|
+
internalDate: 0,
|
|
82
|
+
envelopeId: `env-${messageId}`,
|
|
83
|
+
rootBodyPartId: `bp-${messageId}`,
|
|
84
|
+
createdAt: 0,
|
|
85
|
+
updatedAt: 0,
|
|
86
|
+
});
|
|
87
|
+
await db.insert(entities.threadMessages).values({
|
|
88
|
+
threadMessageId: `tm-${messageId}`,
|
|
89
|
+
threadId: `thread-${messageId}`,
|
|
90
|
+
messageId,
|
|
91
|
+
accountConfigId: CONFIG,
|
|
92
|
+
mailboxId,
|
|
93
|
+
uid,
|
|
94
|
+
referenceOrder: 0,
|
|
95
|
+
internalDate: 0,
|
|
96
|
+
sentDate: 0,
|
|
97
|
+
isRead: false,
|
|
98
|
+
hasAttachment: false,
|
|
99
|
+
hasStars: false,
|
|
100
|
+
isDeleted: false,
|
|
101
|
+
createdAt: 0,
|
|
102
|
+
updatedAt: 0,
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const seedSetting = async (
|
|
107
|
+
{ db }: Handle,
|
|
108
|
+
name: string,
|
|
109
|
+
value: typeof entities.accountSettings.$inferInsert.value,
|
|
110
|
+
): Promise<void> => {
|
|
111
|
+
await db.insert(entities.accountSettings).values({
|
|
112
|
+
accountSettingId: name,
|
|
113
|
+
accountConfigId: CONFIG,
|
|
114
|
+
name,
|
|
115
|
+
value,
|
|
116
|
+
createdAt: 0,
|
|
117
|
+
updatedAt: 0,
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const dedupe = (handle: Handle): void => {
|
|
122
|
+
applyMigration(handle.sqlite, DEDUPE);
|
|
123
|
+
applyMigration(handle.sqlite, UNIQUE);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const mailboxIds = async ({ db }: Handle): Promise<string[]> =>
|
|
127
|
+
(
|
|
128
|
+
await db
|
|
129
|
+
.select({ mailboxId: entities.mailboxes.mailboxId })
|
|
130
|
+
.from(entities.mailboxes)
|
|
131
|
+
.orderBy(asc(entities.mailboxes.mailboxId))
|
|
132
|
+
).map((row) => row.mailboxId);
|
|
133
|
+
|
|
134
|
+
const messagesIn = async ({ db }: Handle, mailboxId: string): Promise<number> =>
|
|
135
|
+
(
|
|
136
|
+
await db
|
|
137
|
+
.select({ messageId: entities.messages.messageId })
|
|
138
|
+
.from(entities.messages)
|
|
139
|
+
.where(eq(entities.messages.mailboxId, mailboxId))
|
|
140
|
+
).length;
|
|
141
|
+
|
|
142
|
+
const threadsIn = async ({ db }: Handle, mailboxId: string): Promise<number> =>
|
|
143
|
+
(
|
|
144
|
+
await db
|
|
145
|
+
.select({ messageId: entities.threadMessages.messageId })
|
|
146
|
+
.from(entities.threadMessages)
|
|
147
|
+
.where(eq(entities.threadMessages.mailboxId, mailboxId))
|
|
148
|
+
).length;
|
|
149
|
+
|
|
150
|
+
const survivorCursor = async ({ db }: Handle, mailboxId: string) => {
|
|
151
|
+
const [row] = await db
|
|
152
|
+
.select({
|
|
153
|
+
lastSyncUid: entities.mailboxes.lastSyncUid,
|
|
154
|
+
highWaterMarkUid: entities.mailboxes.highWaterMarkUid,
|
|
155
|
+
highestModseq: entities.mailboxes.highestModseq,
|
|
156
|
+
})
|
|
157
|
+
.from(entities.mailboxes)
|
|
158
|
+
.where(eq(entities.mailboxes.mailboxId, mailboxId));
|
|
159
|
+
return row;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
describe("(account_id, full_path) becomes unique", () => {
|
|
163
|
+
test("the loser's mail moves onto the survivor, so none of it is lost", async () => {
|
|
164
|
+
const handle = atPredecessor();
|
|
165
|
+
const cursor = {
|
|
166
|
+
lastSyncUid: 1,
|
|
167
|
+
highWaterMarkUid: 105,
|
|
168
|
+
highestModseq: "900",
|
|
169
|
+
};
|
|
170
|
+
await seedMailbox(handle, "a", "Archive", 1, cursor);
|
|
171
|
+
await seedMailbox(handle, "b", "Archive", 2, cursor);
|
|
172
|
+
for (let uid = 1; uid <= 100; uid++) {
|
|
173
|
+
await seedMessage(handle, `a-${uid}`, "a", uid);
|
|
174
|
+
}
|
|
175
|
+
for (let uid = 101; uid <= 105; uid++) {
|
|
176
|
+
await seedMessage(handle, `b-${uid}`, "b", uid);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
dedupe(handle);
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(await mailboxIds(handle), ["a"]);
|
|
182
|
+
assert.equal(await messagesIn(handle, "a"), 105);
|
|
183
|
+
assert.equal(await threadsIn(handle, "a"), 105);
|
|
184
|
+
assert.equal(await messagesIn(handle, PARKED_MAILBOX_ID), 0);
|
|
185
|
+
assert.deepEqual(await survivorCursor(handle, "a"), cursor);
|
|
186
|
+
handle.sqlite.close();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("what named the loser follows the survivor", async () => {
|
|
190
|
+
const handle = atPredecessor();
|
|
191
|
+
await seedMailbox(handle, "older-empty", "INBOX/Notifications", 1);
|
|
192
|
+
await seedMailbox(handle, "with-mail", "INBOX/Notifications", 2);
|
|
193
|
+
await seedMailbox(handle, "child", "INBOX/Notifications/Old", 3);
|
|
194
|
+
await seedMailbox(handle, "elsewhere", "Receipts", 4);
|
|
195
|
+
await seedMessage(handle, "m1", "with-mail");
|
|
196
|
+
await seedMessage(handle, "copied", "elsewhere");
|
|
197
|
+
await handle.db
|
|
198
|
+
.update(entities.messages)
|
|
199
|
+
.set({ originalMailboxId: "older-empty" })
|
|
200
|
+
.where(eq(entities.messages.messageId, "copied"));
|
|
201
|
+
await handle.db
|
|
202
|
+
.update(entities.mailboxes)
|
|
203
|
+
.set({ parentMailboxId: "older-empty" })
|
|
204
|
+
.where(eq(entities.mailboxes.mailboxId, "child"));
|
|
205
|
+
await handle.db.insert(entities.filters).values({
|
|
206
|
+
filterId: "f1",
|
|
207
|
+
accountConfigId: CONFIG,
|
|
208
|
+
name: "Alerts",
|
|
209
|
+
scope: "Standing",
|
|
210
|
+
ruleChangedAt: 0,
|
|
211
|
+
actionChangedAt: 0,
|
|
212
|
+
actionMailboxId: "older-empty",
|
|
213
|
+
createdAt: 0,
|
|
214
|
+
updatedAt: 0,
|
|
215
|
+
});
|
|
216
|
+
await handle.db.insert(entities.organizeJobRequests).values({
|
|
217
|
+
organizeJobId: "job-1",
|
|
218
|
+
accountConfigId: CONFIG,
|
|
219
|
+
userId: "user",
|
|
220
|
+
actionMailboxId: "older-empty",
|
|
221
|
+
ttl: 0,
|
|
222
|
+
createdAt: 0,
|
|
223
|
+
updatedAt: 0,
|
|
224
|
+
});
|
|
225
|
+
await handle.db.insert(entities.messagePlacementMoves).values({
|
|
226
|
+
messageId: "copied",
|
|
227
|
+
accountId: ACCOUNT,
|
|
228
|
+
accountConfigId: CONFIG,
|
|
229
|
+
sourceMailboxId: "elsewhere",
|
|
230
|
+
destinationMailboxId: "older-empty",
|
|
231
|
+
createdAt: 0,
|
|
232
|
+
updatedAt: 0,
|
|
233
|
+
});
|
|
234
|
+
await seedSetting(handle, "FolderRoleAppointment#acct#Archive", {
|
|
235
|
+
kind: "String",
|
|
236
|
+
value: "older-empty",
|
|
237
|
+
});
|
|
238
|
+
await handle.db.insert(entities.mailboxFlags).values({
|
|
239
|
+
mailboxFlagId: "flag-1",
|
|
240
|
+
mailboxId: "older-empty",
|
|
241
|
+
flagName: "\\Seen",
|
|
242
|
+
isPermanent: true,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
dedupe(handle);
|
|
246
|
+
|
|
247
|
+
assert.deepEqual(await mailboxIds(handle), [
|
|
248
|
+
"child",
|
|
249
|
+
"elsewhere",
|
|
250
|
+
"with-mail",
|
|
251
|
+
]);
|
|
252
|
+
const [child] = await handle.db
|
|
253
|
+
.select({ parent: entities.mailboxes.parentMailboxId })
|
|
254
|
+
.from(entities.mailboxes)
|
|
255
|
+
.where(eq(entities.mailboxes.mailboxId, "child"));
|
|
256
|
+
assert.equal(child?.parent, "with-mail");
|
|
257
|
+
const [filter] = await handle.db.select().from(entities.filters);
|
|
258
|
+
assert.equal(filter?.actionMailboxId, "with-mail");
|
|
259
|
+
const [job] = await handle.db.select().from(entities.organizeJobRequests);
|
|
260
|
+
assert.equal(job?.actionMailboxId, "with-mail");
|
|
261
|
+
const [copied] = await handle.db
|
|
262
|
+
.select({ original: entities.messages.originalMailboxId })
|
|
263
|
+
.from(entities.messages)
|
|
264
|
+
.where(eq(entities.messages.messageId, "copied"));
|
|
265
|
+
assert.equal(copied?.original, "with-mail");
|
|
266
|
+
const [move] = await handle.db
|
|
267
|
+
.select()
|
|
268
|
+
.from(entities.messagePlacementMoves);
|
|
269
|
+
assert.equal(move?.destinationMailboxId, "with-mail");
|
|
270
|
+
const [setting] = await handle.db.select().from(entities.accountSettings);
|
|
271
|
+
assert.deepEqual(setting?.value, { kind: "String", value: "with-mail" });
|
|
272
|
+
assert.deepEqual(await handle.db.select().from(entities.mailboxFlags), []);
|
|
273
|
+
const index = (
|
|
274
|
+
handle.sqlite.prepare("PRAGMA index_list(mailbox)").all() as Array<{
|
|
275
|
+
name: string;
|
|
276
|
+
unique: number;
|
|
277
|
+
}>
|
|
278
|
+
).find((entry) => entry.name === "mailbox_by_account_full_path");
|
|
279
|
+
assert.equal(index?.unique, 1);
|
|
280
|
+
handle.sqlite.close();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("a muted losing folder stays muted, and the survivor's own name is kept", async () => {
|
|
284
|
+
const handle = atPredecessor();
|
|
285
|
+
await seedMailbox(handle, "survivor", "Newsletters", 1);
|
|
286
|
+
await seedMailbox(handle, "loser", "Newsletters", 2);
|
|
287
|
+
await seedSetting(handle, "MailboxMuted#loser", {
|
|
288
|
+
kind: "Muted",
|
|
289
|
+
value: true,
|
|
290
|
+
});
|
|
291
|
+
await seedSetting(handle, "MailboxDisplayName#loser", {
|
|
292
|
+
kind: "String",
|
|
293
|
+
value: "Loser name",
|
|
294
|
+
});
|
|
295
|
+
await seedSetting(handle, "MailboxDisplayName#survivor", {
|
|
296
|
+
kind: "String",
|
|
297
|
+
value: "Survivor name",
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
dedupe(handle);
|
|
301
|
+
|
|
302
|
+
const settings = await handle.db
|
|
303
|
+
.select({
|
|
304
|
+
name: entities.accountSettings.name,
|
|
305
|
+
value: entities.accountSettings.value,
|
|
306
|
+
})
|
|
307
|
+
.from(entities.accountSettings)
|
|
308
|
+
.orderBy(asc(entities.accountSettings.name));
|
|
309
|
+
assert.deepEqual(settings, [
|
|
310
|
+
{
|
|
311
|
+
name: "MailboxDisplayName#survivor",
|
|
312
|
+
value: { kind: "String", value: "Survivor name" },
|
|
313
|
+
},
|
|
314
|
+
{ name: "MailboxMuted#survivor", value: { kind: "Muted", value: true } },
|
|
315
|
+
]);
|
|
316
|
+
handle.sqlite.close();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("the survivor is chosen by the mail it holds, not by message_count", async () => {
|
|
320
|
+
const handle = atPredecessor();
|
|
321
|
+
await seedMailbox(handle, "claims-mail", "Archive", 1, {
|
|
322
|
+
messageCount: 500,
|
|
323
|
+
});
|
|
324
|
+
await seedMailbox(handle, "holds-mail", "Archive", 2);
|
|
325
|
+
await seedMessage(handle, "m1", "holds-mail", 1);
|
|
326
|
+
await seedMessage(handle, "m2", "holds-mail", 2);
|
|
327
|
+
|
|
328
|
+
dedupe(handle);
|
|
329
|
+
|
|
330
|
+
assert.deepEqual(await mailboxIds(handle), ["holds-mail"]);
|
|
331
|
+
handle.sqlite.close();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("with equal mail the oldest row survives", async () => {
|
|
335
|
+
const handle = atPredecessor();
|
|
336
|
+
await seedMailbox(handle, "a-newer", "Archive", 2);
|
|
337
|
+
await seedMailbox(handle, "b-older", "Archive", 1);
|
|
338
|
+
|
|
339
|
+
dedupe(handle);
|
|
340
|
+
|
|
341
|
+
assert.deepEqual(await mailboxIds(handle), ["b-older"]);
|
|
342
|
+
handle.sqlite.close();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("with equal mail and age the lowest mailbox_id survives", async () => {
|
|
346
|
+
const handle = atPredecessor();
|
|
347
|
+
await seedMailbox(handle, "b", "Archive", 1);
|
|
348
|
+
await seedMailbox(handle, "a", "Archive", 1);
|
|
349
|
+
|
|
350
|
+
dedupe(handle);
|
|
351
|
+
|
|
352
|
+
assert.deepEqual(await mailboxIds(handle), ["a"]);
|
|
353
|
+
handle.sqlite.close();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("mail the survivor already holds at that uid is parked, removed at boot, and re-fetched", async () => {
|
|
357
|
+
const handle = atPredecessor();
|
|
358
|
+
const cursor = {
|
|
359
|
+
lastSyncUid: 1,
|
|
360
|
+
highWaterMarkUid: 9,
|
|
361
|
+
highestModseq: "900",
|
|
362
|
+
};
|
|
363
|
+
await seedMailbox(handle, "older", "Archive", 1, cursor);
|
|
364
|
+
await seedMailbox(handle, "newer", "Archive", 2, cursor);
|
|
365
|
+
await seedMessage(handle, "kept", "older", 7);
|
|
366
|
+
await seedMessage(handle, "stale", "newer", 7);
|
|
367
|
+
await seedMessage(handle, "unrelated", "no-such-folder", 1);
|
|
368
|
+
|
|
369
|
+
dedupe(handle);
|
|
370
|
+
|
|
371
|
+
assert.deepEqual(await mailboxIds(handle), ["older"]);
|
|
372
|
+
assert.equal(await messagesIn(handle, PARKED_MAILBOX_ID), 1);
|
|
373
|
+
assert.equal(await threadsIn(handle, PARKED_MAILBOX_ID), 1);
|
|
374
|
+
assert.deepEqual(await survivorCursor(handle, "older"), {
|
|
375
|
+
lastSyncUid: 0,
|
|
376
|
+
highWaterMarkUid: 0,
|
|
377
|
+
highestModseq: "0",
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const report = await sweepDuplicateMailboxMail(handle.db, "repair");
|
|
381
|
+
|
|
382
|
+
assert.deepEqual(report, {
|
|
383
|
+
mode: "repair",
|
|
384
|
+
duplicatePaths: 0,
|
|
385
|
+
parked: 1,
|
|
386
|
+
removed: 1,
|
|
387
|
+
});
|
|
388
|
+
const messages = await handle.db
|
|
389
|
+
.select({ messageId: entities.messages.messageId })
|
|
390
|
+
.from(entities.messages)
|
|
391
|
+
.orderBy(asc(entities.messages.messageId));
|
|
392
|
+
assert.deepEqual(messages, [
|
|
393
|
+
{ messageId: "kept" },
|
|
394
|
+
{ messageId: "unrelated" },
|
|
395
|
+
]);
|
|
396
|
+
const events = await handle.db
|
|
397
|
+
.select({ messageId: outboxTable.messageId, event: outboxTable.event })
|
|
398
|
+
.from(outboxTable);
|
|
399
|
+
assert.deepEqual(events, [
|
|
400
|
+
{ messageId: "stale", event: MESSAGE_REMOVED_EVENT },
|
|
401
|
+
]);
|
|
402
|
+
assert.deepEqual(await sweepDuplicateMailboxMail(handle.db, "repair"), {
|
|
403
|
+
mode: "repair",
|
|
404
|
+
duplicatePaths: 0,
|
|
405
|
+
parked: 0,
|
|
406
|
+
removed: 0,
|
|
407
|
+
});
|
|
408
|
+
handle.sqlite.close();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("the check before migrating reports the paths the migration will merge", async () => {
|
|
412
|
+
const handle = atPredecessor();
|
|
413
|
+
await seedMailbox(handle, "a", "Archive", 1);
|
|
414
|
+
await seedMailbox(handle, "b", "Archive", 2);
|
|
415
|
+
|
|
416
|
+
assert.deepEqual(await sweepDuplicateMailboxMail(handle.db, "check"), {
|
|
417
|
+
mode: "check",
|
|
418
|
+
duplicatePaths: 1,
|
|
419
|
+
parked: 0,
|
|
420
|
+
removed: 0,
|
|
421
|
+
});
|
|
422
|
+
handle.sqlite.close();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("the unique index is refused without the dedupe ahead of it", async () => {
|
|
426
|
+
const handle = atPredecessor();
|
|
427
|
+
await seedMailbox(handle, "a", "Archive", 1);
|
|
428
|
+
await seedMailbox(handle, "b", "Archive", 2);
|
|
429
|
+
|
|
430
|
+
assert.throws(
|
|
431
|
+
() => applyMigration(handle.sqlite, UNIQUE),
|
|
432
|
+
/UNIQUE constraint failed/,
|
|
433
|
+
);
|
|
434
|
+
handle.sqlite.close();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { count, eq, gt, inArray } from "drizzle-orm";
|
|
2
|
+
import type { Db } from "../db.js";
|
|
3
|
+
import { deleteMessageSubtree } from "../repos/message.js";
|
|
4
|
+
import { mailboxTable } from "../schema/i4-mailbox.js";
|
|
5
|
+
import { messageTable } from "../schema/message-data.js";
|
|
6
|
+
import { threadMessageTable } from "../schema/thread-message.js";
|
|
7
|
+
import { messageLabelTable } from "../schema.js";
|
|
8
|
+
import { runInTransaction } from "../tx.js";
|
|
9
|
+
|
|
10
|
+
export const PARKED_MAILBOX_ID = "DuplicateMailboxRemoved";
|
|
11
|
+
|
|
12
|
+
type DuplicateMailDb = Db<Record<string, unknown>>;
|
|
13
|
+
|
|
14
|
+
export type DuplicateMailRepairMode = "check" | "repair";
|
|
15
|
+
|
|
16
|
+
export type DuplicateMailReport = {
|
|
17
|
+
readonly mode: DuplicateMailRepairMode;
|
|
18
|
+
readonly duplicatePaths: number;
|
|
19
|
+
readonly parked: number;
|
|
20
|
+
readonly removed: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const BATCH_SIZE = 100;
|
|
24
|
+
|
|
25
|
+
const countDuplicatePaths = async (db: DuplicateMailDb): Promise<number> => {
|
|
26
|
+
const groups = await db
|
|
27
|
+
.select({ rows: count() })
|
|
28
|
+
.from(mailboxTable)
|
|
29
|
+
.groupBy(mailboxTable.accountId, mailboxTable.fullPath)
|
|
30
|
+
.having(gt(count(), 1));
|
|
31
|
+
return groups.length;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const countParked = async (db: DuplicateMailDb): Promise<number> => {
|
|
35
|
+
const [row] = await db
|
|
36
|
+
.select({ parked: count() })
|
|
37
|
+
.from(messageTable)
|
|
38
|
+
.where(eq(messageTable.mailboxId, PARKED_MAILBOX_ID));
|
|
39
|
+
return row?.parked ?? 0;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const nextParkedBatch = async (db: DuplicateMailDb): Promise<string[]> => {
|
|
43
|
+
const rows = await db
|
|
44
|
+
.select({ messageId: messageTable.messageId })
|
|
45
|
+
.from(messageTable)
|
|
46
|
+
.where(eq(messageTable.mailboxId, PARKED_MAILBOX_ID))
|
|
47
|
+
.limit(BATCH_SIZE);
|
|
48
|
+
return rows.map((row) => row.messageId);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const sweepDuplicateMailboxMail = async (
|
|
52
|
+
db: DuplicateMailDb,
|
|
53
|
+
mode: DuplicateMailRepairMode,
|
|
54
|
+
): Promise<DuplicateMailReport> => {
|
|
55
|
+
const duplicatePaths = await countDuplicatePaths(db);
|
|
56
|
+
const parked = await countParked(db);
|
|
57
|
+
if (mode === "check" || parked === 0) {
|
|
58
|
+
return { mode, duplicatePaths, parked, removed: 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let removed = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
const messageIds = await nextParkedBatch(db);
|
|
64
|
+
if (messageIds.length === 0) break;
|
|
65
|
+
await runInTransaction(db, async (tx) => {
|
|
66
|
+
await deleteMessageSubtree(tx, messageIds);
|
|
67
|
+
await tx
|
|
68
|
+
.delete(threadMessageTable)
|
|
69
|
+
.where(inArray(threadMessageTable.messageId, messageIds));
|
|
70
|
+
await tx
|
|
71
|
+
.delete(messageLabelTable)
|
|
72
|
+
.where(inArray(messageLabelTable.messageId, messageIds));
|
|
73
|
+
});
|
|
74
|
+
removed += messageIds.length;
|
|
75
|
+
}
|
|
76
|
+
return { mode, duplicatePaths, parked, removed };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const formatDuplicateMailReport = (
|
|
80
|
+
report: DuplicateMailReport,
|
|
81
|
+
): string[] => {
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
if (report.duplicatePaths > 0) {
|
|
84
|
+
lines.push(
|
|
85
|
+
`${report.duplicatePaths} folder path(s) held by more than one row, merged by the pending migration`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (report.parked === 0) {
|
|
89
|
+
lines.push("No message is parked by the folder dedupe");
|
|
90
|
+
return lines;
|
|
91
|
+
}
|
|
92
|
+
lines.push(
|
|
93
|
+
report.mode === "check"
|
|
94
|
+
? `${report.parked} message(s) parked by the folder dedupe, would be removed`
|
|
95
|
+
: `${report.removed} of ${report.parked} message(s) parked by the folder dedupe removed`,
|
|
96
|
+
);
|
|
97
|
+
return lines;
|
|
98
|
+
};
|
|
@@ -3,6 +3,7 @@ import { after, before, describe, test } from "node:test";
|
|
|
3
3
|
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
4
4
|
import { AccountRepo } from "./i4-account.js";
|
|
5
5
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
6
|
+
import { mailboxCreated } from "./test-helpers.js";
|
|
6
7
|
|
|
7
8
|
function makeAccountInput(accountConfigId: string) {
|
|
8
9
|
return {
|
|
@@ -160,23 +161,25 @@ describe("AccountRepo", () => {
|
|
|
160
161
|
const account = await repo.create(makeAccountInput(accountConfigId));
|
|
161
162
|
|
|
162
163
|
const mailboxRepo = new MailboxRepo(db as never);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
164
|
+
mailboxCreated(
|
|
165
|
+
await mailboxRepo.create({
|
|
166
|
+
accountId: account.accountId,
|
|
167
|
+
namespaceType: "personal",
|
|
168
|
+
namespacePrefix: "",
|
|
169
|
+
hierarchyDelimiter: "/",
|
|
170
|
+
fullPath: "INBOX",
|
|
171
|
+
uidValidity: 1,
|
|
172
|
+
uidNext: 1,
|
|
173
|
+
highestModseq: "0",
|
|
174
|
+
messageCount: 0,
|
|
175
|
+
unseenCount: 0,
|
|
176
|
+
deletedCount: 0,
|
|
177
|
+
totalSize: 0,
|
|
178
|
+
lastSyncUid: 0,
|
|
179
|
+
highWaterMarkUid: 0,
|
|
180
|
+
lastMessageSyncAt: Date.now(),
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
180
183
|
|
|
181
184
|
const desc = await repo.describe(account.accountId);
|
|
182
185
|
assert.equal(desc.account.length, 1);
|
|
@@ -16,6 +16,7 @@ import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
|
16
16
|
import { AccountSettingRepo } from "./i4-account-setting.js";
|
|
17
17
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
18
18
|
import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
|
|
19
|
+
import { mailboxCreated } from "./test-helpers.js";
|
|
19
20
|
|
|
20
21
|
const makeMailboxInput = (
|
|
21
22
|
accountId: string,
|
|
@@ -103,11 +104,15 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
103
104
|
|
|
104
105
|
test("the appointment beats the folder the server flagged", async () => {
|
|
105
106
|
const { accountId, accountConfigId } = await makeAccount();
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
mailboxCreated(
|
|
108
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
109
|
+
);
|
|
110
|
+
const flagged = mailboxCreated(
|
|
111
|
+
await mailboxes.create(makeMailboxInput(accountId, "Junk")),
|
|
112
|
+
);
|
|
108
113
|
await repo.create(flagged.mailboxId, "Junk");
|
|
109
|
-
const chosen =
|
|
110
|
-
makeMailboxInput(accountId, "INBOX/Rubbish"),
|
|
114
|
+
const chosen = mailboxCreated(
|
|
115
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Rubbish")),
|
|
111
116
|
);
|
|
112
117
|
await appoint(
|
|
113
118
|
accountConfigId,
|
|
@@ -126,10 +131,14 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
126
131
|
// the user keeps mail in. `bin` is no longer a hint, and an appointment
|
|
127
132
|
// overrides the proposal either way.
|
|
128
133
|
const { accountId, accountConfigId } = await makeAccount();
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
mailboxCreated(
|
|
135
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
136
|
+
);
|
|
137
|
+
const bin = mailboxCreated(
|
|
138
|
+
await mailboxes.create(makeMailboxInput(accountId, "Bin")),
|
|
139
|
+
);
|
|
140
|
+
const gmailTrash = mailboxCreated(
|
|
141
|
+
await mailboxes.create(makeMailboxInput(accountId, "[Gmail]/Trash")),
|
|
133
142
|
);
|
|
134
143
|
|
|
135
144
|
assert.equal(
|
|
@@ -151,9 +160,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
151
160
|
|
|
152
161
|
test("an appointment on a mailbox that is gone falls back rather than resolving to nothing", async () => {
|
|
153
162
|
const { accountId, accountConfigId } = await makeAccount();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
mailboxCreated(
|
|
164
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
165
|
+
);
|
|
166
|
+
const archive = mailboxCreated(
|
|
167
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Archive")),
|
|
157
168
|
);
|
|
158
169
|
await appoint(
|
|
159
170
|
accountConfigId,
|
|
@@ -171,12 +182,14 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
171
182
|
// `Deleted` matches the name proposal, and emptying it would destroy mail
|
|
172
183
|
// the user never put in a trash folder (audit #841).
|
|
173
184
|
const { accountId, accountConfigId } = await makeAccount();
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
mailboxCreated(
|
|
186
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
187
|
+
);
|
|
188
|
+
const keepsakes = mailboxCreated(
|
|
189
|
+
await mailboxes.create(makeMailboxInput(accountId, "Deleted")),
|
|
177
190
|
);
|
|
178
|
-
const trash =
|
|
179
|
-
makeMailboxInput(accountId, "[Gmail]/Trash"),
|
|
191
|
+
const trash = mailboxCreated(
|
|
192
|
+
await mailboxes.create(makeMailboxInput(accountId, "[Gmail]/Trash")),
|
|
180
193
|
);
|
|
181
194
|
|
|
182
195
|
const proposal = await repo.resolveTrashRole(accountId);
|
|
@@ -201,9 +214,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
201
214
|
// flagged folder both hide that; the resolution names the id the user chose
|
|
202
215
|
// so a refusal can offer the repair (#887).
|
|
203
216
|
const { accountId, accountConfigId } = await makeAccount();
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
217
|
+
mailboxCreated(
|
|
218
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
219
|
+
);
|
|
220
|
+
const flagged = mailboxCreated(
|
|
221
|
+
await mailboxes.create(makeMailboxInput(accountId, "[Gmail]/Trash")),
|
|
207
222
|
);
|
|
208
223
|
await repo.create(flagged.mailboxId, "Trash");
|
|
209
224
|
const appointed = randomUUID();
|
|
@@ -237,9 +252,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
237
252
|
|
|
238
253
|
test("resolves an INBOX-nested Junk folder that advertises \\Junk", async () => {
|
|
239
254
|
const { accountId } = await makeAccount();
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
255
|
+
mailboxCreated(
|
|
256
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
257
|
+
);
|
|
258
|
+
const spam = mailboxCreated(
|
|
259
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Spam")),
|
|
243
260
|
);
|
|
244
261
|
await repo.create(spam.mailboxId, "Junk");
|
|
245
262
|
|
|
@@ -252,9 +269,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
252
269
|
// bare names, so `INBOX/Spam` resolved to nothing on a server that
|
|
253
270
|
// advertises no \Junk.
|
|
254
271
|
const { accountId } = await makeAccount();
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
272
|
+
mailboxCreated(
|
|
273
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
274
|
+
);
|
|
275
|
+
const spam = mailboxCreated(
|
|
276
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Spam")),
|
|
258
277
|
);
|
|
259
278
|
|
|
260
279
|
const found = await repo.findJunkMailbox(accountId);
|
|
@@ -266,9 +285,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
266
285
|
// whole-path rule, so `INBOX/Trash` resolved to nothing and a delete
|
|
267
286
|
// refused on an account that plainly has a Trash folder.
|
|
268
287
|
const { accountId } = await makeAccount();
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
288
|
+
mailboxCreated(
|
|
289
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
290
|
+
);
|
|
291
|
+
const trash = mailboxCreated(
|
|
292
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Trash")),
|
|
272
293
|
);
|
|
273
294
|
|
|
274
295
|
assert.equal(
|
|
@@ -286,9 +307,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
286
307
|
|
|
287
308
|
test("resolves an INBOX-nested Archive folder that advertises no special use", async () => {
|
|
288
309
|
const { accountId } = await makeAccount();
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
310
|
+
mailboxCreated(
|
|
311
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
312
|
+
);
|
|
313
|
+
const archive = mailboxCreated(
|
|
314
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Archive")),
|
|
292
315
|
);
|
|
293
316
|
|
|
294
317
|
assert.equal(
|
|
@@ -299,12 +322,14 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
299
322
|
|
|
300
323
|
test("splits the leaf on the account's own delimiter, not on a slash", async () => {
|
|
301
324
|
const { accountId } = await makeAccount();
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
makeMailboxInput(accountId, "INBOX.Trash", "."),
|
|
325
|
+
mailboxCreated(
|
|
326
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX", ".")),
|
|
305
327
|
);
|
|
306
|
-
const
|
|
307
|
-
makeMailboxInput(accountId, "INBOX.
|
|
328
|
+
const trash = mailboxCreated(
|
|
329
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX.Trash", ".")),
|
|
330
|
+
);
|
|
331
|
+
const archive = mailboxCreated(
|
|
332
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX.Archive", ".")),
|
|
308
333
|
);
|
|
309
334
|
|
|
310
335
|
assert.equal(
|
|
@@ -319,8 +344,12 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
319
344
|
|
|
320
345
|
test("answers null when the account has no Junk folder at all", async () => {
|
|
321
346
|
const { accountId } = await makeAccount();
|
|
322
|
-
|
|
323
|
-
|
|
347
|
+
mailboxCreated(
|
|
348
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX")),
|
|
349
|
+
);
|
|
350
|
+
mailboxCreated(
|
|
351
|
+
await mailboxes.create(makeMailboxInput(accountId, "INBOX/Work")),
|
|
352
|
+
);
|
|
324
353
|
|
|
325
354
|
assert.equal(await repo.findJunkMailbox(accountId), null);
|
|
326
355
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
shippedTableDdl,
|
|
12
12
|
} from "../test-shipped-sqlite-schema.js";
|
|
13
13
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
14
|
+
import { mailboxCreated } from "./test-helpers.js";
|
|
14
15
|
|
|
15
16
|
function makeMailboxInput(accountId: string, fullPath = "INBOX") {
|
|
16
17
|
return {
|
|
@@ -50,10 +51,12 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
50
51
|
const accountId = randomUUID();
|
|
51
52
|
const modseq = "18446744073709551615";
|
|
52
53
|
|
|
53
|
-
const created =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
const created = mailboxCreated(
|
|
55
|
+
await repo.create({
|
|
56
|
+
...makeMailboxInput(accountId),
|
|
57
|
+
highestModseq: modseq,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
57
60
|
assert.equal(created.highestModseq, modseq);
|
|
58
61
|
|
|
59
62
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
@@ -70,8 +73,8 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
70
73
|
|
|
71
74
|
test("a row written without a state reads back synced (D1)", async () => {
|
|
72
75
|
const accountId = randomUUID();
|
|
73
|
-
const created =
|
|
74
|
-
makeMailboxInput(accountId, "Discovered"),
|
|
76
|
+
const created = mailboxCreated(
|
|
77
|
+
await repo.create(makeMailboxInput(accountId, "Discovered")),
|
|
75
78
|
);
|
|
76
79
|
assert.equal(created.syncStatus, MailboxSyncStatus.synced);
|
|
77
80
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
@@ -80,7 +83,9 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
80
83
|
|
|
81
84
|
test("pendingPath round-trips and clears", async () => {
|
|
82
85
|
const accountId = randomUUID();
|
|
83
|
-
const created =
|
|
86
|
+
const created = mailboxCreated(
|
|
87
|
+
await repo.create(makeMailboxInput(accountId, "Archive")),
|
|
88
|
+
);
|
|
84
89
|
assert.equal(created.pendingPath, undefined);
|
|
85
90
|
|
|
86
91
|
const claimed = await repo.transition(accountId, created.mailboxId, {
|
|
@@ -102,6 +107,53 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
102
107
|
assert.equal(settled?.pendingPath, undefined);
|
|
103
108
|
assert.equal(settled?.fullPath, "Records");
|
|
104
109
|
});
|
|
110
|
+
|
|
111
|
+
test("a second row at a path the account holds is refused as PathTaken", async () => {
|
|
112
|
+
const accountId = randomUUID();
|
|
113
|
+
const first = mailboxCreated(
|
|
114
|
+
await repo.create(makeMailboxInput(accountId, "Notifications")),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const second = await repo.create(
|
|
118
|
+
makeMailboxInput(accountId, "Notifications"),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
assert.deepEqual(second, { outcome: "PathTaken" });
|
|
122
|
+
assert.equal(
|
|
123
|
+
(await repo.findByPath(accountId, "Notifications"))?.mailboxId,
|
|
124
|
+
first.mailboxId,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("another account may hold the same path", async () => {
|
|
129
|
+
const created = await repo.create(
|
|
130
|
+
makeMailboxInput(randomUUID(), "Notifications"),
|
|
131
|
+
);
|
|
132
|
+
assert.equal(created.outcome, "Created");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a write that moves a row onto a path another row holds is rejected", async () => {
|
|
136
|
+
const accountId = randomUUID();
|
|
137
|
+
mailboxCreated(
|
|
138
|
+
await repo.create(makeMailboxInput(accountId, "INBOX/Notifications")),
|
|
139
|
+
);
|
|
140
|
+
const recreated = mailboxCreated(
|
|
141
|
+
await repo.create({
|
|
142
|
+
...makeMailboxInput(accountId, "Notifications"),
|
|
143
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await assert.rejects(
|
|
148
|
+
repo.transition(accountId, recreated.mailboxId, {
|
|
149
|
+
from: [MailboxSyncStatus.pending],
|
|
150
|
+
wherePendingPath: null,
|
|
151
|
+
to: MailboxSyncStatus.synced,
|
|
152
|
+
set: { fullPath: "INBOX/Notifications" },
|
|
153
|
+
}),
|
|
154
|
+
/UNIQUE constraint failed: mailbox\.account_id, mailbox\.full_path/,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
105
157
|
});
|
|
106
158
|
|
|
107
159
|
/**
|
|
@@ -126,6 +178,7 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
126
178
|
applyMigration(sqlite, "0026_mailbox_sync_status_backfill");
|
|
127
179
|
applyMigration(sqlite, "0027_mailbox_sync_status_total");
|
|
128
180
|
applyMigration(sqlite, "0028_mailbox_pending_path");
|
|
181
|
+
applyMigration(sqlite, "0031_mailbox_path_unique");
|
|
129
182
|
const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
|
|
130
183
|
repo = new MailboxRepo(db as never);
|
|
131
184
|
close = async () => {
|
|
@@ -146,10 +199,12 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
146
199
|
|
|
147
200
|
test("reads a plain-digit cursor back as a string", async () => {
|
|
148
201
|
const accountId = randomUUID();
|
|
149
|
-
const created =
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
202
|
+
const created = mailboxCreated(
|
|
203
|
+
await repo.create({
|
|
204
|
+
...makeMailboxInput(accountId),
|
|
205
|
+
highestModseq: "900",
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
153
208
|
|
|
154
209
|
assert.strictEqual(created.highestModseq, "900");
|
|
155
210
|
|
|
@@ -159,10 +214,12 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
159
214
|
|
|
160
215
|
test("keeps a resumable cursor intact", async () => {
|
|
161
216
|
const accountId = randomUUID();
|
|
162
|
-
const created =
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
217
|
+
const created = mailboxCreated(
|
|
218
|
+
await repo.create({
|
|
219
|
+
...makeMailboxInput(accountId, "Archive"),
|
|
220
|
+
highestModseq: "900:149",
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
166
223
|
|
|
167
224
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
168
225
|
assert.strictEqual(fetched.highestModseq, "900:149");
|
|
@@ -170,13 +227,17 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
170
227
|
|
|
171
228
|
test("a row inserted without a state reads back synced", async () => {
|
|
172
229
|
const accountId = randomUUID();
|
|
173
|
-
const created =
|
|
230
|
+
const created = mailboxCreated(
|
|
231
|
+
await repo.create(makeMailboxInput(accountId, "Notes")),
|
|
232
|
+
);
|
|
174
233
|
assert.equal(created.syncStatus, MailboxSyncStatus.synced);
|
|
175
234
|
});
|
|
176
235
|
|
|
177
236
|
test("pendingPath round-trips and clears on the shipped shape", async () => {
|
|
178
237
|
const accountId = randomUUID();
|
|
179
|
-
const created =
|
|
238
|
+
const created = mailboxCreated(
|
|
239
|
+
await repo.create(makeMailboxInput(accountId, "Receipts")),
|
|
240
|
+
);
|
|
180
241
|
|
|
181
242
|
const claimed = await repo.transition(accountId, created.mailboxId, {
|
|
182
243
|
from: [MailboxSyncStatus.synced],
|
|
@@ -196,10 +257,12 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
|
196
257
|
test("round-trips a cursor above 2^53 with its exact digits", async () => {
|
|
197
258
|
const accountId = randomUUID();
|
|
198
259
|
const modseq = "18446744073709551615";
|
|
199
|
-
const created =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
260
|
+
const created = mailboxCreated(
|
|
261
|
+
await repo.create({
|
|
262
|
+
...makeMailboxInput(accountId, "Sent"),
|
|
263
|
+
highestModseq: modseq,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
203
266
|
|
|
204
267
|
assert.strictEqual(created.highestModseq, modseq);
|
|
205
268
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
@@ -18,6 +18,7 @@ import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
|
18
18
|
import { runInTransaction } from "../tx.js";
|
|
19
19
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
20
20
|
import { DrizzleMessageRepository, deleteMessageSubtree } from "./message.js";
|
|
21
|
+
import { mailboxCreated } from "./test-helpers.js";
|
|
21
22
|
|
|
22
23
|
/** Every state a row can carry, for a transition that decides against none. */
|
|
23
24
|
const EVERY_STATE = [
|
|
@@ -63,7 +64,9 @@ describe("MailboxRepo", () => {
|
|
|
63
64
|
|
|
64
65
|
test("create and get", async () => {
|
|
65
66
|
const accountId = randomId();
|
|
66
|
-
const mailbox =
|
|
67
|
+
const mailbox = mailboxCreated(
|
|
68
|
+
await repo.create(makeMailboxInput(accountId)),
|
|
69
|
+
);
|
|
67
70
|
assert.ok(mailbox.mailboxId);
|
|
68
71
|
assert.equal(mailbox.fullPath, "INBOX");
|
|
69
72
|
|
|
@@ -76,10 +79,12 @@ describe("MailboxRepo", () => {
|
|
|
76
79
|
test("highestModseq round-trips a value above 2^53 without loss (reader#9)", async () => {
|
|
77
80
|
const accountId = randomId();
|
|
78
81
|
const modseq = "18446744073709551615";
|
|
79
|
-
const created =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
const created = mailboxCreated(
|
|
83
|
+
await repo.create({
|
|
84
|
+
...makeMailboxInput(accountId),
|
|
85
|
+
highestModseq: modseq,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
83
88
|
assert.equal(created.highestModseq, modseq);
|
|
84
89
|
|
|
85
90
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
@@ -98,8 +103,12 @@ describe("MailboxRepo", () => {
|
|
|
98
103
|
|
|
99
104
|
test("batchGet: WHERE id = ANY($1)", async () => {
|
|
100
105
|
const accountId = randomId();
|
|
101
|
-
const m1 =
|
|
102
|
-
|
|
106
|
+
const m1 = mailboxCreated(
|
|
107
|
+
await repo.create(makeMailboxInput(accountId, "INBOX")),
|
|
108
|
+
);
|
|
109
|
+
const m2 = mailboxCreated(
|
|
110
|
+
await repo.create(makeMailboxInput(accountId, "Sent")),
|
|
111
|
+
);
|
|
103
112
|
|
|
104
113
|
const results = await repo.get(accountId, [m1.mailboxId, m2.mailboxId]);
|
|
105
114
|
assert.equal(results.length, 2);
|
|
@@ -114,7 +123,9 @@ describe("MailboxRepo", () => {
|
|
|
114
123
|
|
|
115
124
|
test("findByPath finds existing mailbox", async () => {
|
|
116
125
|
const accountId = randomId();
|
|
117
|
-
|
|
126
|
+
mailboxCreated(
|
|
127
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects")),
|
|
128
|
+
);
|
|
118
129
|
|
|
119
130
|
const found = await repo.findByPath(accountId, "Work/Projects");
|
|
120
131
|
assert.ok(found);
|
|
@@ -145,9 +156,13 @@ describe("MailboxRepo", () => {
|
|
|
145
156
|
|
|
146
157
|
test("findByPathPrefix finds children", async () => {
|
|
147
158
|
const accountId = randomId();
|
|
148
|
-
await repo.create(makeMailboxInput(accountId, "Work"));
|
|
149
|
-
|
|
150
|
-
|
|
159
|
+
mailboxCreated(await repo.create(makeMailboxInput(accountId, "Work")));
|
|
160
|
+
mailboxCreated(
|
|
161
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects")),
|
|
162
|
+
);
|
|
163
|
+
mailboxCreated(
|
|
164
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects/Alpha")),
|
|
165
|
+
);
|
|
151
166
|
|
|
152
167
|
const children = await repo.findByPathPrefix(accountId, "Work");
|
|
153
168
|
assert.ok(children.some((m) => m.fullPath === "Work/Projects"));
|
|
@@ -161,8 +176,8 @@ describe("MailboxRepo", () => {
|
|
|
161
176
|
|
|
162
177
|
test("findByPathPrefix finds nothing in a flat namespace", async () => {
|
|
163
178
|
const accountId = randomId();
|
|
164
|
-
await repo.create(makeMailboxInput(accountId, "Work"));
|
|
165
|
-
await repo.create(makeMailboxInput(accountId, "Workshop"));
|
|
179
|
+
mailboxCreated(await repo.create(makeMailboxInput(accountId, "Work")));
|
|
180
|
+
mailboxCreated(await repo.create(makeMailboxInput(accountId, "Workshop")));
|
|
166
181
|
|
|
167
182
|
assert.deepEqual(await repo.findByPathPrefix(accountId, "Work", ""), []);
|
|
168
183
|
});
|
|
@@ -170,7 +185,9 @@ describe("MailboxRepo", () => {
|
|
|
170
185
|
test("cross-tenant: get refuses a foreign account", async () => {
|
|
171
186
|
const accountId = randomId();
|
|
172
187
|
const other = randomId();
|
|
173
|
-
const mailbox =
|
|
188
|
+
const mailbox = mailboxCreated(
|
|
189
|
+
await repo.create(makeMailboxInput(accountId)),
|
|
190
|
+
);
|
|
174
191
|
|
|
175
192
|
await assert.rejects(
|
|
176
193
|
() => repo.get(other, mailbox.mailboxId),
|
|
@@ -186,7 +203,9 @@ describe("MailboxRepo", () => {
|
|
|
186
203
|
test("cross-tenant: update refuses a foreign account and leaves the row unchanged", async () => {
|
|
187
204
|
const accountId = randomId();
|
|
188
205
|
const other = randomId();
|
|
189
|
-
const mailbox =
|
|
206
|
+
const mailbox = mailboxCreated(
|
|
207
|
+
await repo.create(makeMailboxInput(accountId)),
|
|
208
|
+
);
|
|
190
209
|
|
|
191
210
|
await assert.rejects(
|
|
192
211
|
() => repo.update(other, mailbox.mailboxId, { messageCount: 99 }),
|
|
@@ -201,7 +220,9 @@ describe("MailboxRepo", () => {
|
|
|
201
220
|
test("cross-tenant: delete is a no-op for a foreign account", async () => {
|
|
202
221
|
const accountId = randomId();
|
|
203
222
|
const other = randomId();
|
|
204
|
-
const mailbox =
|
|
223
|
+
const mailbox = mailboxCreated(
|
|
224
|
+
await repo.create(makeMailboxInput(accountId)),
|
|
225
|
+
);
|
|
205
226
|
|
|
206
227
|
await repo.delete(other, mailbox.mailboxId);
|
|
207
228
|
const still = await repo.get(accountId, mailbox.mailboxId);
|
|
@@ -213,8 +234,8 @@ describe("MailboxRepo", () => {
|
|
|
213
234
|
test("cross-tenant: deleteMany only removes ids owned by the tenant", async () => {
|
|
214
235
|
const accountA = randomId();
|
|
215
236
|
const accountB = randomId();
|
|
216
|
-
const a = await repo.create(makeMailboxInput(accountA));
|
|
217
|
-
const b = await repo.create(makeMailboxInput(accountB));
|
|
237
|
+
const a = mailboxCreated(await repo.create(makeMailboxInput(accountA)));
|
|
238
|
+
const b = mailboxCreated(await repo.create(makeMailboxInput(accountB)));
|
|
218
239
|
|
|
219
240
|
await repo.deleteMany(accountA, [a.mailboxId, b.mailboxId]);
|
|
220
241
|
|
|
@@ -231,7 +252,9 @@ describe("MailboxRepo", () => {
|
|
|
231
252
|
describe("transition — the conditional write (D3)", () => {
|
|
232
253
|
test("an accepted from-state applies the new state and the set fields", async () => {
|
|
233
254
|
const accountId = randomId();
|
|
234
|
-
const mailbox =
|
|
255
|
+
const mailbox = mailboxCreated(
|
|
256
|
+
await repo.create(makeMailboxInput(accountId, "Archive")),
|
|
257
|
+
);
|
|
235
258
|
|
|
236
259
|
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
237
260
|
from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
|
|
@@ -246,7 +269,9 @@ describe("MailboxRepo", () => {
|
|
|
246
269
|
|
|
247
270
|
test("a state outside `from` writes nothing and leaves the row byte-identical", async () => {
|
|
248
271
|
const accountId = randomId();
|
|
249
|
-
const mailbox =
|
|
272
|
+
const mailbox = mailboxCreated(
|
|
273
|
+
await repo.create(makeMailboxInput(accountId, "Work")),
|
|
274
|
+
);
|
|
250
275
|
const before = await repo.get(accountId, mailbox.mailboxId);
|
|
251
276
|
|
|
252
277
|
const lost = await repo.transition(accountId, mailbox.mailboxId, {
|
|
@@ -271,7 +296,9 @@ describe("MailboxRepo", () => {
|
|
|
271
296
|
|
|
272
297
|
test("wherePendingPath as a string matches only that target", async () => {
|
|
273
298
|
const accountId = randomId();
|
|
274
|
-
const mailbox =
|
|
299
|
+
const mailbox = mailboxCreated(
|
|
300
|
+
await repo.create(makeMailboxInput(accountId, "Bills")),
|
|
301
|
+
);
|
|
275
302
|
await repo.transition(accountId, mailbox.mailboxId, {
|
|
276
303
|
from: [MailboxSyncStatus.synced],
|
|
277
304
|
to: MailboxSyncStatus.pending,
|
|
@@ -302,7 +329,9 @@ describe("MailboxRepo", () => {
|
|
|
302
329
|
// `synced` with the rename target still on it, and the rename then
|
|
303
330
|
// never runs and is never marked failed.
|
|
304
331
|
const accountId = randomId();
|
|
305
|
-
const claimed =
|
|
332
|
+
const claimed = mailboxCreated(
|
|
333
|
+
await repo.create(makeMailboxInput(accountId, "Notes")),
|
|
334
|
+
);
|
|
306
335
|
await repo.transition(accountId, claimed.mailboxId, {
|
|
307
336
|
from: [MailboxSyncStatus.synced],
|
|
308
337
|
to: MailboxSyncStatus.pending,
|
|
@@ -320,10 +349,12 @@ describe("MailboxRepo", () => {
|
|
|
320
349
|
assert.equal(still.syncStatus, MailboxSyncStatus.pending);
|
|
321
350
|
assert.equal(still.pendingPath, "Journal");
|
|
322
351
|
|
|
323
|
-
const creating =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
352
|
+
const creating = mailboxCreated(
|
|
353
|
+
await repo.create({
|
|
354
|
+
...makeMailboxInput(accountId, "Fresh"),
|
|
355
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
327
358
|
const settled = await repo.transition(accountId, creating.mailboxId, {
|
|
328
359
|
from: [MailboxSyncStatus.pending],
|
|
329
360
|
wherePendingPath: null,
|
|
@@ -334,7 +365,9 @@ describe("MailboxRepo", () => {
|
|
|
334
365
|
|
|
335
366
|
test("omitting wherePendingPath predicates on syncStatus alone", async () => {
|
|
336
367
|
const accountId = randomId();
|
|
337
|
-
const mailbox =
|
|
368
|
+
const mailbox = mailboxCreated(
|
|
369
|
+
await repo.create(makeMailboxInput(accountId, "Old")),
|
|
370
|
+
);
|
|
338
371
|
await repo.transition(accountId, mailbox.mailboxId, {
|
|
339
372
|
from: [MailboxSyncStatus.synced],
|
|
340
373
|
to: MailboxSyncStatus.pending,
|
|
@@ -355,7 +388,9 @@ describe("MailboxRepo", () => {
|
|
|
355
388
|
// rename target on it is what strands a folder whose rename then never
|
|
356
389
|
// runs and is never marked failed.
|
|
357
390
|
const accountId = randomId();
|
|
358
|
-
const mailbox =
|
|
391
|
+
const mailbox = mailboxCreated(
|
|
392
|
+
await repo.create(makeMailboxInput(accountId, "Trips")),
|
|
393
|
+
);
|
|
359
394
|
|
|
360
395
|
for (const to of EVERY_STATE) {
|
|
361
396
|
const written = await repo.transition(accountId, mailbox.mailboxId, {
|
|
@@ -380,7 +415,9 @@ describe("MailboxRepo", () => {
|
|
|
380
415
|
|
|
381
416
|
test("two overlapping transitions with the same from: exactly one wins", async () => {
|
|
382
417
|
const accountId = randomId();
|
|
383
|
-
const mailbox =
|
|
418
|
+
const mailbox = mailboxCreated(
|
|
419
|
+
await repo.create(makeMailboxInput(accountId, "Races")),
|
|
420
|
+
);
|
|
384
421
|
|
|
385
422
|
const outcomes = await Promise.all([
|
|
386
423
|
repo.transition(accountId, mailbox.mailboxId, {
|
|
@@ -398,7 +435,9 @@ describe("MailboxRepo", () => {
|
|
|
398
435
|
|
|
399
436
|
test("cross-tenant: a foreign account transitions nothing", async () => {
|
|
400
437
|
const accountId = randomId();
|
|
401
|
-
const mailbox =
|
|
438
|
+
const mailbox = mailboxCreated(
|
|
439
|
+
await repo.create(makeMailboxInput(accountId, "Private")),
|
|
440
|
+
);
|
|
402
441
|
|
|
403
442
|
assert.equal(
|
|
404
443
|
await repo.transition(randomId(), mailbox.mailboxId, {
|
|
@@ -416,10 +455,14 @@ describe("MailboxRepo", () => {
|
|
|
416
455
|
|
|
417
456
|
describe("transitionSubtree — the intent, all-or-nothing (D6)", () => {
|
|
418
457
|
const seedSubtree = async (accountId: string) => ({
|
|
419
|
-
parent:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
458
|
+
parent: mailboxCreated(
|
|
459
|
+
await repo.create(makeMailboxInput(accountId, "Work")),
|
|
460
|
+
),
|
|
461
|
+
child: mailboxCreated(
|
|
462
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects")),
|
|
463
|
+
),
|
|
464
|
+
grandchild: mailboxCreated(
|
|
465
|
+
await repo.create(makeMailboxInput(accountId, "Work/Projects/Alpha")),
|
|
423
466
|
),
|
|
424
467
|
});
|
|
425
468
|
|
|
@@ -568,8 +611,12 @@ describe("MailboxRepo", () => {
|
|
|
568
611
|
|
|
569
612
|
test("removes the folder, its mail and its own child rows, and nothing else's", async () => {
|
|
570
613
|
const accountId = randomId();
|
|
571
|
-
const doomed =
|
|
572
|
-
|
|
614
|
+
const doomed = mailboxCreated(
|
|
615
|
+
await repo.create(makeMailboxInput(accountId, "Receipts")),
|
|
616
|
+
);
|
|
617
|
+
const spared = mailboxCreated(
|
|
618
|
+
await repo.create(makeMailboxInput(accountId, "Keep")),
|
|
619
|
+
);
|
|
573
620
|
const doomedMessage = await seedMessage(doomed.mailboxId, 1);
|
|
574
621
|
const sparedMessage = await seedMessage(spared.mailboxId, 1);
|
|
575
622
|
await db.insert(mailboxSpecialUseTable).values({
|
|
@@ -636,7 +683,9 @@ describe("MailboxRepo", () => {
|
|
|
636
683
|
// delete is the outcome the design rules out. This is the test that
|
|
637
684
|
// stops a future refactor doing it.
|
|
638
685
|
const accountId = randomId();
|
|
639
|
-
const mailbox =
|
|
686
|
+
const mailbox = mailboxCreated(
|
|
687
|
+
await repo.create(makeMailboxInput(accountId, "Bound")),
|
|
688
|
+
);
|
|
640
689
|
const filterId = randomId();
|
|
641
690
|
await db.insert(filterTable).values({
|
|
642
691
|
filterId,
|
|
@@ -662,7 +711,9 @@ describe("MailboxRepo", () => {
|
|
|
662
711
|
|
|
663
712
|
test("re-running after a partial removal completes", async () => {
|
|
664
713
|
const accountId = randomId();
|
|
665
|
-
const mailbox =
|
|
714
|
+
const mailbox = mailboxCreated(
|
|
715
|
+
await repo.create(makeMailboxInput(accountId, "Resume")),
|
|
716
|
+
);
|
|
666
717
|
const first = await seedMessage(mailbox.mailboxId, 1);
|
|
667
718
|
await seedMessage(mailbox.mailboxId, 2);
|
|
668
719
|
|
|
@@ -691,7 +742,9 @@ describe("MailboxRepo", () => {
|
|
|
691
742
|
|
|
692
743
|
test("cross-tenant: a foreign account removes neither the row nor its mail", async () => {
|
|
693
744
|
const accountId = randomId();
|
|
694
|
-
const mailbox =
|
|
745
|
+
const mailbox = mailboxCreated(
|
|
746
|
+
await repo.create(makeMailboxInput(accountId, "Mine")),
|
|
747
|
+
);
|
|
695
748
|
await seedMessage(mailbox.mailboxId, 1);
|
|
696
749
|
|
|
697
750
|
await repo.deleteMailboxWithMail(randomId(), mailbox.mailboxId);
|
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type {
|
|
3
3
|
CreateMailboxInput,
|
|
4
|
+
CreateMailboxResult,
|
|
4
5
|
IMailboxRepository,
|
|
5
6
|
MailboxItem,
|
|
6
7
|
MailboxStatePredicate,
|
|
@@ -128,7 +129,7 @@ export function rowToMailbox(
|
|
|
128
129
|
export class MailboxRepo implements IMailboxRepository {
|
|
129
130
|
constructor(private db: DB) {}
|
|
130
131
|
|
|
131
|
-
async create(input: CreateMailboxInput): Promise<
|
|
132
|
+
async create(input: CreateMailboxInput): Promise<CreateMailboxResult> {
|
|
132
133
|
const now = Date.now();
|
|
133
134
|
const [row] = await this.db
|
|
134
135
|
.insert(mailboxTable)
|
|
@@ -160,8 +161,12 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
160
161
|
createdAt: now,
|
|
161
162
|
updatedAt: now,
|
|
162
163
|
})
|
|
164
|
+
.onConflictDoNothing({
|
|
165
|
+
target: [mailboxTable.accountId, mailboxTable.fullPath],
|
|
166
|
+
})
|
|
163
167
|
.returning();
|
|
164
|
-
|
|
168
|
+
if (!row) return { outcome: "PathTaken" };
|
|
169
|
+
return { outcome: "Created", mailbox: rowToMailbox(row) };
|
|
165
170
|
}
|
|
166
171
|
|
|
167
172
|
async get(accountId: string, mailboxId: string): Promise<MailboxItem>;
|
|
@@ -444,7 +449,13 @@ export class MailboxRepo implements IMailboxRepository {
|
|
|
444
449
|
): Promise<MailboxItem> {
|
|
445
450
|
const existing = await this.findByPath(accountId, fullPath);
|
|
446
451
|
if (existing) return existing;
|
|
447
|
-
|
|
452
|
+
const created = await this.create({ accountId, fullPath, ...defaults });
|
|
453
|
+
if (created.outcome === "Created") return created.mailbox;
|
|
454
|
+
const winner = await this.findByPath(accountId, fullPath);
|
|
455
|
+
if (!winner) {
|
|
456
|
+
throw new NotFoundError(`Mailbox not found at path: ${fullPath}`);
|
|
457
|
+
}
|
|
458
|
+
return winner;
|
|
448
459
|
}
|
|
449
460
|
|
|
450
461
|
async findByPathPrefix(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CreateMailboxResult, MailboxItem } from "@remit/data-ports";
|
|
1
2
|
import type Database from "better-sqlite3";
|
|
2
3
|
import type { Db } from "../db.js";
|
|
3
4
|
import {
|
|
@@ -20,3 +21,10 @@ export async function createTestDb(): Promise<{
|
|
|
20
21
|
const { db, sqlite, close } = await createSqliteTestDb(messageDataSchema);
|
|
21
22
|
return { db, sqlite, stop: close };
|
|
22
23
|
}
|
|
24
|
+
|
|
25
|
+
export const mailboxCreated = (result: CreateMailboxResult): MailboxItem => {
|
|
26
|
+
if (result.outcome !== "Created") {
|
|
27
|
+
throw new Error("The account already has a folder at that path");
|
|
28
|
+
}
|
|
29
|
+
return result.mailbox;
|
|
30
|
+
};
|