@remit/drizzle-service 0.0.82 → 0.0.84

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.
@@ -1,7 +1,31 @@
1
1
  import assert from "node:assert";
2
2
  import { after, before, describe, test } from "node:test";
3
+ import { MailboxSyncStatus } from "@remit/domain-enums";
4
+ import { eq } from "drizzle-orm";
5
+ import {
6
+ envelopeId as deriveEnvelopeId,
7
+ rootBodyPartId as deriveRootBodyPartId,
8
+ } from "../id.js";
9
+ import {
10
+ filterTable,
11
+ mailboxSpecialUseTable,
12
+ mailboxTable,
13
+ messageTable,
14
+ outboxTable,
15
+ threadMessageTable,
16
+ } from "../schema.js";
3
17
  import { createTestDb, randomId, type TestDb } from "../test-db.js";
18
+ import { runInTransaction } from "../tx.js";
4
19
  import { MailboxRepo } from "./i4-mailbox.js";
20
+ import { DrizzleMessageRepository, deleteMessageSubtree } from "./message.js";
21
+
22
+ /** Every state a row can carry, for a transition that decides against none. */
23
+ const EVERY_STATE = [
24
+ MailboxSyncStatus.synced,
25
+ MailboxSyncStatus.pending,
26
+ MailboxSyncStatus.failed,
27
+ MailboxSyncStatus.deleting,
28
+ ] as const;
5
29
 
6
30
  function makeMailboxInput(accountId: string, fullPath = "INBOX") {
7
31
  return {
@@ -143,19 +167,6 @@ describe("MailboxRepo", () => {
143
167
  assert.deepEqual(await repo.findByPathPrefix(accountId, "Work", ""), []);
144
168
  });
145
169
 
146
- test("renameChildPaths updates all children", async () => {
147
- const accountId = randomId();
148
- await repo.create(makeMailboxInput(accountId, "OldName/Sub1"));
149
- await repo.create(makeMailboxInput(accountId, "OldName/Sub2"));
150
-
151
- await repo.renameChildPaths(accountId, "OldName", "NewName");
152
-
153
- const sub1 = await repo.findByPath(accountId, "NewName/Sub1");
154
- const sub2 = await repo.findByPath(accountId, "NewName/Sub2");
155
- assert.ok(sub1, "Sub1 renamed");
156
- assert.ok(sub2, "Sub2 renamed");
157
- });
158
-
159
170
  test("cross-tenant: get refuses a foreign account", async () => {
160
171
  const accountId = randomId();
161
172
  const other = randomId();
@@ -217,6 +228,490 @@ describe("MailboxRepo", () => {
217
228
  await repo.delete(accountB, b.mailboxId);
218
229
  });
219
230
 
231
+ describe("transition — the conditional write (D3)", () => {
232
+ test("an accepted from-state applies the new state and the set fields", async () => {
233
+ const accountId = randomId();
234
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Archive"));
235
+
236
+ const written = await repo.transition(accountId, mailbox.mailboxId, {
237
+ from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
238
+ to: MailboxSyncStatus.pending,
239
+ set: { pendingPath: "Records" },
240
+ });
241
+
242
+ assert.equal(written?.syncStatus, MailboxSyncStatus.pending);
243
+ assert.equal(written?.pendingPath, "Records");
244
+ assert.equal(written?.fullPath, "Archive", "fullPath stays confirmed");
245
+ });
246
+
247
+ test("a state outside `from` writes nothing and leaves the row byte-identical", async () => {
248
+ const accountId = randomId();
249
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Work"));
250
+ const before = await repo.get(accountId, mailbox.mailboxId);
251
+
252
+ const lost = await repo.transition(accountId, mailbox.mailboxId, {
253
+ from: [MailboxSyncStatus.deleting],
254
+ to: MailboxSyncStatus.synced,
255
+ set: { fullPath: "Nope" },
256
+ });
257
+
258
+ assert.equal(lost, null);
259
+ assert.deepEqual(await repo.get(accountId, mailbox.mailboxId), before);
260
+ });
261
+
262
+ test("an absent id is a null, not a throw", async () => {
263
+ assert.equal(
264
+ await repo.transition(randomId(), "no-such-mailbox", {
265
+ from: EVERY_STATE,
266
+ to: MailboxSyncStatus.synced,
267
+ }),
268
+ null,
269
+ );
270
+ });
271
+
272
+ test("wherePendingPath as a string matches only that target", async () => {
273
+ const accountId = randomId();
274
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Bills"));
275
+ await repo.transition(accountId, mailbox.mailboxId, {
276
+ from: [MailboxSyncStatus.synced],
277
+ to: MailboxSyncStatus.pending,
278
+ set: { pendingPath: "Invoices" },
279
+ });
280
+
281
+ assert.equal(
282
+ await repo.transition(accountId, mailbox.mailboxId, {
283
+ from: [MailboxSyncStatus.pending],
284
+ wherePendingPath: "Somewhere else",
285
+ to: MailboxSyncStatus.synced,
286
+ }),
287
+ null,
288
+ );
289
+
290
+ const settled = await repo.transition(accountId, mailbox.mailboxId, {
291
+ from: [MailboxSyncStatus.pending],
292
+ wherePendingPath: "Invoices",
293
+ to: MailboxSyncStatus.synced,
294
+ set: { fullPath: "Invoices", pendingPath: null },
295
+ });
296
+ assert.equal(settled?.fullPath, "Invoices");
297
+ });
298
+
299
+ test("wherePendingPath: null refuses a row a rename has claimed", async () => {
300
+ // The seventh-state hole (D3): a create settle that predicates on
301
+ // `pending` alone matches a row a rename has since claimed, writes
302
+ // `synced` with the rename target still on it, and the rename then
303
+ // never runs and is never marked failed.
304
+ const accountId = randomId();
305
+ const claimed = await repo.create(makeMailboxInput(accountId, "Notes"));
306
+ await repo.transition(accountId, claimed.mailboxId, {
307
+ from: [MailboxSyncStatus.synced],
308
+ to: MailboxSyncStatus.pending,
309
+ set: { pendingPath: "Journal" },
310
+ });
311
+
312
+ const createSettle = await repo.transition(accountId, claimed.mailboxId, {
313
+ from: [MailboxSyncStatus.pending],
314
+ wherePendingPath: null,
315
+ to: MailboxSyncStatus.synced,
316
+ });
317
+ assert.equal(createSettle, null);
318
+
319
+ const still = await repo.get(accountId, claimed.mailboxId);
320
+ assert.equal(still.syncStatus, MailboxSyncStatus.pending);
321
+ assert.equal(still.pendingPath, "Journal");
322
+
323
+ const creating = await repo.create({
324
+ ...makeMailboxInput(accountId, "Fresh"),
325
+ syncStatus: MailboxSyncStatus.pending,
326
+ });
327
+ const settled = await repo.transition(accountId, creating.mailboxId, {
328
+ from: [MailboxSyncStatus.pending],
329
+ wherePendingPath: null,
330
+ to: MailboxSyncStatus.synced,
331
+ });
332
+ assert.equal(settled?.syncStatus, MailboxSyncStatus.synced);
333
+ });
334
+
335
+ test("omitting wherePendingPath predicates on syncStatus alone", async () => {
336
+ const accountId = randomId();
337
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Old"));
338
+ await repo.transition(accountId, mailbox.mailboxId, {
339
+ from: [MailboxSyncStatus.synced],
340
+ to: MailboxSyncStatus.pending,
341
+ set: { pendingPath: "New" },
342
+ });
343
+
344
+ const written = await repo.transition(accountId, mailbox.mailboxId, {
345
+ from: [MailboxSyncStatus.pending],
346
+ to: MailboxSyncStatus.failed,
347
+ });
348
+ assert.equal(written?.syncStatus, MailboxSyncStatus.failed);
349
+ assert.equal(written?.pendingPath, "New");
350
+ });
351
+
352
+ test("a rename target survives only under pending and failed", async () => {
353
+ // The invariant, over the only writer of either field: a caller that
354
+ // asks for the seventh combination does not get it. `synced` with a
355
+ // rename target on it is what strands a folder whose rename then never
356
+ // runs and is never marked failed.
357
+ const accountId = randomId();
358
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Trips"));
359
+
360
+ for (const to of EVERY_STATE) {
361
+ const written = await repo.transition(accountId, mailbox.mailboxId, {
362
+ from: EVERY_STATE,
363
+ to,
364
+ set: { pendingPath: "Holidays" },
365
+ });
366
+ assert.equal(
367
+ written?.pendingPath,
368
+ to === MailboxSyncStatus.pending || to === MailboxSyncStatus.failed
369
+ ? "Holidays"
370
+ : undefined,
371
+ `transition to ${to}`,
372
+ );
373
+ assert.deepEqual(
374
+ await repo.get(accountId, mailbox.mailboxId),
375
+ written,
376
+ "what the transition returned is what the row now says",
377
+ );
378
+ }
379
+ });
380
+
381
+ test("two overlapping transitions with the same from: exactly one wins", async () => {
382
+ const accountId = randomId();
383
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Races"));
384
+
385
+ const outcomes = await Promise.all([
386
+ repo.transition(accountId, mailbox.mailboxId, {
387
+ from: [MailboxSyncStatus.synced],
388
+ to: MailboxSyncStatus.deleting,
389
+ }),
390
+ repo.transition(accountId, mailbox.mailboxId, {
391
+ from: [MailboxSyncStatus.synced],
392
+ to: MailboxSyncStatus.pending,
393
+ }),
394
+ ]);
395
+
396
+ assert.equal(outcomes.filter((outcome) => outcome !== null).length, 1);
397
+ });
398
+
399
+ test("cross-tenant: a foreign account transitions nothing", async () => {
400
+ const accountId = randomId();
401
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Private"));
402
+
403
+ assert.equal(
404
+ await repo.transition(randomId(), mailbox.mailboxId, {
405
+ from: EVERY_STATE,
406
+ to: MailboxSyncStatus.deleting,
407
+ }),
408
+ null,
409
+ );
410
+ assert.equal(
411
+ (await repo.get(accountId, mailbox.mailboxId)).syncStatus,
412
+ MailboxSyncStatus.synced,
413
+ );
414
+ });
415
+ });
416
+
417
+ describe("transitionSubtree — the intent, all-or-nothing (D6)", () => {
418
+ 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"),
423
+ ),
424
+ });
425
+
426
+ test("transitions the folder and every descendant, each from its own path", async () => {
427
+ const accountId = randomId();
428
+ const { parent, child, grandchild } = await seedSubtree(accountId);
429
+
430
+ const written = await repo.transitionSubtree(
431
+ accountId,
432
+ parent.mailboxId,
433
+ {
434
+ from: [MailboxSyncStatus.synced, MailboxSyncStatus.failed],
435
+ to: MailboxSyncStatus.pending,
436
+ rowSet: (row) => ({
437
+ pendingPath: row.fullPath.replace("Work", "Archive"),
438
+ }),
439
+ },
440
+ );
441
+
442
+ assert.equal(written?.length, 3);
443
+ assert.equal(
444
+ (await repo.get(accountId, parent.mailboxId)).pendingPath,
445
+ "Archive",
446
+ );
447
+ assert.equal(
448
+ (await repo.get(accountId, child.mailboxId)).pendingPath,
449
+ "Archive/Projects",
450
+ );
451
+ assert.equal(
452
+ (await repo.get(accountId, grandchild.mailboxId)).pendingPath,
453
+ "Archive/Projects/Alpha",
454
+ );
455
+ });
456
+
457
+ test("one descendant outside `from` refuses the intent and writes nothing", async () => {
458
+ const accountId = randomId();
459
+ const { parent, child, grandchild } = await seedSubtree(accountId);
460
+ await repo.transition(accountId, grandchild.mailboxId, {
461
+ from: [MailboxSyncStatus.synced],
462
+ to: MailboxSyncStatus.deleting,
463
+ });
464
+
465
+ const refused = await repo.transitionSubtree(
466
+ accountId,
467
+ parent.mailboxId,
468
+ {
469
+ from: [MailboxSyncStatus.synced],
470
+ to: MailboxSyncStatus.pending,
471
+ rowSet: (row) => ({ pendingPath: `Moved/${row.fullPath}` }),
472
+ },
473
+ );
474
+
475
+ assert.equal(refused, null);
476
+ for (const row of [parent, child]) {
477
+ const after = await repo.get(accountId, row.mailboxId);
478
+ assert.equal(after.syncStatus, MailboxSyncStatus.synced);
479
+ assert.equal(after.pendingPath, undefined);
480
+ }
481
+ assert.equal(
482
+ (await repo.get(accountId, grandchild.mailboxId)).syncStatus,
483
+ MailboxSyncStatus.deleting,
484
+ );
485
+ });
486
+
487
+ test("the from-state predicate rides each UPDATE, not a prior read", async () => {
488
+ // A read-then-check-then-write version passes the case above and still
489
+ // misses a row that leaves an accepted state after the read. The
490
+ // predicate is on the UPDATE, so the affected-row count catches it.
491
+ const accountId = randomId();
492
+ const { parent, child } = await seedSubtree(accountId);
493
+
494
+ const refused = await repo.transitionSubtree(
495
+ accountId,
496
+ parent.mailboxId,
497
+ {
498
+ from: [MailboxSyncStatus.synced],
499
+ to: MailboxSyncStatus.pending,
500
+ rowSet: (row) => {
501
+ if (row.mailboxId === parent.mailboxId) {
502
+ // Not a real concurrent writer — the shape of one. The row
503
+ // leaves `synced` after the subtree was resolved.
504
+ db.update(mailboxTable)
505
+ .set({ syncStatus: MailboxSyncStatus.deleting })
506
+ .where(eq(mailboxTable.mailboxId, child.mailboxId))
507
+ .run();
508
+ }
509
+ return { pendingPath: row.fullPath };
510
+ },
511
+ },
512
+ );
513
+
514
+ assert.equal(refused, null);
515
+ assert.equal(
516
+ (await repo.get(accountId, parent.mailboxId)).syncStatus,
517
+ MailboxSyncStatus.synced,
518
+ );
519
+ });
520
+
521
+ test("an absent folder is a null", async () => {
522
+ assert.equal(
523
+ await repo.transitionSubtree(randomId(), "no-such-mailbox", {
524
+ from: EVERY_STATE,
525
+ to: MailboxSyncStatus.pending,
526
+ rowSet: () => ({}),
527
+ }),
528
+ null,
529
+ );
530
+ });
531
+ });
532
+
533
+ describe("deleteMailboxWithMail — the folder's mail goes with it (D8)", () => {
534
+ const seedMessage = async (mailboxId: string, uid: number) => {
535
+ const messageId = randomId();
536
+ await new DrizzleMessageRepository(db as never).create({
537
+ messageId,
538
+ mailboxId,
539
+ uid,
540
+ sequenceNumber: uid,
541
+ rfc822Size: 10,
542
+ internalDate: 1700000000000,
543
+ envelopeId: deriveEnvelopeId(messageId),
544
+ rootBodyPartId: deriveRootBodyPartId(messageId),
545
+ });
546
+ await db.insert(threadMessageTable).values({
547
+ threadMessageId: randomId(),
548
+ accountConfigId: "acct",
549
+ threadId: randomId(),
550
+ messageId,
551
+ mailboxId,
552
+ uid,
553
+ referenceOrder: 0,
554
+ internalDate: 1700000000000,
555
+ sentDate: 1700000000000,
556
+ isRead: false,
557
+ hasAttachment: false,
558
+ hasStars: false,
559
+ isDeleted: false,
560
+ createdAt: 1700000000000,
561
+ updatedAt: 1700000000000,
562
+ });
563
+ return messageId;
564
+ };
565
+
566
+ const outboxEventsFor = async (messageId: string) =>
567
+ db.select().from(outboxTable).where(eq(outboxTable.messageId, messageId));
568
+
569
+ test("removes the folder, its mail and its own child rows, and nothing else's", async () => {
570
+ const accountId = randomId();
571
+ const doomed = await repo.create(makeMailboxInput(accountId, "Receipts"));
572
+ const spared = await repo.create(makeMailboxInput(accountId, "Keep"));
573
+ const doomedMessage = await seedMessage(doomed.mailboxId, 1);
574
+ const sparedMessage = await seedMessage(spared.mailboxId, 1);
575
+ await db.insert(mailboxSpecialUseTable).values({
576
+ mailboxSpecialUseId: randomId(),
577
+ mailboxId: doomed.mailboxId,
578
+ specialUse: "Archive",
579
+ });
580
+
581
+ await repo.deleteMailboxWithMail(accountId, doomed.mailboxId);
582
+
583
+ await assert.rejects(
584
+ () => repo.get(accountId, doomed.mailboxId),
585
+ /Mailbox not found/,
586
+ );
587
+ assert.deepEqual(
588
+ await db
589
+ .select()
590
+ .from(messageTable)
591
+ .where(eq(messageTable.mailboxId, doomed.mailboxId)),
592
+ [],
593
+ );
594
+ assert.deepEqual(
595
+ await db
596
+ .select()
597
+ .from(threadMessageTable)
598
+ .where(eq(threadMessageTable.mailboxId, doomed.mailboxId)),
599
+ [],
600
+ );
601
+ assert.deepEqual(
602
+ await db
603
+ .select()
604
+ .from(mailboxSpecialUseTable)
605
+ .where(eq(mailboxSpecialUseTable.mailboxId, doomed.mailboxId)),
606
+ [],
607
+ );
608
+
609
+ // The search index is cleared by the outbox row, not by the delete.
610
+ const removals = await outboxEventsFor(doomedMessage);
611
+ assert.deepEqual(
612
+ removals.map((row) => row.event),
613
+ ["message.removed"],
614
+ );
615
+
616
+ const survivors = await db
617
+ .select()
618
+ .from(messageTable)
619
+ .where(eq(messageTable.mailboxId, spared.mailboxId));
620
+ assert.equal(survivors.length, 1);
621
+ assert.equal(
622
+ (await outboxEventsFor(sparedMessage)).some(
623
+ (row) => row.event === "message.removed",
624
+ ),
625
+ false,
626
+ );
627
+ assert.equal(
628
+ (await repo.get(accountId, spared.mailboxId)).mailboxId,
629
+ spared.mailboxId,
630
+ );
631
+ });
632
+
633
+ test("leaves a filter bound to the folder alone", async () => {
634
+ // D16 refuses the delete while a binding stands, so there is nothing to
635
+ // unbind — and deleting a user's filters as a side effect of a folder
636
+ // delete is the outcome the design rules out. This is the test that
637
+ // stops a future refactor doing it.
638
+ const accountId = randomId();
639
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Bound"));
640
+ const filterId = randomId();
641
+ await db.insert(filterTable).values({
642
+ filterId,
643
+ accountConfigId: accountId,
644
+ name: "Invoices → Bound",
645
+ scope: "Standing",
646
+ ruleChangedAt: 0,
647
+ actionChangedAt: 0,
648
+ actionMailboxId: mailbox.mailboxId,
649
+ createdAt: 0,
650
+ updatedAt: 0,
651
+ });
652
+
653
+ await repo.deleteMailboxWithMail(accountId, mailbox.mailboxId);
654
+
655
+ const rows = await db
656
+ .select()
657
+ .from(filterTable)
658
+ .where(eq(filterTable.filterId, filterId));
659
+ assert.equal(rows.length, 1);
660
+ assert.equal(rows[0].actionMailboxId, mailbox.mailboxId);
661
+ });
662
+
663
+ test("re-running after a partial removal completes", async () => {
664
+ const accountId = randomId();
665
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Resume"));
666
+ const first = await seedMessage(mailbox.mailboxId, 1);
667
+ await seedMessage(mailbox.mailboxId, 2);
668
+
669
+ // The shape an interruption leaves: one message's rows already gone,
670
+ // the mailbox row still there and still `deleting`.
671
+ await runInTransaction(db, (tx) => deleteMessageSubtree(tx, [first]));
672
+ await repo.transition(accountId, mailbox.mailboxId, {
673
+ from: EVERY_STATE,
674
+ to: MailboxSyncStatus.deleting,
675
+ });
676
+
677
+ await repo.deleteMailboxWithMail(accountId, mailbox.mailboxId);
678
+
679
+ await assert.rejects(
680
+ () => repo.get(accountId, mailbox.mailboxId),
681
+ /Mailbox not found/,
682
+ );
683
+ assert.deepEqual(
684
+ await db
685
+ .select()
686
+ .from(messageTable)
687
+ .where(eq(messageTable.mailboxId, mailbox.mailboxId)),
688
+ [],
689
+ );
690
+ });
691
+
692
+ test("cross-tenant: a foreign account removes neither the row nor its mail", async () => {
693
+ const accountId = randomId();
694
+ const mailbox = await repo.create(makeMailboxInput(accountId, "Mine"));
695
+ await seedMessage(mailbox.mailboxId, 1);
696
+
697
+ await repo.deleteMailboxWithMail(randomId(), mailbox.mailboxId);
698
+
699
+ assert.equal(
700
+ (await repo.get(accountId, mailbox.mailboxId)).mailboxId,
701
+ mailbox.mailboxId,
702
+ );
703
+ assert.equal(
704
+ (
705
+ await db
706
+ .select()
707
+ .from(messageTable)
708
+ .where(eq(messageTable.mailboxId, mailbox.mailboxId))
709
+ ).length,
710
+ 1,
711
+ );
712
+ });
713
+ });
714
+
220
715
  describe("continuation token rejection (#172)", () => {
221
716
  for (const [label, token] of [
222
717
  ["an unparseable", "not-a-cursor"],