@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Drizzle ORM service parameterized by dialect (Postgres / SQLite)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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
- describe("MailboxRepo (sqlite, shipped column shape)", () => {
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(async () => {
86
+ before(() => {
98
87
  const sqlite = new Database(":memory:");
99
- sqlite.exec(shippedMailboxDdl());
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("reads the sync cursor back as a string, whatever the column stores", async () => {
112
- // A plain-digit cursor lands in a column with numeric affinity and comes
113
- // back a number. Callers compare the value they wrote against the value
114
- // they read — `"900" === 900` is false — so an unnormalised read makes a
115
- // stalled cursor undetectable.
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 through the same column", async () => {
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
  });
@@ -33,15 +33,7 @@ export function rowToMailbox(
33
33
  fullPath: row.fullPath,
34
34
  uidValidity: row.uidValidity,
35
35
  uidNext: row.uidNext,
36
- // SQLite returns a column with numeric affinity as a number whatever the
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
- });