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