@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.87",
3
+ "version": "0.0.88",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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
- await mailboxRepo.create({
164
- accountId: account.accountId,
165
- namespaceType: "personal",
166
- namespacePrefix: "",
167
- hierarchyDelimiter: "/",
168
- fullPath: "INBOX",
169
- uidValidity: 1,
170
- uidNext: 1,
171
- highestModseq: "0",
172
- messageCount: 0,
173
- unseenCount: 0,
174
- deletedCount: 0,
175
- totalSize: 0,
176
- lastSyncUid: 0,
177
- highWaterMarkUid: 0,
178
- lastMessageSyncAt: Date.now(),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
107
- const flagged = await mailboxes.create(makeMailboxInput(accountId, "Junk"));
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 = await mailboxes.create(
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
130
- const bin = await mailboxes.create(makeMailboxInput(accountId, "Bin"));
131
- const gmailTrash = await mailboxes.create(
132
- makeMailboxInput(accountId, "[Gmail]/Trash"),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
155
- const archive = await mailboxes.create(
156
- makeMailboxInput(accountId, "INBOX/Archive"),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
175
- const keepsakes = await mailboxes.create(
176
- makeMailboxInput(accountId, "Deleted"),
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 = await mailboxes.create(
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
205
- const flagged = await mailboxes.create(
206
- makeMailboxInput(accountId, "[Gmail]/Trash"),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
241
- const spam = await mailboxes.create(
242
- makeMailboxInput(accountId, "INBOX/Spam"),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
256
- const spam = await mailboxes.create(
257
- makeMailboxInput(accountId, "INBOX/Spam"),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
270
- const trash = await mailboxes.create(
271
- makeMailboxInput(accountId, "INBOX/Trash"),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
290
- const archive = await mailboxes.create(
291
- makeMailboxInput(accountId, "INBOX/Archive"),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX", "."));
303
- const trash = await mailboxes.create(
304
- makeMailboxInput(accountId, "INBOX.Trash", "."),
325
+ mailboxCreated(
326
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX", ".")),
305
327
  );
306
- const archive = await mailboxes.create(
307
- makeMailboxInput(accountId, "INBOX.Archive", "."),
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
- await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
323
- await mailboxes.create(makeMailboxInput(accountId, "INBOX/Work"));
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 = await repo.create({
54
- ...makeMailboxInput(accountId),
55
- highestModseq: modseq,
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 = await repo.create(
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 = await repo.create(makeMailboxInput(accountId, "Archive"));
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 = await repo.create({
150
- ...makeMailboxInput(accountId),
151
- highestModseq: "900",
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 = await repo.create({
163
- ...makeMailboxInput(accountId, "Archive"),
164
- highestModseq: "900:149",
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 = await repo.create(makeMailboxInput(accountId, "Notes"));
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 = await repo.create(makeMailboxInput(accountId, "Receipts"));
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 = await repo.create({
200
- ...makeMailboxInput(accountId, "Sent"),
201
- highestModseq: modseq,
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 = await repo.create(makeMailboxInput(accountId));
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 = await repo.create({
80
- ...makeMailboxInput(accountId),
81
- highestModseq: modseq,
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 = await repo.create(makeMailboxInput(accountId, "INBOX"));
102
- const m2 = await repo.create(makeMailboxInput(accountId, "Sent"));
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
- await repo.create(makeMailboxInput(accountId, "Work/Projects"));
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
- await repo.create(makeMailboxInput(accountId, "Work/Projects"));
150
- await repo.create(makeMailboxInput(accountId, "Work/Projects/Alpha"));
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 = await repo.create(makeMailboxInput(accountId));
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 = await repo.create(makeMailboxInput(accountId));
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 = await repo.create(makeMailboxInput(accountId));
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 = await repo.create(makeMailboxInput(accountId, "Archive"));
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 = await repo.create(makeMailboxInput(accountId, "Work"));
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 = await repo.create(makeMailboxInput(accountId, "Bills"));
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 = await repo.create(makeMailboxInput(accountId, "Notes"));
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 = await repo.create({
324
- ...makeMailboxInput(accountId, "Fresh"),
325
- syncStatus: MailboxSyncStatus.pending,
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 = await repo.create(makeMailboxInput(accountId, "Old"));
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 = await repo.create(makeMailboxInput(accountId, "Trips"));
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 = await repo.create(makeMailboxInput(accountId, "Races"));
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 = await repo.create(makeMailboxInput(accountId, "Private"));
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: await repo.create(makeMailboxInput(accountId, "Work")),
420
- child: await repo.create(makeMailboxInput(accountId, "Work/Projects")),
421
- grandchild: await repo.create(
422
- makeMailboxInput(accountId, "Work/Projects/Alpha"),
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 = await repo.create(makeMailboxInput(accountId, "Receipts"));
572
- const spared = await repo.create(makeMailboxInput(accountId, "Keep"));
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 = await repo.create(makeMailboxInput(accountId, "Bound"));
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 = await repo.create(makeMailboxInput(accountId, "Resume"));
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 = await repo.create(makeMailboxInput(accountId, "Mine"));
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);
@@ -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<MailboxItem> {
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
- return rowToMailbox(row);
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
- return this.create({ accountId, fullPath, ...defaults });
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
+ };