@remit/drizzle-service 0.0.12 → 0.0.14
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/repos/i4-mailbox.sqlite.test.ts +41 -36
- package/src/repos/i4-mailbox.ts +1 -9
- package/src/repos/thread-message.test.ts +275 -0
- package/src/repos/thread-message.ts +59 -0
- package/src/test-shipped-sqlite-schema.ts +39 -0
- package/src/vps-migrations-drift.sqlite.test.ts +101 -0
- package/src/repos/message-flag-wire-format-migration.sqlite.test.ts +0 -186
- package/src/vps-migrations-drift.test.ts +0 -28
package/package.json
CHANGED
|
@@ -1,38 +1,16 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
3
|
import { after, before, describe, test } from "node:test";
|
|
5
4
|
import Database from "better-sqlite3";
|
|
6
5
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
7
6
|
import { mailboxTable } from "../schema.js";
|
|
8
7
|
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
8
|
+
import {
|
|
9
|
+
applyMigration,
|
|
10
|
+
shippedTableDdl,
|
|
11
|
+
} from "../test-shipped-sqlite-schema.js";
|
|
9
12
|
import { MailboxRepo } from "./i4-mailbox.js";
|
|
10
13
|
|
|
11
|
-
/**
|
|
12
|
-
* The `mailbox` DDL as it actually ships, read from the committed migration
|
|
13
|
-
* rather than pushed from the drizzle table objects.
|
|
14
|
-
*
|
|
15
|
-
* The two disagree: the table object declares `highest_modseq` as text, the
|
|
16
|
-
* shipped migration still declares it `integer` (reader#73). Every other
|
|
17
|
-
* SQLite test in this package runs against the pushed shape, so none of them
|
|
18
|
-
* has ever exercised the one deployments run on — and SQLite hands a column
|
|
19
|
-
* with numeric affinity back as a number regardless of what the schema says.
|
|
20
|
-
* Reading the committed file keeps this test honest as the migration changes.
|
|
21
|
-
*/
|
|
22
|
-
const shippedMailboxDdl = (): string => {
|
|
23
|
-
const sql = readFileSync(
|
|
24
|
-
new URL(
|
|
25
|
-
"../../../../deploy/vps/migrations-sqlite/entities/0000_happy_roland_deschain.sql",
|
|
26
|
-
import.meta.url,
|
|
27
|
-
),
|
|
28
|
-
"utf8",
|
|
29
|
-
);
|
|
30
|
-
const match = sql.match(/CREATE TABLE `mailbox` \([\s\S]*?\n\);/);
|
|
31
|
-
if (!match)
|
|
32
|
-
throw new Error("mailbox DDL not found in the committed migration");
|
|
33
|
-
return match[0];
|
|
34
|
-
};
|
|
35
|
-
|
|
36
14
|
function makeMailboxInput(accountId: string, fullPath = "INBOX") {
|
|
37
15
|
return {
|
|
38
16
|
accountId,
|
|
@@ -90,13 +68,25 @@ describe("MailboxRepo (sqlite)", () => {
|
|
|
90
68
|
});
|
|
91
69
|
});
|
|
92
70
|
|
|
93
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The same repository against the shape a deployment actually runs: the
|
|
73
|
+
* committed migrations applied in order, rather than the schema pushed from the
|
|
74
|
+
* drizzle table objects.
|
|
75
|
+
*
|
|
76
|
+
* The two used to disagree — `highest_modseq` shipped as `integer` while the
|
|
77
|
+
* table object said `text` (reader#73) — and SQLite hands a column with numeric
|
|
78
|
+
* affinity back as a number whatever the declared type, so the repo returned a
|
|
79
|
+
* number where its own type said string. Reading the committed files keeps this
|
|
80
|
+
* honest as the migration set changes.
|
|
81
|
+
*/
|
|
82
|
+
describe("MailboxRepo (sqlite, shipped migrations)", () => {
|
|
94
83
|
let close: () => Promise<void>;
|
|
95
84
|
let repo: MailboxRepo;
|
|
96
85
|
|
|
97
|
-
before(
|
|
86
|
+
before(() => {
|
|
98
87
|
const sqlite = new Database(":memory:");
|
|
99
|
-
sqlite.exec(
|
|
88
|
+
sqlite.exec(shippedTableDdl("0000_happy_roland_deschain", "mailbox"));
|
|
89
|
+
applyMigration(sqlite, "0002_highest_modseq_text");
|
|
100
90
|
const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
|
|
101
91
|
repo = new MailboxRepo(db as never);
|
|
102
92
|
close = async () => {
|
|
@@ -108,11 +98,14 @@ describe("MailboxRepo (sqlite, shipped column shape)", () => {
|
|
|
108
98
|
await close();
|
|
109
99
|
});
|
|
110
100
|
|
|
111
|
-
test("
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
101
|
+
test("declares highest_modseq as text", () => {
|
|
102
|
+
assert.match(
|
|
103
|
+
shippedTableDdl("0002_highest_modseq_text", "__new_mailbox"),
|
|
104
|
+
/`highest_modseq` text NOT NULL/,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("reads a plain-digit cursor back as a string", async () => {
|
|
116
109
|
const accountId = randomUUID();
|
|
117
110
|
const created = await repo.create({
|
|
118
111
|
...makeMailboxInput(accountId),
|
|
@@ -123,10 +116,9 @@ describe("MailboxRepo (sqlite, shipped column shape)", () => {
|
|
|
123
116
|
|
|
124
117
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
125
118
|
assert.strictEqual(fetched.highestModseq, "900");
|
|
126
|
-
assert.strictEqual(fetched.highestModseq === "900", true);
|
|
127
119
|
});
|
|
128
120
|
|
|
129
|
-
test("keeps a resumable cursor intact
|
|
121
|
+
test("keeps a resumable cursor intact", async () => {
|
|
130
122
|
const accountId = randomUUID();
|
|
131
123
|
const created = await repo.create({
|
|
132
124
|
...makeMailboxInput(accountId, "Archive"),
|
|
@@ -136,4 +128,17 @@ describe("MailboxRepo (sqlite, shipped column shape)", () => {
|
|
|
136
128
|
const fetched = await repo.get(accountId, created.mailboxId);
|
|
137
129
|
assert.strictEqual(fetched.highestModseq, "900:149");
|
|
138
130
|
});
|
|
131
|
+
|
|
132
|
+
test("round-trips a cursor above 2^53 with its exact digits", async () => {
|
|
133
|
+
const accountId = randomUUID();
|
|
134
|
+
const modseq = "18446744073709551615";
|
|
135
|
+
const created = await repo.create({
|
|
136
|
+
...makeMailboxInput(accountId, "Sent"),
|
|
137
|
+
highestModseq: modseq,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
assert.strictEqual(created.highestModseq, modseq);
|
|
141
|
+
const fetched = await repo.get(accountId, created.mailboxId);
|
|
142
|
+
assert.strictEqual(fetched.highestModseq, modseq);
|
|
143
|
+
});
|
|
139
144
|
});
|
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -33,15 +33,7 @@ export function rowToMailbox(
|
|
|
33
33
|
fullPath: row.fullPath,
|
|
34
34
|
uidValidity: row.uidValidity,
|
|
35
35
|
uidNext: row.uidNext,
|
|
36
|
-
|
|
37
|
-
// schema declares, and the shipped self-host migration still declares
|
|
38
|
-
// this one `integer` (reader#73). A cursor read back as a number is not
|
|
39
|
-
// merely awkward to parse: `"900" === 900` is false, so code comparing
|
|
40
|
-
// the value it just wrote against the value it read would conclude
|
|
41
|
-
// nothing had changed — which is how a stalled cursor goes unreported.
|
|
42
|
-
// Normalising here means every consumer sees the declared type instead
|
|
43
|
-
// of each one guarding separately.
|
|
44
|
-
highestModseq: String(row.highestModseq),
|
|
36
|
+
highestModseq: row.highestModseq,
|
|
45
37
|
messageCount: row.messageCount,
|
|
46
38
|
unseenCount: row.unseenCount,
|
|
47
39
|
deletedCount: row.deletedCount,
|
|
@@ -382,6 +382,281 @@ describe("DrizzleThreadMessageRepository.searchByMailboxWindow / countByMailbox"
|
|
|
382
382
|
});
|
|
383
383
|
});
|
|
384
384
|
|
|
385
|
+
// ─── searchByDate: the unified listing's cross-folder search mode ─────────────
|
|
386
|
+
// The daily brief's unscoped search reaches every folder of every account in
|
|
387
|
+
// one query, so matching must span the caller-supplied mailbox scope rather
|
|
388
|
+
// than a single mailbox.
|
|
389
|
+
|
|
390
|
+
describe("DrizzleThreadMessageRepository.searchByDate", {
|
|
391
|
+
skip: !RUN_INTEG,
|
|
392
|
+
}, () => {
|
|
393
|
+
let repo: DrizzleThreadMessageRepository;
|
|
394
|
+
const cleanup: Array<() => Promise<void>> = [];
|
|
395
|
+
|
|
396
|
+
before(async () => {
|
|
397
|
+
await setupDb();
|
|
398
|
+
repo = new DrizzleThreadMessageRepository(PG_URL);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
after(async () => {
|
|
402
|
+
for (const fn of cleanup.reverse()) {
|
|
403
|
+
await fn();
|
|
404
|
+
}
|
|
405
|
+
await repo.close();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
async function seed(
|
|
409
|
+
accountConfigId: string,
|
|
410
|
+
mailboxId: string,
|
|
411
|
+
rows: Array<Partial<CreateThreadMessageInput>>,
|
|
412
|
+
): Promise<void> {
|
|
413
|
+
for (const overrides of rows) {
|
|
414
|
+
const created = await repo.create(
|
|
415
|
+
makeInput(accountConfigId, mailboxId, overrides),
|
|
416
|
+
);
|
|
417
|
+
cleanup.push(() => repo.delete(accountConfigId, created.threadMessageId));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
test("matches across every mailbox in the scope, not just the inbox", async () => {
|
|
422
|
+
const acct = uuid();
|
|
423
|
+
const inbox = uuid();
|
|
424
|
+
const archive = uuid();
|
|
425
|
+
const spam = uuid();
|
|
426
|
+
const now = Date.now();
|
|
427
|
+
await seed(acct, inbox, [
|
|
428
|
+
{ subject: "invoice inbox", sentDate: now, internalDate: now },
|
|
429
|
+
]);
|
|
430
|
+
await seed(acct, archive, [
|
|
431
|
+
{ subject: "invoice archive", sentDate: now - 1, internalDate: now - 1 },
|
|
432
|
+
]);
|
|
433
|
+
await seed(acct, spam, [
|
|
434
|
+
{ subject: "invoice spam", sentDate: now - 2, internalDate: now - 2 },
|
|
435
|
+
]);
|
|
436
|
+
|
|
437
|
+
const result = await repo.searchByDate(
|
|
438
|
+
acct,
|
|
439
|
+
{ query: "invoice" },
|
|
440
|
+
{ excludeDeleted: true, mailboxIds: new Set([inbox, archive, spam]) },
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
assert.deepEqual(
|
|
444
|
+
result.items.map((r) => r.subject),
|
|
445
|
+
["invoice inbox", "invoice archive", "invoice spam"],
|
|
446
|
+
"newest first, across all three folders",
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("a mailbox outside the scope contributes nothing", async () => {
|
|
451
|
+
const acct = uuid();
|
|
452
|
+
const inbox = uuid();
|
|
453
|
+
const excluded = uuid();
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
await seed(acct, inbox, [
|
|
456
|
+
{ subject: "receipt kept", sentDate: now, internalDate: now },
|
|
457
|
+
]);
|
|
458
|
+
await seed(acct, excluded, [
|
|
459
|
+
{ subject: "receipt dropped", sentDate: now - 1, internalDate: now - 1 },
|
|
460
|
+
]);
|
|
461
|
+
|
|
462
|
+
const result = await repo.searchByDate(
|
|
463
|
+
acct,
|
|
464
|
+
{ query: "receipt" },
|
|
465
|
+
{ excludeDeleted: true, mailboxIds: new Set([inbox]) },
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
assert.deepEqual(
|
|
469
|
+
result.items.map((r) => r.subject),
|
|
470
|
+
["receipt kept"],
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("every whitespace-separated term must match", async () => {
|
|
475
|
+
const acct = uuid();
|
|
476
|
+
const mbx = uuid();
|
|
477
|
+
const now = Date.now();
|
|
478
|
+
await seed(acct, mbx, [
|
|
479
|
+
{
|
|
480
|
+
subject: "quarterly invoice",
|
|
481
|
+
fromEmail: "billing@acme.test",
|
|
482
|
+
sentDate: now,
|
|
483
|
+
internalDate: now,
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
subject: "quarterly report",
|
|
487
|
+
fromEmail: "reports@acme.test",
|
|
488
|
+
sentDate: now - 1,
|
|
489
|
+
internalDate: now - 1,
|
|
490
|
+
},
|
|
491
|
+
]);
|
|
492
|
+
|
|
493
|
+
const result = await repo.searchByDate(
|
|
494
|
+
acct,
|
|
495
|
+
{ query: "quarterly invoice" },
|
|
496
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
assert.deepEqual(
|
|
500
|
+
result.items.map((r) => r.subject),
|
|
501
|
+
["quarterly invoice"],
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("a term matches the From address as well as the subject", async () => {
|
|
506
|
+
const acct = uuid();
|
|
507
|
+
const mbx = uuid();
|
|
508
|
+
const now = Date.now();
|
|
509
|
+
await seed(acct, mbx, [
|
|
510
|
+
{
|
|
511
|
+
subject: "no keyword here",
|
|
512
|
+
fromEmail: "penelope@acme.test",
|
|
513
|
+
sentDate: now,
|
|
514
|
+
internalDate: now,
|
|
515
|
+
},
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
const result = await repo.searchByDate(
|
|
519
|
+
acct,
|
|
520
|
+
{ query: "penelope" },
|
|
521
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
assert.equal(result.items.length, 1);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("soft-deleted rows stay out", async () => {
|
|
528
|
+
const acct = uuid();
|
|
529
|
+
const mbx = uuid();
|
|
530
|
+
const now = Date.now();
|
|
531
|
+
await seed(acct, mbx, [
|
|
532
|
+
{ subject: "parcel live", sentDate: now, internalDate: now },
|
|
533
|
+
{
|
|
534
|
+
subject: "parcel gone",
|
|
535
|
+
isDeleted: true,
|
|
536
|
+
sentDate: now - 1,
|
|
537
|
+
internalDate: now - 1,
|
|
538
|
+
},
|
|
539
|
+
]);
|
|
540
|
+
|
|
541
|
+
const result = await repo.searchByDate(
|
|
542
|
+
acct,
|
|
543
|
+
{ query: "parcel" },
|
|
544
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
assert.deepEqual(
|
|
548
|
+
result.items.map((r) => r.subject),
|
|
549
|
+
["parcel live"],
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// A short page means the matches ran out, never that a read window did — the
|
|
554
|
+
// contract the endpoint documents for search mode.
|
|
555
|
+
test("pages over matches, and a full page yields a resumable cursor", async () => {
|
|
556
|
+
const acct = uuid();
|
|
557
|
+
const inbox = uuid();
|
|
558
|
+
const archive = uuid();
|
|
559
|
+
const now = Date.now();
|
|
560
|
+
await seed(acct, inbox, [
|
|
561
|
+
{ subject: "noise a", sentDate: now, internalDate: now },
|
|
562
|
+
{ subject: "gamma one", sentDate: now - 1, internalDate: now - 1 },
|
|
563
|
+
{ subject: "noise b", sentDate: now - 2, internalDate: now - 2 },
|
|
564
|
+
]);
|
|
565
|
+
await seed(acct, archive, [
|
|
566
|
+
{ subject: "gamma two", sentDate: now - 3, internalDate: now - 3 },
|
|
567
|
+
{ subject: "gamma three", sentDate: now - 4, internalDate: now - 4 },
|
|
568
|
+
]);
|
|
569
|
+
|
|
570
|
+
const scope = {
|
|
571
|
+
excludeDeleted: true,
|
|
572
|
+
mailboxIds: new Set([inbox, archive]),
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const page1 = await repo.searchByDate(
|
|
576
|
+
acct,
|
|
577
|
+
{ query: "gamma" },
|
|
578
|
+
{ ...scope, limit: 2 },
|
|
579
|
+
);
|
|
580
|
+
assert.deepEqual(
|
|
581
|
+
page1.items.map((r) => r.subject),
|
|
582
|
+
["gamma one", "gamma two"],
|
|
583
|
+
"a full page of matches, skipping the non-matching newer rows",
|
|
584
|
+
);
|
|
585
|
+
assert.ok(page1.continuationToken, "more matches remain — cursor expected");
|
|
586
|
+
|
|
587
|
+
const page2 = await repo.searchByDate(
|
|
588
|
+
acct,
|
|
589
|
+
{ query: "gamma" },
|
|
590
|
+
{ ...scope, limit: 2, continuationToken: page1.continuationToken },
|
|
591
|
+
);
|
|
592
|
+
assert.deepEqual(
|
|
593
|
+
page2.items.map((r) => r.subject),
|
|
594
|
+
["gamma three"],
|
|
595
|
+
"the last page is short because the matches ran out",
|
|
596
|
+
);
|
|
597
|
+
assert.equal(
|
|
598
|
+
page2.continuationToken,
|
|
599
|
+
undefined,
|
|
600
|
+
"a short page ends the pagination",
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("starred narrows the search without changing the scope", async () => {
|
|
605
|
+
const acct = uuid();
|
|
606
|
+
const archive = uuid();
|
|
607
|
+
const now = Date.now();
|
|
608
|
+
await seed(acct, archive, [
|
|
609
|
+
{
|
|
610
|
+
subject: "delta starred",
|
|
611
|
+
hasStars: true,
|
|
612
|
+
sentDate: now,
|
|
613
|
+
internalDate: now,
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
subject: "delta plain",
|
|
617
|
+
hasStars: false,
|
|
618
|
+
sentDate: now - 1,
|
|
619
|
+
internalDate: now - 1,
|
|
620
|
+
},
|
|
621
|
+
]);
|
|
622
|
+
|
|
623
|
+
const result = await repo.searchByDate(
|
|
624
|
+
acct,
|
|
625
|
+
{ query: "delta", starred: true },
|
|
626
|
+
{ excludeDeleted: true, mailboxIds: new Set([archive]) },
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
assert.deepEqual(
|
|
630
|
+
result.items.map((r) => r.subject),
|
|
631
|
+
["delta starred"],
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("another config's mail is never returned", async () => {
|
|
636
|
+
const mine = uuid();
|
|
637
|
+
const theirs = uuid();
|
|
638
|
+
const mbx = uuid();
|
|
639
|
+
const now = Date.now();
|
|
640
|
+
await seed(mine, mbx, [
|
|
641
|
+
{ subject: "epsilon mine", sentDate: now, internalDate: now },
|
|
642
|
+
]);
|
|
643
|
+
await seed(theirs, mbx, [
|
|
644
|
+
{ subject: "epsilon theirs", sentDate: now - 1, internalDate: now - 1 },
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
const result = await repo.searchByDate(
|
|
648
|
+
mine,
|
|
649
|
+
{ query: "epsilon" },
|
|
650
|
+
{ excludeDeleted: true, mailboxIds: new Set([mbx]) },
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
assert.deepEqual(
|
|
654
|
+
result.items.map((r) => r.subject),
|
|
655
|
+
["epsilon mine"],
|
|
656
|
+
);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
385
660
|
// ─── Native text-search semantics ─────────────────────────────────────────────
|
|
386
661
|
// The type-ahead search box lowercases the query before sending it. These tests
|
|
387
662
|
// pin the Postgres-native behaviour: case- and accent-insensitive substring
|
|
@@ -478,6 +478,65 @@ export class DrizzleThreadMessageRepository
|
|
|
478
478
|
};
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Cross-mailbox search for the unified listing's search mode. Same predicate
|
|
483
|
+
* builder and keyset cursor as `searchByMailboxWindow`, with the mailbox
|
|
484
|
+
* equality swapped for the caller's scope set. Matching runs in SQL over the
|
|
485
|
+
* whole scope, so a short page means the matches are exhausted.
|
|
486
|
+
*/
|
|
487
|
+
async searchByDate(
|
|
488
|
+
accountConfigId: string,
|
|
489
|
+
search: SearchOptions,
|
|
490
|
+
options?: {
|
|
491
|
+
order?: "asc" | "desc";
|
|
492
|
+
limit?: number;
|
|
493
|
+
continuationToken?: string;
|
|
494
|
+
mailboxIds?: Set<string>;
|
|
495
|
+
excludeDeleted?: boolean;
|
|
496
|
+
},
|
|
497
|
+
): Promise<ResultList<ThreadMessageItem>> {
|
|
498
|
+
const order = options?.order ?? "desc";
|
|
499
|
+
const limit = clampThreadSearchLimit(options?.limit);
|
|
500
|
+
const cursor = options?.continuationToken
|
|
501
|
+
? decodeDateCursor(options.continuationToken)
|
|
502
|
+
: null;
|
|
503
|
+
|
|
504
|
+
const mailboxCond = options?.mailboxIds?.size
|
|
505
|
+
? inArray(threadMessageTable.mailboxId, [...options.mailboxIds])
|
|
506
|
+
: undefined;
|
|
507
|
+
|
|
508
|
+
const rows = await this.db
|
|
509
|
+
.select()
|
|
510
|
+
.from(threadMessageTable)
|
|
511
|
+
.where(
|
|
512
|
+
and(
|
|
513
|
+
eq(threadMessageTable.accountConfigId, accountConfigId),
|
|
514
|
+
mailboxCond,
|
|
515
|
+
options?.excludeDeleted
|
|
516
|
+
? eq(threadMessageTable.isDeleted, false)
|
|
517
|
+
: undefined,
|
|
518
|
+
...buildSearchConditions(search),
|
|
519
|
+
sentDateCursorCond(order, cursor),
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
.orderBy(
|
|
523
|
+
order === "desc"
|
|
524
|
+
? desc(threadMessageTable.sentDate)
|
|
525
|
+
: asc(threadMessageTable.sentDate),
|
|
526
|
+
asc(threadMessageTable.threadMessageId),
|
|
527
|
+
)
|
|
528
|
+
.limit(limit);
|
|
529
|
+
|
|
530
|
+
const lastRow = rows[rows.length - 1];
|
|
531
|
+
return {
|
|
532
|
+
items: rows.map(toItem),
|
|
533
|
+
continuationToken:
|
|
534
|
+
rows.length === limit && lastRow
|
|
535
|
+
? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
|
|
536
|
+
: undefined,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
481
540
|
async listByStarred(
|
|
482
541
|
accountConfigId: string,
|
|
483
542
|
options?: {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
|
|
4
|
+
// Read the committed SQLite entity migrations — the DDL a self-host deployment
|
|
5
|
+
// actually runs — so a test can exercise the shipped shape instead of the one
|
|
6
|
+
// `pushSQLiteSchema` derives from the drizzle table objects. The two are
|
|
7
|
+
// generated from the same entities but only the pushed one is regenerated on
|
|
8
|
+
// every run, so drift between them is invisible to any test that pushes
|
|
9
|
+
// (reader#73). Reading the files means a test fails when they drift, and
|
|
10
|
+
// tracks them when they change.
|
|
11
|
+
|
|
12
|
+
const MIGRATIONS_DIR = new URL(
|
|
13
|
+
"../../../deploy/vps/migrations-sqlite/entities/",
|
|
14
|
+
import.meta.url,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const migrationSql = (tag: string): string =>
|
|
18
|
+
readFileSync(new URL(`${tag}.sql`, MIGRATIONS_DIR), "utf8");
|
|
19
|
+
|
|
20
|
+
/** The `CREATE TABLE` block for one table, as that migration declares it. */
|
|
21
|
+
export const shippedTableDdl = (tag: string, table: string): string => {
|
|
22
|
+
const match = migrationSql(tag).match(
|
|
23
|
+
new RegExp(`CREATE TABLE \`${table}\` \\([\\s\\S]*?\\n\\);`),
|
|
24
|
+
);
|
|
25
|
+
if (!match) {
|
|
26
|
+
throw new Error(`${table} DDL not found in migration ${tag}`);
|
|
27
|
+
}
|
|
28
|
+
return match[0];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Run every statement of a committed migration against an open database. */
|
|
32
|
+
export const applyMigration = (
|
|
33
|
+
sqlite: Database.Database,
|
|
34
|
+
tag: string,
|
|
35
|
+
): void => {
|
|
36
|
+
for (const statement of migrationSql(tag).split("--> statement-breakpoint")) {
|
|
37
|
+
sqlite.exec(statement);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { describe, test } from "node:test";
|
|
4
|
+
import {
|
|
5
|
+
generateSQLiteDrizzleJson,
|
|
6
|
+
generateSQLiteMigration,
|
|
7
|
+
} from "drizzle-kit/api";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every committed SQLite migration set must describe the schema its drizzle
|
|
11
|
+
* source declares.
|
|
12
|
+
*
|
|
13
|
+
* Nothing else checked this. The previous guard shelled out to
|
|
14
|
+
* `npm-scripts/check-vps-migrations.mjs`, which is stripped from this tree, so
|
|
15
|
+
* it skipped on every run. Every other SQLite test pushes its schema from the
|
|
16
|
+
* drizzle table objects, so a migration set that has fallen behind those
|
|
17
|
+
* objects still passes the whole suite while deployments run the stale shape —
|
|
18
|
+
* which is how `mailbox.highest_modseq` shipped as `integer` for as long as it
|
|
19
|
+
* did (reader#73). SQLite column types are affinity rather than constraint, so
|
|
20
|
+
* a wrong declaration corrupts values instead of rejecting them.
|
|
21
|
+
*
|
|
22
|
+
* This is the same diff `drizzle-kit generate` takes, run in-process against
|
|
23
|
+
* each set's latest committed snapshot. A non-empty result means someone
|
|
24
|
+
* changed a schema without regenerating:
|
|
25
|
+
*
|
|
26
|
+
* npx drizzle-kit generate --config <the config named below>
|
|
27
|
+
*
|
|
28
|
+
* The schema and output paths come from the configs themselves, so a set stays
|
|
29
|
+
* covered when either moves.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const REPO_ROOT = new URL("../../../", import.meta.url);
|
|
33
|
+
|
|
34
|
+
const CONFIGS = [
|
|
35
|
+
"deploy/vps/migrate/drizzle.entities.sqlite.config.ts",
|
|
36
|
+
"deploy/vps/migrate/drizzle.auth.sqlite.config.ts",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
type DrizzleConfig = { schema: string; out: string };
|
|
40
|
+
|
|
41
|
+
const loadConfig = async (path: string): Promise<DrizzleConfig> =>
|
|
42
|
+
(
|
|
43
|
+
(await import(new URL(path, REPO_ROOT).href)) as {
|
|
44
|
+
default: DrizzleConfig;
|
|
45
|
+
}
|
|
46
|
+
).default;
|
|
47
|
+
|
|
48
|
+
const latestSnapshot = (out: string): Record<string, unknown> => {
|
|
49
|
+
const dir = new URL(`${out}/`, REPO_ROOT);
|
|
50
|
+
const journal = JSON.parse(
|
|
51
|
+
readFileSync(new URL("meta/_journal.json", dir), "utf8"),
|
|
52
|
+
) as { entries: Array<{ idx: number }> };
|
|
53
|
+
const idx = Math.max(...journal.entries.map((entry) => entry.idx));
|
|
54
|
+
return JSON.parse(
|
|
55
|
+
readFileSync(
|
|
56
|
+
new URL(`meta/${String(idx).padStart(4, "0")}_snapshot.json`, dir),
|
|
57
|
+
"utf8",
|
|
58
|
+
),
|
|
59
|
+
) as Record<string, unknown>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("committed sqlite migrations", () => {
|
|
63
|
+
for (const configPath of CONFIGS) {
|
|
64
|
+
test(`${configPath} — the set matches its schema`, async () => {
|
|
65
|
+
const config = await loadConfig(configPath);
|
|
66
|
+
const schema = (await import(
|
|
67
|
+
new URL(config.schema, REPO_ROOT).href
|
|
68
|
+
)) as Record<string, unknown>;
|
|
69
|
+
|
|
70
|
+
const drift = await generateSQLiteMigration(
|
|
71
|
+
latestSnapshot(config.out) as unknown as Parameters<
|
|
72
|
+
typeof generateSQLiteMigration
|
|
73
|
+
>[0],
|
|
74
|
+
await generateSQLiteDrizzleJson(schema),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
drift,
|
|
79
|
+
[],
|
|
80
|
+
`the committed migrations in ${config.out} no longer match ${config.schema} — regenerate them with drizzle-kit generate`,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test("declare mailbox.highest_modseq as text", async () => {
|
|
86
|
+
const { out } = await loadConfig(CONFIGS[0]);
|
|
87
|
+
const snapshot = latestSnapshot(out) as {
|
|
88
|
+
tables: Record<
|
|
89
|
+
string,
|
|
90
|
+
{ columns: Record<string, { type: string; notNull: boolean }> }
|
|
91
|
+
>;
|
|
92
|
+
};
|
|
93
|
+
const column = snapshot.tables.mailbox.columns.highest_modseq;
|
|
94
|
+
|
|
95
|
+
// A mod-sequence is an unsigned 63-bit value carried as decimal digits and
|
|
96
|
+
// parsed to BigInt, and the stored cursor also takes a `<group>:<uid>`
|
|
97
|
+
// form. Numeric affinity would hand both back as numbers.
|
|
98
|
+
assert.equal(column.type, "text");
|
|
99
|
+
assert.equal(column.notNull, true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { afterEach, beforeEach, describe, test } from "node:test";
|
|
4
|
-
import { MessageSystemFlag } from "@remit/domain-enums";
|
|
5
|
-
import type Database from "better-sqlite3";
|
|
6
|
-
import {
|
|
7
|
-
type MessageDataSchema,
|
|
8
|
-
messageDataSchema,
|
|
9
|
-
} from "../schema/message-data.js";
|
|
10
|
-
import { createSqliteTestDb, type SqliteTestDb } from "../test-db-sqlite.js";
|
|
11
|
-
import { DrizzleMessageFlagRepository } from "./message-flag.js";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The one-time `message_flag.flag_name` rename shipped for issue #64, run
|
|
15
|
-
* against a real database rather than a hand-copied twin: the SQL is read
|
|
16
|
-
* from the committed migration so this test fails if that file drifts.
|
|
17
|
-
*
|
|
18
|
-
* Before the generated-enum fix, `MessageSystemFlag.Seen` held `Seen` and
|
|
19
|
-
* every row landed under the unprefixed spelling. `hasFlag` is an exact
|
|
20
|
-
* string match, so once the corrected code queries `\Seen` those rows go
|
|
21
|
-
* invisible — which is a re-star on unstar, and a silent no-op on
|
|
22
|
-
* mark-as-unread.
|
|
23
|
-
*/
|
|
24
|
-
const MIGRATION_SQL = readFileSync(
|
|
25
|
-
new URL(
|
|
26
|
-
"../../../../deploy/vps/migrations-sqlite/entities/0002_system_flag_wire_format.sql",
|
|
27
|
-
import.meta.url,
|
|
28
|
-
),
|
|
29
|
-
"utf8",
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
const applyMigration = (sqlite: Database.Database): void => {
|
|
33
|
-
for (const statement of MIGRATION_SQL.split("--> statement-breakpoint")) {
|
|
34
|
-
sqlite.exec(statement);
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const MESSAGE_ID = "00000000-0000-0000-6464-000000000001";
|
|
39
|
-
const OTHER_MESSAGE_ID = "00000000-0000-0000-6464-000000000002";
|
|
40
|
-
|
|
41
|
-
describe("message_flag wire-format migration (issue #64, sqlite)", () => {
|
|
42
|
-
let db: SqliteTestDb<MessageDataSchema>;
|
|
43
|
-
let sqlite: Database.Database;
|
|
44
|
-
let close: () => Promise<void>;
|
|
45
|
-
let repo: DrizzleMessageFlagRepository;
|
|
46
|
-
|
|
47
|
-
const insertLegacyRow = (messageId: string, flagName: string): void => {
|
|
48
|
-
sqlite
|
|
49
|
-
.prepare(
|
|
50
|
-
`INSERT INTO message_flag
|
|
51
|
-
(message_flag_id, message_id, flag_name, set_at, created_at, updated_at)
|
|
52
|
-
VALUES (?, ?, ?, 1000, 1000, 1000)`,
|
|
53
|
-
)
|
|
54
|
-
.run(`${messageId}:${flagName}`, messageId, flagName);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const flagNames = (messageId: string): string[] =>
|
|
58
|
-
(
|
|
59
|
-
sqlite
|
|
60
|
-
.prepare(
|
|
61
|
-
"SELECT flag_name FROM message_flag WHERE message_id = ? ORDER BY flag_name",
|
|
62
|
-
)
|
|
63
|
-
.all(messageId) as Array<{ flag_name: string }>
|
|
64
|
-
).map((r) => r.flag_name);
|
|
65
|
-
|
|
66
|
-
beforeEach(async () => {
|
|
67
|
-
({ db, sqlite, close } = await createSqliteTestDb(messageDataSchema));
|
|
68
|
-
repo = new DrizzleMessageFlagRepository(
|
|
69
|
-
db as unknown as ConstructorParameters<
|
|
70
|
-
typeof DrizzleMessageFlagRepository
|
|
71
|
-
>[0],
|
|
72
|
-
);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
afterEach(async () => {
|
|
76
|
-
await close();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("a row written under the old spelling is found by the corrected enum", async () => {
|
|
80
|
-
insertLegacyRow(MESSAGE_ID, "Flagged");
|
|
81
|
-
assert.equal(
|
|
82
|
-
await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Flagged),
|
|
83
|
-
false,
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
applyMigration(sqlite);
|
|
87
|
-
|
|
88
|
-
assert.equal(
|
|
89
|
-
await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Flagged),
|
|
90
|
-
true,
|
|
91
|
-
);
|
|
92
|
-
assert.deepEqual(flagNames(MESSAGE_ID), ["\\Flagged"]);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("unstarring a migrated message removes the star instead of re-adding it", async () => {
|
|
96
|
-
insertLegacyRow(MESSAGE_ID, "Flagged");
|
|
97
|
-
applyMigration(sqlite);
|
|
98
|
-
|
|
99
|
-
// The toggleFlagged decision: hasFlag true => operation "remove".
|
|
100
|
-
const hadFlag = await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Flagged);
|
|
101
|
-
assert.equal(hadFlag, true, "pre-migration row must read as starred");
|
|
102
|
-
|
|
103
|
-
await repo.removeFlag(MESSAGE_ID, MessageSystemFlag.Flagged);
|
|
104
|
-
assert.equal(
|
|
105
|
-
await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Flagged),
|
|
106
|
-
false,
|
|
107
|
-
);
|
|
108
|
-
assert.deepEqual(flagNames(MESSAGE_ID), []);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("mark-as-unread on a migrated message clears the read state", async () => {
|
|
112
|
-
insertLegacyRow(MESSAGE_ID, "Seen");
|
|
113
|
-
applyMigration(sqlite);
|
|
114
|
-
|
|
115
|
-
assert.equal(await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Seen), true);
|
|
116
|
-
await repo.removeFlag(MESSAGE_ID, MessageSystemFlag.Seen);
|
|
117
|
-
assert.equal(await repo.hasFlag(MESSAGE_ID, MessageSystemFlag.Seen), false);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("renames every RFC 9051 system flag", () => {
|
|
121
|
-
for (const name of ["Seen", "Answered", "Flagged", "Deleted", "Draft"]) {
|
|
122
|
-
insertLegacyRow(MESSAGE_ID, name);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
applyMigration(sqlite);
|
|
126
|
-
|
|
127
|
-
assert.deepEqual(
|
|
128
|
-
flagNames(MESSAGE_ID).sort(),
|
|
129
|
-
Object.values(MessageSystemFlag).slice().sort(),
|
|
130
|
-
);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("leaves keyword and custom flags untouched", () => {
|
|
134
|
-
insertLegacyRow(MESSAGE_ID, "$Forwarded");
|
|
135
|
-
insertLegacyRow(MESSAGE_ID, "$Junk");
|
|
136
|
-
insertLegacyRow(MESSAGE_ID, "project-invoices");
|
|
137
|
-
|
|
138
|
-
applyMigration(sqlite);
|
|
139
|
-
|
|
140
|
-
assert.deepEqual(flagNames(MESSAGE_ID), [
|
|
141
|
-
"$Forwarded",
|
|
142
|
-
"$Junk",
|
|
143
|
-
"project-invoices",
|
|
144
|
-
]);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("is idempotent — a second and third run change nothing", () => {
|
|
148
|
-
insertLegacyRow(MESSAGE_ID, "Seen");
|
|
149
|
-
insertLegacyRow(OTHER_MESSAGE_ID, "Flagged");
|
|
150
|
-
insertLegacyRow(OTHER_MESSAGE_ID, "$Forwarded");
|
|
151
|
-
|
|
152
|
-
applyMigration(sqlite);
|
|
153
|
-
const afterFirst = [
|
|
154
|
-
...flagNames(MESSAGE_ID),
|
|
155
|
-
...flagNames(OTHER_MESSAGE_ID),
|
|
156
|
-
];
|
|
157
|
-
|
|
158
|
-
applyMigration(sqlite);
|
|
159
|
-
applyMigration(sqlite);
|
|
160
|
-
|
|
161
|
-
assert.deepEqual(
|
|
162
|
-
[...flagNames(MESSAGE_ID), ...flagNames(OTHER_MESSAGE_ID)],
|
|
163
|
-
afterFirst,
|
|
164
|
-
);
|
|
165
|
-
assert.deepEqual(afterFirst, ["\\Seen", "$Forwarded", "\\Flagged"]);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test("collapses a message already carrying both spellings to one row", () => {
|
|
169
|
-
insertLegacyRow(MESSAGE_ID, "Seen");
|
|
170
|
-
insertLegacyRow(MESSAGE_ID, "\\Seen");
|
|
171
|
-
|
|
172
|
-
applyMigration(sqlite);
|
|
173
|
-
|
|
174
|
-
assert.deepEqual(flagNames(MESSAGE_ID), ["\\Seen"]);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("does not touch other messages' rows", () => {
|
|
178
|
-
insertLegacyRow(MESSAGE_ID, "Seen");
|
|
179
|
-
insertLegacyRow(OTHER_MESSAGE_ID, "Seen");
|
|
180
|
-
|
|
181
|
-
applyMigration(sqlite);
|
|
182
|
-
|
|
183
|
-
assert.deepEqual(flagNames(MESSAGE_ID), ["\\Seen"]);
|
|
184
|
-
assert.deepEqual(flagNames(OTHER_MESSAGE_ID), ["\\Seen"]);
|
|
185
|
-
});
|
|
186
|
-
});
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
5
|
-
import { test } from "node:test";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
|
|
8
|
-
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
9
|
-
|
|
10
|
-
// The migration-check script is stripped from the open-core tree; skip there and
|
|
11
|
-
// run where it ships.
|
|
12
|
-
const hasCheckScript = existsSync(
|
|
13
|
-
resolve(repoRoot, "npm-scripts/check-vps-migrations.mjs"),
|
|
14
|
-
);
|
|
15
|
-
|
|
16
|
-
// Fails when the committed VPS migrations (deploy/vps/migrations/*) no longer
|
|
17
|
-
// produce the schema drizzle would generate from the entity + auth schemas.
|
|
18
|
-
// See npm-scripts/check-vps-migrations.mjs for the mechanism.
|
|
19
|
-
test("committed VPS migrations match the drizzle schema", {
|
|
20
|
-
skip: !hasCheckScript,
|
|
21
|
-
}, () => {
|
|
22
|
-
assert.doesNotThrow(() => {
|
|
23
|
-
execFileSync("node", ["npm-scripts/check-vps-migrations.mjs", "--check"], {
|
|
24
|
-
cwd: repoRoot,
|
|
25
|
-
stdio: "inherit",
|
|
26
|
-
});
|
|
27
|
-
}, "committed VPS migrations are stale — run `npm run migrations:generate`");
|
|
28
|
-
});
|