bunsql-native-migrate 0.2.0 → 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 +135 -34
- package/package.json +2 -1
- package/src/api/down.ts +23 -7
- package/src/api/init.ts +57 -0
- package/src/api/load-migration.ts +45 -0
- package/src/api/mark.ts +43 -0
- package/src/api/options.ts +14 -0
- package/src/api/run-step.ts +4 -2
- package/src/api/run-with-driver.ts +4 -1
- package/src/api/up.ts +42 -11
- package/src/cli/main.ts +77 -6
- package/src/core/driver.ts +18 -4
- package/src/core/duration.ts +7 -0
- package/src/core/fs.ts +1 -1
- package/src/core/identifiers.ts +32 -0
- package/src/drivers/mariadb.ts +70 -41
- package/src/drivers/postgres.ts +73 -33
- package/src/drivers/shared.ts +12 -4
- package/src/drivers/sqlite.ts +58 -31
- package/src/index.ts +15 -1
package/README.md
CHANGED
|
@@ -5,16 +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`/`.ts` 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 or
|
|
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
16
|
- **Concurrent-safe `up`** — an advisory database lock serializes racing `up` runs (two deploy pods, CI + laptop) so a migration body never executes twice.
|
|
17
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).
|
|
18
20
|
- **CLI and library** — use it as `bunx bunsql-native-migrate` or import the functions directly.
|
|
19
21
|
|
|
20
22
|
## Installation
|
|
@@ -25,10 +27,25 @@ bun add bunsql-native-migrate
|
|
|
25
27
|
|
|
26
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.)
|
|
27
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
|
+
|
|
28
41
|
## Quick start
|
|
29
42
|
|
|
30
43
|
```bash
|
|
31
|
-
#
|
|
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
|
|
32
49
|
bunx bunsql-native-migrate create add_users_table
|
|
33
50
|
|
|
34
51
|
# create the tracking table (optional — up() does it automatically)
|
|
@@ -71,6 +88,23 @@ Files live in the migrations directory (default `./migrations`, override with `-
|
|
|
71
88
|
9999999999999_2026_09_13_add_users_table.ts
|
|
72
89
|
```
|
|
73
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:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
migrations/
|
|
97
|
+
9999999999998_2026_09_14_add_index.up.sql
|
|
98
|
+
9999999999998_2026_09_14_add_index.down.sql
|
|
99
|
+
```
|
|
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
|
+
|
|
74
108
|
### Transactional migrations
|
|
75
109
|
|
|
76
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.
|
|
@@ -108,30 +142,43 @@ This resolution is part of the library contract: `resolveListDir` (and therefore
|
|
|
108
142
|
## CLI reference
|
|
109
143
|
|
|
110
144
|
```
|
|
111
|
-
bunsql-native-migrate <up|down [n]|install|create [name]|status> [--dir <migrations-dir>] [--to <name>] [--lock-timeout <seconds>] [--all] [--lang <js|ts>] [--git] [--strict] [--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]
|
|
112
146
|
```
|
|
113
147
|
|
|
114
|
-
| Command | What it does
|
|
115
|
-
| --------------- |
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
120
|
-
| `
|
|
121
|
-
|
|
122
|
-
|
|
|
123
|
-
|
|
124
|
-
|
|
|
125
|
-
|
|
|
126
|
-
| `--
|
|
127
|
-
| `--
|
|
128
|
-
| `--
|
|
129
|
-
| `--
|
|
130
|
-
| `--
|
|
131
|
-
| `--
|
|
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. |
|
|
132
171
|
|
|
133
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.
|
|
134
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
|
+
|
|
135
182
|
## Programmatic API
|
|
136
183
|
|
|
137
184
|
```ts
|
|
@@ -141,8 +188,10 @@ import {
|
|
|
141
188
|
migrateStatus,
|
|
142
189
|
installMigrations,
|
|
143
190
|
createMigration,
|
|
191
|
+
markMigrationsApplied,
|
|
144
192
|
createDriver,
|
|
145
193
|
ChecksumDriftError,
|
|
194
|
+
InvalidIdentifierError,
|
|
146
195
|
MigrationLockError,
|
|
147
196
|
MigrationNotFoundError,
|
|
148
197
|
GitStageError,
|
|
@@ -153,16 +202,26 @@ const { applied } = await migrateUp({
|
|
|
153
202
|
listDir: "./migrations", // default: MIGRATION_LIST_DIR env or ./migrations
|
|
154
203
|
to: "2_add_columns.ts", // optional: apply up to and including this file
|
|
155
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
|
|
156
207
|
});
|
|
157
208
|
|
|
158
209
|
const { reverted } = await migrateDown(); // reverted: string[] (most-recent-first)
|
|
159
210
|
await migrateDown({ steps: 3 }); // revert the last three
|
|
160
211
|
await migrateDown({ steps: "all" }); // revert everything
|
|
161
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
|
+
|
|
162
217
|
const status = await migrateStatus();
|
|
163
218
|
// status.applied: ExecutedMigration[] (name + checksum, in apply order)
|
|
164
219
|
// status.pending: string[] (files waiting to be applied, in apply order)
|
|
165
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
|
|
224
|
+
|
|
166
225
|
await installMigrations(); // creates the tracking table
|
|
167
226
|
|
|
168
227
|
const filename = await createMigration({ name: "add_users_table", listDir: "./migrations" });
|
|
@@ -170,16 +229,19 @@ const filename = await createMigration({ name: "add_users_table", listDir: "./mi
|
|
|
170
229
|
|
|
171
230
|
All options are optional unless stated otherwise:
|
|
172
231
|
|
|
173
|
-
| Option | Where
|
|
174
|
-
| ------------- |
|
|
175
|
-
| `databaseUrl` | `migrateUp`, `migrateDown`, `migrateStatus`, `installMigrations` | `DATABASE_URL` env var
|
|
176
|
-
| `listDir` | all functions
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
180
|
-
| `
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
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 |
|
|
183
245
|
|
|
184
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.
|
|
185
247
|
|
|
@@ -199,7 +261,7 @@ Known MySQL/MariaDB limitation of `Bun.SQL` (not used by this package, good to k
|
|
|
199
261
|
|
|
200
262
|
Bun's client also recognizes `mysql2://` and `file://` URLs — `createDriver` deliberately rejects them. Stick to the protocols listed above.
|
|
201
263
|
|
|
202
|
-
`createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`). 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.
|
|
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.
|
|
203
265
|
|
|
204
266
|
### Tracking table
|
|
205
267
|
|
|
@@ -211,6 +273,45 @@ Applied migrations are recorded in a `migrations` table with a unique name and a
|
|
|
211
273
|
|
|
212
274
|
Records created before checksums existed (checksum `NULL`) are backfilled on the next `up`.
|
|
213
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
|
+
|
|
214
315
|
### Concurrent runs
|
|
215
316
|
|
|
216
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:
|
|
@@ -223,7 +324,7 @@ While the lock is held, another `up` polls and waits up to `lockTimeout` seconds
|
|
|
223
324
|
|
|
224
325
|
### Error handling
|
|
225
326
|
|
|
226
|
-
The library throws instead of exiting: connection errors, failing migrations, [`ChecksumDriftError`](#tracking-table), `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.
|
|
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.
|
|
227
328
|
|
|
228
329
|
`createMigration` with `git: true` throws `GitStageError` when `git add` fails (no repository, ignored path, …) — the migration file itself is still created on disk.
|
|
229
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/down.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { resolveListDir } from "../core/fs.js";
|
|
3
2
|
import { log } from "../core/console.js";
|
|
3
|
+
import { formatDuration } from "../core/duration.js";
|
|
4
4
|
import type { MigrationDriver } from "../core/driver.js";
|
|
5
5
|
import type { MigrateDownOptions, MigrateDownResult } from "./options.js";
|
|
6
6
|
import { runWithDriver } from "./run-with-driver.js";
|
|
7
7
|
import { runMigrationStep } from "./run-step.js";
|
|
8
|
+
import { isSqlMigration, loadMigration } from "./load-migration.js";
|
|
8
9
|
|
|
9
10
|
function resolveStepCount(steps: number | "all" | undefined, appliedCount: number): number {
|
|
10
11
|
if (steps === undefined) return 1;
|
|
@@ -16,31 +17,46 @@ function resolveStepCount(steps: number | "all" | undefined, appliedCount: numbe
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
async function revertOne(driver: MigrationDriver, listDir: string, file: string): Promise<void> {
|
|
19
|
-
const
|
|
20
|
+
const { down } = await loadMigration(listDir, file);
|
|
20
21
|
|
|
21
|
-
if (
|
|
22
|
-
|
|
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" });
|
|
23
25
|
await driver.remove(file);
|
|
24
26
|
log({ text: `${file} tracking record removed`, type: "success" });
|
|
25
27
|
return;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
await runMigrationStep(driver,
|
|
30
|
+
const durationMs = await runMigrationStep(driver, down);
|
|
29
31
|
await driver.remove(file);
|
|
30
|
-
log({ text: `${file} rolled back`, type: "success" });
|
|
32
|
+
log({ text: `${file} rolled back (${formatDuration(durationMs)})`, type: "success" });
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export async function migrateDown(options: MigrateDownOptions = {}): Promise<MigrateDownResult> {
|
|
34
36
|
const listDir = resolveListDir(options.listDir);
|
|
37
|
+
const dryRun = options.dryRun ?? false;
|
|
35
38
|
|
|
36
39
|
return runWithDriver(options, async (driver) => {
|
|
37
40
|
const executed = await driver.listExecuted();
|
|
38
41
|
if (executed.length === 0) {
|
|
39
42
|
log({ text: "No migrations to rollback.", type: "warn" });
|
|
40
|
-
return { reverted: [] };
|
|
43
|
+
return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
const count = resolveStepCount(options.steps, executed.length);
|
|
47
|
+
const plan = executed
|
|
48
|
+
.slice(-count)
|
|
49
|
+
.reverse()
|
|
50
|
+
.map((entry) => entry.name);
|
|
51
|
+
|
|
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 };
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
const reverted: string[] = [];
|
|
45
61
|
|
|
46
62
|
for (const entry of executed.slice(-count).reverse()) {
|
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
|
+
}
|
package/src/api/mark.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
|
|
3
|
+
import { log } from "../core/console.js";
|
|
4
|
+
import { type MarkOptions, type MarkResult, MigrationNotFoundError } from "./options.js";
|
|
5
|
+
import { runWithDriver } from "./run-with-driver.js";
|
|
6
|
+
|
|
7
|
+
export async function markMigrationsApplied(options: MarkOptions = {}): Promise<MarkResult> {
|
|
8
|
+
const listDir = resolveListDir(options.listDir);
|
|
9
|
+
const target = options.to;
|
|
10
|
+
|
|
11
|
+
return runWithDriver(options, async (driver) => {
|
|
12
|
+
await driver.install();
|
|
13
|
+
|
|
14
|
+
const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
|
|
15
|
+
if (target !== undefined && !allFiles.includes(target)) {
|
|
16
|
+
throw new MigrationNotFoundError(target);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const executed = await driver.listExecuted();
|
|
20
|
+
const executedNames = new Set(executed.map((entry) => entry.name));
|
|
21
|
+
let pending = allFiles.filter((file) => !executedNames.has(file));
|
|
22
|
+
if (target !== undefined) {
|
|
23
|
+
if (executedNames.has(target)) {
|
|
24
|
+
log({ text: `${target} is already applied.`, type: "info" });
|
|
25
|
+
return { marked: [] };
|
|
26
|
+
}
|
|
27
|
+
pending = pending.slice(0, pending.indexOf(target) + 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const marked: string[] = [];
|
|
31
|
+
for (const file of pending) {
|
|
32
|
+
const checksum = await checksumFile(path.join(listDir, file));
|
|
33
|
+
await driver.record(file, checksum);
|
|
34
|
+
marked.push(file);
|
|
35
|
+
log({ text: `${file} marked as applied`, type: "success" });
|
|
36
|
+
}
|
|
37
|
+
if (marked.length === 0) {
|
|
38
|
+
log({ text: "No pending migrations to mark.", type: "warn" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { marked };
|
|
42
|
+
});
|
|
43
|
+
}
|
package/src/api/options.ts
CHANGED
|
@@ -3,23 +3,37 @@ import type { ExecutedMigration } from "../core/driver.js";
|
|
|
3
3
|
export interface MigrateOptions {
|
|
4
4
|
databaseUrl?: string;
|
|
5
5
|
listDir?: string;
|
|
6
|
+
tableName?: string;
|
|
7
|
+
schema?: string;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
export interface MigrateUpOptions extends MigrateOptions {
|
|
9
11
|
to?: string;
|
|
10
12
|
lockTimeout?: number;
|
|
13
|
+
dryRun?: boolean;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
16
|
export interface MigrateDownOptions extends MigrateOptions {
|
|
14
17
|
steps?: number | "all";
|
|
18
|
+
dryRun?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MarkOptions extends MigrateOptions {
|
|
22
|
+
to?: string;
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
export interface MigrateUpResult {
|
|
18
26
|
applied: string[];
|
|
27
|
+
planned?: string[];
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
export interface MigrateDownResult {
|
|
22
31
|
reverted: string[];
|
|
32
|
+
planned?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MarkResult {
|
|
36
|
+
marked: string[];
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
export interface MigrateStatusResult {
|
package/src/api/run-step.ts
CHANGED
|
@@ -4,10 +4,12 @@ import type { MigrationDriver } from "../core/driver.js";
|
|
|
4
4
|
export async function runMigrationStep(
|
|
5
5
|
driver: MigrationDriver,
|
|
6
6
|
step: (tx?: SQL) => Promise<void>,
|
|
7
|
-
): Promise<
|
|
7
|
+
): Promise<number> {
|
|
8
|
+
const startedAt = performance.now();
|
|
8
9
|
if (step.length > 0) {
|
|
9
10
|
await driver.transaction((tx) => step(tx));
|
|
10
|
-
return;
|
|
11
|
+
return performance.now() - startedAt;
|
|
11
12
|
}
|
|
12
13
|
await step();
|
|
14
|
+
return performance.now() - startedAt;
|
|
13
15
|
}
|
|
@@ -7,7 +7,10 @@ export async function runWithDriver<T>(
|
|
|
7
7
|
run: (driver: MigrationDriver) => Promise<T>,
|
|
8
8
|
): Promise<T> {
|
|
9
9
|
const url = getDatabaseUrl(options.databaseUrl);
|
|
10
|
-
const driver = await createDriver(url
|
|
10
|
+
const driver = await createDriver(url, {
|
|
11
|
+
...(options.tableName !== undefined ? { tableName: options.tableName } : {}),
|
|
12
|
+
...(options.schema !== undefined ? { schema: options.schema } : {}),
|
|
13
|
+
});
|
|
11
14
|
try {
|
|
12
15
|
return await run(driver);
|
|
13
16
|
} finally {
|