@boxctl/migrate 1.1.2 → 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/LICENSE +21 -674
- package/OVERVIEW.md +79 -0
- package/README.md +42 -4
- package/migrate.js +23 -30
- package/package.json +3 -2
- package/src/commands/create.js +17 -20
- package/src/commands/status.js +54 -43
- package/src/commands/up.js +26 -39
- package/src/db.js +27 -17
- package/src/env.js +30 -10
- package/src/bootstrap.js +0 -18
- package/src/commands/down.js +0 -101
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,22 +12,60 @@ npx @boxctl/migrate <command>
|
|
|
12
12
|
|
|
13
13
|
### Commands
|
|
14
14
|
|
|
15
|
-
- `create <name>` — Create a new migration
|
|
15
|
+
- `create <name>` — Create a new migration file
|
|
16
16
|
- `up` — Run pending migrations
|
|
17
|
-
- `down` — Rollback the last migration
|
|
18
17
|
- `status` — Show migration status
|
|
19
18
|
|
|
19
|
+
### Options
|
|
20
|
+
|
|
21
|
+
- `--env=<file>` — Specify env file (default: .env)
|
|
22
|
+
- `--help` — Show help message
|
|
23
|
+
|
|
20
24
|
## Setup
|
|
21
25
|
|
|
22
26
|
Create a `.env` file in your project root:
|
|
23
27
|
|
|
24
28
|
```
|
|
25
|
-
|
|
29
|
+
DB_SOCK=/var/run/mysqld/mysqld.sock
|
|
26
30
|
DB_NAME=your_database
|
|
27
31
|
DB_USER=username
|
|
28
32
|
DB_PASS=********
|
|
29
33
|
```
|
|
30
34
|
|
|
35
|
+
Use a different env file:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx @boxctl/migrate up --env=.env.staging
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Migrations
|
|
42
|
+
|
|
43
|
+
Migrations are stored in `./migrations/` as single `.sql` files:
|
|
44
|
+
|
|
45
|
+
- `YYYYMMDDHHmmssSSS_name.sql` — Migration to apply
|
|
46
|
+
|
|
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.
|
|
68
|
+
|
|
31
69
|
## Requirements
|
|
32
70
|
|
|
33
71
|
- Node.js >= 24
|
package/migrate.js
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
// migrate.js
|
|
2
|
+
const [, , command, ...args] = process.argv;
|
|
3
|
+
|
|
4
|
+
if (command === "--help" || command === "-h" || !command) {
|
|
5
|
+
console.log("");
|
|
6
|
+
console.log(" Usage: migrate <command> [options]");
|
|
7
|
+
console.log("");
|
|
8
|
+
console.log(" Commands:");
|
|
9
|
+
console.log(" create <name> Create a new migration file");
|
|
10
|
+
console.log(" up Run all pending migrations");
|
|
11
|
+
console.log(" status Show applied and pending migrations");
|
|
12
|
+
console.log("");
|
|
13
|
+
console.log(" Options:");
|
|
14
|
+
console.log(" --env=<file> Specify env file (default: .env)");
|
|
15
|
+
console.log(" --help Show this help message");
|
|
16
|
+
console.log("");
|
|
17
|
+
process.exit(command ? 0 : 1);
|
|
18
|
+
}
|
|
19
|
+
|
|
2
20
|
import { create } from "./src/commands/create.js";
|
|
3
21
|
import { up } from "./src/commands/up.js";
|
|
4
|
-
import { down } from "./src/commands/down.js";
|
|
5
22
|
import { status } from "./src/commands/status.js";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
const [, , command, ...args] = process.argv;
|
|
23
|
+
import { closeDb } from "./src/db.js";
|
|
9
24
|
|
|
10
25
|
async function main() {
|
|
11
26
|
switch (command) {
|
|
@@ -17,42 +32,20 @@ async function main() {
|
|
|
17
32
|
await up();
|
|
18
33
|
break;
|
|
19
34
|
}
|
|
20
|
-
case "down": {
|
|
21
|
-
await down(args[0] ?? 1);
|
|
22
|
-
break;
|
|
23
|
-
}
|
|
24
35
|
case "status": {
|
|
25
36
|
await status();
|
|
26
37
|
break;
|
|
27
38
|
}
|
|
28
|
-
default:
|
|
29
|
-
|
|
30
|
-
console.log(" Usage: migrate <command> [options]");
|
|
31
|
-
console.log("");
|
|
32
|
-
console.log(" Commands:");
|
|
33
|
-
console.log(" create <name> Create a new migration file pair");
|
|
34
|
-
console.log(" up Run all pending migrations");
|
|
35
|
-
console.log(
|
|
36
|
-
" down [n] Revert last n migrations (default: 1)",
|
|
37
|
-
);
|
|
38
|
-
console.log(
|
|
39
|
-
" status Show applied and pending migrations",
|
|
40
|
-
);
|
|
41
|
-
console.log("");
|
|
42
|
-
process.exit(command ? 1 : 0);
|
|
43
|
-
}
|
|
39
|
+
default:
|
|
40
|
+
throw new Error(`Unknown command: ${command}`);
|
|
44
41
|
}
|
|
45
42
|
}
|
|
46
43
|
|
|
47
44
|
main()
|
|
48
45
|
.catch((err) => {
|
|
49
|
-
console.error(
|
|
46
|
+
console.error(err.message);
|
|
50
47
|
process.exit(1);
|
|
51
48
|
})
|
|
52
49
|
.finally(async () => {
|
|
53
|
-
|
|
54
|
-
await db.end();
|
|
55
|
-
} catch {
|
|
56
|
-
// connection may already be closed
|
|
57
|
-
}
|
|
50
|
+
await closeDb();
|
|
58
51
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxctl/migrate",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
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"
|
package/src/commands/create.js
CHANGED
|
@@ -6,11 +6,9 @@ const MIGRATIONS_DIR = "./migrations";
|
|
|
6
6
|
|
|
7
7
|
export async function create(name) {
|
|
8
8
|
if (!name || name.trim() === "") {
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
.
|
|
29
|
-
.
|
|
30
|
-
.
|
|
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
|
|
38
|
-
const downFile = join(MIGRATIONS_DIR, `${baseName}.down.sql`);
|
|
39
|
+
const file = join(MIGRATIONS_DIR, `${baseName}.sql`);
|
|
39
40
|
|
|
40
|
-
if (existsSync(
|
|
41
|
-
|
|
42
|
-
process.exit(1);
|
|
41
|
+
if (existsSync(file)) {
|
|
42
|
+
throw new Error(`Migration file "${baseName}.sql" already exists.`);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
writeFileSync(
|
|
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
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -1,69 +1,80 @@
|
|
|
1
1
|
// src/commands/status.js
|
|
2
2
|
import { readdirSync, existsSync } from "fs";
|
|
3
|
-
import
|
|
4
|
-
import db from "../db.js";
|
|
5
|
-
import { bootstrap } from "../bootstrap.js";
|
|
3
|
+
import getDb from "../db.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
|
-
await
|
|
23
|
+
const db = await getDb();
|
|
24
|
+
await bootstrap(db);
|
|
11
25
|
|
|
12
|
-
const [
|
|
13
|
-
"SELECT name,
|
|
26
|
+
const [dbRows] = await db.query(
|
|
27
|
+
"SELECT name, dirty, applied_at FROM migrations ORDER BY applied_at ASC, id ASC",
|
|
14
28
|
);
|
|
15
29
|
|
|
16
|
-
const
|
|
30
|
+
const dbMap = new Map(dbRows.map((r) => [r.name, r]));
|
|
17
31
|
|
|
18
32
|
const files = existsSync(MIGRATIONS_DIR)
|
|
19
33
|
? readdirSync(MIGRATIONS_DIR)
|
|
20
|
-
.filter((f) => f.endsWith(".
|
|
34
|
+
.filter((f) => f.endsWith(".sql"))
|
|
21
35
|
.sort()
|
|
22
|
-
.map((f) => f.replace(".
|
|
36
|
+
.map((f) => f.replace(".sql", ""))
|
|
23
37
|
: [];
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
// migrations in db but no file on disk (deleted/lost)
|
|
29
|
-
const missing = [...applied.keys()].filter((name) => !files.includes(name));
|
|
39
|
+
const fileSet = new Set(files);
|
|
40
|
+
const allNames = [...new Set([...files, ...dbMap.keys()])];
|
|
30
41
|
|
|
31
|
-
if (
|
|
42
|
+
if (allNames.length === 0) {
|
|
32
43
|
console.log("No migrations found.");
|
|
33
44
|
return;
|
|
34
45
|
}
|
|
35
46
|
|
|
36
|
-
const
|
|
47
|
+
const dirty = dbRows.filter((r) => r.dirty);
|
|
37
48
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
for (const name of files) {
|
|
43
|
-
if (applied.has(name)) {
|
|
44
|
-
const ranAt = new Date(applied.get(name))
|
|
45
|
-
.toISOString()
|
|
46
|
-
.replace("T", " ")
|
|
47
|
-
.slice(0, 19);
|
|
48
|
-
console.log(
|
|
49
|
-
` ${name.padEnd(PAD)} ${"applied".padEnd(12)} ${ranAt}`,
|
|
50
|
-
);
|
|
51
|
-
} else {
|
|
52
|
-
console.log(` ${name.padEnd(PAD)} ${"pending".padEnd(12)}`);
|
|
53
|
-
}
|
|
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) })));
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
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
|
+
});
|
|
63
61
|
|
|
64
62
|
console.log("");
|
|
65
|
-
console.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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`);
|
|
69
80
|
}
|
package/src/commands/up.js
CHANGED
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
// src/commands/up.js
|
|
2
2
|
import { readdirSync, readFileSync, existsSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
|
-
import
|
|
5
|
-
import { bootstrap } from "../bootstrap.js";
|
|
4
|
+
import getDb from "../db.js";
|
|
6
5
|
|
|
7
6
|
const MIGRATIONS_DIR = "./migrations";
|
|
8
7
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
await
|
|
20
|
+
const db = await getDb();
|
|
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
|
+
}
|
|
19
28
|
|
|
20
29
|
if (!existsSync(MIGRATIONS_DIR)) {
|
|
21
30
|
console.log("No migrations directory found. Nothing to run.");
|
|
22
31
|
return;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
// get all .up.sql files, sorted ascending by filename (timestamp prefix ensures order)
|
|
26
34
|
const files = readdirSync(MIGRATIONS_DIR)
|
|
27
|
-
.filter((f) => f.endsWith(".
|
|
35
|
+
.filter((f) => f.endsWith(".sql"))
|
|
28
36
|
.sort();
|
|
29
37
|
|
|
30
38
|
if (files.length === 0) {
|
|
@@ -32,12 +40,11 @@ export async function up() {
|
|
|
32
40
|
return;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
const [rows] = await db.query("SELECT name FROM migrations");
|
|
43
|
+
const [rows] = await db.query("SELECT name FROM migrations WHERE dirty = FALSE");
|
|
37
44
|
const applied = new Set(rows.map((r) => r.name));
|
|
38
45
|
|
|
39
46
|
const pending = files
|
|
40
|
-
.map((f) => ({ file: f, name: f.replace(".
|
|
47
|
+
.map((f) => ({ file: f, name: f.replace(".sql", "") }))
|
|
41
48
|
.filter((m) => !applied.has(m.name));
|
|
42
49
|
|
|
43
50
|
if (pending.length === 0) {
|
|
@@ -48,37 +55,17 @@ export async function up() {
|
|
|
48
55
|
console.log(`Running ${pending.length} migration(s)...`);
|
|
49
56
|
|
|
50
57
|
for (const migration of pending) {
|
|
51
|
-
const
|
|
58
|
+
const sql = readFileSync(join(MIGRATIONS_DIR, migration.file), "utf8").trim();
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
console.error(`Missing down file for migration: ${migration.name}`);
|
|
55
|
-
console.error(`Expected: ${downFile}`);
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
60
|
+
console.log(` Applying: ${migration.name}`);
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
join(MIGRATIONS_DIR, migration.file),
|
|
61
|
-
"utf8",
|
|
62
|
-
).trim();
|
|
63
|
-
|
|
64
|
-
if (isSqlEmpty(sql)) {
|
|
65
|
-
console.error(
|
|
66
|
-
`Migration ${migration.name} contains no SQL (only comments/whitespace).`,
|
|
67
|
-
);
|
|
68
|
-
console.error(`Add SQL or delete the migration files.`);
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
62
|
+
await db.query("INSERT INTO migrations (name, dirty) VALUES (?, TRUE)", [migration.name]);
|
|
71
63
|
|
|
72
|
-
console.log(` Applying: ${migration.name}`);
|
|
73
64
|
try {
|
|
74
65
|
await db.query(sql);
|
|
75
|
-
await db.query("
|
|
76
|
-
migration.name,
|
|
77
|
-
]);
|
|
66
|
+
await db.query("UPDATE migrations SET dirty = FALSE WHERE name = ?", [migration.name]);
|
|
78
67
|
} catch (err) {
|
|
79
|
-
|
|
80
|
-
console.error(err.message);
|
|
81
|
-
process.exit(1);
|
|
68
|
+
throw new Error(`Migration failed: ${migration.name}\n${err.message}`);
|
|
82
69
|
}
|
|
83
70
|
}
|
|
84
71
|
|
package/src/db.js
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
// src/db.js
|
|
2
2
|
import mysql from "mysql2/promise";
|
|
3
|
-
import { env } from "./env.js";
|
|
4
3
|
|
|
5
4
|
let connection;
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
connection
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
6
|
+
export default async function getDb() {
|
|
7
|
+
if (connection) return connection;
|
|
8
|
+
|
|
9
|
+
const { env } = await import("./env.js");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
connection = await mysql.createConnection({
|
|
13
|
+
socketPath: env.DB_SOCK,
|
|
14
|
+
database: env.DB_NAME,
|
|
15
|
+
user: env.DB_USER,
|
|
16
|
+
password: env.DB_PASS,
|
|
17
|
+
multipleStatements: true,
|
|
18
|
+
});
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error(`Failed to connect to database: ${err.message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return connection;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
export
|
|
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
|
@@ -2,18 +2,41 @@
|
|
|
2
2
|
import { readFileSync, existsSync } from "fs";
|
|
3
3
|
import { parse } from "dotenv";
|
|
4
4
|
|
|
5
|
+
function parseArgs() {
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
let envFile = null;
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < args.length; i++) {
|
|
10
|
+
const arg = args[i];
|
|
11
|
+
if (arg === "--env" && i + 1 < args.length) {
|
|
12
|
+
envFile = args[i + 1];
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
if (arg.startsWith("--env=")) {
|
|
16
|
+
envFile = arg.replace("--env=", "");
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return envFile;
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
function loadEnv() {
|
|
25
|
+
const envArg = parseArgs();
|
|
26
|
+
|
|
6
27
|
let envFile;
|
|
7
|
-
let isProd;
|
|
8
28
|
|
|
9
|
-
if (
|
|
10
|
-
envFile =
|
|
11
|
-
isProd = false;
|
|
29
|
+
if (envArg) {
|
|
30
|
+
envFile = envArg;
|
|
12
31
|
} else if (existsSync(".env")) {
|
|
13
32
|
envFile = ".env";
|
|
14
|
-
isProd = true;
|
|
15
33
|
} else {
|
|
16
|
-
console.error("No .env.
|
|
34
|
+
console.error("No .env file found. Use --env to specify a different file.");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!existsSync(envFile)) {
|
|
39
|
+
console.error(`Env file not found: ${envFile}`);
|
|
17
40
|
process.exit(1);
|
|
18
41
|
}
|
|
19
42
|
|
|
@@ -24,9 +47,7 @@ function loadEnv() {
|
|
|
24
47
|
const missing = required.filter((k) => !parsed[k]);
|
|
25
48
|
|
|
26
49
|
if (missing.length > 0) {
|
|
27
|
-
console.error(
|
|
28
|
-
`Missing required variables in ${envFile}: ${missing.join(", ")}`,
|
|
29
|
-
);
|
|
50
|
+
console.error(`Missing required variables in ${envFile}: ${missing.join(", ")}`);
|
|
30
51
|
process.exit(1);
|
|
31
52
|
}
|
|
32
53
|
|
|
@@ -35,7 +56,6 @@ function loadEnv() {
|
|
|
35
56
|
DB_NAME: parsed.DB_NAME,
|
|
36
57
|
DB_USER: parsed.DB_USER,
|
|
37
58
|
DB_PASS: parsed.DB_PASS,
|
|
38
|
-
isProd,
|
|
39
59
|
envFile,
|
|
40
60
|
};
|
|
41
61
|
}
|