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 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 TypeScript 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
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
- # create migrations/<timestamp>_<name>.ts from the stub template
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
- | `up` | Applies pending migrations (creating the tracking table if needed); 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. |
117
- | `down` | Reverts the last applied migration; `down <n>` reverts the last `n`, `down --all` reverts everything — always most-recent-first. Prints `No migrations to rollback.` when there is none. |
118
- | `install` | Creates the tracking table only. |
119
- | `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. |
120
- | `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. |
121
-
122
- | Flag | Applies to | Meaning |
123
- | ------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
124
- | `--dir <migrations-dir>` | all | Migrations directory (default `./migrations`, or the `MIGRATION_LIST_DIR` env var). |
125
- | `--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. |
126
- | `--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. |
127
- | `--all` | `down` | Revert every applied migration (most-recent-first). Cannot be combined with a step count. |
128
- | `--git` | `create` | `git add` the created file. When staging fails, the CLI prints an error and exits 1 — the file itself stays on disk. |
129
- | `--lang <js\|ts>` | `create` | Language of the created stub. Default: `ts`. An unknown or missing value is an error (exit 1). |
130
- | `--strict` | `status` | Exit with code 1 when migrations are pending — a gate for CI/CD pipelines. Exit 0 otherwise. |
131
- | `--help`, `-h` | — | Prints the usage line and exits with code 0. |
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 | Default |
174
- | ------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
175
- | `databaseUrl` | `migrateUp`, `migrateDown`, `migrateStatus`, `installMigrations` | `DATABASE_URL` env var |
176
- | `listDir` | all functions | `MIGRATION_LIST_DIR` env var, then `./migrations` (relative paths resolve against the process cwd) |
177
- | `to` | `migrateUp` | — apply pending migrations in order up to and including the named file; an unknown name throws `MigrationNotFoundError`, an already-applied target applies nothing |
178
- | `lockTimeout` | `migrateUp` | `30` — seconds to wait for the migration lock while another `up` is running; `0` fails immediately; a timeout throws `MigrationLockError` |
179
- | `steps` | `migrateDown` | `1` — revert the last `n` applied migrations (`1`–`n` or `"all"`), most-recent-first |
180
- | `name` | `createMigration` | random `adjective_noun` name |
181
- | `lang` | `createMigration` | `"ts"` — pass `"js"` for a JavaScript stub |
182
- | `git` | `createMigration` | `false` — `git add` the new file |
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.2.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 mod = await import(path.join(listDir, file));
20
+ const { down } = await loadMigration(listDir, file);
20
21
 
21
- if (typeof mod.down !== "function") {
22
- log({ text: `${file} has no down() export, removing tracking record`, type: "warn" });
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, mod.down);
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()) {
@@ -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
+ }
@@ -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
+ }
@@ -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 {
@@ -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<void> {
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 {