bunsql-native-migrate 0.1.1 → 0.2.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 +89 -27
- package/package.json +1 -1
- package/src/api/create.ts +29 -11
- package/src/api/down.ts +40 -14
- package/src/api/lock.ts +50 -0
- package/src/api/options.ts +39 -1
- package/src/api/run-step.ts +13 -0
- package/src/api/status.ts +20 -0
- package/src/api/up.ts +68 -45
- package/src/cli/main.ts +106 -7
- package/src/core/console.ts +1 -1
- package/src/core/driver.ts +5 -0
- package/src/core/fs.ts +4 -2
- package/src/core/random-name.ts +0 -68
- package/src/drivers/mariadb.ts +48 -34
- package/src/drivers/postgres.ts +31 -31
- package/src/drivers/shared.ts +91 -0
- package/src/drivers/sqlite.ts +16 -31
- package/src/index.ts +12 -0
package/README.md
CHANGED
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
Zero-ORM SQL file migrations for [Bun](https://bun.sh): PostgreSQL, MySQL/MariaDB and SQLite through the built-in `Bun.SQL` client.
|
|
7
7
|
|
|
8
|
-
No ORM, no schema diffing, no lock-in — you write plain `.js` migration files with `up()`/`down()` exports and run them with a tiny CLI or the programmatic API.
|
|
8
|
+
No ORM, no schema diffing, no lock-in — you write plain `.js`/`.ts` migration files with `up()`/`down()` exports (optionally `up(tx)`/`down(tx)` for transactional migrations, see below) and run them with a tiny CLI or the programmatic API.
|
|
9
9
|
|
|
10
10
|
## Features
|
|
11
11
|
|
|
12
12
|
- **Bun-native** — built on the unified [`Bun.SQL`](https://bun.com/docs/runtime/sql) client (PostgreSQL, MySQL/MariaDB, SQLite). No Node.js support.
|
|
13
|
-
- **Zero ORM** — migrations are plain JavaScript files; use `sql` tagged templates or any Bun database client you like.
|
|
13
|
+
- **Zero ORM** — migrations are plain JavaScript or TypeScript files; use `sql` tagged templates or any Bun database client you like.
|
|
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
|
+
- **Concurrent-safe `up`** — an advisory database lock serializes racing `up` runs (two deploy pods, CI + laptop) so a migration body never executes twice.
|
|
16
17
|
- **Legacy backfill** — records without a checksum are backfilled automatically on the next `up`.
|
|
17
18
|
- **CLI and library** — use it as `bunx bunsql-native-migrate` or import the functions directly.
|
|
18
19
|
|
|
@@ -27,7 +28,7 @@ Requires Bun ≥ 1.4.2 — the version this package is developed and tested agai
|
|
|
27
28
|
## Quick start
|
|
28
29
|
|
|
29
30
|
```bash
|
|
30
|
-
# create migrations/<timestamp>_<name>.
|
|
31
|
+
# create migrations/<timestamp>_<name>.ts from the stub template
|
|
31
32
|
bunx bunsql-native-migrate create add_users_table
|
|
32
33
|
|
|
33
34
|
# create the tracking table (optional — up() does it automatically)
|
|
@@ -36,6 +37,12 @@ bunx bunsql-native-migrate install
|
|
|
36
37
|
# apply pending migrations
|
|
37
38
|
bunx bunsql-native-migrate up
|
|
38
39
|
|
|
40
|
+
# see what is applied and what is pending
|
|
41
|
+
bunx bunsql-native-migrate status
|
|
42
|
+
|
|
43
|
+
# CI gate: same listing, but exit code 1 while migrations are pending
|
|
44
|
+
bunx bunsql-native-migrate status --strict
|
|
45
|
+
|
|
39
46
|
# roll back the last applied migration
|
|
40
47
|
bunx bunsql-native-migrate down
|
|
41
48
|
```
|
|
@@ -58,12 +65,34 @@ const down = async () => {
|
|
|
58
65
|
export { up, down };
|
|
59
66
|
```
|
|
60
67
|
|
|
61
|
-
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. `bunsql-native-migrate create` generates
|
|
68
|
+
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:
|
|
62
69
|
|
|
63
70
|
```
|
|
64
|
-
9999999999999_2026_09_13_add_users_table.
|
|
71
|
+
9999999999999_2026_09_13_add_users_table.ts
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Transactional migrations
|
|
75
|
+
|
|
76
|
+
Declare a `tx` parameter on `up`/`down` and the migration runs inside a single database transaction: if any statement fails, the partial work is rolled back instead of being left half-applied, and nothing is recorded.
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const up = async (tx) => {
|
|
80
|
+
await tx`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`;
|
|
81
|
+
await tx`INSERT INTO users (id, name) VALUES (1, 'admin')`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const down = async (tx) => {
|
|
85
|
+
await tx`DROP TABLE users`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export { up, down };
|
|
65
89
|
```
|
|
66
90
|
|
|
91
|
+
- `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).
|
|
92
|
+
- 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.
|
|
93
|
+
- 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.
|
|
94
|
+
- 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).
|
|
95
|
+
|
|
67
96
|
### Migrations directory path resolution
|
|
68
97
|
|
|
69
98
|
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.
|
|
@@ -79,21 +108,27 @@ This resolution is part of the library contract: `resolveListDir` (and therefore
|
|
|
79
108
|
## CLI reference
|
|
80
109
|
|
|
81
110
|
```
|
|
82
|
-
bunsql-native-migrate <up|down|install|create [name]> [--dir <migrations-dir>] [--git] [--help]
|
|
111
|
+
bunsql-native-migrate <up|down [n]|install|create [name]|status> [--dir <migrations-dir>] [--to <name>] [--lock-timeout <seconds>] [--all] [--lang <js|ts>] [--git] [--strict] [--help]
|
|
83
112
|
```
|
|
84
113
|
|
|
85
|
-
| Command | What it does
|
|
86
|
-
| --------------- |
|
|
87
|
-
| `up` | Applies pending migrations (creating the tracking table if needed); prints `No pending migrations.` when there is nothing to apply. |
|
|
88
|
-
| `down` | Reverts the last applied migration;
|
|
89
|
-
| `install` | Creates the tracking table only.
|
|
90
|
-
| `create [name]` | Creates a stub migration file from the template; without a `name` a random `adjective_noun` is generated.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
| `--
|
|
96
|
-
| `--
|
|
114
|
+
| Command | What it does |
|
|
115
|
+
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| `up` | Applies pending migrations (creating the tracking table if needed); 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. |
|
|
117
|
+
| `down` | Reverts the last applied migration; `down <n>` reverts the last `n`, `down --all` reverts everything — always most-recent-first. Prints `No migrations to rollback.` when there is none. |
|
|
118
|
+
| `install` | Creates the tracking table only. |
|
|
119
|
+
| `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. |
|
|
120
|
+
| `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. |
|
|
121
|
+
|
|
122
|
+
| Flag | Applies to | Meaning |
|
|
123
|
+
| ------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
124
|
+
| `--dir <migrations-dir>` | all | Migrations directory (default `./migrations`, or the `MIGRATION_LIST_DIR` env var). |
|
|
125
|
+
| `--to <name>` | `up` | Apply pending migrations up to and including the named file. An unknown name is an error (exit 1); a target that is already applied is a no-op. |
|
|
126
|
+
| `--lock-timeout <sec>` | `up` | How long to wait for the migration lock when another `up` is running, in seconds (default `30`, `0` fails immediately). On timeout: exit 1. |
|
|
127
|
+
| `--all` | `down` | Revert every applied migration (most-recent-first). Cannot be combined with a step count. |
|
|
128
|
+
| `--git` | `create` | `git add` the created file. When staging fails, the CLI prints an error and exits 1 — the file itself stays on disk. |
|
|
129
|
+
| `--lang <js\|ts>` | `create` | Language of the created stub. Default: `ts`. An unknown or missing value is an error (exit 1). |
|
|
130
|
+
| `--strict` | `status` | Exit with code 1 when migrations are pending — a gate for CI/CD pipelines. Exit 0 otherwise. |
|
|
131
|
+
| `--help`, `-h` | — | Prints the usage line and exits with code 0. |
|
|
97
132
|
|
|
98
133
|
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.
|
|
99
134
|
|
|
@@ -103,19 +138,30 @@ Any failure (connection errors, a failing migration, a modified applied file) is
|
|
|
103
138
|
import {
|
|
104
139
|
migrateUp,
|
|
105
140
|
migrateDown,
|
|
141
|
+
migrateStatus,
|
|
106
142
|
installMigrations,
|
|
107
143
|
createMigration,
|
|
108
144
|
createDriver,
|
|
109
145
|
ChecksumDriftError,
|
|
146
|
+
MigrationLockError,
|
|
147
|
+
MigrationNotFoundError,
|
|
110
148
|
GitStageError,
|
|
111
149
|
} from "bunsql-native-migrate";
|
|
112
150
|
|
|
113
151
|
const { applied } = await migrateUp({
|
|
114
152
|
databaseUrl: "postgres://user:pass@localhost:5432/app", // default: DATABASE_URL env
|
|
115
153
|
listDir: "./migrations", // default: MIGRATION_LIST_DIR env or ./migrations
|
|
154
|
+
to: "2_add_columns.ts", // optional: apply up to and including this file
|
|
155
|
+
lockTimeout: 60, // optional: seconds to wait for the migration lock (default 30, 0 = fail fast)
|
|
116
156
|
});
|
|
117
157
|
|
|
118
|
-
const { reverted } = await migrateDown(); // reverted: string
|
|
158
|
+
const { reverted } = await migrateDown(); // reverted: string[] (most-recent-first)
|
|
159
|
+
await migrateDown({ steps: 3 }); // revert the last three
|
|
160
|
+
await migrateDown({ steps: "all" }); // revert everything
|
|
161
|
+
|
|
162
|
+
const status = await migrateStatus();
|
|
163
|
+
// status.applied: ExecutedMigration[] (name + checksum, in apply order)
|
|
164
|
+
// status.pending: string[] (files waiting to be applied, in apply order)
|
|
119
165
|
|
|
120
166
|
await installMigrations(); // creates the tracking table
|
|
121
167
|
|
|
@@ -124,12 +170,18 @@ const filename = await createMigration({ name: "add_users_table", listDir: "./mi
|
|
|
124
170
|
|
|
125
171
|
All options are optional unless stated otherwise:
|
|
126
172
|
|
|
127
|
-
| Option | Where
|
|
128
|
-
| ------------- |
|
|
129
|
-
| `databaseUrl` | `migrateUp`, `migrateDown`, `installMigrations` | `DATABASE_URL` env var
|
|
130
|
-
| `listDir` | all functions
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
173
|
+
| Option | Where | Default |
|
|
174
|
+
| ------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
175
|
+
| `databaseUrl` | `migrateUp`, `migrateDown`, `migrateStatus`, `installMigrations` | `DATABASE_URL` env var |
|
|
176
|
+
| `listDir` | all functions | `MIGRATION_LIST_DIR` env var, then `./migrations` (relative paths resolve against the process cwd) |
|
|
177
|
+
| `to` | `migrateUp` | — apply pending migrations in order up to and including the named file; an unknown name throws `MigrationNotFoundError`, an already-applied target applies nothing |
|
|
178
|
+
| `lockTimeout` | `migrateUp` | `30` — seconds to wait for the migration lock while another `up` is running; `0` fails immediately; a timeout throws `MigrationLockError` |
|
|
179
|
+
| `steps` | `migrateDown` | `1` — revert the last `n` applied migrations (`1`–`n` or `"all"`), most-recent-first |
|
|
180
|
+
| `name` | `createMigration` | random `adjective_noun` name |
|
|
181
|
+
| `lang` | `createMigration` | `"ts"` — pass `"js"` for a JavaScript stub |
|
|
182
|
+
| `git` | `createMigration` | `false` — `git add` the new file |
|
|
183
|
+
|
|
184
|
+
`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.
|
|
133
185
|
|
|
134
186
|
### Drivers
|
|
135
187
|
|
|
@@ -147,7 +199,7 @@ Known MySQL/MariaDB limitation of `Bun.SQL` (not used by this package, good to k
|
|
|
147
199
|
|
|
148
200
|
Bun's client also recognizes `mysql2://` and `file://` URLs — `createDriver` deliberately rejects them. Stick to the protocols listed above.
|
|
149
201
|
|
|
150
|
-
`createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`).
|
|
202
|
+
`createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`). 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.
|
|
151
203
|
|
|
152
204
|
### Tracking table
|
|
153
205
|
|
|
@@ -159,9 +211,19 @@ Applied migrations are recorded in a `migrations` table with a unique name and a
|
|
|
159
211
|
|
|
160
212
|
Records created before checksums existed (checksum `NULL`) are backfilled on the next `up`.
|
|
161
213
|
|
|
214
|
+
### Concurrent runs
|
|
215
|
+
|
|
216
|
+
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:
|
|
217
|
+
|
|
218
|
+
- **PostgreSQL** — session advisory lock (`pg_try_advisory_lock` / `pg_advisory_unlock`) on a reserved connection, keyed per database.
|
|
219
|
+
- **MySQL/MariaDB** — `GET_LOCK` / `RELEASE_LOCK` on a reserved connection, named per database.
|
|
220
|
+
- **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.
|
|
221
|
+
|
|
222
|
+
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.`
|
|
223
|
+
|
|
162
224
|
### Error handling
|
|
163
225
|
|
|
164
|
-
The library throws instead of exiting: connection errors, failing migrations
|
|
226
|
+
The library throws instead of exiting: connection errors, failing migrations, [`ChecksumDriftError`](#tracking-table), `MigrationNotFoundError` (an unknown `to` target, thrown before anything is applied) and [`MigrationLockError`](#concurrent-runs) (another `up` held the lock past `lockTimeout`) propagate to the caller, and the driver connection is always closed. The CLI catches these and exits with code 1.
|
|
165
227
|
|
|
166
228
|
`createMigration` with `git: true` throws `GitStageError` when `git add` fails (no repository, ignored path, …) — the migration file itself is still created on disk.
|
|
167
229
|
|
package/package.json
CHANGED
package/src/api/create.ts
CHANGED
|
@@ -5,22 +5,39 @@ import { resolveListDir } from "../core/fs.js";
|
|
|
5
5
|
import { log } from "../core/console.js";
|
|
6
6
|
import { GitStageError } from "./options.js";
|
|
7
7
|
|
|
8
|
+
export type MigrationLang = "js" | "ts";
|
|
9
|
+
|
|
8
10
|
export interface CreateOptions {
|
|
9
11
|
name?: string;
|
|
10
12
|
git?: boolean;
|
|
13
|
+
lang?: MigrationLang;
|
|
11
14
|
listDir: string;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
const
|
|
15
|
-
// Write your migration SQL here
|
|
16
|
-
const up = async () => {};
|
|
17
|
+
const JS_TEMPLATE = `import { sql } from "bun";
|
|
18
|
+
// Write your migration SQL here (tx runs inside a transaction)
|
|
19
|
+
const up = async (tx) => {};
|
|
17
20
|
|
|
18
21
|
// Write your rollback SQL here
|
|
19
|
-
const down = async () => {};
|
|
22
|
+
const down = async (tx) => {};
|
|
20
23
|
|
|
21
24
|
export { up, down };
|
|
22
25
|
`;
|
|
23
26
|
|
|
27
|
+
const TS_TEMPLATE = `import { sql, type SQL } from "bun";
|
|
28
|
+
// Write your migration SQL here (tx runs inside a transaction)
|
|
29
|
+
const up = async (tx: SQL) => {};
|
|
30
|
+
|
|
31
|
+
// Write your rollback SQL here
|
|
32
|
+
const down = async (tx: SQL) => {};
|
|
33
|
+
|
|
34
|
+
export { up, down };
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function stubTemplate(lang: MigrationLang): string {
|
|
38
|
+
return lang === "js" ? JS_TEMPLATE : TS_TEMPLATE;
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
async function stageInGit(filePath: string): Promise<void> {
|
|
25
42
|
const add = Bun.spawn({
|
|
26
43
|
cmd: ["git", "add", filePath],
|
|
@@ -34,7 +51,7 @@ async function stageInGit(filePath: string): Promise<void> {
|
|
|
34
51
|
}
|
|
35
52
|
}
|
|
36
53
|
|
|
37
|
-
function migrationFilename(name: string, date: Date): string {
|
|
54
|
+
function migrationFilename(name: string, date: Date, lang: MigrationLang): string {
|
|
38
55
|
const pad = (value: number) => (value <= 9 ? `0${value}` : `${value}`);
|
|
39
56
|
const MAX_TIME = 9999999999999;
|
|
40
57
|
const invertedTime = (MAX_TIME - date.getTime()).toString().padStart(13, "0");
|
|
@@ -44,10 +61,10 @@ function migrationFilename(name: string, date: Date): string {
|
|
|
44
61
|
pad(date.getUTCMonth() + 1),
|
|
45
62
|
pad(date.getUTCDate()),
|
|
46
63
|
].join("_");
|
|
47
|
-
return `${timestamp}_${name}
|
|
64
|
+
return `${timestamp}_${name}.${lang}`;
|
|
48
65
|
}
|
|
49
66
|
|
|
50
|
-
async function writeStubExclusively(filePath: string): Promise<boolean> {
|
|
67
|
+
async function writeStubExclusively(filePath: string, template: string): Promise<boolean> {
|
|
51
68
|
let handle: FileHandle;
|
|
52
69
|
try {
|
|
53
70
|
handle = await open(filePath, "wx");
|
|
@@ -58,7 +75,7 @@ async function writeStubExclusively(filePath: string): Promise<boolean> {
|
|
|
58
75
|
throw error;
|
|
59
76
|
}
|
|
60
77
|
try {
|
|
61
|
-
await handle.writeFile(
|
|
78
|
+
await handle.writeFile(template);
|
|
62
79
|
} finally {
|
|
63
80
|
await handle.close();
|
|
64
81
|
}
|
|
@@ -67,12 +84,13 @@ async function writeStubExclusively(filePath: string): Promise<boolean> {
|
|
|
67
84
|
|
|
68
85
|
export async function createMigration(options: CreateOptions): Promise<string> {
|
|
69
86
|
const name = options.name ?? randomName();
|
|
87
|
+
const lang = options.lang ?? "ts";
|
|
70
88
|
await mkdir(options.listDir, { recursive: true });
|
|
71
|
-
let filename = migrationFilename(name, new Date());
|
|
89
|
+
let filename = migrationFilename(name, new Date(), lang);
|
|
72
90
|
let filePath = path.join(options.listDir, filename);
|
|
73
|
-
while (!(await writeStubExclusively(filePath))) {
|
|
91
|
+
while (!(await writeStubExclusively(filePath, stubTemplate(lang)))) {
|
|
74
92
|
await Bun.sleep(1);
|
|
75
|
-
filename = migrationFilename(name, new Date());
|
|
93
|
+
filename = migrationFilename(name, new Date(), lang);
|
|
76
94
|
filePath = path.join(options.listDir, filename);
|
|
77
95
|
}
|
|
78
96
|
|
package/src/api/down.ts
CHANGED
|
@@ -1,32 +1,58 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { resolveListDir } from "../core/fs.js";
|
|
3
3
|
import { log } from "../core/console.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { MigrationDriver } from "../core/driver.js";
|
|
5
|
+
import type { MigrateDownOptions, MigrateDownResult } from "./options.js";
|
|
5
6
|
import { runWithDriver } from "./run-with-driver.js";
|
|
7
|
+
import { runMigrationStep } from "./run-step.js";
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
function resolveStepCount(steps: number | "all" | undefined, appliedCount: number): number {
|
|
10
|
+
if (steps === undefined) return 1;
|
|
11
|
+
if (steps === "all") return appliedCount;
|
|
12
|
+
if (!Number.isInteger(steps) || steps < 1) {
|
|
13
|
+
throw new Error(`Invalid steps: ${String(steps)} — expected a positive integer or "all"`);
|
|
14
|
+
}
|
|
15
|
+
return steps;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function revertOne(driver: MigrationDriver, listDir: string, file: string): Promise<void> {
|
|
19
|
+
const mod = await import(path.join(listDir, file));
|
|
20
|
+
|
|
21
|
+
if (typeof mod.down !== "function") {
|
|
22
|
+
log({ text: `${file} has no down() export, removing tracking record`, type: "warn" });
|
|
23
|
+
await driver.remove(file);
|
|
24
|
+
log({ text: `${file} tracking record removed`, type: "success" });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
await runMigrationStep(driver, mod.down);
|
|
29
|
+
await driver.remove(file);
|
|
30
|
+
log({ text: `${file} rolled back`, type: "success" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function migrateDown(options: MigrateDownOptions = {}): Promise<MigrateDownResult> {
|
|
8
34
|
const listDir = resolveListDir(options.listDir);
|
|
9
35
|
|
|
10
36
|
return runWithDriver(options, async (driver) => {
|
|
11
37
|
const executed = await driver.listExecuted();
|
|
12
38
|
if (executed.length === 0) {
|
|
13
39
|
log({ text: "No migrations to rollback.", type: "warn" });
|
|
14
|
-
return { reverted:
|
|
40
|
+
return { reverted: [] };
|
|
15
41
|
}
|
|
16
42
|
|
|
17
|
-
const
|
|
18
|
-
const
|
|
43
|
+
const count = resolveStepCount(options.steps, executed.length);
|
|
44
|
+
const reverted: string[] = [];
|
|
19
45
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
46
|
+
for (const entry of executed.slice(-count).reverse()) {
|
|
47
|
+
try {
|
|
48
|
+
await revertOne(driver, listDir, entry.name);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
log({ text: `${entry.name} rollback failed`, type: "error", error });
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
reverted.push(entry.name);
|
|
25
54
|
}
|
|
26
55
|
|
|
27
|
-
|
|
28
|
-
await driver.remove(file);
|
|
29
|
-
log({ text: `${file} rolled back`, type: "success" });
|
|
30
|
-
return { reverted: file };
|
|
56
|
+
return { reverted };
|
|
31
57
|
});
|
|
32
58
|
}
|
package/src/api/lock.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { log } from "../core/console.js";
|
|
2
|
+
import type { MigrationDriver } from "../core/driver.js";
|
|
3
|
+
import { MigrationLockError } from "./options.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_LOCK_TIMEOUT_SECONDS = 30;
|
|
6
|
+
|
|
7
|
+
const LOCK_RETRY_DELAY_MS = 100;
|
|
8
|
+
|
|
9
|
+
export function resolveLockTimeout(lockTimeout: number | undefined): number {
|
|
10
|
+
if (lockTimeout === undefined) return DEFAULT_LOCK_TIMEOUT_SECONDS;
|
|
11
|
+
if (!Number.isInteger(lockTimeout) || lockTimeout < 0) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Invalid lockTimeout: ${String(lockTimeout)} — expected a non-negative integer of seconds`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return lockTimeout;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function withMigrationLock<T>(
|
|
20
|
+
driver: MigrationDriver,
|
|
21
|
+
timeoutSeconds: number,
|
|
22
|
+
run: () => Promise<T>,
|
|
23
|
+
): Promise<T> {
|
|
24
|
+
const { tryLock, releaseLock } = driver;
|
|
25
|
+
if (tryLock === undefined || releaseLock === undefined) {
|
|
26
|
+
return run();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
30
|
+
let waitingLogged = false;
|
|
31
|
+
while (!(await tryLock(timeoutSeconds))) {
|
|
32
|
+
if (Date.now() >= deadline) {
|
|
33
|
+
throw new MigrationLockError(timeoutSeconds);
|
|
34
|
+
}
|
|
35
|
+
if (!waitingLogged) {
|
|
36
|
+
log({ text: "another migrate up holds the lock — waiting", type: "info" });
|
|
37
|
+
waitingLogged = true;
|
|
38
|
+
}
|
|
39
|
+
await Bun.sleep(LOCK_RETRY_DELAY_MS);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result = await run();
|
|
44
|
+
await releaseLock();
|
|
45
|
+
return result;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await releaseLock().catch(() => undefined);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/api/options.ts
CHANGED
|
@@ -1,14 +1,30 @@
|
|
|
1
|
+
import type { ExecutedMigration } from "../core/driver.js";
|
|
2
|
+
|
|
1
3
|
export interface MigrateOptions {
|
|
2
4
|
databaseUrl?: string;
|
|
3
5
|
listDir?: string;
|
|
4
6
|
}
|
|
5
7
|
|
|
8
|
+
export interface MigrateUpOptions extends MigrateOptions {
|
|
9
|
+
to?: string;
|
|
10
|
+
lockTimeout?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MigrateDownOptions extends MigrateOptions {
|
|
14
|
+
steps?: number | "all";
|
|
15
|
+
}
|
|
16
|
+
|
|
6
17
|
export interface MigrateUpResult {
|
|
7
18
|
applied: string[];
|
|
8
19
|
}
|
|
9
20
|
|
|
10
21
|
export interface MigrateDownResult {
|
|
11
|
-
reverted: string
|
|
22
|
+
reverted: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MigrateStatusResult {
|
|
26
|
+
applied: ExecutedMigration[];
|
|
27
|
+
pending: string[];
|
|
12
28
|
}
|
|
13
29
|
|
|
14
30
|
export class ChecksumDriftError extends Error {
|
|
@@ -23,6 +39,28 @@ export class ChecksumDriftError extends Error {
|
|
|
23
39
|
}
|
|
24
40
|
}
|
|
25
41
|
|
|
42
|
+
export class MigrationNotFoundError extends Error {
|
|
43
|
+
readonly file: string;
|
|
44
|
+
|
|
45
|
+
constructor(file: string) {
|
|
46
|
+
super(`${file} is not in the migrations directory — nothing was applied`);
|
|
47
|
+
this.name = "MigrationNotFoundError";
|
|
48
|
+
this.file = file;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class MigrationLockError extends Error {
|
|
53
|
+
readonly timeoutSeconds: number;
|
|
54
|
+
|
|
55
|
+
constructor(timeoutSeconds: number) {
|
|
56
|
+
super(
|
|
57
|
+
`could not acquire the migration lock within ${timeoutSeconds}s — another migrate up is probably still running`,
|
|
58
|
+
);
|
|
59
|
+
this.name = "MigrationLockError";
|
|
60
|
+
this.timeoutSeconds = timeoutSeconds;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
26
64
|
export class GitStageError extends Error {
|
|
27
65
|
readonly file: string;
|
|
28
66
|
readonly exitCode: number;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type SQL } from "bun";
|
|
2
|
+
import type { MigrationDriver } from "../core/driver.js";
|
|
3
|
+
|
|
4
|
+
export async function runMigrationStep(
|
|
5
|
+
driver: MigrationDriver,
|
|
6
|
+
step: (tx?: SQL) => Promise<void>,
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
if (step.length > 0) {
|
|
9
|
+
await driver.transaction((tx) => step(tx));
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
await step();
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
|
|
2
|
+
import type { MigrateOptions, MigrateStatusResult } from "./options.js";
|
|
3
|
+
import { runWithDriver } from "./run-with-driver.js";
|
|
4
|
+
|
|
5
|
+
export async function migrateStatus(options: MigrateOptions = {}): Promise<MigrateStatusResult> {
|
|
6
|
+
const listDir = resolveListDir(options.listDir);
|
|
7
|
+
|
|
8
|
+
return runWithDriver(options, async (driver) => {
|
|
9
|
+
await driver.install();
|
|
10
|
+
|
|
11
|
+
const files = await listFiles(listDir, MIGRATION_EXTENSIONS);
|
|
12
|
+
const executed = await driver.listExecuted();
|
|
13
|
+
const appliedNames = new Set(executed.map((entry) => entry.name));
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
applied: executed,
|
|
17
|
+
pending: files.filter((file) => !appliedNames.has(file)),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|