bunsql-native-migrate 0.3.2 → 0.4.1

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).
@@ -57,14 +60,18 @@ bunx bunsql-native-migrate up
57
60
  # see what is applied and what is pending
58
61
  bunx bunsql-native-migrate status
59
62
 
60
- # CI gate: same listing, but exit code 1 while migrations are pending
63
+ # CI gate: same listing, but exit code 2 while migrations are pending
61
64
  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. Any of these can also live in a [configuration file](#configuration-file) in the project root.
68
75
 
69
76
  ### Migration file format
70
77
 
@@ -82,7 +89,7 @@ const down = async () => {
82
89
  export { up, down };
83
90
  ```
84
91
 
85
- Files live in the migrations directory (default `./migrations`, override with `--dir` or the `MIGRATION_LIST_DIR` env var) and are applied in descending filename order. Both `.js` and `.ts` files work — Bun runs TypeScript natively — and the order is decided purely by the filename, never by the extension. `bunsql-native-migrate create` generates TypeScript stubs by default (`--lang js` for JavaScript) with an inverted timestamp prefix so newer migrations sort first:
92
+ Files live in the migrations directory (default `./migrations`, override with `--dir` or the `MIGRATION_LIST_DIR` env var) and are applied in descending filename order. Any file ending in `.js`, `.ts` or `.up.sql` is a migration (TypeScript declaration files — `*.d.ts` — are ignored, so a `types.d.ts` next to the migrations never shows up as pending), `.js` and `.ts` both work — Bun runs TypeScript natively — and the order is decided purely by the filename, never by the extension. `bunsql-native-migrate create` generates TypeScript stubs by default (`--lang js` for JavaScript) with an inverted timestamp prefix so newer migrations sort first:
86
93
 
87
94
  ```
88
95
  9999999999999_2026_09_13_add_users_table.ts
@@ -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
@@ -124,9 +131,39 @@ export { up, down };
124
131
 
125
132
  - `bunsql-native-migrate create` generates stubs in this form by default (as TypeScript, with `tx` typed as a Bun `SQL` client bound to the same database the runner is connected to).
126
133
  - Migrations declared **without** parameters keep using the global `sql` client and run without a transaction — both styles can coexist in one project, decided per migration by the declared signature.
134
+ - The mode is decided by the **number of declared parameters** (`function.length`). A parameter with a default value or a rest parameter (`async (tx = sql) => …`, `async (...args) => …`) is counted as zero parameters and would silently run outside the transaction, so such a migration is rejected with a clear error at load time — declare `tx` without a default, or export `noTransaction = true` if you really want the non-transactional path.
127
135
  - 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
136
  - 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
137
 
138
+ ### Non-transactional migrations
139
+
140
+ 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:
141
+
142
+ ```js
143
+ const up = async (db) => {
144
+ await db`CREATE INDEX CONCURRENTLY users_email_idx ON users (email)`;
145
+ };
146
+
147
+ const down = async (db) => {
148
+ await db`DROP INDEX CONCURRENTLY users_email_idx`;
149
+ };
150
+
151
+ const noTransaction = true;
152
+ export { up, down, noTransaction };
153
+ ```
154
+
155
+ - **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`.
156
+ - 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.
157
+ - On MySQL/MariaDB the marker changes little (DDL already implicitly commits) but it also gives up the DML rollback protection there.
158
+ - For SQL pairs the equivalent is a directive in the leading comment block of the file:
159
+
160
+ ```sql
161
+ -- bunsql-migrate:no-transaction
162
+ CREATE INDEX CONCURRENTLY users_email_idx ON users (email);
163
+ ```
164
+
165
+ 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).
166
+
130
167
  ### Migrations directory path resolution
131
168
 
132
169
  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.
@@ -139,37 +176,91 @@ DATABASE_URL=$SECRET_URL bunx bunsql-native-migrate up --dir "$CI_WORKSPACE/migr
139
176
 
140
177
  This resolution is part of the library contract: `resolveListDir` (and therefore every API function) always returns a fully qualified absolute path, so programmatic callers can pass either form and get identical behavior from any working directory.
141
178
 
179
+ ### Configuration file
180
+
181
+ Typing `--dir`, `--table`, `--schema` and `--url` on every command gets old fast, and a typo there can point a run at the wrong database. A config file in the project root holds the defaults once:
182
+
183
+ ```ts
184
+ // bunsql-migrate.config.ts
185
+ export default {
186
+ databaseUrl: "postgres://user:pass@localhost:5432/app",
187
+ listDir: "./migrations",
188
+ tableName: "app_migrations",
189
+ schema: "private",
190
+ lang: "ts",
191
+ lockTimeout: 60,
192
+ waitTimeout: 10,
193
+ };
194
+ ```
195
+
196
+ The keys mirror the programmatic options; named exports (`export const tableName = …`) work too. Resolution order for every key:
197
+
198
+ 1. the CLI flag (`--table …`),
199
+ 2. the config file,
200
+ 3. the env var / built-in default (`DATABASE_URL`, `MIGRATION_LIST_DIR`, table `migrations`, lock timeout `30`, …).
201
+
202
+ Notes:
203
+
204
+ - The CLI looks for `bunsql-migrate.config.ts`, then `bunsql-migrate.config.js` in the working directory. `--config <path>` loads a file elsewhere (a relative path resolves against the cwd) — it is the only flag that can point at a file that is not in the project root.
205
+ - No config file is not an error — the built-in defaults apply as before.
206
+ - A config file that cannot be parsed, exports a non-object, holds an unknown key or a value of the wrong type fails the run with `InvalidConfigError` before any database connection is made (exit 5, see [Exit codes](#exit-codes)).
207
+ - `tableName`/`schema` from the config go through the same identifier validation as the flags, so a hostile name there never reaches the database either.
208
+ - The config file is a normal TypeScript module: `import` secrets from env or compute paths, but keep it out of version control when it holds credentials.
209
+
210
+ Programmatic callers can load the same file with the exported `loadProjectConfig(path?)` (returns an empty object when no file is found; throws `InvalidConfigError` on a broken file) and merge it into their option objects.
211
+
142
212
  ## CLI reference
143
213
 
144
214
  ```
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]
215
+ bunsql-native-migrate <init|up|down [n]|redo [n]|install|create [name]|mark [name]|status|version> [--url <url>] [--dir <migrations-dir>] [--config <path>] [--to <name>] [--lock-timeout <seconds>] [--wait <seconds>] [--table <name>] [--schema <name>] [--dry-run] [--all] [--lang <js|ts>] [--git] [--strict] [--version] [--help]
146
216
  ```
147
217
 
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. |
171
-
172
- Any failure (connection errors, a failing migration, a modified applied file) is printed and the CLI exits with code 1; the same happens for an unknown command or a call without a command.
218
+ | Command | What it does |
219
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --- |
220
+ | `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. |
221
+ | `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. |
222
+ | `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. |
223
+ | `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). |
224
+ | `install` | Creates the tracking table only. |
225
+ | `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. The name may only contain letters, digits, hyphens and underscores — anything else (a path separator, `..`, a space) is rejected with exit 5 before anything is created. | |
226
+ | `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)). |
227
+ | `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. |
228
+ | `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. |
229
+
230
+ | Flag | Applies to | Meaning |
231
+ | ------------------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
232
+ | `--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`. |
233
+ | `--dir <migrations-dir>` | up/down/redo/init/create/mark/status | Migrations directory (default `./migrations`, or the `MIGRATION_LIST_DIR` env var). Not accepted by `install` — it never reads the migrations directory. |
234
+ | `--config <path>` | all | Load project defaults from this file instead of auto-detecting `bunsql-migrate.config.ts` (see [Configuration file](#configuration-file)). |
235
+ | `--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). |
236
+ | `--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 4. Applies to redo's `up` phase; `down` also takes the lock, always with the default timeout. |
237
+ | `--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` (see [Exit codes](#exit-codes)). |
238
+ | `--table <name>` | up/down/redo/install/mark/status | Track history in this table instead of `migrations` (see [Custom tracking table](#custom-tracking-table)). |
239
+ | `--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. |
240
+ | `--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)). |
241
+ | `--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. |
242
+ | `--git` | `create` | `git add` the created file. When staging fails, the CLI prints an error and exits 1 — the file itself stays on disk. |
243
+ | `--lang <js\|ts>` | `create`, `init` | Language of the created stub. Default: `ts`. An unknown or missing value is an error (exit 5). |
244
+ | `--strict` | `status` | Exit with code 2 when migrations are pending — a gate for CI/CD pipelines. Exit 0 otherwise. |
245
+ | `--version` | — | Print the package version and exit 0 (same as the `version` command; works without `DATABASE_URL`). |
246
+ | `--help`, `-h` | — | Prints the usage line and exits with code 0. |
247
+
248
+ 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 …`, `install --dir …`) is a usage error — the CLI prints `redo does not support --all`, shows the usage line and exits 5 before touching the database. Flags with a value (`--url`, `--dir`, `--config`, `--to`, `--table`, `--schema`) require one: a missing, empty or flag-like value (`down --dir --dry-run 2`) is a usage error too, so a value can never silently swallow the next flag or fall back to defaults. Only `--help` and `--version` are global (either wins over any command); `--config` is accepted by every command as well, but the `version` command ignores it entirely (it must work outside a project). Positional arguments are command-specific too: `up`, `install`, `status`, `init` and `version` take none, and an empty positional is a usage error.
249
+
250
+ ### Exit codes
251
+
252
+ The CLI distinguishes error categories so a CI/CD pipeline can react to each one:
253
+
254
+ | Code | Meaning |
255
+ | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
256
+ | `0` | Success. |
257
+ | `1` | Any other failure: connection errors, a failing migration, an unknown `--to` target, an applied migration file missing from disk, a wait timeout, … |
258
+ | `2` | `status --strict` with pending migrations (the CI gate). |
259
+ | `3` | Checksum drift — an applied file was modified after it was applied. |
260
+ | `4` | Migration lock timeout — another `up` held the lock past `--lock-timeout`. |
261
+ | `5` | Usage error or an invalid configuration/identifier, reported before any connection is made. |
262
+
263
+ Any failure (connection errors, a failing migration, a modified applied file) is printed and the CLI exits with a category-specific code — see [Exit codes](#exit-codes) above.
173
264
 
174
265
  Example `up` output:
175
266
 
@@ -185,12 +276,16 @@ Applied 2 migration(s).
185
276
  import {
186
277
  migrateUp,
187
278
  migrateDown,
279
+ migrateRedo,
188
280
  migrateStatus,
189
281
  installMigrations,
190
282
  createMigration,
191
283
  markMigrationsApplied,
192
284
  createDriver,
285
+ loadProjectConfig,
193
286
  ChecksumDriftError,
287
+ DatabaseWaitTimeoutError,
288
+ InvalidConfigError,
194
289
  InvalidIdentifierError,
195
290
  MigrationLockError,
196
291
  MigrationNotFoundError,
@@ -202,6 +297,7 @@ const { applied } = await migrateUp({
202
297
  listDir: "./migrations", // default: MIGRATION_LIST_DIR env or ./migrations
203
298
  to: "2_add_columns.ts", // optional: apply up to and including this file
204
299
  lockTimeout: 60, // optional: seconds to wait for the migration lock (default 30, 0 = fail fast)
300
+ waitTimeout: 30, // optional: seconds to wait for the database to become ready (default 0 = single attempt)
205
301
  tableName: "app_migrations", // optional: custom tracking table (default "migrations")
206
302
  schema: "private", // optional: postgres schema for the tracking table
207
303
  });
@@ -209,6 +305,11 @@ const { applied } = await migrateUp({
209
305
  const { reverted } = await migrateDown(); // reverted: string[] (most-recent-first)
210
306
  await migrateDown({ steps: 3 }); // revert the last three
211
307
  await migrateDown({ steps: "all" }); // revert everything
308
+ await migrateDown({ to: "2_add_columns.ts" }); // revert down to this file inclusive
309
+
310
+ const redo = await migrateRedo(); // redo: { reverted, applied } — revert the last and re-apply
311
+ await migrateRedo({ steps: 3 }); // redo the last three
312
+ await migrateRedo({ to: "2_batch.ts" }); // redo everything down to 2_batch.ts inclusive
212
313
 
213
314
  const { planned } = await migrateUp({ dryRun: true }); // preview only (see Dry run)
214
315
  // planned: string[] — what a real up would apply, applied stays []
@@ -225,25 +326,30 @@ await markMigrationsApplied(); // mark every pending migration
225
326
  await installMigrations(); // creates the tracking table
226
327
 
227
328
  const filename = await createMigration({ name: "add_users_table", listDir: "./migrations" });
329
+
330
+ const config = await loadProjectConfig("./bunsql-migrate.config.ts");
331
+ // config: { databaseUrl?, listDir?, tableName?, schema?, lang?, lockTimeout?, waitTimeout? }
332
+ // — {} when the file is missing, throws InvalidConfigError when it is broken
228
333
  ```
229
334
 
230
335
  All options are optional unless stated otherwise:
231
336
 
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.
337
+ | Option | Where | Default |
338
+ | ------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
339
+ | `databaseUrl` | `migrateUp`, `migrateDown`, `migrateRedo`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | `DATABASE_URL` env var |
340
+ | `listDir` | all functions | `MIGRATION_LIST_DIR` env var, then `./migrations` (relative paths resolve against the process cwd) |
341
+ | `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` |
342
+ | `tableName` | `migrateUp`, `migrateDown`, `migrateRedo`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | `"migrations"` — a custom tracking table name (see [Custom tracking table](#custom-tracking-table)) |
343
+ | `schema` | `migrateUp`, `migrateDown`, `migrateRedo`, `migrateStatus`, `markMigrationsApplied`, `installMigrations` | — PostgreSQL only: the schema holding the tracking table (must already exist); rejected on other URLs |
344
+ | `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 |
345
+ | `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 |
346
+ | `dryRun` | `migrateUp`, `migrateDown` | `false` — plan only: the result carries `planned: string[]` while `applied`/`reverted` stay empty (see [Dry run](#dry-run)) |
347
+ | `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` |
348
+ | `name` | `createMigration` | random `adjective_noun` name; only letters, digits, hyphens and underscores are accepted — anything else throws `InvalidMigrationNameError` before a file is created |
349
+ | `lang` | `createMigration` | `"ts"` — pass `"js"` for a JavaScript stub |
350
+ | `git` | `createMigration` | `false` — `git add` the new file |
351
+
352
+ `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. An applied migration whose file is missing from the migrations directory stops the `down` the same way — it throws `MigrationFileMissingError` (`<file> is missing from the migrations directory — restore the file or remove its tracking record manually`) instead of the module loader's raw error; nothing about it is reverted or removed automatically. `to` and `steps` are two ways to pick the revert window — passing both is an error before anything runs.
247
353
 
248
354
  ### Drivers
249
355
 
@@ -261,7 +367,7 @@ Known MySQL/MariaDB limitation of `Bun.SQL` (not used by this package, good to k
261
367
 
262
368
  Bun's client also recognizes `mysql2://` and `file://` URLs — `createDriver` deliberately rejects them. Stick to the protocols listed above.
263
369
 
264
- `createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`). It also accepts the same `{ tableName, schema }` options as the API functions. Custom drivers may also implement the optional `tryLock(timeoutSeconds)` / `releaseLock()` pair to participate in [concurrent-run locking](#concurrent-runs) — without them, `migrateUp` simply runs unlocked — the optional `trackingTableExists()` used by `up`'s [dry run](#dry-run) to plan against a database that has no tracking table yet, and the optional `trackingTableCurrent()` probe that lets `up`/`down`/`status`/`mark` skip re-running `install()` when the tracking table already has its checksum column and unique index — without the probe, those commands install the table up front as before.
370
+ `createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`). It also accepts the same `{ tableName, schema }` options as the API functions. Custom drivers may also implement the optional `tryLock(timeoutSeconds)` / `releaseLock()` pair to participate in [concurrent-run locking](#concurrent-runs) — without them, `migrateUp` simply runs unlocked; `tryLock` is non-blocking by contract: it reports whether the lock is free right now and takes it if so, while the waiting (polling every 100 ms up to `lockTimeout`) is implemented by the caller — none of the built-in drivers blocks inside `tryLock`, and custom implementations should not either — the optional `trackingTableExists()` used by `up`'s [dry run](#dry-run) to plan against a database that has no tracking table yet, and the optional `trackingTableCurrent()` probe that lets `up`/`down`/`status`/`mark` skip re-running `install()` when the tracking table already has its checksum column and unique index — without the probe, those commands install the table up front as before.
265
371
 
266
372
  ### Tracking table
267
373
 
@@ -277,7 +383,7 @@ Records created before checksums existed (checksum `NULL`) are backfilled on the
277
383
 
278
384
  Several projects can point at one database without sharing a history: give each its own tracking table with `tableName` (CLI `--table`), optionally in a PostgreSQL schema with `schema` (CLI `--schema`, the schema must already exist — it is not created for you). The unique index is derived from the table name (`<table>_migration_unique`), so custom tables never collide, and the legacy checksum backfill works in custom tables too. Two concurrent `up` runs still serialize on the same per-database lock even with different tables — they share the database, after all.
279
385
 
280
- Names are validated before the driver connects: an identifier of letters, digits, underscores and dollar signs, starting with a letter or underscore (PostgreSQL ≤ 63 chars, MySQL/MariaDB ≤ 47, SQLite ≤ 128; schema ≤ 63). Anything else — spaces, quotes, semicolons — throws `InvalidIdentifierError` and the CLI exits 1, so a hostile name can never reach the database. The `schema` option on a MySQL or SQLite URL is an error as well: MySQL selects the database in the URL itself and SQLite has no schemas.
386
+ Names are validated before the driver connects: an identifier of letters, digits, underscores and dollar signs, starting with a letter or underscore (PostgreSQL ≤ 63 chars, MySQL/MariaDB ≤ 47, SQLite ≤ 128; schema ≤ 63). Anything else — spaces, quotes, semicolons — throws `InvalidIdentifierError` and the CLI exits 5, so a hostile name can never reach the database. The `schema` option on a MySQL or SQLite URL is an error as well: MySQL selects the database in the URL itself and SQLite has no schemas.
281
387
 
282
388
  ### Baseline: adopting an existing database (mark)
283
389
 
@@ -312,19 +418,35 @@ Would apply 2 migration(s).
312
418
  - `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
419
  - In the API result the plan lands in `planned: string[]` while `applied`/`reverted` stay empty — existing consumers keep working untouched.
314
420
 
421
+ ### Redo: re-running migrations you are still editing
422
+
423
+ 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`):
424
+
425
+ ```bash
426
+ bunx bunsql-native-migrate redo # re-run the last applied migration
427
+ bunx bunsql-native-migrate redo 3 # re-run the last three
428
+ bunx bunsql-native-migrate redo --to 2_add_users.ts # re-run everything down to 2_add_users.ts inclusive
429
+ ```
430
+
431
+ - 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.
432
+ - 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.
433
+ - 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.
434
+ - 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.
435
+ - Concurrency: both phases take the migration lock (`--lock-timeout` honors the up phase; the down phase uses the default timeout), but the window between the phases is still not atomic — `redo` is a local dev-loop tool, not something to race on production.
436
+
315
437
  ### Concurrent runs
316
438
 
317
- Two `up` runs racing (two deploy pods, CI and a laptop) deduplicate only the tracking record against each other — without a lock both would read the same pending list and execute the migration bodies twice. `migrateUp` therefore takes an exclusive database-level lock right after creating the tracking table and holds it until the run ends. The lock is released through a `finally` path on any failure, and it dies with the connection even on a crashed process:
439
+ 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 before creating the tracking table and holds it until the run ends, so a waiter re-reads the history after it gets through and finds nothing pending. The lock is released through a `finally` path on any failure, and it dies with the connection even on a crashed process (a lock file left by a killed process is reclaimed on the next run when its recorded PID is gone):
318
440
 
319
441
  - **PostgreSQL** — session advisory lock (`pg_try_advisory_lock` / `pg_advisory_unlock`) on a reserved connection, keyed per database.
320
442
  - **MySQL/MariaDB** — `GET_LOCK` / `RELEASE_LOCK` on a reserved connection, named per database.
321
- - **SQLite** — there is no advisory lock; the connection gets `PRAGMA busy_timeout`, so a concurrent run waits on the file write lock and fails with a busy error once the timeout is exceeded. The database file itself is the serialization point.
443
+ - **SQLite** — there is no advisory lock; instead a sidecar lock file (`<database>.bunsql-migrate.lock`, created with `O_EXCL`) is held for the whole run, so a concurrent run waits and then re-reads the history instead of re-running the bodies. The connection additionally gets `PRAGMA busy_timeout`, so any write that does contend on the file waits rather than failing immediately. In-memory databases have nothing to serialize across processes and skip the lock file.
322
444
 
323
- While the lock is held, another `up` polls and waits up to `lockTimeout` seconds (default `30`, CLI `up --lock-timeout <seconds>`, `0` fails immediately). When the wait times out, `migrateUp` throws `MigrationLockError` and the CLI exits with code 1 — rerun after the first run finishes; a waiter that gets through just reports `No pending migrations.`
445
+ While the lock is held, another `up` polls and waits up to `lockTimeout` seconds (default `30`, CLI `up --lock-timeout <seconds>`, `0` fails immediately). When the wait times out, `migrateUp` throws `MigrationLockError` and the CLI exits with code 4 — rerun after the first run finishes; a waiter that gets through just reports `No pending migrations.`
324
446
 
325
447
  ### Error handling
326
448
 
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.
449
+ The library throws instead of exiting: connection errors, failing migrations, [`ChecksumDriftError`](#tracking-table), `InvalidIdentifierError` (an invalid `tableName`/`schema`, thrown before anything is applied), `InvalidMigrationNameError` (a `createMigration` name with path separators or other unsafe characters, thrown before a file is created), `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 a category-specific code (see [Exit codes](#exit-codes)).
328
450
 
329
451
  `createMigration` with `git: true` throws `GitStageError` when `git add` fails (no repository, ignored path, …) — the migration file itself is still created on disk.
330
452
 
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.1",
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/create.ts CHANGED
@@ -3,7 +3,7 @@ import { mkdir, open, type FileHandle } from "node:fs/promises";
3
3
  import { randomName } from "../core/random-name.js";
4
4
  import { resolveListDir } from "../core/fs.js";
5
5
  import { log } from "../core/console.js";
6
- import { GitStageError } from "./options.js";
6
+ import { GitStageError, validateMigrationName } from "./options.js";
7
7
 
8
8
  export type MigrationLang = "js" | "ts";
9
9
 
@@ -84,6 +84,7 @@ async function writeStubExclusively(filePath: string, template: string): Promise
84
84
 
85
85
  export async function createMigration(options: CreateOptions): Promise<string> {
86
86
  const name = options.name ?? randomName();
87
+ validateMigrationName(name);
87
88
  const lang = options.lang ?? "ts";
88
89
  await mkdir(options.listDir, { recursive: true });
89
90
  let filename = migrationFilename(name, new Date(), lang);
package/src/api/down.ts CHANGED
@@ -2,12 +2,16 @@ 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 { ensureTrackingTable, listExecutedForPlan } from "./tracking-table.js";
9
+ import { assertTargetOptions } from "./pending.js";
10
+ import { resolveLockTimeout, withMigrationLock } from "./lock.js";
11
+ import { loadExecutedHistory } from "./tracking-table.js";
10
12
 
13
+ export function parseSteps(steps: number | undefined): number;
14
+ export function parseSteps(steps: number | "all" | undefined): number | "all";
11
15
  export function parseSteps(steps: number | "all" | undefined): number | "all" {
12
16
  if (steps === undefined) return 1;
13
17
  if (steps === "all") return "all";
@@ -41,42 +45,57 @@ async function revertOne(driver: MigrationDriver, listDir: string, file: string)
41
45
  export async function migrateDown(options: MigrateDownOptions = {}): Promise<MigrateDownResult> {
42
46
  const listDir = resolveListDir(options.listDir);
43
47
  const dryRun = options.dryRun ?? false;
48
+ const target = options.to;
44
49
 
45
- return runWithDriver(options, async (driver) => {
46
- if (!dryRun) {
47
- await ensureTrackingTable(driver);
48
- }
50
+ await assertTargetOptions("down", listDir, target, options.steps);
49
51
 
50
- const executed = dryRun ? await listExecutedForPlan(driver) : await driver.listExecuted();
51
- if (executed.length === 0) {
52
- log({ text: "No migrations to rollback.", type: "warn" });
53
- return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
54
- }
52
+ return runWithDriver(options, async (driver) => {
53
+ const run = async (): Promise<MigrateDownResult> => {
54
+ const executed = await loadExecutedHistory(driver, dryRun);
55
+ if (executed.length === 0) {
56
+ log({ text: "No migrations to rollback.", type: "warn" });
57
+ return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
58
+ }
55
59
 
56
- const count = resolveStepCount(options.steps, executed.length);
57
- const revertList = executed.slice(-count).reverse();
58
- const plan = revertList.map((entry) => entry.name);
60
+ const appliedNames = executed.map((entry) => entry.name);
61
+ let count = resolveStepCount(options.steps, executed.length);
62
+ if (target !== undefined) {
63
+ const boundary = appliedNames.indexOf(target);
64
+ if (boundary === -1) {
65
+ log({ text: `${target} is not applied — nothing to rollback.`, type: "warn" });
66
+ return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
67
+ }
68
+ count = executed.length - boundary;
69
+ }
70
+ const revertList = executed.slice(-count).reverse();
71
+ const plan = revertList.map((entry) => entry.name);
59
72
 
60
- if (dryRun) {
61
- log({ text: "Dry run — no changes will be made.", type: "info" });
62
- for (const file of plan) {
63
- log({ text: `${file} would be rolled back`, type: "info" });
73
+ if (dryRun) {
74
+ log({ text: "Dry run — no changes will be made.", type: "info" });
75
+ for (const file of plan) {
76
+ log({ text: `${file} would be rolled back`, type: "info" });
77
+ }
78
+ return { reverted: [], planned: plan };
64
79
  }
65
- return { reverted: [], planned: plan };
66
- }
67
80
 
68
- const reverted: string[] = [];
81
+ const reverted: string[] = [];
69
82
 
70
- for (const entry of revertList) {
71
- try {
72
- await revertOne(driver, listDir, entry.name);
73
- } catch (error) {
74
- log({ text: `${entry.name} rollback failed`, type: "error", error });
75
- throw error;
83
+ for (const entry of revertList) {
84
+ try {
85
+ await revertOne(driver, listDir, entry.name);
86
+ } catch (error) {
87
+ log({ text: `${entry.name} rollback failed`, type: "error", error });
88
+ throw error;
89
+ }
90
+ reverted.push(entry.name);
76
91
  }
77
- reverted.push(entry.name);
78
- }
79
92
 
80
- return { reverted };
93
+ return { reverted };
94
+ };
95
+
96
+ if (dryRun) {
97
+ return run();
98
+ }
99
+ return withMigrationLock(driver, resolveLockTimeout(undefined), run);
81
100
  });
82
101
  }
package/src/api/init.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
1
+ import { listMigrationFiles, resolveListDir } from "../core/fs.js";
2
2
  import { log } from "../core/console.js";
3
3
  import { createMigration, type MigrationLang } from "./create.js";
4
4
 
@@ -14,7 +14,7 @@ export interface InitResult {
14
14
 
15
15
  async function existingMigrations(listDir: string): Promise<string[]> {
16
16
  try {
17
- return await listFiles(listDir, MIGRATION_EXTENSIONS);
17
+ return await listMigrationFiles(listDir);
18
18
  } catch (error) {
19
19
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
20
20
  return [];