@mandujs/core 0.25.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,661 +1,665 @@
1
- /**
2
- * @mandujs/core/db/migrations — runner integration tests
3
- *
4
- * These tests hit the REAL `Bun.SQL` SQLite (in-memory + tmpdir-backed)
5
- * adapter so the full path — tagged-template parameter binding, WAL
6
- * semantics, transaction rollback, BEGIN IMMEDIATE locks — gets
7
- * exercised. No mocks.
8
- *
9
- * We use a gate to skip cleanly when run under a Bun build that lacks
10
- * `Bun.SQL` (matches the pattern in `tests/db/db-sqlite.test.ts`). On
11
- * the CI target (Bun 1.3.x) every test runs.
12
- */
13
-
14
- import { afterEach, beforeEach, describe, expect, it } from "bun:test";
15
- import { mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs";
16
- import { writeFile } from "node:fs/promises";
17
- import { tmpdir } from "node:os";
18
- import { join } from "node:path";
19
-
20
- import { createDb, type Db } from "../../index";
21
- import {
22
- DEFAULT_HISTORY_TABLE,
23
- historyTableDdl,
24
- readAllHistory,
25
- } from "../history-table";
26
- import {
27
- MigrationTamperedError,
28
- MigrationTimeoutError,
29
- computeMigrationChecksum,
30
- createMigrationRunner,
31
- splitStatements,
32
- } from "../runner";
33
-
34
- // ─── Gate ───────────────────────────────────────────────────────────────────
35
-
36
- const hasBunSql = (() => {
37
- const g = globalThis as unknown as { Bun?: { SQL?: unknown } };
38
- return typeof g.Bun?.SQL === "function";
39
- })();
40
-
41
- const describeIfBunSql = hasBunSql ? describe : describe.skip;
42
-
43
- // ─── Fixture helpers ────────────────────────────────────────────────────────
44
-
45
- interface Fixture {
46
- db: Db;
47
- dbPath: string;
48
- scratchDir: string;
49
- migrationsDir: string;
50
- }
51
-
52
- async function setupFixture(): Promise<Fixture> {
53
- const scratchDir = mkdtempSync(join(tmpdir(), "mandu-migrations-"));
54
- const migrationsDir = join(scratchDir, "migrations");
55
- // Create the dir up-front — matches what Agent E's CLI will do.
56
- await writeFile(join(scratchDir, ".gitkeep"), "");
57
- const { mkdirSync } = await import("node:fs");
58
- mkdirSync(migrationsDir, { recursive: true });
59
-
60
- const dbPath = join(scratchDir, "app.db");
61
- const db = createDb({ url: `sqlite://${dbPath}` });
62
- // Kick the lazy init.
63
- await db`SELECT 1`;
64
- return { db, dbPath, scratchDir, migrationsDir };
65
- }
66
-
67
- async function teardownFixture(f: Fixture): Promise<void> {
68
- try {
69
- await f.db.close();
70
- } catch {
71
- /* already closed */
72
- }
73
- rmSync(f.scratchDir, { recursive: true, force: true });
74
- }
75
-
76
- function writeMigration(
77
- dir: string,
78
- filename: string,
79
- sql: string,
80
- ): string {
81
- const fullPath = join(dir, filename);
82
- writeFileSync(fullPath, sql, "utf8");
83
- return fullPath;
84
- }
85
-
86
- // ─── Checksum unit tests (no DB) ────────────────────────────────────────────
87
-
88
- describe("computeMigrationChecksum", () => {
89
- it("normalises CRLF to LF before hashing — CRLF and LF produce the same digest", () => {
90
- const lf = computeMigrationChecksum("abc\n");
91
- const crlf = computeMigrationChecksum("abc\r\n");
92
- expect(lf).toBe(crlf);
93
- });
94
-
95
- it("is deterministic: identical input → identical digest", () => {
96
- const sql = "CREATE TABLE t (id INTEGER);\nINSERT INTO t VALUES (1);\n";
97
- expect(computeMigrationChecksum(sql)).toBe(computeMigrationChecksum(sql));
98
- });
99
-
100
- it("produces a 64-char lowercase hex SHA-256 digest", () => {
101
- const digest = computeMigrationChecksum("hello world");
102
- expect(digest).toMatch(/^[0-9a-f]{64}$/);
103
- });
104
-
105
- it("differs for even-a-single-byte mutation (whitespace preserved)", () => {
106
- const a = computeMigrationChecksum("CREATE TABLE t (id INTEGER);");
107
- const b = computeMigrationChecksum("CREATE TABLE t (id INTEGER); ");
108
- expect(a).not.toBe(b);
109
- });
110
- });
111
-
112
- // ─── Statement splitter unit tests ──────────────────────────────────────────
113
-
114
- describe("splitStatements", () => {
115
- it("returns a single statement when no trailing semicolons exist", () => {
116
- expect(splitStatements("CREATE TABLE t (id INTEGER)")).toEqual([
117
- "CREATE TABLE t (id INTEGER)",
118
- ]);
119
- });
120
-
121
- it("splits two SQL statements separated by end-of-line semicolons", () => {
122
- const sql = "CREATE TABLE a (x INTEGER);\nCREATE INDEX idx ON a(x);";
123
- expect(splitStatements(sql)).toEqual([
124
- "CREATE TABLE a (x INTEGER)",
125
- "CREATE INDEX idx ON a(x)",
126
- ]);
127
- });
128
-
129
- it("drops empty statements between semicolons", () => {
130
- const sql = "CREATE TABLE a (x INTEGER);\n\n;\n";
131
- expect(splitStatements(sql)).toEqual(["CREATE TABLE a (x INTEGER)"]);
132
- });
133
-
134
- it("preserves inline comments within a statement", () => {
135
- const sql = "-- header\nCREATE TABLE a (x INTEGER); -- trailing\n";
136
- const out = splitStatements(sql);
137
- expect(out).toHaveLength(1);
138
- expect(out[0]).toContain("header");
139
- expect(out[0]).toContain("CREATE TABLE");
140
- });
141
- });
142
-
143
- // ─── historyTableDdl per-dialect verification ───────────────────────────────
144
-
145
- describe("historyTableDdl", () => {
146
- it("emits TIMESTAMPTZ + double-quoted identifiers for Postgres", () => {
147
- const ddl = historyTableDdl(DEFAULT_HISTORY_TABLE, "postgres");
148
- expect(ddl).toContain('"__mandu_migrations"');
149
- expect(ddl).toContain("TIMESTAMPTZ");
150
- expect(ddl).toMatch(/"version"\s+TEXT\s+PRIMARY KEY/);
151
- });
152
-
153
- it("emits DATETIME(6) + backtick-quoted identifiers for MySQL", () => {
154
- const ddl = historyTableDdl(DEFAULT_HISTORY_TABLE, "mysql");
155
- expect(ddl).toContain("`__mandu_migrations`");
156
- expect(ddl).toContain("DATETIME(6)");
157
- expect(ddl).toContain("VARCHAR(50) NOT NULL");
158
- expect(ddl).toContain("PRIMARY KEY (`version`)");
159
- });
160
-
161
- it("emits TEXT timestamps + double-quoted identifiers for SQLite", () => {
162
- const ddl = historyTableDdl(DEFAULT_HISTORY_TABLE, "sqlite");
163
- expect(ddl).toContain('"__mandu_migrations"');
164
- expect(ddl).toMatch(/"applied_at"\s+TEXT\s+NOT NULL/);
165
- });
166
-
167
- it("rejects unsafe identifiers (SQL-injection guard)", () => {
168
- expect(() => historyTableDdl("bad; DROP TABLE users", "sqlite")).toThrow(
169
- /Invalid identifier/,
170
- );
171
- });
172
- });
173
-
174
- // ─── Full runner integration ────────────────────────────────────────────────
175
-
176
- describeIfBunSql("createMigrationRunner — integration", () => {
177
- let f: Fixture;
178
-
179
- beforeEach(async () => {
180
- f = await setupFixture();
181
- });
182
-
183
- afterEach(async () => {
184
- await teardownFixture(f);
185
- });
186
-
187
- it("ensureHistoryTable() is idempotent — calling twice is a no-op", async () => {
188
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
189
- await runner.ensureHistoryTable();
190
- await runner.ensureHistoryTable();
191
- // Sanity — table exists and is queryable.
192
- const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
193
- expect(rows).toEqual([]);
194
- });
195
-
196
- it("plan() on empty dir + empty history returns []", async () => {
197
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
198
- const pending = await runner.plan();
199
- expect(pending).toEqual([]);
200
- });
201
-
202
- it("plan() returns pending migrations sorted by version, ignoring already-applied", async () => {
203
- writeMigration(f.migrationsDir, "0001_one.sql", "CREATE TABLE a (id INTEGER);");
204
- writeMigration(f.migrationsDir, "0002_two.sql", "CREATE TABLE b (id INTEGER);");
205
- writeMigration(f.migrationsDir, "0003_three.sql", "CREATE TABLE c (id INTEGER);");
206
-
207
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
208
- // Pre-seed history as if 0001 was already applied.
209
- await runner.ensureHistoryTable();
210
- await f.db`INSERT INTO "__mandu_migrations"
211
- (version, filename, checksum, applied_at, execution_ms, success, installed_by)
212
- VALUES (${"0001"}, ${"0001_one.sql"},
213
- ${computeMigrationChecksum("CREATE TABLE a (id INTEGER);")},
214
- ${new Date().toISOString()}, ${0}, ${1}, ${"test"})`;
215
-
216
- const pending = await runner.plan();
217
- expect(pending.map((p) => p.version)).toEqual(["0002", "0003"]);
218
- });
219
-
220
- it("plan() ignores non-.sql files silently", async () => {
221
- writeMigration(f.migrationsDir, "0001_valid.sql", "CREATE TABLE t (id INTEGER);");
222
- writeMigration(f.migrationsDir, "README.md", "# notes");
223
- writeMigration(f.migrationsDir, "0002_also_valid.sql", "CREATE TABLE u (id INTEGER);");
224
-
225
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
226
- const pending = await runner.plan();
227
- expect(pending.map((p) => p.version)).toEqual(["0001", "0002"]);
228
- });
229
-
230
- it("plan() warns and skips .sql files that do not match NNNN_description.sql", async () => {
231
- writeMigration(f.migrationsDir, "0001_ok.sql", "CREATE TABLE a (id INTEGER);");
232
- writeMigration(f.migrationsDir, "not_a_migration.sql", "SELECT 1;");
233
- writeMigration(f.migrationsDir, "also-bad.sql", "SELECT 1;");
234
-
235
- // Capture the warn so the test output stays clean.
236
- const warns: string[] = [];
237
- const origWarn = console.warn;
238
- console.warn = (...args: unknown[]) => {
239
- warns.push(args.map(String).join(" "));
240
- };
241
- try {
242
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
243
- const pending = await runner.plan();
244
- expect(pending.map((p) => p.version)).toEqual(["0001"]);
245
- expect(warns.some((m) => m.includes("not_a_migration.sql"))).toBe(true);
246
- expect(warns.some((m) => m.includes("also-bad.sql"))).toBe(true);
247
- } finally {
248
- console.warn = origWarn;
249
- }
250
- });
251
-
252
- it("plan() throws when two files share the same version prefix", async () => {
253
- writeMigration(f.migrationsDir, "0001_first.sql", "SELECT 1;");
254
- writeMigration(f.migrationsDir, "0001_conflict.sql", "SELECT 2;");
255
-
256
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
257
- await expect(runner.plan()).rejects.toThrow(/Duplicate migration version/);
258
- });
259
-
260
- it("apply() happy path: applies all pending, writes history, plan() becomes empty", async () => {
261
- writeMigration(
262
- f.migrationsDir,
263
- "0001_create_users.sql",
264
- "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
265
- );
266
- writeMigration(
267
- f.migrationsDir,
268
- "0002_create_posts.sql",
269
- "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER);",
270
- );
271
-
272
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
273
- const applied = await runner.apply();
274
- expect(applied.map((a) => a.version)).toEqual(["0001", "0002"]);
275
- expect(applied.every((a) => a.success === true)).toBe(true);
276
-
277
- const afterPlan = await runner.plan();
278
- expect(afterPlan).toEqual([]);
279
-
280
- const history = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
281
- expect(history).toHaveLength(2);
282
- expect(history[0]!.version).toBe("0001");
283
- expect(history[1]!.version).toBe("0002");
284
-
285
- // Sanity — the migrations actually took effect.
286
- const tables = await f.db<{ name: string }>`
287
- SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name
288
- `;
289
- expect(tables.map((t) => t.name)).toEqual(
290
- expect.arrayContaining(["posts", "users"]),
291
- );
292
- });
293
-
294
- it("apply({ dryRun: true }) does NOT execute SQL and does NOT insert history", async () => {
295
- writeMigration(
296
- f.migrationsDir,
297
- "0001_side_effect.sql",
298
- "CREATE TABLE should_not_exist (id INTEGER);",
299
- );
300
-
301
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
302
- const preview = await runner.apply({ dryRun: true });
303
- expect(preview).toHaveLength(1);
304
- expect(preview[0]!.version).toBe("0001");
305
- expect(preview[0]!.success).toBe(false); // dry-run marker
306
-
307
- // Table must not exist and history must be empty.
308
- const tables = await f.db<{ name: string }>`
309
- SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'should_not_exist'
310
- `;
311
- expect(tables).toEqual([]);
312
-
313
- const history = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
314
- expect(history).toEqual([]);
315
- });
316
-
317
- it("apply() on a file with a syntax error: rolls back, no history row, throws with filename", async () => {
318
- writeMigration(
319
- f.migrationsDir,
320
- "0001_good.sql",
321
- "CREATE TABLE good (id INTEGER);",
322
- );
323
- writeMigration(
324
- f.migrationsDir,
325
- "0002_bad.sql",
326
- "CREATE TABLE bad (id INTEGER); INSERT INTO this_table_does_not_exist VALUES (1);",
327
- );
328
-
329
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
330
- let caught: unknown = null;
331
- try {
332
- await runner.apply();
333
- } catch (e) {
334
- caught = e;
335
- }
336
- expect(caught).toBeInstanceOf(Error);
337
- expect((caught as Error).message).toMatch(/0002_bad\.sql/);
338
-
339
- // 0001 succeeded, 0002 left no trace.
340
- const history = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
341
- expect(history.map((h) => h.version)).toEqual(["0001"]);
342
-
343
- // The `bad` table from 0002 must NOT exist — tx rolled back.
344
- const badTable = await f.db<{ name: string }>`
345
- SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'bad'
346
- `;
347
- expect(badTable).toEqual([]);
348
-
349
- // The `good` table from 0001 IS there.
350
- const goodTable = await f.db<{ name: string }>`
351
- SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'good'
352
- `;
353
- expect(goodTable).toHaveLength(1);
354
- });
355
-
356
- it("plan() does NOT include a file that has a history row, even if checksum mismatches", async () => {
357
- const originalSql = "CREATE TABLE x (id INTEGER);";
358
- writeMigration(f.migrationsDir, "0001_x.sql", originalSql);
359
-
360
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
361
- await runner.ensureHistoryTable();
362
-
363
- // Apply then rewrite the file to break the checksum.
364
- await runner.apply();
365
- writeMigration(f.migrationsDir, "0001_x.sql", "CREATE TABLE x (id INTEGER, new_col TEXT);");
366
-
367
- const pending = await runner.plan();
368
- expect(pending).toEqual([]); // history wins; file is not "pending"
369
-
370
- // But status() surfaces the tamper.
371
- const status = await runner.status();
372
- expect(status.tampered).toHaveLength(1);
373
- expect(status.tampered[0]!.filename).toBe("0001_x.sql");
374
- });
375
-
376
- it("status() reports tampered after file modification", async () => {
377
- writeMigration(f.migrationsDir, "0001_init.sql", "CREATE TABLE t1 (id INTEGER);");
378
-
379
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
380
- await runner.apply();
381
-
382
- // Tamper: rewrite the migration content.
383
- writeMigration(f.migrationsDir, "0001_init.sql", "CREATE TABLE t1 (id INTEGER, extra TEXT);");
384
- // Force mtime bump so some filesystems update the stat promptly.
385
- const newTime = new Date();
386
- utimesSync(join(f.migrationsDir, "0001_init.sql"), newTime, newTime);
387
-
388
- const status = await runner.status();
389
- expect(status.tampered).toHaveLength(1);
390
- expect(status.tampered[0]!.version).toBe("0001");
391
- expect(status.tampered[0]!.storedChecksum).not.toBe(
392
- status.tampered[0]!.currentChecksum,
393
- );
394
- });
395
-
396
- it("apply() throws MigrationTamperedError when a prior row's file has been mutated", async () => {
397
- writeMigration(f.migrationsDir, "0001_a.sql", "CREATE TABLE a (id INTEGER);");
398
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
399
- await runner.apply();
400
-
401
- // Mutate + add a new pending migration.
402
- writeMigration(f.migrationsDir, "0001_a.sql", "CREATE TABLE a (id INTEGER, x TEXT);");
403
- writeMigration(f.migrationsDir, "0002_b.sql", "CREATE TABLE b (id INTEGER);");
404
-
405
- await expect(runner.apply()).rejects.toBeInstanceOf(MigrationTamperedError);
406
- });
407
-
408
- it("status() surfaces applied + pending + tampered + orphaned simultaneously", async () => {
409
- writeMigration(f.migrationsDir, "0001_applied.sql", "CREATE TABLE a (id INTEGER);");
410
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
411
- await runner.apply();
412
-
413
- // Now introduce: (1) a pending file, (2) tamper on applied, (3) an
414
- // orphaned history row pointing at a now-deleted version.
415
- writeMigration(f.migrationsDir, "0002_pending.sql", "CREATE TABLE b (id INTEGER);");
416
- writeMigration(f.migrationsDir, "0001_applied.sql", "CREATE TABLE a (id INTEGER, mod TEXT);");
417
-
418
- // Insert an orphan history row directly.
419
- await f.db`INSERT INTO "__mandu_migrations"
420
- (version, filename, checksum, applied_at, execution_ms, success, installed_by)
421
- VALUES (${"9999"}, ${"9999_deleted.sql"},
422
- ${"deadbeef".repeat(8)}, ${new Date().toISOString()}, ${0}, ${1}, ${"test"})`;
423
-
424
- const status = await runner.status();
425
- expect(status.applied.map((a) => a.version).sort()).toEqual(["0001", "9999"]);
426
- expect(status.pending.map((p) => p.version)).toEqual(["0002"]);
427
- expect(status.tampered.map((t) => t.version)).toEqual(["0001"]);
428
- expect(status.orphaned.map((o) => o.filename)).toEqual(["9999_deleted.sql"]);
429
- });
430
-
431
- it("apply() on multi-statement file (CREATE TABLE + CREATE INDEX) executes all statements", async () => {
432
- writeMigration(
433
- f.migrationsDir,
434
- "0001_multi.sql",
435
- `CREATE TABLE items (id INTEGER PRIMARY KEY, slug TEXT);
436
- CREATE INDEX items_slug_idx ON items (slug);`,
437
- );
438
-
439
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
440
- const applied = await runner.apply();
441
- expect(applied).toHaveLength(1);
442
-
443
- const indexes = await f.db<{ name: string }>`
444
- SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'items_slug_idx'
445
- `;
446
- expect(indexes).toHaveLength(1);
447
- });
448
-
449
- it("concurrent apply() calls: second runner waits / fails rather than clobbering", async () => {
450
- writeMigration(
451
- f.migrationsDir,
452
- "0001_slow.sql",
453
- "CREATE TABLE slow (id INTEGER);",
454
- );
455
-
456
- // Two runners, same DB handle. SQLite BEGIN IMMEDIATE on the same
457
- // connection errors immediately for the second acquirer ("cannot
458
- // start a transaction within a transaction"), which is exactly
459
- // the serialisation behaviour we want — the second call fails fast
460
- // rather than silently interleaving.
461
- const runnerA = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
462
- const runnerB = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
463
-
464
- const results = await Promise.allSettled([runnerA.apply(), runnerB.apply()]);
465
-
466
- const fulfilled = results.filter((r) => r.status === "fulfilled");
467
- expect(fulfilled.length).toBeGreaterThanOrEqual(1);
468
-
469
- // Final state: exactly one history row for 0001 (the other call
470
- // either waited and found it applied, or errored mid-lock).
471
- const history = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
472
- expect(history.filter((h) => h.version === "0001")).toHaveLength(1);
473
- });
474
-
475
- it("dispose() releases held lock and is idempotent (no-op on second call)", async () => {
476
- writeMigration(f.migrationsDir, "0001_init.sql", "CREATE TABLE d (id INTEGER);");
477
-
478
- const runner = createMigrationRunner(f.db, {
479
- migrationsDir: f.migrationsDir,
480
- // "none" so we can safely call dispose() without depending on
481
- // transaction state from BEGIN IMMEDIATE.
482
- lockStrategy: "none",
483
- });
484
- await runner.apply();
485
- await runner.dispose();
486
- await runner.dispose(); // must not throw
487
- expect(true).toBe(true);
488
- });
489
-
490
- it("first operation auto-runs ensureHistoryTable when user forgot", async () => {
491
- writeMigration(f.migrationsDir, "0001_auto.sql", "CREATE TABLE auto (id INTEGER);");
492
-
493
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
494
- // Skip ensureHistoryTable() — plan() should auto-initialise.
495
- const pending = await runner.plan();
496
- expect(pending.map((p) => p.version)).toEqual(["0001"]);
497
-
498
- // History table now exists.
499
- const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
500
- expect(Array.isArray(rows)).toBe(true);
501
- });
502
-
503
- it("custom historyTable override flows through ensureHistoryTable/plan/apply/status", async () => {
504
- writeMigration(f.migrationsDir, "0001_custom.sql", "CREATE TABLE c1 (id INTEGER);");
505
-
506
- const runner = createMigrationRunner(f.db, {
507
- migrationsDir: f.migrationsDir,
508
- historyTable: "project_migrations",
509
- });
510
- await runner.apply();
511
-
512
- // The default table is NOT created.
513
- const defaultTbl = await f.db<{ name: string }>`
514
- SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__mandu_migrations'
515
- `;
516
- expect(defaultTbl).toEqual([]);
517
-
518
- // The custom one IS.
519
- const customTbl = await f.db<{ name: string }>`
520
- SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'project_migrations'
521
- `;
522
- expect(customTbl).toHaveLength(1);
523
-
524
- // And reads use the override.
525
- const rows = await readAllHistory(f.db, "project_migrations");
526
- expect(rows.map((r) => r.version)).toEqual(["0001"]);
527
- });
528
-
529
- it("installed_by defaults to MANDU_MIGRATION_USER env var when set", async () => {
530
- writeMigration(f.migrationsDir, "0001_who.sql", "CREATE TABLE who (id INTEGER);");
531
-
532
- const prev = process.env.MANDU_MIGRATION_USER;
533
- process.env.MANDU_MIGRATION_USER = "ci-bot";
534
- try {
535
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
536
- await runner.apply();
537
- const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
538
- expect(rows[0]!.installed_by).toBe("ci-bot");
539
- } finally {
540
- if (prev === undefined) delete process.env.MANDU_MIGRATION_USER;
541
- else process.env.MANDU_MIGRATION_USER = prev;
542
- }
543
- });
544
-
545
- it("installed_by falls back to 'mandu' when the env var is unset", async () => {
546
- writeMigration(f.migrationsDir, "0001_fallback.sql", "CREATE TABLE f (id INTEGER);");
547
-
548
- const prev = process.env.MANDU_MIGRATION_USER;
549
- delete process.env.MANDU_MIGRATION_USER;
550
- try {
551
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
552
- await runner.apply();
553
- const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
554
- expect(rows[0]!.installed_by).toBe("mandu");
555
- } finally {
556
- if (prev !== undefined) process.env.MANDU_MIGRATION_USER = prev;
557
- }
558
- });
559
-
560
- it("plan() returns freshly-computed checksums (does NOT cache across calls)", async () => {
561
- writeMigration(f.migrationsDir, "0001_v.sql", "CREATE TABLE v (id INTEGER);");
562
-
563
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
564
- const plan1 = await runner.plan();
565
- const checksum1 = plan1[0]!.checksum;
566
-
567
- writeMigration(f.migrationsDir, "0001_v.sql", "CREATE TABLE v (id INTEGER, more TEXT);");
568
- const plan2 = await runner.plan();
569
- const checksum2 = plan2[0]!.checksum;
570
-
571
- expect(checksum1).not.toBe(checksum2);
572
- });
573
-
574
- it("apply() on an empty migrations directory returns [] without errors", async () => {
575
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
576
- const applied = await runner.apply();
577
- expect(applied).toEqual([]);
578
- });
579
-
580
- it("apply() is a no-op when everything is already applied", async () => {
581
- writeMigration(f.migrationsDir, "0001_noop.sql", "CREATE TABLE n (id INTEGER);");
582
-
583
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
584
- const first = await runner.apply();
585
- expect(first).toHaveLength(1);
586
- const second = await runner.apply();
587
- expect(second).toEqual([]);
588
- });
589
-
590
- it("applyTimeoutMs of 1ms aborts a file whose cumulative SQL runs longer", async () => {
591
- // Force a timeout by using `applyTimeoutMs = 1` and a multi-statement
592
- // migration. After the first statement executes, elapsed > 1 ms so
593
- // the timeout check fires; tx rolls back; no history row written.
594
- writeMigration(
595
- f.migrationsDir,
596
- "0001_timeout.sql",
597
- `CREATE TABLE slow1 (id INTEGER);
598
- CREATE TABLE slow2 (id INTEGER);
599
- CREATE TABLE slow3 (id INTEGER);`,
600
- );
601
-
602
- const runner = createMigrationRunner(f.db, {
603
- migrationsDir: f.migrationsDir,
604
- applyTimeoutMs: 1,
605
- });
606
-
607
- let err: unknown = null;
608
- try {
609
- await runner.apply();
610
- } catch (e) {
611
- err = e;
612
- }
613
- expect(err).toBeInstanceOf(MigrationTimeoutError);
614
- expect((err as MigrationTimeoutError).filename).toBe("0001_timeout.sql");
615
-
616
- // No history row for the timed-out migration.
617
- const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
618
- expect(rows).toEqual([]);
619
-
620
- // Tables rolled back.
621
- const tables = await f.db<{ name: string }>`
622
- SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'slow%'
623
- `;
624
- expect(tables).toEqual([]);
625
- });
626
-
627
- it("MigrationTamperedError exposes filename + both checksums", async () => {
628
- writeMigration(f.migrationsDir, "0001_t.sql", "CREATE TABLE t (id INTEGER);");
629
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
630
- await runner.apply();
631
-
632
- writeMigration(f.migrationsDir, "0001_t.sql", "CREATE TABLE t (id INTEGER, ex TEXT);");
633
-
634
- let err: unknown = null;
635
- try {
636
- await runner.apply();
637
- } catch (e) {
638
- err = e;
639
- }
640
- expect(err).toBeInstanceOf(MigrationTamperedError);
641
- const mte = err as MigrationTamperedError;
642
- expect(mte.filename).toBe("0001_t.sql");
643
- expect(mte.storedChecksum).toMatch(/^[0-9a-f]{64}$/);
644
- expect(mte.currentChecksum).toMatch(/^[0-9a-f]{64}$/);
645
- expect(mte.storedChecksum).not.toBe(mte.currentChecksum);
646
- });
647
-
648
- it("applied migrations carry strict checksum + execution_ms + appliedAt values", async () => {
649
- writeMigration(f.migrationsDir, "0001_x.sql", "CREATE TABLE x (id INTEGER);");
650
-
651
- const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
652
- const applied = await runner.apply();
653
- expect(applied).toHaveLength(1);
654
- const a = applied[0]!;
655
- expect(a.checksum).toMatch(/^[0-9a-f]{64}$/);
656
- expect(typeof a.executionMs).toBe("number");
657
- expect(a.executionMs).toBeGreaterThanOrEqual(0);
658
- expect(a.appliedAt).toBeInstanceOf(Date);
659
- expect(a.success).toBe(true);
660
- });
661
- });
1
+ /**
2
+ * @mandujs/core/db/migrations — runner integration tests
3
+ *
4
+ * These tests hit the REAL `Bun.SQL` SQLite (in-memory + tmpdir-backed)
5
+ * adapter so the full path — tagged-template parameter binding, WAL
6
+ * semantics, transaction rollback, BEGIN IMMEDIATE locks — gets
7
+ * exercised. No mocks.
8
+ *
9
+ * We use a gate to skip cleanly when run under a Bun build that lacks
10
+ * `Bun.SQL` (matches the pattern in `tests/db/db-sqlite.test.ts`). On
11
+ * the CI target (Bun 1.3.x) every test runs.
12
+ */
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
15
+ import { mkdtempSync, rmSync, writeFileSync, utimesSync } from "node:fs";
16
+ import { writeFile } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ import { createDb, type Db } from "../../index";
21
+ import {
22
+ DEFAULT_HISTORY_TABLE,
23
+ historyTableDdl,
24
+ readAllHistory,
25
+ } from "../history-table";
26
+ import {
27
+ MigrationTamperedError,
28
+ MigrationTimeoutError,
29
+ computeMigrationChecksum,
30
+ createMigrationRunner,
31
+ splitStatements,
32
+ } from "../runner";
33
+
34
+ // ─── Gate ───────────────────────────────────────────────────────────────────
35
+
36
+ const hasBunSql = (() => {
37
+ const g = globalThis as unknown as { Bun?: { SQL?: unknown } };
38
+ return typeof g.Bun?.SQL === "function";
39
+ })();
40
+
41
+ const describeIfBunSql = hasBunSql ? describe : describe.skip;
42
+
43
+ // ─── Fixture helpers ────────────────────────────────────────────────────────
44
+
45
+ interface Fixture {
46
+ db: Db;
47
+ dbPath: string;
48
+ scratchDir: string;
49
+ migrationsDir: string;
50
+ }
51
+
52
+ async function setupFixture(): Promise<Fixture> {
53
+ const scratchDir = mkdtempSync(join(tmpdir(), "mandu-migrations-"));
54
+ const migrationsDir = join(scratchDir, "migrations");
55
+ // Create the dir up-front — matches what Agent E's CLI will do.
56
+ await writeFile(join(scratchDir, ".gitkeep"), "");
57
+ const { mkdirSync } = await import("node:fs");
58
+ mkdirSync(migrationsDir, { recursive: true });
59
+
60
+ const dbPath = join(scratchDir, "app.db");
61
+ const db = createDb({ url: `sqlite://${dbPath}` });
62
+ // Kick the lazy init.
63
+ await db`SELECT 1`;
64
+ return { db, dbPath, scratchDir, migrationsDir };
65
+ }
66
+
67
+ async function teardownFixture(f: Fixture): Promise<void> {
68
+ try {
69
+ await f.db.close();
70
+ } catch {
71
+ /* already closed */
72
+ }
73
+ rmSync(f.scratchDir, { recursive: true, force: true });
74
+ }
75
+
76
+ function writeMigration(
77
+ dir: string,
78
+ filename: string,
79
+ sql: string,
80
+ ): string {
81
+ const fullPath = join(dir, filename);
82
+ writeFileSync(fullPath, sql, "utf8");
83
+ return fullPath;
84
+ }
85
+
86
+ // ─── Checksum unit tests (no DB) ────────────────────────────────────────────
87
+
88
+ describe("computeMigrationChecksum", () => {
89
+ it("normalises CRLF to LF before hashing — CRLF and LF produce the same digest", () => {
90
+ const lf = computeMigrationChecksum("abc\n");
91
+ const crlf = computeMigrationChecksum("abc\r\n");
92
+ expect(lf).toBe(crlf);
93
+ });
94
+
95
+ it("is deterministic: identical input → identical digest", () => {
96
+ const sql = "CREATE TABLE t (id INTEGER);\nINSERT INTO t VALUES (1);\n";
97
+ expect(computeMigrationChecksum(sql)).toBe(computeMigrationChecksum(sql));
98
+ });
99
+
100
+ it("produces a 64-char lowercase hex SHA-256 digest", () => {
101
+ const digest = computeMigrationChecksum("hello world");
102
+ expect(digest).toMatch(/^[0-9a-f]{64}$/);
103
+ });
104
+
105
+ it("differs for even-a-single-byte mutation (whitespace preserved)", () => {
106
+ const a = computeMigrationChecksum("CREATE TABLE t (id INTEGER);");
107
+ const b = computeMigrationChecksum("CREATE TABLE t (id INTEGER); ");
108
+ expect(a).not.toBe(b);
109
+ });
110
+ });
111
+
112
+ // ─── Statement splitter unit tests ──────────────────────────────────────────
113
+
114
+ describe("splitStatements", () => {
115
+ it("returns a single statement when no trailing semicolons exist", () => {
116
+ expect(splitStatements("CREATE TABLE t (id INTEGER)")).toEqual([
117
+ "CREATE TABLE t (id INTEGER)",
118
+ ]);
119
+ });
120
+
121
+ it("splits two SQL statements separated by end-of-line semicolons", () => {
122
+ const sql = "CREATE TABLE a (x INTEGER);\nCREATE INDEX idx ON a(x);";
123
+ expect(splitStatements(sql)).toEqual([
124
+ "CREATE TABLE a (x INTEGER)",
125
+ "CREATE INDEX idx ON a(x)",
126
+ ]);
127
+ });
128
+
129
+ it("drops empty statements between semicolons", () => {
130
+ const sql = "CREATE TABLE a (x INTEGER);\n\n;\n";
131
+ expect(splitStatements(sql)).toEqual(["CREATE TABLE a (x INTEGER)"]);
132
+ });
133
+
134
+ it("preserves inline comments within a statement", () => {
135
+ const sql = "-- header\nCREATE TABLE a (x INTEGER); -- trailing\n";
136
+ const out = splitStatements(sql);
137
+ expect(out).toHaveLength(1);
138
+ expect(out[0]).toContain("header");
139
+ expect(out[0]).toContain("CREATE TABLE");
140
+ });
141
+ });
142
+
143
+ // ─── historyTableDdl per-dialect verification ───────────────────────────────
144
+
145
+ describe("historyTableDdl", () => {
146
+ it("emits TIMESTAMPTZ + double-quoted identifiers for Postgres", () => {
147
+ const ddl = historyTableDdl(DEFAULT_HISTORY_TABLE, "postgres");
148
+ expect(ddl).toContain('"__mandu_migrations"');
149
+ expect(ddl).toContain("TIMESTAMPTZ");
150
+ expect(ddl).toMatch(/"version"\s+TEXT\s+PRIMARY KEY/);
151
+ });
152
+
153
+ it("emits DATETIME(6) + backtick-quoted identifiers for MySQL", () => {
154
+ const ddl = historyTableDdl(DEFAULT_HISTORY_TABLE, "mysql");
155
+ expect(ddl).toContain("`__mandu_migrations`");
156
+ expect(ddl).toContain("DATETIME(6)");
157
+ expect(ddl).toContain("VARCHAR(50) NOT NULL");
158
+ expect(ddl).toContain("PRIMARY KEY (`version`)");
159
+ });
160
+
161
+ it("emits TEXT timestamps + double-quoted identifiers for SQLite", () => {
162
+ const ddl = historyTableDdl(DEFAULT_HISTORY_TABLE, "sqlite");
163
+ expect(ddl).toContain('"__mandu_migrations"');
164
+ expect(ddl).toMatch(/"applied_at"\s+TEXT\s+NOT NULL/);
165
+ });
166
+
167
+ it("rejects unsafe identifiers (SQL-injection guard)", () => {
168
+ expect(() => historyTableDdl("bad; DROP TABLE users", "sqlite")).toThrow(
169
+ /Invalid identifier/,
170
+ );
171
+ });
172
+ });
173
+
174
+ // ─── Full runner integration ────────────────────────────────────────────────
175
+
176
+ describeIfBunSql("createMigrationRunner — integration", () => {
177
+ let f: Fixture;
178
+
179
+ beforeEach(async () => {
180
+ f = await setupFixture();
181
+ });
182
+
183
+ afterEach(async () => {
184
+ await teardownFixture(f);
185
+ });
186
+
187
+ it("ensureHistoryTable() is idempotent — calling twice is a no-op", async () => {
188
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
189
+ await runner.ensureHistoryTable();
190
+ await runner.ensureHistoryTable();
191
+ // Sanity — table exists and is queryable.
192
+ const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
193
+ expect(rows).toEqual([]);
194
+ });
195
+
196
+ it("plan() on empty dir + empty history returns []", async () => {
197
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
198
+ const pending = await runner.plan();
199
+ expect(pending).toEqual([]);
200
+ });
201
+
202
+ it("plan() returns pending migrations sorted by version, ignoring already-applied", async () => {
203
+ writeMigration(f.migrationsDir, "0001_one.sql", "CREATE TABLE a (id INTEGER);");
204
+ writeMigration(f.migrationsDir, "0002_two.sql", "CREATE TABLE b (id INTEGER);");
205
+ writeMigration(f.migrationsDir, "0003_three.sql", "CREATE TABLE c (id INTEGER);");
206
+
207
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
208
+ // Pre-seed history as if 0001 was already applied.
209
+ await runner.ensureHistoryTable();
210
+ await f.db`INSERT INTO "__mandu_migrations"
211
+ (version, filename, checksum, applied_at, execution_ms, success, installed_by)
212
+ VALUES (${"0001"}, ${"0001_one.sql"},
213
+ ${computeMigrationChecksum("CREATE TABLE a (id INTEGER);")},
214
+ ${new Date().toISOString()}, ${0}, ${1}, ${"test"})`;
215
+
216
+ const pending = await runner.plan();
217
+ expect(pending.map((p) => p.version)).toEqual(["0002", "0003"]);
218
+ });
219
+
220
+ it("plan() ignores non-.sql files silently", async () => {
221
+ writeMigration(f.migrationsDir, "0001_valid.sql", "CREATE TABLE t (id INTEGER);");
222
+ writeMigration(f.migrationsDir, "README.md", "# notes");
223
+ writeMigration(f.migrationsDir, "0002_also_valid.sql", "CREATE TABLE u (id INTEGER);");
224
+
225
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
226
+ const pending = await runner.plan();
227
+ expect(pending.map((p) => p.version)).toEqual(["0001", "0002"]);
228
+ });
229
+
230
+ it("plan() warns and skips .sql files that do not match NNNN_description.sql", async () => {
231
+ writeMigration(f.migrationsDir, "0001_ok.sql", "CREATE TABLE a (id INTEGER);");
232
+ writeMigration(f.migrationsDir, "not_a_migration.sql", "SELECT 1;");
233
+ writeMigration(f.migrationsDir, "also-bad.sql", "SELECT 1;");
234
+
235
+ // Capture the warn so the test output stays clean.
236
+ const warns: string[] = [];
237
+ const origWarn = console.warn;
238
+ console.warn = (...args: unknown[]) => {
239
+ warns.push(args.map(String).join(" "));
240
+ };
241
+ try {
242
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
243
+ const pending = await runner.plan();
244
+ expect(pending.map((p) => p.version)).toEqual(["0001"]);
245
+ expect(warns.some((m) => m.includes("not_a_migration.sql"))).toBe(true);
246
+ expect(warns.some((m) => m.includes("also-bad.sql"))).toBe(true);
247
+ } finally {
248
+ console.warn = origWarn;
249
+ }
250
+ });
251
+
252
+ it("plan() throws when two files share the same version prefix", async () => {
253
+ writeMigration(f.migrationsDir, "0001_first.sql", "SELECT 1;");
254
+ writeMigration(f.migrationsDir, "0001_conflict.sql", "SELECT 2;");
255
+
256
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
257
+ await expect(runner.plan()).rejects.toThrow(/Duplicate migration version/);
258
+ });
259
+
260
+ it("apply() happy path: applies all pending, writes history, plan() becomes empty", async () => {
261
+ writeMigration(
262
+ f.migrationsDir,
263
+ "0001_create_users.sql",
264
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
265
+ );
266
+ writeMigration(
267
+ f.migrationsDir,
268
+ "0002_create_posts.sql",
269
+ "CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER);",
270
+ );
271
+
272
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
273
+ const applied = await runner.apply();
274
+ expect(applied.map((a) => a.version)).toEqual(["0001", "0002"]);
275
+ expect(applied.every((a) => a.success === true)).toBe(true);
276
+
277
+ const afterPlan = await runner.plan();
278
+ expect(afterPlan).toEqual([]);
279
+
280
+ const history = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
281
+ expect(history).toHaveLength(2);
282
+ expect(history[0]!.version).toBe("0001");
283
+ expect(history[1]!.version).toBe("0002");
284
+
285
+ // Sanity — the migrations actually took effect.
286
+ const tables = await f.db<{ name: string }>`
287
+ SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name
288
+ `;
289
+ expect(tables.map((t) => t.name)).toEqual(
290
+ expect.arrayContaining(["posts", "users"]),
291
+ );
292
+ });
293
+
294
+ it("apply({ dryRun: true }) does NOT execute SQL and does NOT insert history", async () => {
295
+ writeMigration(
296
+ f.migrationsDir,
297
+ "0001_side_effect.sql",
298
+ "CREATE TABLE should_not_exist (id INTEGER);",
299
+ );
300
+
301
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
302
+ const preview = await runner.apply({ dryRun: true });
303
+ expect(preview).toHaveLength(1);
304
+ expect(preview[0]!.version).toBe("0001");
305
+ expect(preview[0]!.success).toBe(false); // dry-run marker
306
+
307
+ // Table must not exist and history must be empty.
308
+ const tables = await f.db<{ name: string }>`
309
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'should_not_exist'
310
+ `;
311
+ expect(tables).toEqual([]);
312
+
313
+ const history = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
314
+ expect(history).toEqual([]);
315
+ });
316
+
317
+ it("apply() on a file with a syntax error: rolls back, no history row, throws with filename", async () => {
318
+ writeMigration(
319
+ f.migrationsDir,
320
+ "0001_good.sql",
321
+ "CREATE TABLE good (id INTEGER);",
322
+ );
323
+ writeMigration(
324
+ f.migrationsDir,
325
+ "0002_bad.sql",
326
+ "CREATE TABLE bad (id INTEGER); INSERT INTO this_table_does_not_exist VALUES (1);",
327
+ );
328
+
329
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
330
+ let caught: unknown = null;
331
+ try {
332
+ await runner.apply();
333
+ } catch (e) {
334
+ caught = e;
335
+ }
336
+ expect(caught).toBeInstanceOf(Error);
337
+ expect((caught as Error).message).toMatch(/0002_bad\.sql/);
338
+
339
+ // 0001 succeeded, 0002 left no trace.
340
+ const history = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
341
+ expect(history.map((h) => h.version)).toEqual(["0001"]);
342
+
343
+ // The `bad` table from 0002 must NOT exist — tx rolled back.
344
+ const badTable = await f.db<{ name: string }>`
345
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'bad'
346
+ `;
347
+ expect(badTable).toEqual([]);
348
+
349
+ // The `good` table from 0001 IS there.
350
+ const goodTable = await f.db<{ name: string }>`
351
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'good'
352
+ `;
353
+ expect(goodTable).toHaveLength(1);
354
+ });
355
+
356
+ it("plan() does NOT include a file that has a history row, even if checksum mismatches", async () => {
357
+ const originalSql = "CREATE TABLE x (id INTEGER);";
358
+ writeMigration(f.migrationsDir, "0001_x.sql", originalSql);
359
+
360
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
361
+ await runner.ensureHistoryTable();
362
+
363
+ // Apply then rewrite the file to break the checksum.
364
+ await runner.apply();
365
+ writeMigration(f.migrationsDir, "0001_x.sql", "CREATE TABLE x (id INTEGER, new_col TEXT);");
366
+
367
+ const pending = await runner.plan();
368
+ expect(pending).toEqual([]); // history wins; file is not "pending"
369
+
370
+ // But status() surfaces the tamper.
371
+ const status = await runner.status();
372
+ expect(status.tampered).toHaveLength(1);
373
+ expect(status.tampered[0]!.filename).toBe("0001_x.sql");
374
+ });
375
+
376
+ it("status() reports tampered after file modification", async () => {
377
+ writeMigration(f.migrationsDir, "0001_init.sql", "CREATE TABLE t1 (id INTEGER);");
378
+
379
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
380
+ await runner.apply();
381
+
382
+ // Tamper: rewrite the migration content.
383
+ writeMigration(f.migrationsDir, "0001_init.sql", "CREATE TABLE t1 (id INTEGER, extra TEXT);");
384
+ // Force mtime bump so some filesystems update the stat promptly.
385
+ const newTime = new Date();
386
+ utimesSync(join(f.migrationsDir, "0001_init.sql"), newTime, newTime);
387
+
388
+ const status = await runner.status();
389
+ expect(status.tampered).toHaveLength(1);
390
+ expect(status.tampered[0]!.version).toBe("0001");
391
+ expect(status.tampered[0]!.storedChecksum).not.toBe(
392
+ status.tampered[0]!.currentChecksum,
393
+ );
394
+ });
395
+
396
+ it("apply() throws MigrationTamperedError when a prior row's file has been mutated", async () => {
397
+ writeMigration(f.migrationsDir, "0001_a.sql", "CREATE TABLE a (id INTEGER);");
398
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
399
+ await runner.apply();
400
+
401
+ // Mutate + add a new pending migration.
402
+ writeMigration(f.migrationsDir, "0001_a.sql", "CREATE TABLE a (id INTEGER, x TEXT);");
403
+ writeMigration(f.migrationsDir, "0002_b.sql", "CREATE TABLE b (id INTEGER);");
404
+
405
+ await expect(runner.apply()).rejects.toBeInstanceOf(MigrationTamperedError);
406
+ });
407
+
408
+ it("status() surfaces applied + pending + tampered + orphaned simultaneously", async () => {
409
+ writeMigration(f.migrationsDir, "0001_applied.sql", "CREATE TABLE a (id INTEGER);");
410
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
411
+ await runner.apply();
412
+
413
+ // Now introduce: (1) a pending file, (2) tamper on applied, (3) an
414
+ // orphaned history row pointing at a now-deleted version.
415
+ writeMigration(f.migrationsDir, "0002_pending.sql", "CREATE TABLE b (id INTEGER);");
416
+ writeMigration(f.migrationsDir, "0001_applied.sql", "CREATE TABLE a (id INTEGER, mod TEXT);");
417
+
418
+ // Insert an orphan history row directly.
419
+ await f.db`INSERT INTO "__mandu_migrations"
420
+ (version, filename, checksum, applied_at, execution_ms, success, installed_by)
421
+ VALUES (${"9999"}, ${"9999_deleted.sql"},
422
+ ${"deadbeef".repeat(8)}, ${new Date().toISOString()}, ${0}, ${1}, ${"test"})`;
423
+
424
+ const status = await runner.status();
425
+ expect(status.applied.map((a) => a.version).sort()).toEqual(["0001", "9999"]);
426
+ expect(status.pending.map((p) => p.version)).toEqual(["0002"]);
427
+ expect(status.tampered.map((t) => t.version)).toEqual(["0001"]);
428
+ expect(status.orphaned.map((o) => o.filename)).toEqual(["9999_deleted.sql"]);
429
+ });
430
+
431
+ it("apply() on multi-statement file (CREATE TABLE + CREATE INDEX) executes all statements", async () => {
432
+ writeMigration(
433
+ f.migrationsDir,
434
+ "0001_multi.sql",
435
+ `CREATE TABLE items (id INTEGER PRIMARY KEY, slug TEXT);
436
+ CREATE INDEX items_slug_idx ON items (slug);`,
437
+ );
438
+
439
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
440
+ const applied = await runner.apply();
441
+ expect(applied).toHaveLength(1);
442
+
443
+ const indexes = await f.db<{ name: string }>`
444
+ SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'items_slug_idx'
445
+ `;
446
+ expect(indexes).toHaveLength(1);
447
+ });
448
+
449
+ it("concurrent apply() calls: second runner waits / fails rather than clobbering", async () => {
450
+ writeMigration(
451
+ f.migrationsDir,
452
+ "0001_slow.sql",
453
+ "CREATE TABLE slow (id INTEGER);",
454
+ );
455
+
456
+ // Two runners, same DB handle. SQLite BEGIN IMMEDIATE on the same
457
+ // connection errors immediately for the second acquirer ("cannot
458
+ // start a transaction within a transaction"), which is exactly
459
+ // the serialisation behaviour we want — the second call fails fast
460
+ // rather than silently interleaving.
461
+ const runnerA = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
462
+ const runnerB = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
463
+
464
+ const results = await Promise.allSettled([runnerA.apply(), runnerB.apply()]);
465
+
466
+ const fulfilled = results.filter((r) => r.status === "fulfilled");
467
+ expect(fulfilled.length).toBeGreaterThanOrEqual(1);
468
+
469
+ // Final state: exactly one history row for 0001 (the other call
470
+ // either waited and found it applied, or errored mid-lock).
471
+ const history = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
472
+ expect(history.filter((h) => h.version === "0001")).toHaveLength(1);
473
+ });
474
+
475
+ it("dispose() releases held lock and is idempotent (no-op on second call)", async () => {
476
+ writeMigration(f.migrationsDir, "0001_init.sql", "CREATE TABLE d (id INTEGER);");
477
+
478
+ const runner = createMigrationRunner(f.db, {
479
+ migrationsDir: f.migrationsDir,
480
+ // "none" so we can safely call dispose() without depending on
481
+ // transaction state from BEGIN IMMEDIATE.
482
+ lockStrategy: "none",
483
+ });
484
+ await runner.apply();
485
+ await runner.dispose();
486
+ await runner.dispose(); // must not throw
487
+ expect(true).toBe(true);
488
+ });
489
+
490
+ it("first operation auto-runs ensureHistoryTable when user forgot", async () => {
491
+ writeMigration(f.migrationsDir, "0001_auto.sql", "CREATE TABLE auto (id INTEGER);");
492
+
493
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
494
+ // Skip ensureHistoryTable() — plan() should auto-initialise.
495
+ const pending = await runner.plan();
496
+ expect(pending.map((p) => p.version)).toEqual(["0001"]);
497
+
498
+ // History table now exists.
499
+ const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
500
+ expect(Array.isArray(rows)).toBe(true);
501
+ });
502
+
503
+ it("custom historyTable override flows through ensureHistoryTable/plan/apply/status", async () => {
504
+ writeMigration(f.migrationsDir, "0001_custom.sql", "CREATE TABLE c1 (id INTEGER);");
505
+
506
+ const runner = createMigrationRunner(f.db, {
507
+ migrationsDir: f.migrationsDir,
508
+ historyTable: "project_migrations",
509
+ });
510
+ await runner.apply();
511
+
512
+ // The default table is NOT created.
513
+ const defaultTbl = await f.db<{ name: string }>`
514
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__mandu_migrations'
515
+ `;
516
+ expect(defaultTbl).toEqual([]);
517
+
518
+ // The custom one IS.
519
+ const customTbl = await f.db<{ name: string }>`
520
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'project_migrations'
521
+ `;
522
+ expect(customTbl).toHaveLength(1);
523
+
524
+ // And reads use the override.
525
+ const rows = await readAllHistory(f.db, "project_migrations");
526
+ expect(rows.map((r) => r.version)).toEqual(["0001"]);
527
+ });
528
+
529
+ it("installed_by defaults to MANDU_MIGRATION_USER env var when set", async () => {
530
+ writeMigration(f.migrationsDir, "0001_who.sql", "CREATE TABLE who (id INTEGER);");
531
+
532
+ const prev = process.env.MANDU_MIGRATION_USER;
533
+ process.env.MANDU_MIGRATION_USER = "ci-bot";
534
+ try {
535
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
536
+ await runner.apply();
537
+ const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
538
+ expect(rows[0]!.installed_by).toBe("ci-bot");
539
+ } finally {
540
+ if (prev === undefined) delete process.env.MANDU_MIGRATION_USER;
541
+ else process.env.MANDU_MIGRATION_USER = prev;
542
+ }
543
+ });
544
+
545
+ it("installed_by falls back to 'mandu' when the env var is unset", async () => {
546
+ writeMigration(f.migrationsDir, "0001_fallback.sql", "CREATE TABLE f (id INTEGER);");
547
+
548
+ const prev = process.env.MANDU_MIGRATION_USER;
549
+ delete process.env.MANDU_MIGRATION_USER;
550
+ try {
551
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
552
+ await runner.apply();
553
+ const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
554
+ expect(rows[0]!.installed_by).toBe("mandu");
555
+ } finally {
556
+ if (prev !== undefined) process.env.MANDU_MIGRATION_USER = prev;
557
+ }
558
+ });
559
+
560
+ it("plan() returns freshly-computed checksums (does NOT cache across calls)", async () => {
561
+ writeMigration(f.migrationsDir, "0001_v.sql", "CREATE TABLE v (id INTEGER);");
562
+
563
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
564
+ const plan1 = await runner.plan();
565
+ const checksum1 = plan1[0]!.checksum;
566
+
567
+ writeMigration(f.migrationsDir, "0001_v.sql", "CREATE TABLE v (id INTEGER, more TEXT);");
568
+ const plan2 = await runner.plan();
569
+ const checksum2 = plan2[0]!.checksum;
570
+
571
+ expect(checksum1).not.toBe(checksum2);
572
+ });
573
+
574
+ it("apply() on an empty migrations directory returns [] without errors", async () => {
575
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
576
+ const applied = await runner.apply();
577
+ expect(applied).toEqual([]);
578
+ });
579
+
580
+ it("apply() is a no-op when everything is already applied", async () => {
581
+ writeMigration(f.migrationsDir, "0001_noop.sql", "CREATE TABLE n (id INTEGER);");
582
+
583
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
584
+ const first = await runner.apply();
585
+ expect(first).toHaveLength(1);
586
+ const second = await runner.apply();
587
+ expect(second).toEqual([]);
588
+ });
589
+
590
+ it("applyTimeoutMs of 1ms aborts a file whose cumulative SQL runs longer", async () => {
591
+ // Force a timeout by using `applyTimeoutMs = 1` and a migration with
592
+ // enough statements that cumulative execution reliably crosses 1 ms
593
+ // on any hardware. The previous 3-statement version (total ~0.3-1.5
594
+ // ms on fast in-memory SQLite) could finish within the 1 ms budget
595
+ // and leave the expected `MigrationTimeoutError` unthrown on ~40 %
596
+ // of isolated runs. 120 small `CREATE TABLE` statements clear 1 ms
597
+ // by a wide margin on every target — observed ~2-8 ms on the
598
+ // current test boxes.
599
+ const statementCount = 120;
600
+ const sqlBlock = Array.from(
601
+ { length: statementCount },
602
+ (_, i) => `CREATE TABLE slow${i} (id INTEGER);`,
603
+ ).join("\n");
604
+ writeMigration(f.migrationsDir, "0001_timeout.sql", sqlBlock);
605
+
606
+ const runner = createMigrationRunner(f.db, {
607
+ migrationsDir: f.migrationsDir,
608
+ applyTimeoutMs: 1,
609
+ });
610
+
611
+ let err: unknown = null;
612
+ try {
613
+ await runner.apply();
614
+ } catch (e) {
615
+ err = e;
616
+ }
617
+ expect(err).toBeInstanceOf(MigrationTimeoutError);
618
+ expect((err as MigrationTimeoutError).filename).toBe("0001_timeout.sql");
619
+
620
+ // No history row for the timed-out migration.
621
+ const rows = await readAllHistory(f.db, DEFAULT_HISTORY_TABLE);
622
+ expect(rows).toEqual([]);
623
+
624
+ // Tables rolled back.
625
+ const tables = await f.db<{ name: string }>`
626
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'slow%'
627
+ `;
628
+ expect(tables).toEqual([]);
629
+ });
630
+
631
+ it("MigrationTamperedError exposes filename + both checksums", async () => {
632
+ writeMigration(f.migrationsDir, "0001_t.sql", "CREATE TABLE t (id INTEGER);");
633
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
634
+ await runner.apply();
635
+
636
+ writeMigration(f.migrationsDir, "0001_t.sql", "CREATE TABLE t (id INTEGER, ex TEXT);");
637
+
638
+ let err: unknown = null;
639
+ try {
640
+ await runner.apply();
641
+ } catch (e) {
642
+ err = e;
643
+ }
644
+ expect(err).toBeInstanceOf(MigrationTamperedError);
645
+ const mte = err as MigrationTamperedError;
646
+ expect(mte.filename).toBe("0001_t.sql");
647
+ expect(mte.storedChecksum).toMatch(/^[0-9a-f]{64}$/);
648
+ expect(mte.currentChecksum).toMatch(/^[0-9a-f]{64}$/);
649
+ expect(mte.storedChecksum).not.toBe(mte.currentChecksum);
650
+ });
651
+
652
+ it("applied migrations carry strict checksum + execution_ms + appliedAt values", async () => {
653
+ writeMigration(f.migrationsDir, "0001_x.sql", "CREATE TABLE x (id INTEGER);");
654
+
655
+ const runner = createMigrationRunner(f.db, { migrationsDir: f.migrationsDir });
656
+ const applied = await runner.apply();
657
+ expect(applied).toHaveLength(1);
658
+ const a = applied[0]!;
659
+ expect(a.checksum).toMatch(/^[0-9a-f]{64}$/);
660
+ expect(typeof a.executionMs).toBe("number");
661
+ expect(a.executionMs).toBeGreaterThanOrEqual(0);
662
+ expect(a.appliedAt).toBeInstanceOf(Date);
663
+ expect(a.success).toBe(true);
664
+ });
665
+ });