@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.82",
3
+ "version": "0.0.84",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import Database from "better-sqlite3";
4
+ import {
5
+ applyMigration,
6
+ migrationJournal,
7
+ } from "./test-shipped-sqlite-schema.js";
8
+
9
+ /**
10
+ * `sync_status` becomes NOT NULL, and on a real install almost every row is
11
+ * NULL: the sweep's insert is the only create path that omitted it, and the
12
+ * sweep is what discovers a folder in the first place.
13
+ *
14
+ * drizzle-kit does not emit data migrations, and SQLite cannot alter
15
+ * nullability, so the schema change is a copy-and-swap whose `INSERT … SELECT`
16
+ * would violate the new constraint on every one of those rows. Because drizzle
17
+ * runs a folder's whole pending set inside one `BEGIN`/`COMMIT`, that failure
18
+ * rolls the entities set back and the migrate one-shot that gates all six
19
+ * services never completes.
20
+ *
21
+ * So the pair is proven here in the order the migrator runs it, against the
22
+ * data it will actually meet.
23
+ */
24
+ const BACKFILL = "0026_mailbox_sync_status_backfill";
25
+ const SCHEMA_CHANGE = "0027_mailbox_sync_status_total";
26
+
27
+ const atPredecessor = (): Database.Database => {
28
+ const sqlite = new Database(":memory:");
29
+ for (const entry of migrationJournal()) {
30
+ if (entry.tag === BACKFILL) return sqlite;
31
+ applyMigration(sqlite, entry.tag);
32
+ }
33
+ throw new Error(`${BACKFILL} is not in the journal`);
34
+ };
35
+
36
+ const seed = (
37
+ sqlite: Database.Database,
38
+ rows: Array<{ mailboxId: string; syncStatus: string | null }>,
39
+ ): void => {
40
+ const insert = sqlite.prepare(
41
+ `INSERT INTO mailbox (
42
+ mailbox_id, account_id, namespace_type, namespace_prefix,
43
+ hierarchy_delimiter, full_path, uid_validity, uid_next, highest_modseq,
44
+ message_count, unseen_count, deleted_count, total_size, last_sync_uid,
45
+ high_water_mark_uid, last_message_sync_at, parent_mailbox_id,
46
+ sync_status, cursor_state, created_at, updated_at
47
+ ) VALUES (?, 'acct', 'personal', '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0,
48
+ 'None', ?, 'normal', 0, 0)`,
49
+ );
50
+ for (const row of rows) {
51
+ insert.run(row.mailboxId, `Folder/${row.mailboxId}`, row.syncStatus);
52
+ }
53
+ };
54
+
55
+ describe("mailbox sync_status becomes total", () => {
56
+ test("the backfill runs first, so the rebuild sees no NULLs", () => {
57
+ const sqlite = atPredecessor();
58
+ seed(sqlite, [
59
+ { mailboxId: "swept-1", syncStatus: null },
60
+ { mailboxId: "swept-2", syncStatus: null },
61
+ { mailboxId: "created", syncStatus: "pending" },
62
+ { mailboxId: "failed", syncStatus: "failed" },
63
+ ]);
64
+
65
+ applyMigration(sqlite, BACKFILL);
66
+ applyMigration(sqlite, SCHEMA_CHANGE);
67
+
68
+ const rows = sqlite
69
+ .prepare(
70
+ "SELECT mailbox_id, sync_status FROM mailbox ORDER BY mailbox_id",
71
+ )
72
+ .all() as Array<{ mailbox_id: string; sync_status: string }>;
73
+
74
+ assert.deepEqual(rows, [
75
+ { mailbox_id: "created", sync_status: "pending" },
76
+ { mailbox_id: "failed", sync_status: "failed" },
77
+ { mailbox_id: "swept-1", sync_status: "synced" },
78
+ { mailbox_id: "swept-2", sync_status: "synced" },
79
+ ]);
80
+ sqlite.close();
81
+ });
82
+
83
+ test("the rebuild is refused without the backfill ahead of it", () => {
84
+ // The failure this ordering exists to prevent, made visible: without the
85
+ // backfill the copy-and-swap cannot insert a NULL into the new column.
86
+ const sqlite = atPredecessor();
87
+ seed(sqlite, [{ mailboxId: "swept-1", syncStatus: null }]);
88
+
89
+ assert.throws(
90
+ () => applyMigration(sqlite, SCHEMA_CHANGE),
91
+ /NOT NULL constraint failed/,
92
+ );
93
+ sqlite.close();
94
+ });
95
+
96
+ test("the rebuild keeps the account index and the new column", () => {
97
+ const sqlite = atPredecessor();
98
+ applyMigration(sqlite, BACKFILL);
99
+ applyMigration(sqlite, SCHEMA_CHANGE);
100
+ applyMigration(sqlite, "0028_mailbox_pending_path");
101
+
102
+ const columns = (
103
+ sqlite.prepare("PRAGMA table_info(mailbox)").all() as Array<{
104
+ name: string;
105
+ }>
106
+ ).map((column) => column.name);
107
+ assert.equal(columns.includes("pending_path"), true);
108
+ assert.equal(columns.includes("old_path"), false);
109
+
110
+ const indexes = (
111
+ sqlite.prepare("PRAGMA index_list(mailbox)").all() as Array<{
112
+ name: string;
113
+ }>
114
+ ).map((index) => index.name);
115
+ assert.equal(indexes.includes("mailbox_by_account_id"), true);
116
+ sqlite.close();
117
+ });
118
+ });
@@ -7,7 +7,10 @@ import Database from "better-sqlite3";
7
7
  import { drizzle } from "drizzle-orm/better-sqlite3";
8
8
  import { AccountSettingRepo } from "../repos/i4-account-setting.js";
9
9
  import { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
10
- import { shippedTableDdl } from "../test-shipped-sqlite-schema.js";
10
+ import {
11
+ applyMigration,
12
+ shippedTableDdl,
13
+ } from "../test-shipped-sqlite-schema.js";
11
14
  import {
12
15
  type JunkOnlyRepairClient,
13
16
  type JunkOnlyRepairMode,
@@ -197,6 +200,9 @@ describe("addresses standing only on mail in Junk", () => {
197
200
  ]) {
198
201
  sqlite.exec(shippedTableDdl(DDL_TAG, table));
199
202
  }
203
+ // `mailbox` is read through the repo, which selects every column the
204
+ // entity declares — including the rename target added after this DDL.
205
+ applyMigration(sqlite, "0028_mailbox_pending_path");
200
206
  sqlite.exec(
201
207
  readFileSync(
202
208
  new URL(
@@ -68,31 +68,39 @@ describe("MailboxRepo (sqlite)", () => {
68
68
  assert.equal(reread.highestModseq, "9007199254740993");
69
69
  });
70
70
 
71
- test("renameChildPaths marks each child pending along with its new path (#290)", async () => {
72
- // A renamed parent is set pending by the caller; its children's new paths
73
- // are equally absent from the server until MAILBOX_RENAME lands, so they
74
- // must be pending too — otherwise a reconcile in that window reaps the
75
- // child as server-deleted.
71
+ test("a row written without a state reads back synced (D1)", async () => {
76
72
  const accountId = randomUUID();
77
- const parent = await repo.create({
78
- ...makeMailboxInput(accountId, "Work"),
79
- syncStatus: MailboxSyncStatus.pending,
80
- });
81
- const child = await repo.create({
82
- ...makeMailboxInput(accountId, "Work/sub"),
83
- syncStatus: MailboxSyncStatus.synced,
84
- });
73
+ const created = await repo.create(
74
+ makeMailboxInput(accountId, "Discovered"),
75
+ );
76
+ assert.equal(created.syncStatus, MailboxSyncStatus.synced);
77
+ const fetched = await repo.get(accountId, created.mailboxId);
78
+ assert.equal(fetched.syncStatus, MailboxSyncStatus.synced);
79
+ });
85
80
 
86
- await repo.renameChildPaths(accountId, "Work", "Projects", "/");
81
+ test("pendingPath round-trips and clears", async () => {
82
+ const accountId = randomUUID();
83
+ const created = await repo.create(makeMailboxInput(accountId, "Archive"));
84
+ assert.equal(created.pendingPath, undefined);
87
85
 
88
- const renamedChild = await repo.get(accountId, child.mailboxId);
89
- assert.equal(renamedChild.fullPath, "Projects/sub");
90
- assert.equal(renamedChild.syncStatus, MailboxSyncStatus.pending);
86
+ const claimed = await repo.transition(accountId, created.mailboxId, {
87
+ from: [MailboxSyncStatus.synced],
88
+ to: MailboxSyncStatus.pending,
89
+ set: { pendingPath: "Records" },
90
+ });
91
+ assert.equal(claimed?.pendingPath, "Records");
92
+ assert.equal(
93
+ (await repo.get(accountId, created.mailboxId)).pendingPath,
94
+ "Records",
95
+ );
91
96
 
92
- // The parent row is untouched by this call (its own path/status is the
93
- // caller's job).
94
- const parentRow = await repo.get(accountId, parent.mailboxId);
95
- assert.equal(parentRow.fullPath, "Work");
97
+ const settled = await repo.transition(accountId, created.mailboxId, {
98
+ from: [MailboxSyncStatus.pending],
99
+ to: MailboxSyncStatus.synced,
100
+ set: { fullPath: "Records", pendingPath: null },
101
+ });
102
+ assert.equal(settled?.pendingPath, undefined);
103
+ assert.equal(settled?.fullPath, "Records");
96
104
  });
97
105
  });
98
106
 
@@ -115,6 +123,9 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
115
123
  const sqlite = new Database(":memory:");
116
124
  sqlite.exec(shippedTableDdl("0000_happy_roland_deschain", "mailbox"));
117
125
  applyMigration(sqlite, "0002_highest_modseq_text");
126
+ applyMigration(sqlite, "0026_mailbox_sync_status_backfill");
127
+ applyMigration(sqlite, "0027_mailbox_sync_status_total");
128
+ applyMigration(sqlite, "0028_mailbox_pending_path");
118
129
  const db = drizzle(sqlite, { schema: { mailbox: mailboxTable } });
119
130
  repo = new MailboxRepo(db as never);
120
131
  close = async () => {
@@ -157,6 +168,31 @@ describe("MailboxRepo (sqlite, shipped migrations)", () => {
157
168
  assert.strictEqual(fetched.highestModseq, "900:149");
158
169
  });
159
170
 
171
+ test("a row inserted without a state reads back synced", async () => {
172
+ const accountId = randomUUID();
173
+ const created = await repo.create(makeMailboxInput(accountId, "Notes"));
174
+ assert.equal(created.syncStatus, MailboxSyncStatus.synced);
175
+ });
176
+
177
+ test("pendingPath round-trips and clears on the shipped shape", async () => {
178
+ const accountId = randomUUID();
179
+ const created = await repo.create(makeMailboxInput(accountId, "Receipts"));
180
+
181
+ const claimed = await repo.transition(accountId, created.mailboxId, {
182
+ from: [MailboxSyncStatus.synced],
183
+ to: MailboxSyncStatus.pending,
184
+ set: { pendingPath: "Invoices" },
185
+ });
186
+ assert.equal(claimed?.pendingPath, "Invoices");
187
+
188
+ const cleared = await repo.transition(accountId, created.mailboxId, {
189
+ from: [MailboxSyncStatus.pending],
190
+ to: MailboxSyncStatus.synced,
191
+ set: { pendingPath: null },
192
+ });
193
+ assert.equal(cleared?.pendingPath, undefined);
194
+ });
195
+
160
196
  test("round-trips a cursor above 2^53 with its exact digits", async () => {
161
197
  const accountId = randomUUID();
162
198
  const modseq = "18446744073709551615";