bunsql-native-migrate 0.1.2 → 0.3.0
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/README.md +169 -28
- package/package.json +2 -1
- package/src/api/create.ts +26 -8
- package/src/api/down.ts +56 -15
- package/src/api/init.ts +57 -0
- package/src/api/load-migration.ts +45 -0
- package/src/api/lock.ts +50 -0
- package/src/api/mark.ts +43 -0
- package/src/api/options.ts +53 -1
- package/src/api/run-step.ts +4 -2
- package/src/api/run-with-driver.ts +4 -1
- package/src/api/status.ts +20 -0
- package/src/api/up.ts +104 -49
- package/src/cli/main.ts +178 -8
- package/src/core/driver.ts +20 -4
- package/src/core/duration.ts +7 -0
- package/src/core/fs.ts +4 -2
- package/src/core/identifiers.ts +32 -0
- package/src/drivers/mariadb.ts +73 -27
- package/src/drivers/postgres.ts +83 -18
- package/src/drivers/shared.ts +69 -5
- package/src/drivers/sqlite.ts +59 -22
- package/src/index.ts +27 -1
package/README.md
CHANGED
|
@@ -5,15 +5,18 @@
|
|
|
5
5
|
|
|
6
6
|
Zero-ORM SQL file migrations for [Bun](https://bun.sh): PostgreSQL, MySQL/MariaDB and SQLite through the built-in `Bun.SQL` client.
|
|
7
7
|
|
|
8
|
-
No ORM, no schema diffing, no lock-in — you write plain `.js` migration files with `up()`/`down()` exports (optionally `up(tx)`/`down(tx)` for transactional migrations, see below) and run them with a tiny CLI or the programmatic API.
|
|
8
|
+
No ORM, no schema diffing, no lock-in — you write plain `.js`/`.ts` migration files with `up()`/`down()` exports (optionally `up(tx)`/`down(tx)` for transactional migrations, see below), or pure SQL pairs (`name.up.sql` / `name.down.sql`) for migrations with no JS logic — and run them with a tiny CLI or the programmatic API.
|
|
9
9
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
12
12
|
- **Bun-native** — built on the unified [`Bun.SQL`](https://bun.com/docs/runtime/sql) client (PostgreSQL, MySQL/MariaDB, SQLite). No Node.js support.
|
|
13
|
-
- **Zero ORM** — migrations are plain JavaScript files; use `sql` tagged templates or any Bun database client you like.
|
|
13
|
+
- **Zero ORM** — migrations are plain JavaScript, TypeScript or SQL files; use `sql` tagged templates or any Bun database client you like.
|
|
14
14
|
- **Zero dependencies**.
|
|
15
15
|
- **Checksums** — every applied migration is checksummed (SHA-256). A modified applied file fails the run instead of silently drifting.
|
|
16
|
+
- **Concurrent-safe `up`** — an advisory database lock serializes racing `up` runs (two deploy pods, CI + laptop) so a migration body never executes twice.
|
|
16
17
|
- **Legacy backfill** — records without a checksum are backfilled automatically on the next `up`.
|
|
18
|
+
- **Dry run** — `up --dry-run` / `down --dry-run` print the plan without touching the database.
|
|
19
|
+
- **Custom tracking table** — several projects can share one database, each with its own history table (`--table`, and `--schema` for PostgreSQL).
|
|
17
20
|
- **CLI and library** — use it as `bunx bunsql-native-migrate` or import the functions directly.
|
|
18
21
|
|
|
19
22
|
## Installation
|
|
@@ -24,10 +27,25 @@ bun add bunsql-native-migrate
|
|
|
24
27
|
|
|
25
28
|
Requires Bun ≥ 1.4.2 — the version this package is developed and tested against. (The unified `Bun.SQL` client it is built on exists since Bun 1.2.21, when MySQL/MariaDB and SQLite support were added.)
|
|
26
29
|
|
|
30
|
+
## Supported database versions
|
|
31
|
+
|
|
32
|
+
| Engine | Tested versions | Notes |
|
|
33
|
+
| ---------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
34
|
+
| PostgreSQL | 14, 16, 17 | Wired through the full `Bun.SQL` PostgreSQL backend. |
|
|
35
|
+
| MySQL | 8.0, 8.4 (LTS) | `caching_sha2_password` over plain TCP requires TLS or public-key retrieval — see the note in [Drivers](#drivers) for the native-password setup. |
|
|
36
|
+
| MariaDB | 10.11 (LTS), 11.x | |
|
|
37
|
+
| SQLite | follows Bun | The engine is the SQLite bundled with your Bun runtime (`bun:sqlite`); there is no separate server to version. |
|
|
38
|
+
|
|
39
|
+
CI runs one version per engine on every push (PostgreSQL 14 + MariaDB 11); the full matrix above is exercised before every release — locally through the repo's `compose.yaml` profiles (`docker compose up -d pg14 mariadb11 …`) and `bun run test:matrix`, or via the manual "CI" workflow dispatch on GitHub Actions.
|
|
40
|
+
|
|
27
41
|
## Quick start
|
|
28
42
|
|
|
29
43
|
```bash
|
|
30
|
-
#
|
|
44
|
+
# scaffold the migrations directory with your first migration stub
|
|
45
|
+
# (prints the next steps: set DATABASE_URL, then run up)
|
|
46
|
+
bunx bunsql-native-migrate init
|
|
47
|
+
|
|
48
|
+
# add more migrations as you go
|
|
31
49
|
bunx bunsql-native-migrate create add_users_table
|
|
32
50
|
|
|
33
51
|
# create the tracking table (optional — up() does it automatically)
|
|
@@ -36,6 +54,12 @@ bunx bunsql-native-migrate install
|
|
|
36
54
|
# apply pending migrations
|
|
37
55
|
bunx bunsql-native-migrate up
|
|
38
56
|
|
|
57
|
+
# see what is applied and what is pending
|
|
58
|
+
bunx bunsql-native-migrate status
|
|
59
|
+
|
|
60
|
+
# CI gate: same listing, but exit code 1 while migrations are pending
|
|
61
|
+
bunx bunsql-native-migrate status --strict
|
|
62
|
+
|
|
39
63
|
# roll back the last applied migration
|
|
40
64
|
bunx bunsql-native-migrate down
|
|
41
65
|
```
|
|
@@ -58,12 +82,29 @@ const down = async () => {
|
|
|
58
82
|
export { up, down };
|
|
59
83
|
```
|
|
60
84
|
|
|
61
|
-
Files live in the migrations directory (default `./migrations`, override with `--dir` or the `MIGRATION_LIST_DIR` env var) and are applied in descending filename order. `bunsql-native-migrate create` generates
|
|
85
|
+
Files live in the migrations directory (default `./migrations`, override with `--dir` or the `MIGRATION_LIST_DIR` env var) and are applied in descending filename order. Both `.js` and `.ts` files work — Bun runs TypeScript natively — and the order is decided purely by the filename, never by the extension. `bunsql-native-migrate create` generates TypeScript stubs by default (`--lang js` for JavaScript) with an inverted timestamp prefix so newer migrations sort first:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
9999999999999_2026_09_13_add_users_table.ts
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### SQL migration pairs
|
|
92
|
+
|
|
93
|
+
Migrations without JS logic can be plain SQL files. The format is a pair named after the migration:
|
|
62
94
|
|
|
63
95
|
```
|
|
64
|
-
|
|
96
|
+
migrations/
|
|
97
|
+
9999999999998_2026_09_14_add_index.up.sql
|
|
98
|
+
9999999999998_2026_09_14_add_index.down.sql
|
|
65
99
|
```
|
|
66
100
|
|
|
101
|
+
- The `.up.sql` file **is** the migration: its full filename is what gets recorded, shown in `status` and used as the `--to` target (`...add_index.up.sql`).
|
|
102
|
+
- The `.down.sql` pair is the rollback. A migration without one reverts like a `.js` file without a `down()` export: the tracking record is removed with a warning and nothing is executed.
|
|
103
|
+
- Pairs sort together with `.js`/`.ts` files strictly by filename, so all three kinds interleave in one history.
|
|
104
|
+
- Both files may contain several statements separated by semicolons — the whole file is sent as one batch.
|
|
105
|
+
- A `.sql` migration always runs inside a transaction on the runner's connection: on PostgreSQL and SQLite a failing statement rolls back the whole file and nothing is recorded; on MySQL/MariaDB DDL implicitly commits, so there only DML gets rollback protection (the same caveat as transactional JS migrations).
|
|
106
|
+
- The checksum covers the `.up.sql` file; editing a `.down.sql` after the fact is not tracked, exactly like JS `down()` bodies.
|
|
107
|
+
|
|
67
108
|
### Transactional migrations
|
|
68
109
|
|
|
69
110
|
Declare a `tx` parameter on `up`/`down` and the migration runs inside a single database transaction: if any statement fails, the partial work is rolled back instead of being left half-applied, and nothing is recorded.
|
|
@@ -81,7 +122,7 @@ const down = async (tx) => {
|
|
|
81
122
|
export { up, down };
|
|
82
123
|
```
|
|
83
124
|
|
|
84
|
-
- `bunsql-native-migrate create` generates stubs in this form by default
|
|
125
|
+
- `bunsql-native-migrate create` generates stubs in this form by default (as TypeScript, with `tx` typed as a Bun `SQL` client bound to the same database the runner is connected to).
|
|
85
126
|
- Migrations declared **without** parameters keep using the global `sql` client and run without a transaction — both styles can coexist in one project, decided per migration by the declared signature.
|
|
86
127
|
- Engine caveats: PostgreSQL and SQLite roll back everything, DDL included. On MySQL/MariaDB any DDL statement implicitly commits the current transaction, so there only DML gets rollback protection.
|
|
87
128
|
- The tracking record is written right after the transaction commits. A crash in that single-statement window leaves the migration applied but unrecorded — the next `up` would re-run it, so keep critical migrations idempotent (this window exists for plain `up()` migrations too, just wider).
|
|
@@ -101,43 +142,85 @@ This resolution is part of the library contract: `resolveListDir` (and therefore
|
|
|
101
142
|
## CLI reference
|
|
102
143
|
|
|
103
144
|
```
|
|
104
|
-
bunsql-native-migrate <up|down|install|create [name]> [--dir <migrations-dir>] [--git] [--help]
|
|
145
|
+
bunsql-native-migrate <init|up|down [n]|install|create [name]|mark [name]|status> [--dir <migrations-dir>] [--to <name>] [--lock-timeout <seconds>] [--table <name>] [--schema <name>] [--dry-run] [--all] [--lang <js|ts>] [--git] [--strict] [--help]
|
|
105
146
|
```
|
|
106
147
|
|
|
107
|
-
| Command | What it does
|
|
108
|
-
| --------------- |
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
|
|
114
|
-
|
|
|
115
|
-
|
|
|
116
|
-
|
|
117
|
-
|
|
|
118
|
-
|
|
|
148
|
+
| Command | What it does |
|
|
149
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
150
|
+
| `init` | Onboarding: creates the migrations directory (honoring `--dir`) with the first stub migration `..._initial.ts` and prints the next steps (set `DATABASE_URL`, run `up`). Idempotent: when the directory already contains migrations it prints a note and creates nothing. |
|
|
151
|
+
| `up` | Applies pending migrations (creating the tracking table if needed); each applied file prints `<file> migrated up (<duration>)`, e.g. `2_add_users.ts migrated up (120ms)` (seconds with one decimal once past a second). Prints `No pending migrations.` when there is nothing to apply. `up --to <name>` applies pending migrations in order up to and including the named one. |
|
|
152
|
+
| `down` | Reverts the last applied migration; `down <n>` reverts the last `n`, `down --all` reverts everything — always most-recent-first, each printing `<file> rolled back (<duration>)`. Prints `No migrations to rollback.` when there is none. |
|
|
153
|
+
| `install` | Creates the tracking table only. |
|
|
154
|
+
| `create [name]` | Creates a stub migration file from the template — TypeScript by default, `--lang js` for JavaScript; without a `name` a random `adjective_noun` is generated. |
|
|
155
|
+
| `mark [name]` | Baseline for an existing database: writes tracking records **without running anything**. `mark <name>` marks every pending migration up to and including the named one; `mark --all` marks all of them. Records carry the actual file checksums, so the next `up` treats them as applied (see [Baseline](#baseline-adopting-an-existing-database-mark)). |
|
|
156
|
+
| `status` | Lists applied and pending migrations (no changes to the database except creating the tracking table if missing) and prints an `N applied, M pending` summary. |
|
|
157
|
+
|
|
158
|
+
| Flag | Applies to | Meaning |
|
|
159
|
+
| ------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
160
|
+
| `--dir <migrations-dir>` | all | Migrations directory (default `./migrations`, or the `MIGRATION_LIST_DIR` env var). |
|
|
161
|
+
| `--to <name>` | `up` | Apply pending migrations up to and including the named file. An unknown name is an error (exit 1); a target that is already applied is a no-op. |
|
|
162
|
+
| `--lock-timeout <sec>` | `up` | How long to wait for the migration lock when another `up` is running, in seconds (default `30`, `0` fails immediately). On timeout: exit 1. |
|
|
163
|
+
| `--table <name>` | up/down/install/status | Track history in this table instead of `migrations` (see [Custom tracking table](#custom-tracking-table)). |
|
|
164
|
+
| `--schema <name>` | up/down/install/status | PostgreSQL only: create/read the tracking table in this schema (must already exist). Rejected with an error on MySQL and SQLite. |
|
|
165
|
+
| `--dry-run` | `up`, `down` | Print what a real run would apply or revert and exit — no migration bodies run, nothing is recorded, the tracking table is not even created (see [Dry run](#dry-run)). |
|
|
166
|
+
| `--all` | `down`, `mark` | `down`: revert every applied migration (most-recent-first); cannot be combined with a step count. `mark`: mark every pending migration as applied; cannot be combined with a file name. |
|
|
167
|
+
| `--git` | `create` | `git add` the created file. When staging fails, the CLI prints an error and exits 1 — the file itself stays on disk. |
|
|
168
|
+
| `--lang <js\|ts>` | `create` | Language of the created stub. Default: `ts`. An unknown or missing value is an error (exit 1). |
|
|
169
|
+
| `--strict` | `status` | Exit with code 1 when migrations are pending — a gate for CI/CD pipelines. Exit 0 otherwise. |
|
|
170
|
+
| `--help`, `-h` | — | Prints the usage line and exits with code 0. |
|
|
119
171
|
|
|
120
172
|
Any failure (connection errors, a failing migration, a modified applied file) is printed and the CLI exits with code 1; the same happens for an unknown command or a call without a command.
|
|
121
173
|
|
|
174
|
+
Example `up` output:
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
2_add_index.up.sql migrated up (120ms)
|
|
178
|
+
1_add_users.ts migrated up (1.4s)
|
|
179
|
+
Applied 2 migration(s).
|
|
180
|
+
```
|
|
181
|
+
|
|
122
182
|
## Programmatic API
|
|
123
183
|
|
|
124
184
|
```ts
|
|
125
185
|
import {
|
|
126
186
|
migrateUp,
|
|
127
187
|
migrateDown,
|
|
188
|
+
migrateStatus,
|
|
128
189
|
installMigrations,
|
|
129
190
|
createMigration,
|
|
191
|
+
markMigrationsApplied,
|
|
130
192
|
createDriver,
|
|
131
193
|
ChecksumDriftError,
|
|
194
|
+
InvalidIdentifierError,
|
|
195
|
+
MigrationLockError,
|
|
196
|
+
MigrationNotFoundError,
|
|
132
197
|
GitStageError,
|
|
133
198
|
} from "bunsql-native-migrate";
|
|
134
199
|
|
|
135
200
|
const { applied } = await migrateUp({
|
|
136
201
|
databaseUrl: "postgres://user:pass@localhost:5432/app", // default: DATABASE_URL env
|
|
137
202
|
listDir: "./migrations", // default: MIGRATION_LIST_DIR env or ./migrations
|
|
203
|
+
to: "2_add_columns.ts", // optional: apply up to and including this file
|
|
204
|
+
lockTimeout: 60, // optional: seconds to wait for the migration lock (default 30, 0 = fail fast)
|
|
205
|
+
tableName: "app_migrations", // optional: custom tracking table (default "migrations")
|
|
206
|
+
schema: "private", // optional: postgres schema for the tracking table
|
|
138
207
|
});
|
|
139
208
|
|
|
140
|
-
const { reverted } = await migrateDown(); // reverted: string
|
|
209
|
+
const { reverted } = await migrateDown(); // reverted: string[] (most-recent-first)
|
|
210
|
+
await migrateDown({ steps: 3 }); // revert the last three
|
|
211
|
+
await migrateDown({ steps: "all" }); // revert everything
|
|
212
|
+
|
|
213
|
+
const { planned } = await migrateUp({ dryRun: true }); // preview only (see Dry run)
|
|
214
|
+
// planned: string[] — what a real up would apply, applied stays []
|
|
215
|
+
await migrateDown({ dryRun: true }); // planned: what down would revert, reverted stays []
|
|
216
|
+
|
|
217
|
+
const status = await migrateStatus();
|
|
218
|
+
// status.applied: ExecutedMigration[] (name + checksum, in apply order)
|
|
219
|
+
// status.pending: string[] (files waiting to be applied, in apply order)
|
|
220
|
+
|
|
221
|
+
const { marked } = await markMigrationsApplied({ to: "2_baseline.up.sql" });
|
|
222
|
+
// marked: string[] — files recorded as applied without running (see Baseline)
|
|
223
|
+
await markMigrationsApplied(); // mark every pending migration
|
|
141
224
|
|
|
142
225
|
await installMigrations(); // creates the tracking table
|
|
143
226
|
|
|
@@ -146,12 +229,21 @@ const filename = await createMigration({ name: "add_users_table", listDir: "./mi
|
|
|
146
229
|
|
|
147
230
|
All options are optional unless stated otherwise:
|
|
148
231
|
|
|
149
|
-
| Option | Where
|
|
150
|
-
| ------------- |
|
|
151
|
-
| `databaseUrl` | `migrateUp`, `migrateDown`, `installMigrations` | `DATABASE_URL` env var
|
|
152
|
-
| `listDir` | all functions
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
232
|
+
| Option | Where | Default |
|
|
233
|
+
| ------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
234
|
+
| `databaseUrl` | `migrateUp`, `migrateDown`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | `DATABASE_URL` env var |
|
|
235
|
+
| `listDir` | all functions | `MIGRATION_LIST_DIR` env var, then `./migrations` (relative paths resolve against the process cwd) |
|
|
236
|
+
| `tableName` | `migrateUp`, `migrateDown`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | `"migrations"` — a custom tracking table name (see [Custom tracking table](#custom-tracking-table)) |
|
|
237
|
+
| `schema` | `migrateUp`, `migrateDown`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | — PostgreSQL only: the schema holding the tracking table (must already exist); rejected on other URLs |
|
|
238
|
+
| `to` | `migrateUp`, `markMigrationsApplied` | `migrateUp`: apply pending migrations in order up to and including the named file (unknown name throws `MigrationNotFoundError`, an already-applied target applies nothing). `markMigrationsApplied`: same boundary, but the files are only recorded as applied; omitted — mark every pending migration |
|
|
239
|
+
| `lockTimeout` | `migrateUp` | `30` — seconds to wait for the migration lock while another `up` is running; `0` fails immediately; a timeout throws `MigrationLockError` |
|
|
240
|
+
| `dryRun` | `migrateUp`, `migrateDown` | `false` — plan only: the result carries `planned: string[]` while `applied`/`reverted` stay empty (see [Dry run](#dry-run)) |
|
|
241
|
+
| `steps` | `migrateDown` | `1` — revert the last `n` applied migrations (`1`–`n` or `"all"`), most-recent-first |
|
|
242
|
+
| `name` | `createMigration` | random `adjective_noun` name |
|
|
243
|
+
| `lang` | `createMigration` | `"ts"` — pass `"js"` for a JavaScript stub |
|
|
244
|
+
| `git` | `createMigration` | `false` — `git add` the new file |
|
|
245
|
+
|
|
246
|
+
`migrateDown` reverts strictly in reverse apply order and stops at the first failing rollback: migrations reverted before the failure stay reverted, the failing one keeps its tracking record, and the error propagates to the caller.
|
|
155
247
|
|
|
156
248
|
### Drivers
|
|
157
249
|
|
|
@@ -169,7 +261,7 @@ Known MySQL/MariaDB limitation of `Bun.SQL` (not used by this package, good to k
|
|
|
169
261
|
|
|
170
262
|
Bun's client also recognizes `mysql2://` and `file://` URLs — `createDriver` deliberately rejects them. Stick to the protocols listed above.
|
|
171
263
|
|
|
172
|
-
`createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`).
|
|
264
|
+
`createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`). It also accepts the same `{ tableName, schema }` options as the API functions. Custom drivers may also implement the optional `tryLock(timeoutSeconds)` / `releaseLock()` pair to participate in [concurrent-run locking](#concurrent-runs) — without them, `migrateUp` simply runs unlocked — and the optional `trackingTableExists()` used by `up`'s [dry run](#dry-run) to plan against a database that has no tracking table yet.
|
|
173
265
|
|
|
174
266
|
### Tracking table
|
|
175
267
|
|
|
@@ -181,9 +273,58 @@ Applied migrations are recorded in a `migrations` table with a unique name and a
|
|
|
181
273
|
|
|
182
274
|
Records created before checksums existed (checksum `NULL`) are backfilled on the next `up`.
|
|
183
275
|
|
|
276
|
+
### Custom tracking table
|
|
277
|
+
|
|
278
|
+
Several projects can point at one database without sharing a history: give each its own tracking table with `tableName` (CLI `--table`), optionally in a PostgreSQL schema with `schema` (CLI `--schema`, the schema must already exist — it is not created for you). The unique index is derived from the table name (`<table>_migration_unique`), so custom tables never collide, and the legacy checksum backfill works in custom tables too. Two concurrent `up` runs still serialize on the same per-database lock even with different tables — they share the database, after all.
|
|
279
|
+
|
|
280
|
+
Names are validated before the driver connects: an identifier of letters, digits, underscores and dollar signs, starting with a letter or underscore (PostgreSQL ≤ 63 chars, MySQL/MariaDB ≤ 47, SQLite ≤ 128; schema ≤ 63). Anything else — spaces, quotes, semicolons — throws `InvalidIdentifierError` and the CLI exits 1, so a hostile name can never reach the database. The `schema` option on a MySQL or SQLite URL is an error as well: MySQL selects the database in the URL itself and SQLite has no schemas.
|
|
281
|
+
|
|
282
|
+
### Baseline: adopting an existing database (mark)
|
|
283
|
+
|
|
284
|
+
Adopting the tool on a database whose schema was created before it existed? `mark` writes the tracking records without running anything — the files are declared applied, so the next `up` skips them instead of re-creating what is already there:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
# mark every pending migration up to and including 2_baseline.up.sql
|
|
288
|
+
bunx bunsql-native-migrate mark 2_baseline.up.sql
|
|
289
|
+
|
|
290
|
+
# mark every pending migration
|
|
291
|
+
bunx bunsql-native-migrate mark --all
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- Records carry the actual file checksums, so a later `up` neither re-applies the marked files nor throws `ChecksumDriftError`; only migrations newer than the boundary are executed from then on.
|
|
295
|
+
- `up()`/`down()` are never called by `mark` — but a later `down` **will** run the `down()` bodies of marked migrations, so make sure they match the schema that actually exists before rolling anything back.
|
|
296
|
+
- An unknown name is an error before anything is written (exit 1, `MigrationNotFoundError`); a target that is already applied is a no-op.
|
|
297
|
+
- Re-running is safe: `record` deduplicates at the database level (unique name), so even racing `mark` runs converge on the same history.
|
|
298
|
+
|
|
299
|
+
### Dry run
|
|
300
|
+
|
|
301
|
+
`up --dry-run` and `down --dry-run` (API: `dryRun: true`) preview a run without changing anything:
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
Dry run — no changes will be made.
|
|
305
|
+
2_add_index.up.sql would be applied
|
|
306
|
+
1_add_users.ts would be applied
|
|
307
|
+
Would apply 2 migration(s).
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
- Nothing is executed and nothing is recorded — not even the tracking table is created, so it is safe against any database, production included. A database without the table simply plans everything as pending.
|
|
311
|
+
- The plan honors every option the real run would: `to`, `steps`, `--table`/`--schema`. The checksum drift check runs too — a dry run reports `ChecksumDriftError` exactly where the real run would fail (the legacy NULL-checksum backfill is the one write it skips).
|
|
312
|
+
- `down --dry-run` plans the revert list in reverse apply order; on an empty history it prints `No migrations to rollback.` like the real command.
|
|
313
|
+
- In the API result the plan lands in `planned: string[]` while `applied`/`reverted` stay empty — existing consumers keep working untouched.
|
|
314
|
+
|
|
315
|
+
### Concurrent runs
|
|
316
|
+
|
|
317
|
+
Two `up` runs racing (two deploy pods, CI and a laptop) deduplicate only the tracking record against each other — without a lock both would read the same pending list and execute the migration bodies twice. `migrateUp` therefore takes an exclusive database-level lock right after creating the tracking table and holds it until the run ends. The lock is released through a `finally` path on any failure, and it dies with the connection even on a crashed process:
|
|
318
|
+
|
|
319
|
+
- **PostgreSQL** — session advisory lock (`pg_try_advisory_lock` / `pg_advisory_unlock`) on a reserved connection, keyed per database.
|
|
320
|
+
- **MySQL/MariaDB** — `GET_LOCK` / `RELEASE_LOCK` on a reserved connection, named per database.
|
|
321
|
+
- **SQLite** — there is no advisory lock; the connection gets `PRAGMA busy_timeout`, so a concurrent run waits on the file write lock and fails with a busy error once the timeout is exceeded. The database file itself is the serialization point.
|
|
322
|
+
|
|
323
|
+
While the lock is held, another `up` polls and waits up to `lockTimeout` seconds (default `30`, CLI `up --lock-timeout <seconds>`, `0` fails immediately). When the wait times out, `migrateUp` throws `MigrationLockError` and the CLI exits with code 1 — rerun after the first run finishes; a waiter that gets through just reports `No pending migrations.`
|
|
324
|
+
|
|
184
325
|
### Error handling
|
|
185
326
|
|
|
186
|
-
The library throws instead of exiting: connection errors, failing migrations
|
|
327
|
+
The library throws instead of exiting: connection errors, failing migrations, [`ChecksumDriftError`](#tracking-table), `InvalidIdentifierError` (an invalid `tableName`/`schema`, thrown before anything is applied), `MigrationNotFoundError` (an unknown `to` target, thrown before anything is applied) and [`MigrationLockError`](#concurrent-runs) (another `up` held the lock past `lockTimeout`) propagate to the caller, and the driver connection is always closed. The CLI catches these and exits with code 1.
|
|
187
328
|
|
|
188
329
|
`createMigration` with `git: true` throws `GitStageError` when `git add` fails (no repository, ignored path, …) — the migration file itself is still created on disk.
|
|
189
330
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bunsql-native-migrate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Zero-ORM SQL file migrations for Bun: PostgreSQL, MySQL/MariaDB and SQLite through the built-in Bun.SQL client",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"test": "bun test",
|
|
41
|
+
"test:matrix": "bun scripts/matrix.ts",
|
|
41
42
|
"typecheck": "tsgo --noEmit",
|
|
42
43
|
"lint": "oxlint",
|
|
43
44
|
"fmt": "oxfmt",
|
package/src/api/create.ts
CHANGED
|
@@ -5,13 +5,16 @@ import { resolveListDir } from "../core/fs.js";
|
|
|
5
5
|
import { log } from "../core/console.js";
|
|
6
6
|
import { GitStageError } from "./options.js";
|
|
7
7
|
|
|
8
|
+
export type MigrationLang = "js" | "ts";
|
|
9
|
+
|
|
8
10
|
export interface CreateOptions {
|
|
9
11
|
name?: string;
|
|
10
12
|
git?: boolean;
|
|
13
|
+
lang?: MigrationLang;
|
|
11
14
|
listDir: string;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
const
|
|
17
|
+
const JS_TEMPLATE = `import { sql } from "bun";
|
|
15
18
|
// Write your migration SQL here (tx runs inside a transaction)
|
|
16
19
|
const up = async (tx) => {};
|
|
17
20
|
|
|
@@ -21,6 +24,20 @@ const down = async (tx) => {};
|
|
|
21
24
|
export { up, down };
|
|
22
25
|
`;
|
|
23
26
|
|
|
27
|
+
const TS_TEMPLATE = `import { sql, type SQL } from "bun";
|
|
28
|
+
// Write your migration SQL here (tx runs inside a transaction)
|
|
29
|
+
const up = async (tx: SQL) => {};
|
|
30
|
+
|
|
31
|
+
// Write your rollback SQL here
|
|
32
|
+
const down = async (tx: SQL) => {};
|
|
33
|
+
|
|
34
|
+
export { up, down };
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function stubTemplate(lang: MigrationLang): string {
|
|
38
|
+
return lang === "js" ? JS_TEMPLATE : TS_TEMPLATE;
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
async function stageInGit(filePath: string): Promise<void> {
|
|
25
42
|
const add = Bun.spawn({
|
|
26
43
|
cmd: ["git", "add", filePath],
|
|
@@ -34,7 +51,7 @@ async function stageInGit(filePath: string): Promise<void> {
|
|
|
34
51
|
}
|
|
35
52
|
}
|
|
36
53
|
|
|
37
|
-
function migrationFilename(name: string, date: Date): string {
|
|
54
|
+
function migrationFilename(name: string, date: Date, lang: MigrationLang): string {
|
|
38
55
|
const pad = (value: number) => (value <= 9 ? `0${value}` : `${value}`);
|
|
39
56
|
const MAX_TIME = 9999999999999;
|
|
40
57
|
const invertedTime = (MAX_TIME - date.getTime()).toString().padStart(13, "0");
|
|
@@ -44,10 +61,10 @@ function migrationFilename(name: string, date: Date): string {
|
|
|
44
61
|
pad(date.getUTCMonth() + 1),
|
|
45
62
|
pad(date.getUTCDate()),
|
|
46
63
|
].join("_");
|
|
47
|
-
return `${timestamp}_${name}
|
|
64
|
+
return `${timestamp}_${name}.${lang}`;
|
|
48
65
|
}
|
|
49
66
|
|
|
50
|
-
async function writeStubExclusively(filePath: string): Promise<boolean> {
|
|
67
|
+
async function writeStubExclusively(filePath: string, template: string): Promise<boolean> {
|
|
51
68
|
let handle: FileHandle;
|
|
52
69
|
try {
|
|
53
70
|
handle = await open(filePath, "wx");
|
|
@@ -58,7 +75,7 @@ async function writeStubExclusively(filePath: string): Promise<boolean> {
|
|
|
58
75
|
throw error;
|
|
59
76
|
}
|
|
60
77
|
try {
|
|
61
|
-
await handle.writeFile(
|
|
78
|
+
await handle.writeFile(template);
|
|
62
79
|
} finally {
|
|
63
80
|
await handle.close();
|
|
64
81
|
}
|
|
@@ -67,12 +84,13 @@ async function writeStubExclusively(filePath: string): Promise<boolean> {
|
|
|
67
84
|
|
|
68
85
|
export async function createMigration(options: CreateOptions): Promise<string> {
|
|
69
86
|
const name = options.name ?? randomName();
|
|
87
|
+
const lang = options.lang ?? "ts";
|
|
70
88
|
await mkdir(options.listDir, { recursive: true });
|
|
71
|
-
let filename = migrationFilename(name, new Date());
|
|
89
|
+
let filename = migrationFilename(name, new Date(), lang);
|
|
72
90
|
let filePath = path.join(options.listDir, filename);
|
|
73
|
-
while (!(await writeStubExclusively(filePath))) {
|
|
91
|
+
while (!(await writeStubExclusively(filePath, stubTemplate(lang)))) {
|
|
74
92
|
await Bun.sleep(1);
|
|
75
|
-
filename = migrationFilename(name, new Date());
|
|
93
|
+
filename = migrationFilename(name, new Date(), lang);
|
|
76
94
|
filePath = path.join(options.listDir, filename);
|
|
77
95
|
}
|
|
78
96
|
|
package/src/api/down.ts
CHANGED
|
@@ -1,33 +1,74 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { resolveListDir } from "../core/fs.js";
|
|
3
2
|
import { log } from "../core/console.js";
|
|
4
|
-
import
|
|
3
|
+
import { formatDuration } from "../core/duration.js";
|
|
4
|
+
import type { MigrationDriver } from "../core/driver.js";
|
|
5
|
+
import type { MigrateDownOptions, MigrateDownResult } from "./options.js";
|
|
5
6
|
import { runWithDriver } from "./run-with-driver.js";
|
|
6
7
|
import { runMigrationStep } from "./run-step.js";
|
|
8
|
+
import { isSqlMigration, loadMigration } from "./load-migration.js";
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
function resolveStepCount(steps: number | "all" | undefined, appliedCount: number): number {
|
|
11
|
+
if (steps === undefined) return 1;
|
|
12
|
+
if (steps === "all") return appliedCount;
|
|
13
|
+
if (!Number.isInteger(steps) || steps < 1) {
|
|
14
|
+
throw new Error(`Invalid steps: ${String(steps)} — expected a positive integer or "all"`);
|
|
15
|
+
}
|
|
16
|
+
return steps;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function revertOne(driver: MigrationDriver, listDir: string, file: string): Promise<void> {
|
|
20
|
+
const { down } = await loadMigration(listDir, file);
|
|
21
|
+
|
|
22
|
+
if (down === null) {
|
|
23
|
+
const reason = isSqlMigration(file) ? "has no .down.sql pair" : "has no down() export";
|
|
24
|
+
log({ text: `${file} ${reason}, removing tracking record`, type: "warn" });
|
|
25
|
+
await driver.remove(file);
|
|
26
|
+
log({ text: `${file} tracking record removed`, type: "success" });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const durationMs = await runMigrationStep(driver, down);
|
|
31
|
+
await driver.remove(file);
|
|
32
|
+
log({ text: `${file} rolled back (${formatDuration(durationMs)})`, type: "success" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function migrateDown(options: MigrateDownOptions = {}): Promise<MigrateDownResult> {
|
|
9
36
|
const listDir = resolveListDir(options.listDir);
|
|
37
|
+
const dryRun = options.dryRun ?? false;
|
|
10
38
|
|
|
11
39
|
return runWithDriver(options, async (driver) => {
|
|
12
40
|
const executed = await driver.listExecuted();
|
|
13
41
|
if (executed.length === 0) {
|
|
14
42
|
log({ text: "No migrations to rollback.", type: "warn" });
|
|
15
|
-
return { reverted:
|
|
43
|
+
return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
|
|
16
44
|
}
|
|
17
45
|
|
|
18
|
-
const
|
|
19
|
-
const
|
|
46
|
+
const count = resolveStepCount(options.steps, executed.length);
|
|
47
|
+
const plan = executed
|
|
48
|
+
.slice(-count)
|
|
49
|
+
.reverse()
|
|
50
|
+
.map((entry) => entry.name);
|
|
20
51
|
|
|
21
|
-
if (
|
|
22
|
-
log({ text:
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
52
|
+
if (dryRun) {
|
|
53
|
+
log({ text: "Dry run — no changes will be made.", type: "info" });
|
|
54
|
+
for (const file of plan) {
|
|
55
|
+
log({ text: `${file} would be rolled back`, type: "info" });
|
|
56
|
+
}
|
|
57
|
+
return { reverted: [], planned: plan };
|
|
26
58
|
}
|
|
27
59
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
const reverted: string[] = [];
|
|
61
|
+
|
|
62
|
+
for (const entry of executed.slice(-count).reverse()) {
|
|
63
|
+
try {
|
|
64
|
+
await revertOne(driver, listDir, entry.name);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
log({ text: `${entry.name} rollback failed`, type: "error", error });
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
reverted.push(entry.name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { reverted };
|
|
32
73
|
});
|
|
33
74
|
}
|
package/src/api/init.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
|
|
2
|
+
import { log } from "../core/console.js";
|
|
3
|
+
import { createMigration, type MigrationLang } from "./create.js";
|
|
4
|
+
|
|
5
|
+
export interface InitOptions {
|
|
6
|
+
listDir?: string;
|
|
7
|
+
lang?: MigrationLang;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface InitResult {
|
|
11
|
+
created: boolean;
|
|
12
|
+
filename: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function existingMigrations(listDir: string): Promise<string[]> {
|
|
16
|
+
try {
|
|
17
|
+
return await listFiles(listDir, MIGRATION_EXTENSIONS);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printNextSteps(): void {
|
|
27
|
+
log({ text: "Next steps:", type: "info" });
|
|
28
|
+
log({
|
|
29
|
+
text: "1. Point DATABASE_URL at your database (postgres://, mariadb://, mysql:// or sqlite:)",
|
|
30
|
+
type: "info",
|
|
31
|
+
});
|
|
32
|
+
log({ text: "2. Fill in the up() and down() bodies of the created migration", type: "info" });
|
|
33
|
+
log({ text: "3. Run bunx bunsql-native-migrate up", type: "info" });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function initMigrations(options: InitOptions = {}): Promise<InitResult> {
|
|
37
|
+
const listDir = resolveListDir(options.listDir);
|
|
38
|
+
const existing = await existingMigrations(listDir);
|
|
39
|
+
|
|
40
|
+
if (existing.length > 0) {
|
|
41
|
+
log({
|
|
42
|
+
text: `Migrations directory already has ${existing.length} migration(s): ${listDir}`,
|
|
43
|
+
type: "info",
|
|
44
|
+
});
|
|
45
|
+
printNextSteps();
|
|
46
|
+
return { created: false, filename: null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const filename = await createMigration({
|
|
50
|
+
name: "initial",
|
|
51
|
+
...(options.lang !== undefined ? { lang: options.lang } : {}),
|
|
52
|
+
listDir,
|
|
53
|
+
});
|
|
54
|
+
log({ text: `Migration created: ${filename}`, type: "success" });
|
|
55
|
+
printNextSteps();
|
|
56
|
+
return { created: true, filename };
|
|
57
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { type SQL } from "bun";
|
|
3
|
+
|
|
4
|
+
export type MigrationStep = (tx?: SQL) => Promise<void>;
|
|
5
|
+
|
|
6
|
+
export interface MigrationFunctions {
|
|
7
|
+
up: MigrationStep | null;
|
|
8
|
+
down: MigrationStep | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SQL_UP_SUFFIX = ".up.sql";
|
|
12
|
+
|
|
13
|
+
export function isSqlMigration(file: string): boolean {
|
|
14
|
+
return file.endsWith(SQL_UP_SUFFIX);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sqlDownFile(file: string): string {
|
|
18
|
+
return `${file.slice(0, -SQL_UP_SUFFIX.length)}.down.sql`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sqlFileStep(filePath: string): MigrationStep {
|
|
22
|
+
return async (tx) => {
|
|
23
|
+
if (tx === undefined) {
|
|
24
|
+
throw new Error(`${filePath} can only run inside a migration transaction`);
|
|
25
|
+
}
|
|
26
|
+
await tx.file(filePath);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function loadMigration(listDir: string, file: string): Promise<MigrationFunctions> {
|
|
31
|
+
if (isSqlMigration(file)) {
|
|
32
|
+
const upPath = path.join(listDir, file);
|
|
33
|
+
const downPath = path.join(listDir, sqlDownFile(file));
|
|
34
|
+
return {
|
|
35
|
+
up: sqlFileStep(upPath),
|
|
36
|
+
down: (await Bun.file(downPath).exists()) ? sqlFileStep(downPath) : null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mod = await import(path.join(listDir, file));
|
|
41
|
+
return {
|
|
42
|
+
up: typeof mod.up === "function" ? mod.up : null,
|
|
43
|
+
down: typeof mod.down === "function" ? mod.down : null,
|
|
44
|
+
};
|
|
45
|
+
}
|