@remit/drizzle-service 0.0.11 → 0.0.13

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.11",
3
+ "version": "0.0.13",
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,
@@ -212,6 +212,76 @@ describe("DrizzleThreadMessageRepository (sqlite)", () => {
212
212
  );
213
213
  });
214
214
 
215
+ test("listByThread defaults to oldest first, by the date the mail was sent", async () => {
216
+ const acct = "acct-conversation-dates";
217
+ const threadId = "t-conversation-dates";
218
+ const base = Date.now();
219
+ // The folder a message was delivered to decides its internalDate, so a
220
+ // reply synced from Sent can carry an earlier internalDate than the
221
+ // message it answers. sentDate is what the conversation is ordered by.
222
+ const turns = [
223
+ { messageId: "m-question", sentDate: base, internalDate: base + 5000 },
224
+ { messageId: "m-answer", sentDate: base + 1000, internalDate: base },
225
+ ];
226
+ for (const turn of turns) {
227
+ await repo.create(
228
+ makeInput({
229
+ accountConfigId: acct,
230
+ threadId,
231
+ messageId: turn.messageId,
232
+ sentDate: turn.sentDate,
233
+ internalDate: turn.internalDate,
234
+ }),
235
+ );
236
+ }
237
+
238
+ const result = await repo.listByThread(threadId, acct, {
239
+ excludeDeleted: true,
240
+ });
241
+ assert.deepEqual(
242
+ result.items.map((item) => item.messageId),
243
+ ["m-question", "m-answer"],
244
+ "the reply follows the message it answers",
245
+ );
246
+ });
247
+
248
+ test("listByThread paginates in sentDate order without skipping or repeating", async () => {
249
+ const acct = "acct-conversation-pages";
250
+ const threadId = "t-conversation-pages";
251
+ const base = Date.now();
252
+ for (let index = 0; index < 5; index++) {
253
+ await repo.create(
254
+ makeInput({
255
+ accountConfigId: acct,
256
+ threadId,
257
+ messageId: `m-page-${index}`,
258
+ sentDate: base + index * 1000,
259
+ internalDate: base,
260
+ }),
261
+ );
262
+ }
263
+
264
+ const seen: string[] = [];
265
+ let continuationToken: string | undefined;
266
+ do {
267
+ const page = await repo.listByThread(threadId, acct, {
268
+ limit: 2,
269
+ continuationToken,
270
+ excludeDeleted: true,
271
+ });
272
+ seen.push(...page.items.map((item) => item.messageId));
273
+ continuationToken = page.continuationToken;
274
+ } while (continuationToken);
275
+
276
+ assert.deepEqual(seen, [
277
+ "m-page-0",
278
+ "m-page-1",
279
+ "m-page-2",
280
+ "m-page-3",
281
+ "m-page-4",
282
+ ]);
283
+ });
284
+
215
285
  test("listByThread excludes soft-deleted messages but keeps the rest of the conversation", async () => {
216
286
  const acct = "acct-conversation-deleted";
217
287
  const threadId = "t-conversation-deleted";
@@ -56,7 +56,6 @@ export const clampThreadSearchLimit = (limit?: number): number => {
56
56
  // ─── Cursor ──────────────────────────────────────────────────────────────────
57
57
 
58
58
  type DateCursor = { s: number; id: string };
59
- type ThreadCursor = { d: number; id: string };
60
59
  type AccountCursor = { id: string };
61
60
 
62
61
  function encodeDateCursor(sentDate: number, threadMessageId: string): string {
@@ -73,23 +72,6 @@ function decodeDateCursor(token: string): DateCursor | null {
73
72
  }
74
73
  }
75
74
 
76
- function encodeThreadCursor(
77
- internalDate: number,
78
- threadMessageId: string,
79
- ): string {
80
- return Buffer.from(
81
- JSON.stringify({ d: internalDate, id: threadMessageId }),
82
- ).toString("base64");
83
- }
84
-
85
- function decodeThreadCursor(token: string): ThreadCursor | null {
86
- try {
87
- return JSON.parse(Buffer.from(token, "base64").toString()) as ThreadCursor;
88
- } catch {
89
- return null;
90
- }
91
- }
92
-
93
75
  function encodeAccountCursor(threadMessageId: string): string {
94
76
  return Buffer.from(JSON.stringify({ id: threadMessageId })).toString(
95
77
  "base64",
@@ -563,22 +545,22 @@ export class DrizzleThreadMessageRepository
563
545
  ): Promise<ResultList<ThreadMessageItem>> {
564
546
  const order = options?.order ?? "asc";
565
547
  const cursor = options?.continuationToken
566
- ? decodeThreadCursor(options.continuationToken)
548
+ ? decodeDateCursor(options.continuationToken)
567
549
  : null;
568
550
 
569
551
  const cursorCond = cursor
570
552
  ? order === "asc"
571
553
  ? or(
572
- gt(threadMessageTable.internalDate, cursor.d),
554
+ gt(threadMessageTable.sentDate, cursor.s),
573
555
  and(
574
- eq(threadMessageTable.internalDate, cursor.d),
556
+ eq(threadMessageTable.sentDate, cursor.s),
575
557
  gt(threadMessageTable.threadMessageId, cursor.id),
576
558
  ),
577
559
  )
578
560
  : or(
579
- lt(threadMessageTable.internalDate, cursor.d),
561
+ lt(threadMessageTable.sentDate, cursor.s),
580
562
  and(
581
- eq(threadMessageTable.internalDate, cursor.d),
563
+ eq(threadMessageTable.sentDate, cursor.s),
582
564
  gt(threadMessageTable.threadMessageId, cursor.id),
583
565
  ),
584
566
  )
@@ -599,8 +581,8 @@ export class DrizzleThreadMessageRepository
599
581
  )
600
582
  .orderBy(
601
583
  order === "asc"
602
- ? asc(threadMessageTable.internalDate)
603
- : desc(threadMessageTable.internalDate),
584
+ ? asc(threadMessageTable.sentDate)
585
+ : desc(threadMessageTable.sentDate),
604
586
  asc(threadMessageTable.threadMessageId),
605
587
  )
606
588
  .limit(options?.limit ?? 100);
@@ -612,7 +594,7 @@ export class DrizzleThreadMessageRepository
612
594
  items: rows.map(toItem),
613
595
  continuationToken:
614
596
  hasMore && lastRow
615
- ? encodeThreadCursor(lastRow.internalDate, lastRow.threadMessageId)
597
+ ? encodeDateCursor(lastRow.sentDate, lastRow.threadMessageId)
616
598
  : undefined,
617
599
  };
618
600
  }
@@ -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
- });