@boxctl/migrate 1.1.3 → 2.0.1

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/OVERVIEW.md ADDED
@@ -0,0 +1,79 @@
1
+ # @boxctl/migrate — Overview
2
+
3
+ ## What this is
4
+
5
+ A minimal, roll-forward only SQL migration tool for MySQL/MariaDB accessed over a Unix socket. Built for a specific production setup — a rootless Podman-based server stack where direct socket access is available and simplicity is more valuable than features.
6
+
7
+ If you need rollbacks, multi-database support, or a general-purpose migration framework, use something else. This tool does one thing.
8
+
9
+ ---
10
+
11
+ ## Philosophy
12
+
13
+ Roll-forward only. Migrations are append-only history. If something goes wrong, you write a new migration that fixes it — you do not rewind. This is not a limitation, it is a deliberate constraint that keeps the mental model simple and the tool small.
14
+
15
+ The loose baseline is golang-migrate. Not in implementation, but in behavior and honesty about what it does. The dirty flag mechanism is taken directly from golang-migrate's approach: record intent before acting, not outcome after.
16
+
17
+ ---
18
+
19
+ ## How migrations work
20
+
21
+ Each migration is a single `.sql` file. The filename is a millisecond-precision UTC timestamp followed by a sanitized name. Lexicographic sort on the filename is the execution order — no separate ordering metadata, no manifest file.
22
+
23
+ When `up` runs a migration it does three things in order:
24
+
25
+ 1. Insert a record into the `migrations` table with `dirty = true`
26
+ 2. Execute the SQL
27
+ 3. On success, update the record to `dirty = false`
28
+
29
+ This order matters. If the process is killed mid-migration, or the SQL fails, the record exists and is dirty. Nothing is silently lost. The dirty flag is evidence that something went wrong, not an absence of evidence that something succeeded.
30
+
31
+ ---
32
+
33
+ ## The dirty flag
34
+
35
+ A dirty migration means the schema is in an unknown state. `up` will refuse to run while any dirty records exist. This is intentional — running further migrations on top of an unknown state compounds the problem.
36
+
37
+ Resolution is manual. The developer inspects what happened, fixes the schema directly if needed, then clears or removes the dirty record. The tool does not try to be clever about recovery because recovery from a failed DDL migration in MySQL is inherently a human problem.
38
+
39
+ ---
40
+
41
+ ## Why no down command
42
+
43
+ MySQL and MariaDB do not support transactional DDL. `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE` and similar statements are auto-committed the moment they execute. A rollback command gives a false sense of safety — you can run a `.down.sql` file but you are not rolling back, you are applying a new forward change that happens to be the inverse. That is just another migration.
44
+
45
+ Rolling forward with a corrective migration is honest about what is actually happening. The down command was removed to eliminate the illusion.
46
+
47
+ ---
48
+
49
+ ## Why no checksums
50
+
51
+ Checksums on applied migration files detect modifications after the fact, but in a roll-forward only system there is nothing actionable to do with that information. You cannot re-apply or roll back. A modified applied migration is a developer problem to reason about — the tool blocking `up` on a comment edit adds friction with no safety benefit.
52
+
53
+ ---
54
+
55
+ ## The migrations table
56
+
57
+ Five columns. `id` for ordering within the same second. `name` as the unique identifier matching the filename without extension. `dirty` as the state flag. `applied_at` as the timestamp. Nothing else.
58
+
59
+ The table is created automatically on first run of `up` or `status`. No separate setup step required.
60
+
61
+ ---
62
+
63
+ ## Environment and connectivity
64
+
65
+ Connection details are read from an env file. The default is `.env` in the working directory. A different file can be specified with `--env=<file>`, which makes it practical to use the same tool across development, staging, and production without changing anything except which env file is loaded.
66
+
67
+ Required variables are `DB_SOCK`, `DB_NAME`, `DB_USER`, and `DB_PASS`. Connection is over Unix socket only — no TCP, no host, no port. This matches the production setup this tool was built for.
68
+
69
+ ---
70
+
71
+ ## What the create command does and does not do
72
+
73
+ `create` is filesystem only. It does not touch the database or read the env file. It creates a single `.sql` file with a millisecond-precision timestamp prefix and a two-line comment header. The `refs:` line in the header is a convention for documenting dependencies between migrations — which other migration files this one relates to or builds on. It is not enforced by the tool.
74
+
75
+ ---
76
+
77
+ ## Scope
78
+
79
+ Three commands. One direction. One database driver. One connection method. The tool is not intended to grow beyond this.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @boxctl/migrate
2
2
 
3
- Minimal SQL migration tool for MySQL/MariaDB over Unix socket.
3
+ Minimal roll-forward SQL migration tool for MySQL/MariaDB over Unix socket.
4
4
 
5
5
  > **Note:** This tool was created for very specific needs. If it doesn't fit yours, don't use it.
6
6
 
@@ -12,9 +12,8 @@ npx @boxctl/migrate <command>
12
12
 
13
13
  ### Commands
14
14
 
15
- - `create <name>` — Create a new migration file pair
15
+ - `create <name>` — Create a new migration file
16
16
  - `up` — Run pending migrations
17
- - `down [n]` — Cleanup last n migrations (default: 1)
18
17
  - `status` — Show migration status
19
18
 
20
19
  ### Options
@@ -27,7 +26,7 @@ npx @boxctl/migrate <command>
27
26
  Create a `.env` file in your project root:
28
27
 
29
28
  ```
30
- DB_SOCKET=/var/run/mysqld/mysqld.sock
29
+ DB_SOCK=/var/run/mysqld/mysqld.sock
31
30
  DB_NAME=your_database
32
31
  DB_USER=username
33
32
  DB_PASS=********
@@ -41,12 +40,31 @@ npx @boxctl/migrate up --env=.env.staging
41
40
 
42
41
  ## Migrations
43
42
 
44
- Migrations are stored in `./migrations/` directory with two files per migration:
43
+ Migrations are stored in `./migrations/` as single `.sql` files:
45
44
 
46
- - `*.up.sql` — Migration to apply
47
- - `*.down.sql` — Cleanup script (manual inverse, not automatic rollback)
45
+ - `YYYYMMDDHHmmssSSS_name.sql` — Migration to apply
48
46
 
49
- > **Important:** `.down.sql` is manual cleanup, not automatic database rollback. Since MySQL DDL cannot be rolled back, you must write the inverse operation yourself.
47
+ Each file is created with a header:
48
+
49
+ ```sql
50
+ -- 20260317120000000_create_users.sql
51
+ -- refs:
52
+ ```
53
+
54
+ Use `refs:` to document any other migration files this one depends on or modifies.
55
+
56
+ > **Important:** This is a roll-forward only system. There is no down command. Write migrations accordingly.
57
+
58
+ ## How it works
59
+
60
+ For each pending migration `up` will:
61
+
62
+ 1. Insert a record with `dirty = true`
63
+ 2. Execute the SQL
64
+ 3. On success — update to `dirty = false`
65
+ 4. On failure — leave as `dirty = true` and stop
66
+
67
+ A dirty migration means something went wrong. Fix the issue manually then resolve the dirty flag in the database before running `up` again.
50
68
 
51
69
  ## Requirements
52
70
 
package/migrate.js CHANGED
@@ -6,9 +6,8 @@ if (command === "--help" || command === "-h" || !command) {
6
6
  console.log(" Usage: migrate <command> [options]");
7
7
  console.log("");
8
8
  console.log(" Commands:");
9
- console.log(" create <name> Create a new migration file pair");
9
+ console.log(" create <name> Create a new migration file");
10
10
  console.log(" up Run all pending migrations");
11
- console.log(" down [n] Cleanup last n migrations (default: 1)");
12
11
  console.log(" status Show applied and pending migrations");
13
12
  console.log("");
14
13
  console.log(" Options:");
@@ -20,9 +19,8 @@ if (command === "--help" || command === "-h" || !command) {
20
19
 
21
20
  import { create } from "./src/commands/create.js";
22
21
  import { up } from "./src/commands/up.js";
23
- import { down } from "./src/commands/down.js";
24
22
  import { status } from "./src/commands/status.js";
25
- import getDb from "./src/db.js";
23
+ import { closeDb } from "./src/db.js";
26
24
 
27
25
  async function main() {
28
26
  switch (command) {
@@ -34,27 +32,20 @@ async function main() {
34
32
  await up();
35
33
  break;
36
34
  }
37
- case "down": {
38
- await down(args[0] ?? 1);
39
- break;
40
- }
41
35
  case "status": {
42
36
  await status();
43
37
  break;
44
38
  }
39
+ default:
40
+ throw new Error(`Unknown command: ${command}`);
45
41
  }
46
42
  }
47
43
 
48
44
  main()
49
45
  .catch((err) => {
50
- console.error("Unexpected error:", err.message);
46
+ console.error(err.message);
51
47
  process.exit(1);
52
48
  })
53
49
  .finally(async () => {
54
- try {
55
- const db = await getDb();
56
- await db.end();
57
- } catch {
58
- // connection may already be closed
59
- }
50
+ await closeDb();
60
51
  });
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@boxctl/migrate",
3
- "version": "1.1.3",
4
- "description": "Minimal SQL migration tool for MySQL/MariaDB over Unix socket",
3
+ "version": "2.0.1",
4
+ "license": "MIT",
5
+ "description": "Minimal roll-forward SQL migration tool for MySQL/MariaDB over Unix socket.",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "migrate": "./migrate.js"
@@ -6,11 +6,9 @@ const MIGRATIONS_DIR = "./migrations";
6
6
 
7
7
  export async function create(name) {
8
8
  if (!name || name.trim() === "") {
9
- console.error("Usage: migrate create <name>");
10
- process.exit(1);
9
+ throw new Error("Usage: migrate create <name>");
11
10
  }
12
11
 
13
- // sanitize name: lowercase, spaces to underscores, strip special chars
14
12
  const safeName = name
15
13
  .trim()
16
14
  .toLowerCase()
@@ -18,34 +16,33 @@ export async function create(name) {
18
16
  .replace(/[^a-z0-9_]/g, "");
19
17
 
20
18
  if (safeName === "") {
21
- console.error("Migration name is invalid after sanitization.");
22
- process.exit(1);
19
+ throw new Error("Migration name is invalid after sanitization.");
23
20
  }
24
21
 
25
22
  const now = new Date();
26
- const timestamp = now
27
- .toISOString()
28
- .replace(/[-T:Z]/g, "")
29
- .replace(".", "")
30
- .slice(0, 17);
23
+ const pad = (n, l = 2) => String(n).padStart(l, "0");
24
+ const timestamp =
25
+ now.getUTCFullYear() +
26
+ pad(now.getUTCMonth() + 1) +
27
+ pad(now.getUTCDate()) +
28
+ pad(now.getUTCHours()) +
29
+ pad(now.getUTCMinutes()) +
30
+ pad(now.getUTCSeconds()) +
31
+ pad(now.getUTCMilliseconds(), 3);
32
+
31
33
  const baseName = `${timestamp}_${safeName}`;
32
34
 
33
35
  if (!existsSync(MIGRATIONS_DIR)) {
34
36
  mkdirSync(MIGRATIONS_DIR, { recursive: true });
35
37
  }
36
38
 
37
- const upFile = join(MIGRATIONS_DIR, `${baseName}.up.sql`);
38
- const downFile = join(MIGRATIONS_DIR, `${baseName}.down.sql`);
39
+ const file = join(MIGRATIONS_DIR, `${baseName}.sql`);
39
40
 
40
- if (existsSync(upFile) || existsSync(downFile)) {
41
- console.error(`Migration files for "${baseName}" already exist.`);
42
- process.exit(1);
41
+ if (existsSync(file)) {
42
+ throw new Error(`Migration file "${baseName}.sql" already exists.`);
43
43
  }
44
44
 
45
- writeFileSync(upFile, `-- migrate up: ${safeName}\n`);
46
- writeFileSync(downFile, `-- migrate down: ${safeName}\n`);
45
+ writeFileSync(file, `-- ${baseName}.sql\n-- refs:\n`);
47
46
 
48
- console.log(`Created:`);
49
- console.log(` ${upFile}`);
50
- console.log(` ${downFile}`);
47
+ console.log(`Created: ${file}`);
51
48
  }
@@ -1,70 +1,80 @@
1
1
  // src/commands/status.js
2
2
  import { readdirSync, existsSync } from "fs";
3
- import { join } from "path";
4
3
  import getDb from "../db.js";
5
- import { bootstrap } from "../bootstrap.js";
6
4
 
7
5
  const MIGRATIONS_DIR = "./migrations";
8
6
 
7
+ async function bootstrap(db) {
8
+ await db.query(`
9
+ CREATE TABLE IF NOT EXISTS migrations (
10
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
11
+ name VARCHAR(255) NOT NULL UNIQUE,
12
+ dirty BOOLEAN DEFAULT FALSE,
13
+ applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
14
+ )
15
+ `);
16
+ }
17
+
18
+ function formatDate(d) {
19
+ return new Date(d).toISOString().replace("T", " ").slice(0, 19);
20
+ }
21
+
9
22
  export async function status() {
10
23
  const db = await getDb();
11
- await bootstrap();
24
+ await bootstrap(db);
12
25
 
13
- const [rows] = await db.query(
14
- "SELECT name, ran_at FROM migrations ORDER BY ran_at ASC, id ASC",
26
+ const [dbRows] = await db.query(
27
+ "SELECT name, dirty, applied_at FROM migrations ORDER BY applied_at ASC, id ASC",
15
28
  );
16
29
 
17
- const applied = new Map(rows.map((r) => [r.name, r.ran_at]));
30
+ const dbMap = new Map(dbRows.map((r) => [r.name, r]));
18
31
 
19
32
  const files = existsSync(MIGRATIONS_DIR)
20
33
  ? readdirSync(MIGRATIONS_DIR)
21
- .filter((f) => f.endsWith(".up.sql"))
34
+ .filter((f) => f.endsWith(".sql"))
22
35
  .sort()
23
- .map((f) => f.replace(".up.sql", ""))
36
+ .map((f) => f.replace(".sql", ""))
24
37
  : [];
25
38
 
26
- // migrations on disk but not in db
27
- const pending = files.filter((name) => !applied.has(name));
28
-
29
- // migrations in db but no file on disk (deleted/lost)
30
- const missing = [...applied.keys()].filter((name) => !files.includes(name));
39
+ const fileSet = new Set(files);
40
+ const allNames = [...new Set([...files, ...dbMap.keys()])];
31
41
 
32
- if (applied.size === 0 && pending.length === 0) {
42
+ if (allNames.length === 0) {
33
43
  console.log("No migrations found.");
34
44
  return;
35
45
  }
36
46
 
37
- const PAD = 42;
47
+ const dirty = dbRows.filter((r) => r.dirty);
38
48
 
39
- console.log("");
40
- console.log(` ${"Migration".padEnd(PAD)} ${"Status".padEnd(12)} Ran At`);
41
- console.log(` ${"─".repeat(PAD)} ${"─".repeat(12)} ${"".repeat(20)}`);
42
-
43
- for (const name of files) {
44
- if (applied.has(name)) {
45
- const ranAt = new Date(applied.get(name))
46
- .toISOString()
47
- .replace("T", " ")
48
- .slice(0, 19);
49
- console.log(
50
- ` ${name.padEnd(PAD)} ${"applied".padEnd(12)} ${ranAt}`,
51
- );
52
- } else {
53
- console.log(` ${name.padEnd(PAD)} ${"pending".padEnd(12)}`);
54
- }
49
+ if (dirty.length > 0) {
50
+ console.log("\n ! DIRTY MIGRATIONS\n");
51
+ console.table(dirty.map((r) => ({ Migration: r.name, "Applied At": formatDate(r.applied_at) })));
55
52
  }
56
53
 
57
- for (const name of missing) {
58
- const ranAt = new Date(applied.get(name))
59
- .toISOString()
60
- .replace("T", " ")
61
- .slice(0, 19);
62
- console.log(` ${name.padEnd(PAD)} ${"! missing".padEnd(12)} ${ranAt}`);
63
- }
54
+ const rows = allNames.map((name) => {
55
+ const rec = dbMap.get(name);
56
+ if (!rec) return { Migration: name, Status: "pending", "Applied At": "" };
57
+ if (rec.dirty) return { Migration: name, Status: "dirty", "Applied At": formatDate(rec.applied_at) };
58
+ if (!fileSet.has(name)) return { Migration: name, Status: "missing", "Applied At": formatDate(rec.applied_at) };
59
+ return { Migration: name, Status: "applied", "Applied At": formatDate(rec.applied_at) };
60
+ });
64
61
 
65
62
  console.log("");
66
- console.log(
67
- ` ${applied.size} applied, ${pending.length} pending${missing.length > 0 ? `, ${missing.length} missing file(s)` : ""}`,
68
- );
69
- console.log("");
63
+ console.table(rows);
64
+
65
+ const counts = {
66
+ applied: rows.filter((r) => r.Status === "applied").length,
67
+ pending: rows.filter((r) => r.Status === "pending").length,
68
+ dirty: dirty.length,
69
+ missing: rows.filter((r) => r.Status === "missing").length,
70
+ };
71
+
72
+ const summary = [
73
+ `${counts.applied} applied`,
74
+ `${counts.pending} pending`,
75
+ ...(counts.dirty > 0 ? [`${counts.dirty} dirty`] : []),
76
+ ...(counts.missing > 0 ? [`${counts.missing} missing`] : []),
77
+ ].join(", ");
78
+
79
+ console.log(` ${summary}\n`);
70
80
  }
@@ -2,30 +2,37 @@
2
2
  import { readdirSync, readFileSync, existsSync } from "fs";
3
3
  import { join } from "path";
4
4
  import getDb from "../db.js";
5
- import { bootstrap } from "../bootstrap.js";
6
5
 
7
6
  const MIGRATIONS_DIR = "./migrations";
8
7
 
9
- function isSqlEmpty(sql) {
10
- const lines = sql.split("\n");
11
- return lines.every((line) => {
12
- const trimmed = line.trim();
13
- return trimmed === "" || trimmed.startsWith("--");
14
- });
8
+ async function bootstrap(db) {
9
+ await db.query(`
10
+ CREATE TABLE IF NOT EXISTS migrations (
11
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
12
+ name VARCHAR(255) NOT NULL UNIQUE,
13
+ dirty BOOLEAN DEFAULT FALSE,
14
+ applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
15
+ )
16
+ `);
15
17
  }
16
18
 
17
19
  export async function up() {
18
20
  const db = await getDb();
19
- await bootstrap();
21
+ await bootstrap(db);
22
+
23
+ const [dirtyRows] = await db.query("SELECT name FROM migrations WHERE dirty = TRUE");
24
+ if (dirtyRows.length > 0) {
25
+ const names = dirtyRows.map((r) => ` - ${r.name}`).join("\n");
26
+ throw new Error(`Dirty migrations detected, resolve before running up:\n${names}`);
27
+ }
20
28
 
21
29
  if (!existsSync(MIGRATIONS_DIR)) {
22
30
  console.log("No migrations directory found. Nothing to run.");
23
31
  return;
24
32
  }
25
33
 
26
- // get all .up.sql files, sorted ascending by filename (timestamp prefix ensures order)
27
34
  const files = readdirSync(MIGRATIONS_DIR)
28
- .filter((f) => f.endsWith(".up.sql"))
35
+ .filter((f) => f.endsWith(".sql"))
29
36
  .sort();
30
37
 
31
38
  if (files.length === 0) {
@@ -33,12 +40,11 @@ export async function up() {
33
40
  return;
34
41
  }
35
42
 
36
- // get already applied migrations from db
37
- const [rows] = await db.query("SELECT name FROM migrations");
43
+ const [rows] = await db.query("SELECT name FROM migrations WHERE dirty = FALSE");
38
44
  const applied = new Set(rows.map((r) => r.name));
39
45
 
40
46
  const pending = files
41
- .map((f) => ({ file: f, name: f.replace(".up.sql", "") }))
47
+ .map((f) => ({ file: f, name: f.replace(".sql", "") }))
42
48
  .filter((m) => !applied.has(m.name));
43
49
 
44
50
  if (pending.length === 0) {
@@ -49,37 +55,17 @@ export async function up() {
49
55
  console.log(`Running ${pending.length} migration(s)...`);
50
56
 
51
57
  for (const migration of pending) {
52
- const downFile = join(MIGRATIONS_DIR, `${migration.name}.down.sql`);
58
+ const sql = readFileSync(join(MIGRATIONS_DIR, migration.file), "utf8").trim();
53
59
 
54
- if (!existsSync(downFile)) {
55
- console.error(`Missing down file for migration: ${migration.name}`);
56
- console.error(`Expected: ${downFile}`);
57
- process.exit(1);
58
- }
60
+ console.log(` Applying: ${migration.name}`);
59
61
 
60
- const sql = readFileSync(
61
- join(MIGRATIONS_DIR, migration.file),
62
- "utf8",
63
- ).trim();
64
-
65
- if (isSqlEmpty(sql)) {
66
- console.error(
67
- `Migration ${migration.name} contains no SQL (only comments/whitespace).`,
68
- );
69
- console.error(`Add SQL or delete the migration files.`);
70
- process.exit(1);
71
- }
62
+ await db.query("INSERT INTO migrations (name, dirty) VALUES (?, TRUE)", [migration.name]);
72
63
 
73
- console.log(` Applying: ${migration.name}`);
74
64
  try {
75
65
  await db.query(sql);
76
- await db.query("INSERT INTO migrations (name) VALUES (?)", [
77
- migration.name,
78
- ]);
66
+ await db.query("UPDATE migrations SET dirty = FALSE WHERE name = ?", [migration.name]);
79
67
  } catch (err) {
80
- console.error(`Migration failed: ${migration.name}`);
81
- console.error(err.message);
82
- process.exit(1);
68
+ throw new Error(`Migration failed: ${migration.name}\n${err.message}`);
83
69
  }
84
70
  }
85
71
 
package/src/db.js CHANGED
@@ -17,13 +17,18 @@ export default async function getDb() {
17
17
  multipleStatements: true,
18
18
  });
19
19
  } catch (err) {
20
- console.error(`Failed to connect to database:`);
21
- console.error(` Socket: ${env.DB_SOCK}`);
22
- console.error(` Database: ${env.DB_NAME}`);
23
- console.error(` User: ${env.DB_USER}`);
24
- console.error(` Error: ${err.message}`);
20
+ console.error(`Failed to connect to database: ${err.message}`);
25
21
  process.exit(1);
26
22
  }
27
23
 
28
24
  return connection;
29
25
  }
26
+
27
+ export async function closeDb() {
28
+ if (!connection) return;
29
+ try {
30
+ await connection.end();
31
+ } catch {
32
+ // connection may already be closed
33
+ }
34
+ }
package/src/env.js CHANGED
@@ -47,9 +47,7 @@ function loadEnv() {
47
47
  const missing = required.filter((k) => !parsed[k]);
48
48
 
49
49
  if (missing.length > 0) {
50
- console.error(
51
- `Missing required variables in ${envFile}: ${missing.join(", ")}`,
52
- );
50
+ console.error(`Missing required variables in ${envFile}: ${missing.join(", ")}`);
53
51
  process.exit(1);
54
52
  }
55
53
 
package/src/bootstrap.js DELETED
@@ -1,19 +0,0 @@
1
- // src/bootstrap.js
2
- import getDb from "./db.js";
3
-
4
- export async function bootstrap() {
5
- const db = await getDb();
6
- try {
7
- await db.query(`
8
- CREATE TABLE IF NOT EXISTS migrations (
9
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
10
- name VARCHAR(255) NOT NULL UNIQUE,
11
- ran_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
12
- )
13
- `);
14
- } catch (err) {
15
- console.error("Failed to initialize migrations table:");
16
- console.error(err.message);
17
- process.exit(1);
18
- }
19
- }
@@ -1,97 +0,0 @@
1
- // src/commands/down.js
2
- import { readFileSync, existsSync } from "fs";
3
- import { join } from "path";
4
- import { createInterface } from "readline";
5
- import getDb from "../db.js";
6
- import { bootstrap } from "../bootstrap.js";
7
-
8
- const MIGRATIONS_DIR = "./migrations";
9
-
10
- function prompt(question) {
11
- return new Promise((resolve) => {
12
- const rl = createInterface({
13
- input: process.stdin,
14
- output: process.stdout,
15
- });
16
-
17
- const onSigint = () => {
18
- rl.close();
19
- console.log("\nAborted.");
20
- process.exit(0);
21
- };
22
-
23
- process.on("SIGINT", onSigint);
24
-
25
- rl.question(question, (answer) => {
26
- process.removeListener("SIGINT", onSigint);
27
- rl.close();
28
- resolve(answer.trim().toLowerCase());
29
- });
30
- });
31
- }
32
-
33
- export async function down(n = 1) {
34
- const db = await getDb();
35
- const count = parseInt(n, 10);
36
-
37
- if (isNaN(count) || count < 1) {
38
- console.error(
39
- "Usage: migrate down [n] — n must be a positive integer",
40
- );
41
- process.exit(1);
42
- }
43
-
44
- await bootstrap();
45
-
46
- // get applied migrations, most recent first
47
- const [rows] = await db.query(
48
- "SELECT name FROM migrations ORDER BY ran_at DESC, id DESC LIMIT ?",
49
- [count],
50
- );
51
-
52
- if (rows.length === 0) {
53
- console.log("Nothing to cleanup. No migrations have been applied.");
54
- return;
55
- }
56
-
57
- const toRevert = rows.map((r) => r.name);
58
-
59
- console.log(`About to cleanup ${toRevert.length} migration(s):`);
60
- toRevert.forEach((name) => console.log(` - ${name}`));
61
-
62
- const answer = await prompt("Are you sure? [y/N]: ");
63
-
64
- if (answer !== "y") {
65
- console.log("Aborted.");
66
- return;
67
- }
68
-
69
- for (const name of toRevert) {
70
- const downFile = join(MIGRATIONS_DIR, `${name}.down.sql`);
71
-
72
- if (!existsSync(downFile)) {
73
- console.error(`Down file not found for migration: ${name}`);
74
- console.error(`Expected: ${downFile}`);
75
- process.exit(1);
76
- }
77
-
78
- const sql = readFileSync(downFile, "utf8").trim();
79
-
80
- if (!sql || sql.startsWith("--")) {
81
- console.error(`Down file is empty for migration: ${name}`);
82
- process.exit(1);
83
- }
84
-
85
- console.log(` Cleaning up: ${name}`);
86
- try {
87
- await db.query(sql);
88
- await db.query("DELETE FROM migrations WHERE name = ?", [name]);
89
- } catch (err) {
90
- console.error(`Cleanup failed: ${name}`);
91
- console.error(err.message);
92
- process.exit(1);
93
- }
94
- }
95
-
96
- console.log("Cleanup complete.");
97
- }