bunsql-native-migrate 0.3.1 → 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.1",
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,21 +2,29 @@ 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
 
11
- function resolveStepCount(steps: number | "all" | undefined, appliedCount: number): number {
12
+ export function parseSteps(steps: number | undefined): number;
13
+ export function parseSteps(steps: number | "all" | undefined): number | "all";
14
+ export function parseSteps(steps: number | "all" | undefined): number | "all" {
12
15
  if (steps === undefined) return 1;
13
- if (steps === "all") return appliedCount;
16
+ if (steps === "all") return "all";
14
17
  if (!Number.isInteger(steps) || steps < 1) {
15
18
  throw new Error(`Invalid steps: ${String(steps)} — expected a positive integer or "all"`);
16
19
  }
17
20
  return steps;
18
21
  }
19
22
 
23
+ function resolveStepCount(steps: number | "all" | undefined, appliedCount: number): number {
24
+ const parsed = parseSteps(steps);
25
+ return parsed === "all" ? appliedCount : parsed;
26
+ }
27
+
20
28
  async function revertOne(driver: MigrationDriver, listDir: string, file: string): Promise<void> {
21
29
  const { down } = await loadMigration(listDir, file);
22
30
 
@@ -36,6 +44,9 @@ async function revertOne(driver: MigrationDriver, listDir: string, file: string)
36
44
  export async function migrateDown(options: MigrateDownOptions = {}): Promise<MigrateDownResult> {
37
45
  const listDir = resolveListDir(options.listDir);
38
46
  const dryRun = options.dryRun ?? false;
47
+ const target = options.to;
48
+
49
+ await assertTargetOptions("down", listDir, target, options.steps);
39
50
 
40
51
  return runWithDriver(options, async (driver) => {
41
52
  if (!dryRun) {
@@ -48,11 +59,18 @@ export async function migrateDown(options: MigrateDownOptions = {}): Promise<Mig
48
59
  return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
49
60
  }
50
61
 
51
- const count = resolveStepCount(options.steps, executed.length);
52
- const plan = executed
53
- .slice(-count)
54
- .reverse()
55
- .map((entry) => entry.name);
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
+ }
72
+ const revertList = executed.slice(-count).reverse();
73
+ const plan = revertList.map((entry) => entry.name);
56
74
 
57
75
  if (dryRun) {
58
76
  log({ text: "Dry run — no changes will be made.", type: "info" });
@@ -64,7 +82,7 @@ export async function migrateDown(options: MigrateDownOptions = {}): Promise<Mig
64
82
 
65
83
  const reverted: string[] = [];
66
84
 
67
- for (const entry of executed.slice(-count).reverse()) {
85
+ for (const entry of revertList) {
68
86
  try {
69
87
  await revertOne(driver, listDir, entry.name);
70
88
  } catch (error) {
@@ -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>(
package/src/api/mark.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
3
3
  import { log } from "../core/console.js";
4
- import { type MarkOptions, type MarkResult, MigrationNotFoundError } from "./options.js";
4
+ import { type MarkOptions, type MarkResult } from "./options.js";
5
+ import { resolvePendingToTarget } from "./pending.js";
5
6
  import { runWithDriver } from "./run-with-driver.js";
6
7
  import { ensureTrackingTable } from "./tracking-table.js";
7
8
 
@@ -13,24 +14,27 @@ export async function markMigrationsApplied(options: MarkOptions = {}): Promise<
13
14
  await ensureTrackingTable(driver);
14
15
 
15
16
  const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
16
- if (target !== undefined && !allFiles.includes(target)) {
17
- throw new MigrationNotFoundError(target);
18
- }
19
-
20
17
  const executed = await driver.listExecuted();
21
- const executedNames = new Set(executed.map((entry) => entry.name));
22
- let pending = allFiles.filter((file) => !executedNames.has(file));
23
- if (target !== undefined) {
24
- if (executedNames.has(target)) {
25
- log({ text: `${target} is already applied.`, type: "info" });
26
- return { marked: [] };
27
- }
28
- pending = pending.slice(0, pending.indexOf(target) + 1);
18
+
19
+ const { pending, targetApplied } = resolvePendingToTarget({
20
+ allFiles,
21
+ executedNames: executed.map((entry) => entry.name),
22
+ target,
23
+ });
24
+ if (targetApplied) {
25
+ return { marked: [] };
29
26
  }
30
27
 
28
+ const checksums = new Map(
29
+ await Promise.all(
30
+ pending.map(async (file) => [file, await checksumFile(path.join(listDir, file))] as const),
31
+ ),
32
+ );
33
+
31
34
  const marked: string[] = [];
32
35
  for (const file of pending) {
33
- const checksum = await checksumFile(path.join(listDir, file));
36
+ const checksum = checksums.get(file);
37
+ if (!checksum) continue;
34
38
  await driver.record(file, checksum);
35
39
  marked.push(file);
36
40
  log({ text: `${file} marked as applied`, type: "success" });
@@ -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;