bunsql-native-migrate 0.4.0 → 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
@@ -60,7 +60,7 @@ bunx bunsql-native-migrate up
60
60
  # see what is applied and what is pending
61
61
  bunx bunsql-native-migrate status
62
62
 
63
- # 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
64
64
  bunx bunsql-native-migrate status --strict
65
65
 
66
66
  # roll back the last applied migration
@@ -71,7 +71,7 @@ bunx bunsql-native-migrate down
71
71
  bunx bunsql-native-migrate redo
72
72
  ```
73
73
 
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.
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.
75
75
 
76
76
  ### Migration file format
77
77
 
@@ -89,7 +89,7 @@ const down = async () => {
89
89
  export { up, down };
90
90
  ```
91
91
 
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. 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:
93
93
 
94
94
  ```
95
95
  9999999999999_2026_09_13_add_users_table.ts
@@ -131,6 +131,7 @@ export { up, down };
131
131
 
132
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).
133
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.
134
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.
135
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).
136
137
 
@@ -175,44 +176,91 @@ DATABASE_URL=$SECRET_URL bunx bunsql-native-migrate up --dir "$CI_WORKSPACE/migr
175
176
 
176
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.
177
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
+
178
212
  ## CLI reference
179
213
 
180
214
  ```
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]
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]
182
216
  ```
183
217
 
184
218
  | Command | What it does |
185
- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
219
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --- |
186
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. |
187
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. |
188
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. |
189
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). |
190
224
  | `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. |
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. | |
192
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)). |
193
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. |
194
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. |
195
229
 
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.
214
-
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.
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.
216
264
 
217
265
  Example `up` output:
218
266
 
@@ -234,8 +282,10 @@ import {
234
282
  createMigration,
235
283
  markMigrationsApplied,
236
284
  createDriver,
285
+ loadProjectConfig,
237
286
  ChecksumDriftError,
238
287
  DatabaseWaitTimeoutError,
288
+ InvalidConfigError,
239
289
  InvalidIdentifierError,
240
290
  MigrationLockError,
241
291
  MigrationNotFoundError,
@@ -276,6 +326,10 @@ await markMigrationsApplied(); // mark every pending migration
276
326
  await installMigrations(); // creates the tracking table
277
327
 
278
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
279
333
  ```
280
334
 
281
335
  All options are optional unless stated otherwise:
@@ -291,11 +345,11 @@ All options are optional unless stated otherwise:
291
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 |
292
346
  | `dryRun` | `migrateUp`, `migrateDown` | `false` — plan only: the result carries `planned: string[]` while `applied`/`reverted` stay empty (see [Dry run](#dry-run)) |
293
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` |
294
- | `name` | `createMigration` | random `adjective_noun` name |
348
+ | `name` | `createMigration` | random `adjective_noun` name; only letters, digits, hyphens and underscores are accepted — anything else throws `InvalidMigrationNameError` before a file is created |
295
349
  | `lang` | `createMigration` | `"ts"` — pass `"js"` for a JavaScript stub |
296
350
  | `git` | `createMigration` | `false` — `git add` the new file |
297
351
 
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.
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.
299
353
 
300
354
  ### Drivers
301
355
 
@@ -313,7 +367,7 @@ Known MySQL/MariaDB limitation of `Bun.SQL` (not used by this package, good to k
313
367
 
314
368
  Bun's client also recognizes `mysql2://` and `file://` URLs — `createDriver` deliberately rejects them. Stick to the protocols listed above.
315
369
 
316
- `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.
317
371
 
318
372
  ### Tracking table
319
373
 
@@ -329,7 +383,7 @@ Records created before checksums existed (checksum `NULL`) are backfilled on the
329
383
 
330
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.
331
385
 
332
- 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.
333
387
 
334
388
  ### Baseline: adopting an existing database (mark)
335
389
 
@@ -378,21 +432,21 @@ bunx bunsql-native-migrate redo --to 2_add_users.ts # re-run everything down t
378
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.
379
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.
380
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.
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.
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.
382
436
 
383
437
  ### Concurrent runs
384
438
 
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:
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):
386
440
 
387
441
  - **PostgreSQL** — session advisory lock (`pg_try_advisory_lock` / `pg_advisory_unlock`) on a reserved connection, keyed per database.
388
442
  - **MySQL/MariaDB** — `GET_LOCK` / `RELEASE_LOCK` on a reserved connection, named per database.
389
- - **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.
390
444
 
391
- 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.`
392
446
 
393
447
  ### Error handling
394
448
 
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.
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)).
396
450
 
397
451
  `createMigration` with `git: true` throws `GitStageError` when `git add` fails (no repository, ignored path, …) — the migration file itself is still created on disk.
398
452
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsql-native-migrate",
3
- "version": "0.4.0",
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
@@ -7,7 +7,8 @@ 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
9
  import { assertTargetOptions } from "./pending.js";
10
- import { ensureTrackingTable, listExecutedForPlan } from "./tracking-table.js";
10
+ import { resolveLockTimeout, withMigrationLock } from "./lock.js";
11
+ import { loadExecutedHistory } from "./tracking-table.js";
11
12
 
12
13
  export function parseSteps(steps: number | undefined): number;
13
14
  export function parseSteps(steps: number | "all" | undefined): number | "all";
@@ -49,49 +50,52 @@ export async function migrateDown(options: MigrateDownOptions = {}): Promise<Mig
49
50
  await assertTargetOptions("down", listDir, target, options.steps);
50
51
 
51
52
  return runWithDriver(options, async (driver) => {
52
- if (!dryRun) {
53
- await ensureTrackingTable(driver);
54
- }
55
-
56
- const executed = dryRun ? await listExecutedForPlan(driver) : await driver.listExecuted();
57
- if (executed.length === 0) {
58
- log({ text: "No migrations to rollback.", type: "warn" });
59
- return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
60
- }
61
-
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" });
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" });
68
57
  return dryRun ? { reverted: [], planned: [] } : { reverted: [] };
69
58
  }
70
- count = executed.length - boundary;
71
- }
72
- const revertList = executed.slice(-count).reverse();
73
- const plan = revertList.map((entry) => entry.name);
74
59
 
75
- if (dryRun) {
76
- log({ text: "Dry run — no changes will be made.", type: "info" });
77
- for (const file of plan) {
78
- log({ text: `${file} would be rolled back`, type: "info" });
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;
79
69
  }
80
- return { reverted: [], planned: plan };
81
- }
70
+ const revertList = executed.slice(-count).reverse();
71
+ const plan = revertList.map((entry) => entry.name);
82
72
 
83
- const reverted: string[] = [];
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 };
79
+ }
80
+
81
+ const reverted: string[] = [];
84
82
 
85
- for (const entry of revertList) {
86
- try {
87
- await revertOne(driver, listDir, entry.name);
88
- } catch (error) {
89
- log({ text: `${entry.name} rollback failed`, type: "error", error });
90
- 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);
91
91
  }
92
- reverted.push(entry.name);
93
- }
94
92
 
95
- return { reverted };
93
+ return { reverted };
94
+ };
95
+
96
+ if (dryRun) {
97
+ return run();
98
+ }
99
+ return withMigrationLock(driver, resolveLockTimeout(undefined), run);
96
100
  });
97
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 [];
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { type SQL } from "bun";
3
+ import { MigrationFileMissingError } from "./options.js";
3
4
 
4
5
  export type MigrationStep = (tx?: SQL) => Promise<void>;
5
6
 
@@ -43,6 +44,37 @@ function hasNoTransactionDirective(content: string): boolean {
43
44
  return false;
44
45
  }
45
46
 
47
+ function hasParameterList(step: MigrationStep): boolean {
48
+ const source = step.toString();
49
+ const open = source.indexOf("(");
50
+ if (open === -1) return false;
51
+ let depth = 0;
52
+ for (let index = open; index < source.length; index++) {
53
+ const char = source[index];
54
+ if (char === "(") depth++;
55
+ else if (char === ")") {
56
+ depth--;
57
+ if (depth === 0) {
58
+ return source.slice(open + 1, index).trim().length > 0;
59
+ }
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function assertExplicitTransactionMode(
66
+ file: string,
67
+ direction: "up" | "down",
68
+ step: MigrationStep,
69
+ ): void {
70
+ if (step.length > 0 || !hasParameterList(step)) return;
71
+ throw new Error(
72
+ `${file}: ${direction}() declares its parameter with a default value or as a rest parameter — ` +
73
+ `function.length is 0, so the step would silently run outside the migration transaction; ` +
74
+ `declare the parameter without a default (async (tx) => …) or add "export const noTransaction = true" to opt out explicitly`,
75
+ );
76
+ }
77
+
46
78
  async function sqlFilePlan(filePath: string): Promise<MigrationStepPlan> {
47
79
  const content = await Bun.file(filePath).text();
48
80
  return {
@@ -52,19 +84,26 @@ async function sqlFilePlan(filePath: string): Promise<MigrationStepPlan> {
52
84
  }
53
85
 
54
86
  export async function loadMigration(listDir: string, file: string): Promise<MigrationFunctions> {
87
+ const migrationPath = path.join(listDir, file);
88
+ if (!(await Bun.file(migrationPath).exists())) {
89
+ throw new MigrationFileMissingError(file);
90
+ }
91
+
55
92
  if (isSqlMigration(file)) {
56
- const upPath = path.join(listDir, file);
57
93
  const downPath = path.join(listDir, sqlDownFile(file));
58
94
  return {
59
- up: await sqlFilePlan(upPath),
95
+ up: await sqlFilePlan(migrationPath),
60
96
  down: (await Bun.file(downPath).exists()) ? await sqlFilePlan(downPath) : null,
61
97
  };
62
98
  }
63
99
 
64
- const mod = await import(path.join(listDir, file));
100
+ const mod = await import(migrationPath);
65
101
  const noTransaction = mod.noTransaction === true;
66
- return {
67
- up: typeof mod.up === "function" ? { step: mod.up, noTransaction } : null,
68
- down: typeof mod.down === "function" ? { step: mod.down, noTransaction } : null,
69
- };
102
+ const up = typeof mod.up === "function" ? { step: mod.up, noTransaction } : null;
103
+ const down = typeof mod.down === "function" ? { step: mod.down, noTransaction } : null;
104
+ if (!noTransaction) {
105
+ if (up !== null) assertExplicitTransactionMode(file, "up", up.step);
106
+ if (down !== null) assertExplicitTransactionMode(file, "down", down.step);
107
+ }
108
+ return { up, down };
70
109
  }
package/src/api/mark.ts CHANGED
@@ -1,5 +1,4 @@
1
- import path from "node:path";
2
- import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
1
+ import { checksumFiles, listMigrationFiles, resolveListDir } from "../core/fs.js";
3
2
  import { log } from "../core/console.js";
4
3
  import { type MarkOptions, type MarkResult } from "./options.js";
5
4
  import { resolvePendingToTarget } from "./pending.js";
@@ -13,7 +12,7 @@ export async function markMigrationsApplied(options: MarkOptions = {}): Promise<
13
12
  return runWithDriver(options, async (driver) => {
14
13
  await ensureTrackingTable(driver);
15
14
 
16
- const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
15
+ const allFiles = await listMigrationFiles(listDir);
17
16
  const executed = await driver.listExecuted();
18
17
 
19
18
  const { pending, targetApplied } = resolvePendingToTarget({
@@ -25,16 +24,14 @@ export async function markMigrationsApplied(options: MarkOptions = {}): Promise<
25
24
  return { marked: [] };
26
25
  }
27
26
 
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
- );
27
+ const checksums = await checksumFiles(listDir, pending);
33
28
 
34
29
  const marked: string[] = [];
35
30
  for (const file of pending) {
36
31
  const checksum = checksums.get(file);
37
- if (!checksum) continue;
32
+ if (checksum === undefined) {
33
+ throw new Error(`checksum for ${file} was not computed`);
34
+ }
38
35
  await driver.record(file, checksum);
39
36
  marked.push(file);
40
37
  log({ text: `${file} marked as applied`, type: "success" });
@@ -76,6 +76,18 @@ export class MigrationNotFoundError extends Error {
76
76
  }
77
77
  }
78
78
 
79
+ export class MigrationFileMissingError extends Error {
80
+ readonly file: string;
81
+
82
+ constructor(file: string) {
83
+ super(
84
+ `${file} is missing from the migrations directory — restore the file or remove its tracking record manually`,
85
+ );
86
+ this.name = "MigrationFileMissingError";
87
+ this.file = file;
88
+ }
89
+ }
90
+
79
91
  export class MigrationLockError extends Error {
80
92
  readonly timeoutSeconds: number;
81
93
 
@@ -113,3 +125,26 @@ export class GitStageError extends Error {
113
125
  this.exitCode = exitCode;
114
126
  }
115
127
  }
128
+
129
+ export const MIGRATION_NAME_MAX_LENGTH = 128;
130
+
131
+ const MIGRATION_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
132
+
133
+ export class InvalidMigrationNameError extends Error {
134
+ readonly value: string;
135
+
136
+ constructor(value: string) {
137
+ super(
138
+ `Invalid migration name: "${value}" — expected letters, digits, hyphens and underscores only ` +
139
+ `(no path separators, dots or spaces), at most ${MIGRATION_NAME_MAX_LENGTH} characters`,
140
+ );
141
+ this.name = "InvalidMigrationNameError";
142
+ this.value = value;
143
+ }
144
+ }
145
+
146
+ export function validateMigrationName(name: string): void {
147
+ if (!MIGRATION_NAME_PATTERN.test(name) || name.length > MIGRATION_NAME_MAX_LENGTH) {
148
+ throw new InvalidMigrationNameError(name);
149
+ }
150
+ }