@remit/drizzle-service 0.0.87 → 0.0.89

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.
@@ -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
+ };