bunsql-native-migrate 0.3.2 → 0.4.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
@@ -14,6 +14,9 @@ No ORM, no schema diffing, no lock-in — you write plain `.js`/`.ts` migration
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
+ - **Dev loop `redo`** — `down` + `up` of the last migrations in one command: re-run a migration you are still editing without checksum-drift noise.
18
+ - **Wait for the database** — `--wait <seconds>` polls the connection before running, so a CI step next to a starting container needs no `sleep`/wait-for-it hacks.
19
+ - **Non-transactional migrations** — a `noTransaction` marker (or a header directive in a `.sql` file) runs a migration outside the transaction wrapper, for operations like `CREATE INDEX CONCURRENTLY`.
17
20
  - **Legacy backfill** — records without a checksum are backfilled automatically on the next `up`.
18
21
  - **Dry run** — `up --dry-run` / `down --dry-run` print the plan without touching the database.
19
22
  - **Custom tracking table** — several projects can share one database, each with its own history table (`--table`, and `--schema` for PostgreSQL).
@@ -62,9 +65,13 @@ bunx bunsql-native-migrate status --strict
62
65
 
63
66
  # roll back the last applied migration
64
67
  bunx bunsql-native-migrate down
68
+
69
+ # dev loop: roll back the last migration and re-apply it in one command
70
+ # (also: redo 3, redo --to 2_add_users.ts — see Redo below)
71
+ bunx bunsql-native-migrate redo
65
72
  ```
66
73
 
67
- The CLI reads `DATABASE_URL` from the environment (or a `.env` file — Bun loads it automatically).
74
+ The CLI reads `DATABASE_URL` from the environment (or a `.env` file — Bun loads it automatically). Pass `--url <url>` to point a single run at another database; the flag wins when both are set.
68
75
 
69
76
  ### Migration file format
70
77
 
@@ -102,7 +109,7 @@ migrations/
102
109
  - 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
110
  - Pairs sort together with `.js`/`.ts` files strictly by filename, so all three kinds interleave in one history.
104
111
  - 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).
112
+ - A `.sql` migration 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). A file whose leading comment block carries the `-- bunsql-migrate:no-transaction` directive runs outside the transaction instead — see [Non-transactional migrations](#non-transactional-migrations-notransaction).
106
113
  - The checksum covers the `.up.sql` file; editing a `.down.sql` after the fact is not tracked, exactly like JS `down()` bodies.
107
114
 
108
115
  ### Transactional migrations
@@ -127,6 +134,35 @@ export { up, down };
127
134
  - 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.
128
135
  - 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).
129
136
 
137
+ ### Non-transactional migrations
138
+
139
+ Some operations cannot run inside a transaction — most notably `CREATE INDEX CONCURRENTLY` on PostgreSQL. Export `noTransaction` from the migration file and both `up` and `down` run outside the transaction wrapper, with the runner's own database client passed where the `tx` client normally goes:
140
+
141
+ ```js
142
+ const up = async (db) => {
143
+ await db`CREATE INDEX CONCURRENTLY users_email_idx ON users (email)`;
144
+ };
145
+
146
+ const down = async (db) => {
147
+ await db`DROP INDEX CONCURRENTLY users_email_idx`;
148
+ };
149
+
150
+ const noTransaction = true;
151
+ export { up, down, noTransaction };
152
+ ```
153
+
154
+ - **There is no automatic rollback.** A failing statement leaves whatever ran before it applied, and nothing is recorded — write such migrations accordingly: one statement per migration, idempotent bodies, or manual cleanup in `down`.
155
+ - The client you receive is the driver's live connection, not a transaction. On pooled engines (PostgreSQL, MySQL/MariaDB) consecutive statements of one migration may run on different pooled connections — keep that in mind for anything session-scoped.
156
+ - On MySQL/MariaDB the marker changes little (DDL already implicitly commits) but it also gives up the DML rollback protection there.
157
+ - For SQL pairs the equivalent is a directive in the leading comment block of the file:
158
+
159
+ ```sql
160
+ -- bunsql-migrate:no-transaction
161
+ CREATE INDEX CONCURRENTLY users_email_idx ON users (email);
162
+ ```
163
+
164
+ The directive is honored only before the first statement; each file of the pair is marked separately, so a concurrently-built index needs the directive on its `.down.sql` too (`DROP INDEX CONCURRENTLY` cannot run in a transaction either).
165
+
130
166
  ### Migrations directory path resolution
131
167
 
132
168
  Relative paths — whether from `--dir`, the `listDir` option or `MIGRATION_LIST_DIR` — are always resolved against the **current working directory** of the process (the same anchor Bun uses to load `.env`). Run the CLI from your project root and plain `./migrations` works as expected.
@@ -142,32 +178,39 @@ This resolution is part of the library contract: `resolveListDir` (and therefore
142
178
  ## CLI reference
143
179
 
144
180
  ```
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]
181
+ bunsql-native-migrate <init|up|down [n]|redo [n]|install|create [name]|mark [name]|status|version> [--url <url>] [--dir <migrations-dir>] [--to <name>] [--lock-timeout <seconds>] [--wait <seconds>] [--table <name>] [--schema <name>] [--dry-run] [--all] [--lang <js|ts>] [--git] [--strict] [--version] [--help]
146
182
  ```
147
183
 
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. |
184
+ | Command | What it does |
185
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
186
+ | `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. |
187
+ | `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. |
188
+ | `down` | Reverts the last applied migration; `down <n>` reverts the last `n`, `down --all` reverts everything, `down --to <name>` reverts everything from the latest back to the named migration **inclusive** (the target becomes pending, exactly the mirror of `up --to`) — always most-recent-first, each printing `<file> rolled back (<duration>)`. An unknown name is an error before any writes (exit 1); a target that is not applied is a reported no-op. Prints `No migrations to rollback.` when there is none. |
189
+ | `redo [n]` | The edit-rerun dev loop: reverts the last applied migration (or the last `n`, or everything down to `--to <name>` inclusive) and immediately re-applies it. Prints `No migrations to redo.` on a fresh database (exit 0); an unapplied `--to` target is a reported no-op. See [Redo](#redo-re-running-migrations-you-are-still-editing). |
190
+ | `install` | Creates the tracking table only. |
191
+ | `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. |
192
+ | `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)). |
193
+ | `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. |
194
+ | `version` | Prints the package version (from its own `package.json`, not yours) and exits 0 — no `DATABASE_URL` needed, no connection opened. The `--version` flag does the same from any invocation and wins over the command. |
195
+
196
+ | Flag | Applies to | Meaning |
197
+ | ------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
198
+ | `--url <url>` | up/down/redo/install/mark/status | Connection string for this run. Takes priority over the `DATABASE_URL` env var when both are set. Handy for engine matrices and CI without mutating `process.env`. |
199
+ | `--dir <migrations-dir>` | all | Migrations directory (default `./migrations`, or the `MIGRATION_LIST_DIR` env var). |
200
+ | `--to <name>` | `up`, `down`, `redo` | `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. `down`: revert everything from the latest back to the named migration inclusive (the target must be applied; an unknown name is an error before any writes, a pending target is a no-op; cannot be combined with a step count or `--all`). `redo`: revert everything down to the named migration inclusive, then re-apply it (the target must be applied; unknown name is an error before any writes). |
201
+ | `--lock-timeout <sec>` | `up`, `redo` | How long to wait for the migration lock when another `up` is running, in seconds (default `30`, `0` fails immediately). On timeout: exit 1. Applies to redo's `up` phase. |
202
+ | `--wait <sec>` | up/down/redo/install/mark/status | Wait for the database to become ready: poll the connection up to N seconds before running the command. Default (or `0`) is a single attempt, the previous behavior. Config mistakes — an unparseable URL, an invalid `--table` — still fail immediately; a wait that times out exits 1 with `database was not ready within Ns`. |
203
+ | `--table <name>` | up/down/redo/install/mark/status | Track history in this table instead of `migrations` (see [Custom tracking table](#custom-tracking-table)). |
204
+ | `--schema <name>` | up/down/redo/install/mark/status | PostgreSQL only: create/read the tracking table in this schema (must already exist). Rejected with an error on MySQL and SQLite. |
205
+ | `--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)). |
206
+ | `--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. |
207
+ | `--git` | `create` | `git add` the created file. When staging fails, the CLI prints an error and exits 1 — the file itself stays on disk. |
208
+ | `--lang <js\|ts>` | `create` | Language of the created stub. Default: `ts`. An unknown or missing value is an error (exit 1). |
209
+ | `--strict` | `status` | Exit with code 1 when migrations are pending — a gate for CI/CD pipelines. Exit 0 otherwise. |
210
+ | `--version` | — | Print the package version and exit 0 (same as the `version` command; works without `DATABASE_URL`). |
211
+ | `--help`, `-h` | — | Prints the usage line and exits with code 0. |
212
+
213
+ Flags are validated per command following the "Applies to" column: passing a flag the command does not take (`redo --all`, `redo --dry-run`, `mark --to …`) is a usage error — the CLI prints `redo does not support --all`, shows the usage line and exits 1 before touching the database. Only `--help` and `--version` are global (either wins over any command). Positional arguments are command-specific too: `up`, `install`, `status`, `init` and `version` take none.
171
214
 
172
215
  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.
173
216
 
@@ -185,12 +228,14 @@ Applied 2 migration(s).
185
228
  import {
186
229
  migrateUp,
187
230
  migrateDown,
231
+ migrateRedo,
188
232
  migrateStatus,
189
233
  installMigrations,
190
234
  createMigration,
191
235
  markMigrationsApplied,
192
236
  createDriver,
193
237
  ChecksumDriftError,
238
+ DatabaseWaitTimeoutError,
194
239
  InvalidIdentifierError,
195
240
  MigrationLockError,
196
241
  MigrationNotFoundError,
@@ -202,6 +247,7 @@ const { applied } = await migrateUp({
202
247
  listDir: "./migrations", // default: MIGRATION_LIST_DIR env or ./migrations
203
248
  to: "2_add_columns.ts", // optional: apply up to and including this file
204
249
  lockTimeout: 60, // optional: seconds to wait for the migration lock (default 30, 0 = fail fast)
250
+ waitTimeout: 30, // optional: seconds to wait for the database to become ready (default 0 = single attempt)
205
251
  tableName: "app_migrations", // optional: custom tracking table (default "migrations")
206
252
  schema: "private", // optional: postgres schema for the tracking table
207
253
  });
@@ -209,6 +255,11 @@ const { applied } = await migrateUp({
209
255
  const { reverted } = await migrateDown(); // reverted: string[] (most-recent-first)
210
256
  await migrateDown({ steps: 3 }); // revert the last three
211
257
  await migrateDown({ steps: "all" }); // revert everything
258
+ await migrateDown({ to: "2_add_columns.ts" }); // revert down to this file inclusive
259
+
260
+ const redo = await migrateRedo(); // redo: { reverted, applied } — revert the last and re-apply
261
+ await migrateRedo({ steps: 3 }); // redo the last three
262
+ await migrateRedo({ to: "2_batch.ts" }); // redo everything down to 2_batch.ts inclusive
212
263
 
213
264
  const { planned } = await migrateUp({ dryRun: true }); // preview only (see Dry run)
214
265
  // planned: string[] — what a real up would apply, applied stays []
@@ -229,21 +280,22 @@ const filename = await createMigration({ name: "add_users_table", listDir: "./mi
229
280
 
230
281
  All options are optional unless stated otherwise:
231
282
 
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.
283
+ | Option | Where | Default |
284
+ | ------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `databaseUrl` | `migrateUp`, `migrateDown`, `migrateRedo`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | `DATABASE_URL` env var |
286
+ | `listDir` | all functions | `MIGRATION_LIST_DIR` env var, then `./migrations` (relative paths resolve against the process cwd) |
287
+ | `waitTimeout` | `migrateUp`, `migrateDown`, `migrateRedo`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | `0` — a single connection attempt, the previous behavior. A positive value polls the connection for that many seconds before running (CLI `--wait`); on expiry it throws `DatabaseWaitTimeoutError` |
288
+ | `tableName` | `migrateUp`, `migrateDown`, `migrateRedo`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | `"migrations"` — a custom tracking table name (see [Custom tracking table](#custom-tracking-table)) |
289
+ | `schema` | `migrateUp`, `migrateDown`, `migrateRedo`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | — PostgreSQL only: the schema holding the tracking table (must already exist); rejected on other URLs |
290
+ | `to` | `migrateUp`, `migrateDown`, `markMigrationsApplied`, `migrateRedo` | `migrateUp`: apply pending migrations in order up to and including the named file (unknown name throws `MigrationNotFoundError`, an already-applied target applies nothing). `migrateDown`: revert everything from the latest back to the named migration inclusive — the target must be applied (a pending target is a reported no-op), an unknown name throws `MigrationNotFoundError` before any writes, cannot be combined with `steps`. `markMigrationsApplied`: same boundary as `up`, but the files are only recorded as applied; omitted — mark every pending migration. `migrateRedo`: revert everything down to the named migration inclusive and re-apply it; the target must be applied, otherwise a reported no-op |
291
+ | `lockTimeout` | `migrateUp`, `migrateRedo` | `30` — seconds to wait for the migration lock while another `up` is running; `0` fails immediately; a timeout throws `MigrationLockError`. Applies to redo's `up` phase |
292
+ | `dryRun` | `migrateUp`, `migrateDown` | `false` — plan only: the result carries `planned: string[]` while `applied`/`reverted` stay empty (see [Dry run](#dry-run)) |
293
+ | `steps` | `migrateDown`, `migrateRedo` | `1` — `migrateDown`: revert the last `n` applied migrations (`1`–`n` or `"all"`), most-recent-first; cannot be combined with `to`. `migrateRedo`: redo the last `n` (positive integer, no `"all"`); cannot be combined with `to` |
294
+ | `name` | `createMigration` | random `adjective_noun` name |
295
+ | `lang` | `createMigration` | `"ts"` — pass `"js"` for a JavaScript stub |
296
+ | `git` | `createMigration` | `false` — `git add` the new file |
297
+
298
+ `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. `to` and `steps` are two ways to pick the revert window — passing both is an error before anything runs.
247
299
 
248
300
  ### Drivers
249
301
 
@@ -312,6 +364,22 @@ Would apply 2 migration(s).
312
364
  - `down --dry-run` plans the revert list in reverse apply order; on an empty history — a database that has never seen an `up`, included — it prints `No migrations to rollback.` like the real command. A real `down` creates the tracking table when it is missing, so it degrades to the same message instead of a driver error.
313
365
  - In the API result the plan lands in `planned: string[]` while `applied`/`reverted` stay empty — existing consumers keep working untouched.
314
366
 
367
+ ### Redo: re-running migrations you are still editing
368
+
369
+ While a migration is still being shaped, the loop is _edit → down → up_. `redo` collapses it into one command (CLI `redo`, `redo <n>`, `redo --to <name>`; API `migrateRedo`):
370
+
371
+ ```bash
372
+ bunx bunsql-native-migrate redo # re-run the last applied migration
373
+ bunx bunsql-native-migrate redo 3 # re-run the last three
374
+ bunx bunsql-native-migrate redo --to 2_add_users.ts # re-run everything down to 2_add_users.ts inclusive
375
+ ```
376
+
377
+ - It composes the existing commands: a `down` of the window (the last `n`, or everything from the latest back to the `--to` target inclusive), then an `up` that re-applies exactly that window — bounded by the migration that was most recently applied before the redo, so migrations that were already pending and sort older stay pending.
378
+ - Editing an applied file normally triggers `ChecksumDriftError` on the next `up`. After a `redo` the old tracking record is gone (the down phase removed it), so the edited file re-applies cleanly with its **new** checksum — that is the point of the command. Note that the down phase runs the _current_ `down()` body against the _old_ up's effects: keep edits additive, or expect the down phase to fail if the shapes diverge.
379
+ - An unknown `--to` name throws `MigrationNotFoundError` before any writes; a name that exists but is not applied is a reported no-op (exit 0). On a fresh database `redo` prints `No migrations to redo.` and exits 0.
380
+ - If the `up` phase fails, everything the down phase reverted stays reverted — the rollbacks are printed, a warning points at them, the error propagates and the CLI exits 1. Run `up` to re-apply.
381
+ - Concurrency: the down phase runs unlocked (like `down`), the up phase takes the advisory lock (like `up`, honoring `--lock-timeout`). The window between the phases is therefore not atomic — `redo` is a local dev-loop tool, not something to race on production.
382
+
315
383
  ### Concurrent runs
316
384
 
317
385
  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:
@@ -324,7 +392,7 @@ While the lock is held, another `up` polls and waits up to `lockTimeout` seconds
324
392
 
325
393
  ### Error handling
326
394
 
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.
395
+ 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), [`MigrationLockError`](#concurrent-runs) (another `up` held the lock past `lockTimeout`) and `DatabaseWaitTimeoutError` (the database did not become ready within `waitTimeout`; config mistakes like an unparseable URL or an invalid `tableName` are not retried — they throw immediately) propagate to the caller, and the driver connection is always closed. The CLI catches these and exits with code 1.
328
396
 
329
397
  `createMigration` with `git: true` throws `GitStageError` when `git add` fails (no repository, ignored path, …) — the migration file itself is still created on disk.
330
398
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsql-native-migrate",
3
- "version": "0.3.2",
3
+ "version": "0.4.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",
package/src/api/down.ts CHANGED
@@ -2,12 +2,15 @@ import { resolveListDir } from "../core/fs.js";
2
2
  import { log } from "../core/console.js";
3
3
  import { formatDuration } from "../core/duration.js";
4
4
  import type { MigrationDriver } from "../core/driver.js";
5
- import type { MigrateDownOptions, MigrateDownResult } from "./options.js";
5
+ import { type MigrateDownOptions, type MigrateDownResult } from "./options.js";
6
6
  import { runWithDriver } from "./run-with-driver.js";
7
7
  import { runMigrationStep } from "./run-step.js";
8
8
  import { isSqlMigration, loadMigration } from "./load-migration.js";
9
+ import { assertTargetOptions } from "./pending.js";
9
10
  import { ensureTrackingTable, listExecutedForPlan } from "./tracking-table.js";
10
11
 
12
+ export function parseSteps(steps: number | undefined): number;
13
+ export function parseSteps(steps: number | "all" | undefined): number | "all";
11
14
  export function parseSteps(steps: number | "all" | undefined): number | "all" {
12
15
  if (steps === undefined) return 1;
13
16
  if (steps === "all") return "all";
@@ -41,6 +44,9 @@ async function revertOne(driver: MigrationDriver, listDir: string, file: string)
41
44
  export async function migrateDown(options: MigrateDownOptions = {}): Promise<MigrateDownResult> {
42
45
  const listDir = resolveListDir(options.listDir);
43
46
  const dryRun = options.dryRun ?? false;
47
+ const target = options.to;
48
+
49
+ await assertTargetOptions("down", listDir, target, options.steps);
44
50
 
45
51
  return runWithDriver(options, async (driver) => {
46
52
  if (!dryRun) {
@@ -53,7 +59,16 @@ export async function migrateDown(options: MigrateDownOptions = {}): Promise<Mig
53
59
  return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
54
60
  }
55
61
 
56
- const count = resolveStepCount(options.steps, executed.length);
62
+ const appliedNames = executed.map((entry) => entry.name);
63
+ let count = resolveStepCount(options.steps, executed.length);
64
+ if (target !== undefined) {
65
+ const boundary = appliedNames.indexOf(target);
66
+ if (boundary === -1) {
67
+ log({ text: `${target} is not applied — nothing to rollback.`, type: "warn" });
68
+ return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
69
+ }
70
+ count = executed.length - boundary;
71
+ }
57
72
  const revertList = executed.slice(-count).reverse();
58
73
  const plan = revertList.map((entry) => entry.name);
59
74
 
@@ -3,12 +3,18 @@ import { type SQL } from "bun";
3
3
 
4
4
  export type MigrationStep = (tx?: SQL) => Promise<void>;
5
5
 
6
+ export interface MigrationStepPlan {
7
+ step: MigrationStep;
8
+ noTransaction: boolean;
9
+ }
10
+
6
11
  export interface MigrationFunctions {
7
- up: MigrationStep | null;
8
- down: MigrationStep | null;
12
+ up: MigrationStepPlan | null;
13
+ down: MigrationStepPlan | null;
9
14
  }
10
15
 
11
16
  const SQL_UP_SUFFIX = ".up.sql";
17
+ const NO_TRANSACTION_DIRECTIVE = "-- bunsql-migrate:no-transaction";
12
18
 
13
19
  export function isSqlMigration(file: string): boolean {
14
20
  return file.endsWith(SQL_UP_SUFFIX);
@@ -27,19 +33,38 @@ function sqlFileStep(filePath: string): MigrationStep {
27
33
  };
28
34
  }
29
35
 
36
+ function hasNoTransactionDirective(content: string): boolean {
37
+ for (const line of content.split("\n")) {
38
+ const trimmed = line.trim();
39
+ if (trimmed === "") continue;
40
+ if (!trimmed.startsWith("--")) return false;
41
+ if (trimmed === NO_TRANSACTION_DIRECTIVE) return true;
42
+ }
43
+ return false;
44
+ }
45
+
46
+ async function sqlFilePlan(filePath: string): Promise<MigrationStepPlan> {
47
+ const content = await Bun.file(filePath).text();
48
+ return {
49
+ step: sqlFileStep(filePath),
50
+ noTransaction: hasNoTransactionDirective(content),
51
+ };
52
+ }
53
+
30
54
  export async function loadMigration(listDir: string, file: string): Promise<MigrationFunctions> {
31
55
  if (isSqlMigration(file)) {
32
56
  const upPath = path.join(listDir, file);
33
57
  const downPath = path.join(listDir, sqlDownFile(file));
34
58
  return {
35
- up: sqlFileStep(upPath),
36
- down: (await Bun.file(downPath).exists()) ? sqlFileStep(downPath) : null,
59
+ up: await sqlFilePlan(upPath),
60
+ down: (await Bun.file(downPath).exists()) ? await sqlFilePlan(downPath) : null,
37
61
  };
38
62
  }
39
63
 
40
64
  const mod = await import(path.join(listDir, file));
65
+ const noTransaction = mod.noTransaction === true;
41
66
  return {
42
- up: typeof mod.up === "function" ? mod.up : null,
43
- down: typeof mod.down === "function" ? mod.down : null,
67
+ up: typeof mod.up === "function" ? { step: mod.up, noTransaction } : null,
68
+ down: typeof mod.down === "function" ? { step: mod.down, noTransaction } : null,
44
69
  };
45
70
  }
package/src/api/lock.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { log } from "../core/console.js";
2
+ import { resolveSecondsOption } from "../core/duration.js";
2
3
  import type { MigrationDriver } from "../core/driver.js";
3
4
  import { MigrationLockError } from "./options.js";
4
5
 
@@ -7,13 +8,7 @@ export const DEFAULT_LOCK_TIMEOUT_SECONDS = 30;
7
8
  const LOCK_RETRY_DELAY_MS = 100;
8
9
 
9
10
  export function resolveLockTimeout(lockTimeout: number | undefined): number {
10
- if (lockTimeout === undefined) return DEFAULT_LOCK_TIMEOUT_SECONDS;
11
- if (!Number.isInteger(lockTimeout) || lockTimeout < 0) {
12
- throw new Error(
13
- `Invalid lockTimeout: ${String(lockTimeout)} — expected a non-negative integer of seconds`,
14
- );
15
- }
16
- return lockTimeout;
11
+ return resolveSecondsOption("lockTimeout", lockTimeout, DEFAULT_LOCK_TIMEOUT_SECONDS);
17
12
  }
18
13
 
19
14
  export async function withMigrationLock<T>(
@@ -5,6 +5,7 @@ export interface MigrateOptions {
5
5
  listDir?: string;
6
6
  tableName?: string;
7
7
  schema?: string;
8
+ waitTimeout?: number;
8
9
  }
9
10
 
10
11
  export interface MigrateUpOptions extends MigrateOptions {
@@ -15,9 +16,16 @@ export interface MigrateUpOptions extends MigrateOptions {
15
16
 
16
17
  export interface MigrateDownOptions extends MigrateOptions {
17
18
  steps?: number | "all";
19
+ to?: string;
18
20
  dryRun?: boolean;
19
21
  }
20
22
 
23
+ export interface RedoOptions extends MigrateOptions {
24
+ steps?: number;
25
+ to?: string;
26
+ lockTimeout?: number;
27
+ }
28
+
21
29
  export interface MarkOptions extends MigrateOptions {
22
30
  to?: string;
23
31
  }
@@ -32,6 +40,11 @@ export interface MigrateDownResult {
32
40
  planned?: string[];
33
41
  }
34
42
 
43
+ export interface RedoResult {
44
+ reverted: string[];
45
+ applied: string[];
46
+ }
47
+
35
48
  export interface MarkResult {
36
49
  marked: string[];
37
50
  }
@@ -75,6 +88,17 @@ export class MigrationLockError extends Error {
75
88
  }
76
89
  }
77
90
 
91
+ export class DatabaseWaitTimeoutError extends Error {
92
+ readonly timeoutSeconds: number;
93
+
94
+ constructor(timeoutSeconds: number, cause?: unknown) {
95
+ const reason = cause instanceof Error ? `: ${cause.message}` : "";
96
+ super(`database was not ready within ${timeoutSeconds}s${reason}`);
97
+ this.name = "DatabaseWaitTimeoutError";
98
+ this.timeoutSeconds = timeoutSeconds;
99
+ }
100
+ }
101
+
78
102
  export class GitStageError extends Error {
79
103
  readonly file: string;
80
104
  readonly exitCode: number;
@@ -1,3 +1,4 @@
1
+ import { listFiles, MIGRATION_EXTENSIONS } from "../core/fs.js";
1
2
  import { log } from "../core/console.js";
2
3
  import { MigrationNotFoundError } from "./options.js";
3
4
 
@@ -12,6 +13,26 @@ interface ResolvePendingOptions {
12
13
  target: string | undefined;
13
14
  }
14
15
 
16
+ export function assertTargetInFiles(allFiles: readonly string[], target: string): void {
17
+ if (!allFiles.includes(target)) {
18
+ throw new MigrationNotFoundError(target);
19
+ }
20
+ }
21
+
22
+ export async function assertTargetOptions(
23
+ command: string,
24
+ listDir: string,
25
+ target: string | undefined,
26
+ steps: unknown,
27
+ ): Promise<void> {
28
+ if (target !== undefined && steps !== undefined) {
29
+ throw new Error(`Invalid ${command} options: "to" and "steps" cannot be combined`);
30
+ }
31
+ if (target !== undefined) {
32
+ assertTargetInFiles(await listFiles(listDir, MIGRATION_EXTENSIONS), target);
33
+ }
34
+ }
35
+
15
36
  export function resolvePendingToTarget({
16
37
  allFiles,
17
38
  executedNames,
@@ -19,8 +40,8 @@ export function resolvePendingToTarget({
19
40
  }: ResolvePendingOptions): PendingToTargetResult {
20
41
  const executed = new Set(executedNames);
21
42
 
22
- if (target !== undefined && !allFiles.includes(target)) {
23
- throw new MigrationNotFoundError(target);
43
+ if (target !== undefined) {
44
+ assertTargetInFiles(allFiles, target);
24
45
  }
25
46
 
26
47
  let pending = allFiles.filter((file) => !executed.has(file));
@@ -0,0 +1,44 @@
1
+ import { resolveListDir } from "../core/fs.js";
2
+ import { log } from "../core/console.js";
3
+ import { type RedoOptions, type RedoResult } from "./options.js";
4
+ import { migrateDown, parseSteps } from "./down.js";
5
+ import { migrateUp } from "./up.js";
6
+ import { migrateStatus } from "./status.js";
7
+ import { assertTargetOptions } from "./pending.js";
8
+
9
+ export async function migrateRedo(options: RedoOptions = {}): Promise<RedoResult> {
10
+ const listDir = resolveListDir(options.listDir);
11
+ const target = options.to;
12
+ const steps = parseSteps(options.steps);
13
+
14
+ await assertTargetOptions("redo", listDir, target, options.steps);
15
+
16
+ const { applied } = await migrateStatus(options);
17
+ const lastApplied = applied.at(-1)?.name;
18
+ if (lastApplied === undefined) {
19
+ log({ text: "No migrations to redo.", type: "warn" });
20
+ return { reverted: [], applied: [] };
21
+ }
22
+
23
+ const appliedNames = applied.map((entry) => entry.name);
24
+ if (target !== undefined && !appliedNames.includes(target)) {
25
+ log({ text: `${target} is not applied — nothing to redo.`, type: "warn" });
26
+ return { reverted: [], applied: [] };
27
+ }
28
+
29
+ let reverted: string[] = [];
30
+ try {
31
+ ({ reverted } =
32
+ target !== undefined ? await migrateDown(options) : await migrateDown({ ...options, steps }));
33
+ const up = await migrateUp({ ...options, to: lastApplied });
34
+ return { reverted, applied: up.applied };
35
+ } catch (error) {
36
+ if (reverted.length > 0) {
37
+ log({
38
+ text: "Redo: the up phase failed — the rollbacks above stay reverted; run up to re-apply them",
39
+ type: "warn",
40
+ });
41
+ }
42
+ throw error;
43
+ }
44
+ }
@@ -1,15 +1,33 @@
1
1
  import { type SQL } from "bun";
2
2
  import type { MigrationDriver } from "../core/driver.js";
3
+ import type { MigrationStepPlan } from "./load-migration.js";
3
4
 
4
5
  export async function runMigrationStep(
5
6
  driver: MigrationDriver,
6
- step: (tx?: SQL) => Promise<void>,
7
+ plan: MigrationStepPlan,
7
8
  ): Promise<number> {
8
9
  const startedAt = performance.now();
9
- if (step.length > 0) {
10
- await driver.transaction((tx) => step(tx));
10
+ if (plan.noTransaction) {
11
+ await runOutsideTransaction(driver, plan.step);
12
+ return performance.now() - startedAt;
13
+ }
14
+ if (plan.step.length > 0) {
15
+ await driver.transaction((tx) => plan.step(tx));
11
16
  return performance.now() - startedAt;
12
17
  }
13
- await step();
18
+ await plan.step();
14
19
  return performance.now() - startedAt;
15
20
  }
21
+
22
+ function runOutsideTransaction(
23
+ driver: MigrationDriver,
24
+ step: (tx?: SQL) => Promise<void>,
25
+ ): Promise<void> {
26
+ const client = driver.client?.();
27
+ if (client === undefined) {
28
+ throw new Error(
29
+ "this driver does not expose a non-transactional client — the noTransaction marker is unsupported here",
30
+ );
31
+ }
32
+ return step(client);
33
+ }
@@ -1,16 +1,21 @@
1
1
  import { getDatabaseUrl } from "../core/env.js";
2
- import { createDriver, type MigrationDriver } from "../core/driver.js";
2
+ import type { MigrationDriver } from "../core/driver.js";
3
3
  import type { MigrateOptions } from "./options.js";
4
+ import { connectDriver, resolveWaitTimeout } from "./wait.js";
4
5
 
5
6
  export async function runWithDriver<T>(
6
7
  options: MigrateOptions,
7
8
  run: (driver: MigrationDriver) => Promise<T>,
8
9
  ): Promise<T> {
9
10
  const url = getDatabaseUrl(options.databaseUrl);
10
- const driver = await createDriver(url, {
11
- ...(options.tableName !== undefined ? { tableName: options.tableName } : {}),
12
- ...(options.schema !== undefined ? { schema: options.schema } : {}),
13
- });
11
+ const driver = await connectDriver(
12
+ url,
13
+ {
14
+ ...(options.tableName !== undefined ? { tableName: options.tableName } : {}),
15
+ ...(options.schema !== undefined ? { schema: options.schema } : {}),
16
+ },
17
+ resolveWaitTimeout(options.waitTimeout),
18
+ );
14
19
  try {
15
20
  return await run(driver);
16
21
  } finally {
@@ -0,0 +1,67 @@
1
+ import { log } from "../core/console.js";
2
+ import { createDriver, type DriverTableOptions, type MigrationDriver } from "../core/driver.js";
3
+ import { resolveSecondsOption } from "../core/duration.js";
4
+ import { DatabaseWaitTimeoutError } from "./options.js";
5
+
6
+ export const WAIT_RETRY_DELAY_MS = 500;
7
+
8
+ export function resolveWaitTimeout(waitTimeout: number | undefined): number {
9
+ return resolveSecondsOption("waitTimeout", waitTimeout, 0);
10
+ }
11
+
12
+ export async function waitForDatabase<T>(
13
+ attempt: () => Promise<T>,
14
+ timeoutSeconds: number,
15
+ retryDelayMs: number = WAIT_RETRY_DELAY_MS,
16
+ ): Promise<T> {
17
+ const deadline = Date.now() + timeoutSeconds * 1000;
18
+ let waitingLogged = false;
19
+ while (true) {
20
+ try {
21
+ return await attempt();
22
+ } catch (error) {
23
+ if (Date.now() >= deadline) {
24
+ throw new DatabaseWaitTimeoutError(timeoutSeconds, error);
25
+ }
26
+ if (!waitingLogged) {
27
+ log({ text: `database is not ready — waiting up to ${timeoutSeconds}s`, type: "info" });
28
+ waitingLogged = true;
29
+ }
30
+ await Bun.sleep(retryDelayMs);
31
+ }
32
+ }
33
+ }
34
+
35
+ async function connectProbed(
36
+ databaseUrl: string,
37
+ tableOptions: DriverTableOptions,
38
+ ): Promise<MigrationDriver> {
39
+ const driver = await createDriver(databaseUrl, tableOptions);
40
+ try {
41
+ await driver.transaction(async () => {});
42
+ return driver;
43
+ } catch (error) {
44
+ await driver.close().catch(() => undefined);
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ async function assertDriverConfig(
50
+ databaseUrl: string,
51
+ tableOptions: DriverTableOptions,
52
+ ): Promise<void> {
53
+ const driver = await createDriver(databaseUrl, tableOptions);
54
+ await driver.close();
55
+ }
56
+
57
+ export async function connectDriver(
58
+ databaseUrl: string,
59
+ tableOptions: DriverTableOptions,
60
+ waitTimeout: number,
61
+ ): Promise<MigrationDriver> {
62
+ if (waitTimeout <= 0) {
63
+ return createDriver(databaseUrl, tableOptions);
64
+ }
65
+ await assertDriverConfig(databaseUrl, tableOptions);
66
+ return waitForDatabase(() => connectProbed(databaseUrl, tableOptions), waitTimeout);
67
+ }
package/src/cli/main.ts CHANGED
@@ -1,48 +1,145 @@
1
1
  #!/usr/bin/env bun
2
+ import path from "node:path";
2
3
  import { migrateUp } from "../api/up.js";
3
4
  import { migrateDown, parseSteps } from "../api/down.js";
5
+ import { migrateRedo } from "../api/redo.js";
4
6
  import { migrateStatus } from "../api/status.js";
5
7
  import { installMigrations } from "../api/install.js";
6
8
  import { createMigrationCommand, type MigrationLang } from "../api/create.js";
7
9
  import { initMigrations } from "../api/init.js";
8
10
  import { markMigrationsApplied } from "../api/mark.js";
9
- import { resolveLockTimeout } from "../api/lock.js";
10
- import { ChecksumDriftError, MigrationLockError, MigrationNotFoundError } from "../api/options.js";
11
+ import { resolveSecondsOption } from "../core/duration.js";
12
+ import {
13
+ ChecksumDriftError,
14
+ DatabaseWaitTimeoutError,
15
+ MigrationLockError,
16
+ MigrationNotFoundError,
17
+ } from "../api/options.js";
11
18
  import { InvalidIdentifierError } from "../core/identifiers.js";
12
19
  import { log } from "../core/console.js";
13
20
 
14
21
  interface CliArgs {
15
22
  command: string | undefined;
16
23
  positional: string[];
24
+ url?: string | undefined;
17
25
  dir?: string | undefined;
18
26
  git: boolean;
19
27
  lang?: MigrationLang | undefined;
20
28
  to?: string | undefined;
21
29
  lockTimeout?: number | undefined;
30
+ wait?: number | undefined;
22
31
  table?: string | undefined;
23
32
  schema?: string | undefined;
24
33
  dryRun: boolean;
25
34
  all: boolean;
26
35
  strict: boolean;
27
36
  help: boolean;
37
+ version: boolean;
38
+ }
39
+
40
+ type FlagKey = Exclude<keyof CliArgs, "command" | "positional" | "help" | "version">;
41
+
42
+ const FLAG_NAMES: Record<FlagKey, string> = {
43
+ url: "--url",
44
+ dir: "--dir",
45
+ git: "--git",
46
+ lang: "--lang",
47
+ to: "--to",
48
+ lockTimeout: "--lock-timeout",
49
+ wait: "--wait",
50
+ table: "--table",
51
+ schema: "--schema",
52
+ dryRun: "--dry-run",
53
+ all: "--all",
54
+ strict: "--strict",
55
+ };
56
+
57
+ interface CommandSpec {
58
+ flags: ReadonlySet<FlagKey>;
59
+ positionalLimit: number;
60
+ }
61
+
62
+ const COMMAND_SPECS: Record<string, CommandSpec> = {
63
+ version: { flags: new Set<FlagKey>([]), positionalLimit: 0 },
64
+ up: {
65
+ flags: new Set(["url", "dir", "to", "lockTimeout", "wait", "table", "schema", "dryRun"]),
66
+ positionalLimit: 0,
67
+ },
68
+ down: {
69
+ flags: new Set(["url", "dir", "to", "wait", "table", "schema", "dryRun", "all"]),
70
+ positionalLimit: 1,
71
+ },
72
+ redo: {
73
+ flags: new Set(["url", "dir", "to", "lockTimeout", "wait", "table", "schema"]),
74
+ positionalLimit: 1,
75
+ },
76
+ init: { flags: new Set(["dir", "lang"]), positionalLimit: 0 },
77
+ install: { flags: new Set(["url", "dir", "wait", "table", "schema"]), positionalLimit: 0 },
78
+ create: { flags: new Set(["dir", "lang", "git"]), positionalLimit: 1 },
79
+ mark: { flags: new Set(["url", "dir", "wait", "table", "schema", "all"]), positionalLimit: 1 },
80
+ status: {
81
+ flags: new Set(["url", "dir", "wait", "table", "schema", "strict"]),
82
+ positionalLimit: 0,
83
+ },
84
+ };
85
+
86
+ function rejectDisallowedFlags(command: string, args: CliArgs): void {
87
+ const spec = COMMAND_SPECS[command];
88
+ if (spec === undefined) {
89
+ return;
90
+ }
91
+ const [extra] = args.positional.slice(spec.positionalLimit);
92
+ if (extra !== undefined) {
93
+ log({ text: `Unexpected argument for ${command}: ${extra}`, type: "error" });
94
+ usage(1);
95
+ }
96
+ for (const flag of Object.keys(FLAG_NAMES) as FlagKey[]) {
97
+ const value = args[flag];
98
+ if (value === undefined || value === false || spec.flags.has(flag)) {
99
+ continue;
100
+ }
101
+ log({ text: `${command} does not support ${FLAG_NAMES[flag]}.`, type: "error" });
102
+ usage(1);
103
+ }
104
+ }
105
+
106
+ function parseSecondsValue(flag: string, value: string | undefined): number {
107
+ try {
108
+ return resolveSecondsOption(flag, Number(value), 0);
109
+ } catch {
110
+ log({
111
+ text: `Invalid ${flag}: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
112
+ type: "error",
113
+ });
114
+ usage(1);
115
+ }
28
116
  }
29
117
 
30
118
  function parseArgs(argv: string[]): CliArgs {
31
119
  const positional: string[] = [];
120
+ let url: string | undefined;
32
121
  let dir: string | undefined;
33
122
  let git = false;
34
123
  let lang: MigrationLang | undefined;
35
124
  let to: string | undefined;
36
125
  let lockTimeout: number | undefined;
126
+ let wait: number | undefined;
37
127
  let table: string | undefined;
38
128
  let schema: string | undefined;
39
129
  let dryRun = false;
40
130
  let all = false;
41
131
  let strict = false;
42
132
  let help = false;
133
+ let version = false;
43
134
  for (let i = 0; i < argv.length; i++) {
44
135
  const arg = argv[i]!;
45
- if (arg === "--dir") {
136
+ if (arg === "--url") {
137
+ url = argv[++i];
138
+ if (url === undefined) {
139
+ log({ text: "--url requires a database URL", type: "error" });
140
+ usage(1);
141
+ }
142
+ } else if (arg === "--dir") {
46
143
  dir = argv[++i];
47
144
  } else if (arg === "--git") {
48
145
  git = true;
@@ -63,16 +160,9 @@ function parseArgs(argv: string[]): CliArgs {
63
160
  usage(1);
64
161
  }
65
162
  } else if (arg === "--lock-timeout") {
66
- const value = argv[++i];
67
- try {
68
- lockTimeout = resolveLockTimeout(Number(value));
69
- } catch {
70
- log({
71
- text: `Invalid --lock-timeout: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
72
- type: "error",
73
- });
74
- usage(1);
75
- }
163
+ lockTimeout = parseSecondsValue("--lock-timeout", argv[++i]);
164
+ } else if (arg === "--wait") {
165
+ wait = parseSecondsValue("--wait", argv[++i]);
76
166
  } else if (arg === "--table") {
77
167
  table = argv[++i];
78
168
  if (table === undefined) {
@@ -93,6 +183,8 @@ function parseArgs(argv: string[]): CliArgs {
93
183
  strict = true;
94
184
  } else if (arg === "--help" || arg === "-h") {
95
185
  help = true;
186
+ } else if (arg === "--version") {
187
+ version = true;
96
188
  } else {
97
189
  positional.push(arg);
98
190
  }
@@ -100,45 +192,72 @@ function parseArgs(argv: string[]): CliArgs {
100
192
  return {
101
193
  command: positional.shift(),
102
194
  positional,
195
+ url,
103
196
  dir,
104
197
  git,
105
198
  lang,
106
199
  to,
107
200
  lockTimeout,
201
+ wait,
108
202
  table,
109
203
  schema,
110
204
  dryRun,
111
205
  all,
112
206
  strict,
113
207
  help,
208
+ version,
114
209
  };
115
210
  }
116
211
 
117
212
  function usage(exitCode: number): never {
118
213
  log({
119
- text: "Usage: 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]",
214
+ text: "Usage: bunsql-native-migrate <init|up|down [n]|redo [n]|install|create [name]|mark [name]|status|version> [--url <url>] [--dir <migrations-dir>] [--to <name>] [--lock-timeout <seconds>] [--wait <seconds>] [--table <name>] [--schema <name>] [--dry-run] [--all] [--lang <js|ts>] [--git] [--strict] [--version] [--help]",
120
215
  type: "info",
121
216
  });
122
217
  process.exit(exitCode);
123
218
  }
124
219
 
220
+ async function printVersion(): Promise<void> {
221
+ const manifest = await Bun.file(path.resolve(import.meta.dir, "..", "..", "package.json")).json();
222
+ log({ text: String(manifest.version), type: "info" });
223
+ }
224
+
125
225
  const args = parseArgs(process.argv.slice(2));
226
+ const urlOptions = args.url !== undefined ? { databaseUrl: args.url } : {};
227
+ const waitOptions = args.wait !== undefined && args.wait > 0 ? { waitTimeout: args.wait } : {};
126
228
  const listDirOptions = args.dir ? { listDir: args.dir } : {};
127
229
  const tableOptions = {
128
230
  ...(args.table !== undefined ? { tableName: args.table } : {}),
129
231
  ...(args.schema !== undefined ? { schema: args.schema } : {}),
130
232
  };
233
+ const connectOptions = {
234
+ ...urlOptions,
235
+ ...listDirOptions,
236
+ ...tableOptions,
237
+ ...waitOptions,
238
+ };
131
239
 
132
240
  if (args.help) {
133
241
  usage(0);
134
242
  }
135
243
 
244
+ if (args.version) {
245
+ await printVersion();
246
+ process.exit(0);
247
+ }
248
+
136
249
  try {
250
+ if (args.command !== undefined) {
251
+ rejectDisallowedFlags(args.command, args);
252
+ }
137
253
  switch (args.command) {
254
+ case "version": {
255
+ await printVersion();
256
+ break;
257
+ }
138
258
  case "up": {
139
259
  const { applied, planned } = await migrateUp({
140
- ...listDirOptions,
141
- ...tableOptions,
260
+ ...connectOptions,
142
261
  ...(args.to ? { to: args.to } : {}),
143
262
  ...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
144
263
  ...(args.dryRun ? { dryRun: true } : {}),
@@ -157,6 +276,13 @@ try {
157
276
  log({ text: "Use either --all or a number of steps, not both.", type: "error" });
158
277
  usage(1);
159
278
  }
279
+ if (args.to !== undefined && (args.all || stepsArg !== undefined)) {
280
+ log({
281
+ text: "Use either --to, --all, or a number of steps, not more than one of them.",
282
+ type: "error",
283
+ });
284
+ usage(1);
285
+ }
160
286
  let steps: number | "all" = 1;
161
287
  if (args.all) {
162
288
  steps = "all";
@@ -172,9 +298,8 @@ try {
172
298
  }
173
299
  }
174
300
  const { reverted, planned } = await migrateDown({
175
- ...listDirOptions,
176
- ...tableOptions,
177
- steps,
301
+ ...connectOptions,
302
+ ...(args.to !== undefined ? { to: args.to } : { steps }),
178
303
  ...(args.dryRun ? { dryRun: true } : {}),
179
304
  });
180
305
  if (planned !== undefined && planned.length > 0) {
@@ -185,6 +310,35 @@ try {
185
310
  }
186
311
  break;
187
312
  }
313
+ case "redo": {
314
+ const [stepsArg] = args.positional;
315
+ if (stepsArg !== undefined && args.to !== undefined) {
316
+ log({ text: "Use either --to or a step count, not both.", type: "error" });
317
+ usage(1);
318
+ }
319
+ let steps: number | undefined;
320
+ if (stepsArg !== undefined) {
321
+ try {
322
+ steps = parseSteps(Number(stepsArg));
323
+ } catch {
324
+ log({
325
+ text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
326
+ type: "error",
327
+ });
328
+ usage(1);
329
+ }
330
+ }
331
+ const { reverted } = await migrateRedo({
332
+ ...connectOptions,
333
+ ...(steps !== undefined ? { steps } : {}),
334
+ ...(args.to !== undefined ? { to: args.to } : {}),
335
+ ...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
336
+ });
337
+ if (reverted.length > 0) {
338
+ log({ text: `Redid ${reverted.length} migration(s).`, type: "success" });
339
+ }
340
+ break;
341
+ }
188
342
  case "init": {
189
343
  await initMigrations({
190
344
  ...listDirOptions,
@@ -193,7 +347,7 @@ try {
193
347
  break;
194
348
  }
195
349
  case "install": {
196
- await installMigrations({ ...listDirOptions, ...tableOptions });
350
+ await installMigrations(connectOptions);
197
351
  break;
198
352
  }
199
353
  case "create": {
@@ -217,8 +371,7 @@ try {
217
371
  usage(1);
218
372
  }
219
373
  const { marked } = await markMigrationsApplied({
220
- ...listDirOptions,
221
- ...tableOptions,
374
+ ...connectOptions,
222
375
  ...(name !== undefined ? { to: name } : {}),
223
376
  });
224
377
  if (marked.length > 0) {
@@ -227,7 +380,7 @@ try {
227
380
  break;
228
381
  }
229
382
  case "status": {
230
- const { applied, pending } = await migrateStatus({ ...listDirOptions, ...tableOptions });
383
+ const { applied, pending } = await migrateStatus(connectOptions);
231
384
  for (const entry of applied) {
232
385
  log({ text: `${entry.name} applied`, type: "info" });
233
386
  }
@@ -249,6 +402,7 @@ try {
249
402
  error instanceof ChecksumDriftError ||
250
403
  error instanceof MigrationNotFoundError ||
251
404
  error instanceof MigrationLockError ||
405
+ error instanceof DatabaseWaitTimeoutError ||
252
406
  error instanceof InvalidIdentifierError
253
407
  ) {
254
408
  log({ text: error.message, type: "error" });
@@ -17,6 +17,7 @@ export interface MigrationDriver {
17
17
  tryLock?(timeoutSeconds: number): Promise<boolean>;
18
18
  releaseLock?(): Promise<void>;
19
19
  close(): Promise<void>;
20
+ client?(): SQL;
20
21
  }
21
22
 
22
23
  export interface DriverTableOptions {
@@ -1,3 +1,19 @@
1
+ export function resolveSecondsOption(
2
+ option: string,
3
+ value: number | undefined,
4
+ fallback: number,
5
+ ): number {
6
+ if (value === undefined) {
7
+ return fallback;
8
+ }
9
+ if (!Number.isInteger(value) || value < 0) {
10
+ throw new Error(
11
+ `Invalid ${option}: ${String(value)} — expected a non-negative integer of seconds`,
12
+ );
13
+ }
14
+ return value;
15
+ }
16
+
1
17
  export function formatDuration(durationMs: number): string {
2
18
  const roundedMs = Math.round(durationMs);
3
19
  if (roundedMs < 1000) {
@@ -47,7 +47,10 @@ export function createReservedLock(
47
47
  let lockConnection: ReservedSQL | null = null;
48
48
 
49
49
  return {
50
- async tryLock() {
50
+ async tryLock(_timeoutSeconds: number): Promise<boolean> {
51
+ if (lockConnection !== null) {
52
+ return true;
53
+ }
51
54
  const connection = await db.reserve();
52
55
  lockConnection = connection;
53
56
  try {
@@ -90,6 +93,7 @@ export function createSqlDriver(
90
93
 
91
94
  return {
92
95
  install: () => dialect.install(db),
96
+ client: () => db,
93
97
  async listExecuted() {
94
98
  const rows = await db`SELECT migration, checksum FROM ${db.unsafe(table)} ORDER BY id ASC`;
95
99
  return rows.map(
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import { InvalidIdentifierError } from "./core/identifiers.js";
8
8
  import { migrateUp } from "./api/up.js";
9
9
  import { migrateDown } from "./api/down.js";
10
+ import { migrateRedo } from "./api/redo.js";
10
11
  import { migrateStatus } from "./api/status.js";
11
12
  import { installMigrations } from "./api/install.js";
12
13
  import { createMigration } from "./api/create.js";
@@ -20,7 +21,10 @@ import {
20
21
  type MigrateStatusResult,
21
22
  type MigrateUpOptions,
22
23
  type MigrateUpResult,
24
+ type RedoOptions,
25
+ type RedoResult,
23
26
  ChecksumDriftError,
27
+ DatabaseWaitTimeoutError,
24
28
  GitStageError,
25
29
  MigrationLockError,
26
30
  MigrationNotFoundError,
@@ -30,11 +34,13 @@ export {
30
34
  createDriver,
31
35
  migrateUp,
32
36
  migrateDown,
37
+ migrateRedo,
33
38
  migrateStatus,
34
39
  installMigrations,
35
40
  createMigration,
36
41
  markMigrationsApplied,
37
42
  ChecksumDriftError,
43
+ DatabaseWaitTimeoutError,
38
44
  GitStageError,
39
45
  InvalidIdentifierError,
40
46
  MigrationLockError,
@@ -49,6 +55,8 @@ export type {
49
55
  MigrateUpResult,
50
56
  MigrateDownOptions,
51
57
  MigrateDownResult,
58
+ RedoOptions,
59
+ RedoResult,
52
60
  MarkOptions,
53
61
  MarkResult,
54
62
  MigrateStatusResult,