bunsql-native-migrate 0.1.2 → 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 +68 -28
- package/package.json +1 -1
- package/src/api/create.ts +26 -8
- package/src/api/down.ts +39 -14
- package/src/api/lock.ts +50 -0
- package/src/api/options.ts +39 -1
- package/src/api/status.ts +20 -0
- package/src/api/up.ts +68 -44
- package/src/cli/main.ts +106 -7
- package/src/core/driver.ts +2 -0
- package/src/core/fs.ts +4 -2
- package/src/drivers/mariadb.ts +18 -1
- package/src/drivers/postgres.ts +26 -1
- package/src/drivers/shared.ts +57 -1
- package/src/drivers/sqlite.ts +10 -0
- 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 (optionally `up(tx)`/`down(tx)` for transactional migrations, see below) 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,10 +65,10 @@ 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
|
|
65
72
|
```
|
|
66
73
|
|
|
67
74
|
### Transactional migrations
|
|
@@ -81,7 +88,7 @@ const down = async (tx) => {
|
|
|
81
88
|
export { up, down };
|
|
82
89
|
```
|
|
83
90
|
|
|
84
|
-
- `bunsql-native-migrate create` generates stubs in this form by default
|
|
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).
|
|
85
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.
|
|
86
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.
|
|
87
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).
|
|
@@ -101,21 +108,27 @@ This resolution is part of the library contract: `resolveListDir` (and therefore
|
|
|
101
108
|
## CLI reference
|
|
102
109
|
|
|
103
110
|
```
|
|
104
|
-
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]
|
|
105
112
|
```
|
|
106
113
|
|
|
107
|
-
| Command | What it does
|
|
108
|
-
| --------------- |
|
|
109
|
-
| `up` | Applies pending migrations (creating the tracking table if needed); prints `No pending migrations.` when there is nothing to apply. |
|
|
110
|
-
| `down` | Reverts the last applied migration;
|
|
111
|
-
| `install` | Creates the tracking table only.
|
|
112
|
-
| `create [name]` | Creates a stub migration file from the template; without a `name` a random `adjective_noun` is generated.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
| `--
|
|
118
|
-
| `--
|
|
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. |
|
|
119
132
|
|
|
120
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.
|
|
121
134
|
|
|
@@ -125,19 +138,30 @@ Any failure (connection errors, a failing migration, a modified applied file) is
|
|
|
125
138
|
import {
|
|
126
139
|
migrateUp,
|
|
127
140
|
migrateDown,
|
|
141
|
+
migrateStatus,
|
|
128
142
|
installMigrations,
|
|
129
143
|
createMigration,
|
|
130
144
|
createDriver,
|
|
131
145
|
ChecksumDriftError,
|
|
146
|
+
MigrationLockError,
|
|
147
|
+
MigrationNotFoundError,
|
|
132
148
|
GitStageError,
|
|
133
149
|
} from "bunsql-native-migrate";
|
|
134
150
|
|
|
135
151
|
const { applied } = await migrateUp({
|
|
136
152
|
databaseUrl: "postgres://user:pass@localhost:5432/app", // default: DATABASE_URL env
|
|
137
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)
|
|
138
156
|
});
|
|
139
157
|
|
|
140
|
-
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)
|
|
141
165
|
|
|
142
166
|
await installMigrations(); // creates the tracking table
|
|
143
167
|
|
|
@@ -146,12 +170,18 @@ const filename = await createMigration({ name: "add_users_table", listDir: "./mi
|
|
|
146
170
|
|
|
147
171
|
All options are optional unless stated otherwise:
|
|
148
172
|
|
|
149
|
-
| Option | Where
|
|
150
|
-
| ------------- |
|
|
151
|
-
| `databaseUrl` | `migrateUp`, `migrateDown`, `installMigrations` | `DATABASE_URL` env var
|
|
152
|
-
| `listDir` | all functions
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
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.
|
|
155
185
|
|
|
156
186
|
### Drivers
|
|
157
187
|
|
|
@@ -169,7 +199,7 @@ Known MySQL/MariaDB limitation of `Bun.SQL` (not used by this package, good to k
|
|
|
169
199
|
|
|
170
200
|
Bun's client also recognizes `mysql2://` and `file://` URLs — `createDriver` deliberately rejects them. Stick to the protocols listed above.
|
|
171
201
|
|
|
172
|
-
`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.
|
|
173
203
|
|
|
174
204
|
### Tracking table
|
|
175
205
|
|
|
@@ -181,9 +211,19 @@ Applied migrations are recorded in a `migrations` table with a unique name and a
|
|
|
181
211
|
|
|
182
212
|
Records created before checksums existed (checksum `NULL`) are backfilled on the next `up`.
|
|
183
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
|
+
|
|
184
224
|
### Error handling
|
|
185
225
|
|
|
186
|
-
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.
|
|
187
227
|
|
|
188
228
|
`createMigration` with `git: true` throws `GitStageError` when `git add` fails (no repository, ignored path, …) — the migration file itself is still created on disk.
|
|
189
229
|
|
package/package.json
CHANGED
package/src/api/create.ts
CHANGED
|
@@ -5,13 +5,16 @@ 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
|
|
17
|
+
const JS_TEMPLATE = `import { sql } from "bun";
|
|
15
18
|
// Write your migration SQL here (tx runs inside a transaction)
|
|
16
19
|
const up = async (tx) => {};
|
|
17
20
|
|
|
@@ -21,6 +24,20 @@ const down = async (tx) => {};
|
|
|
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,33 +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";
|
|
6
7
|
import { runMigrationStep } from "./run-step.js";
|
|
7
8
|
|
|
8
|
-
|
|
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> {
|
|
9
34
|
const listDir = resolveListDir(options.listDir);
|
|
10
35
|
|
|
11
36
|
return runWithDriver(options, async (driver) => {
|
|
12
37
|
const executed = await driver.listExecuted();
|
|
13
38
|
if (executed.length === 0) {
|
|
14
39
|
log({ text: "No migrations to rollback.", type: "warn" });
|
|
15
|
-
return { reverted:
|
|
40
|
+
return { reverted: [] };
|
|
16
41
|
}
|
|
17
42
|
|
|
18
|
-
const
|
|
19
|
-
const
|
|
43
|
+
const count = resolveStepCount(options.steps, executed.length);
|
|
44
|
+
const reverted: string[] = [];
|
|
20
45
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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);
|
|
26
54
|
}
|
|
27
55
|
|
|
28
|
-
|
|
29
|
-
await driver.remove(file);
|
|
30
|
-
log({ text: `${file} rolled back`, type: "success" });
|
|
31
|
-
return { reverted: file };
|
|
56
|
+
return { reverted };
|
|
32
57
|
});
|
|
33
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,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
|
+
}
|
package/src/api/up.ts
CHANGED
|
@@ -1,68 +1,92 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { checksumFile, listFiles, resolveListDir } from "../core/fs.js";
|
|
2
|
+
import { checksumFile, listFiles, MIGRATION_EXTENSIONS, resolveListDir } from "../core/fs.js";
|
|
3
3
|
import { log } from "../core/console.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type MigrateUpOptions,
|
|
6
|
+
type MigrateUpResult,
|
|
7
|
+
ChecksumDriftError,
|
|
8
|
+
MigrationNotFoundError,
|
|
9
|
+
} from "./options.js";
|
|
5
10
|
import { runWithDriver } from "./run-with-driver.js";
|
|
6
11
|
import { runMigrationStep } from "./run-step.js";
|
|
12
|
+
import { resolveLockTimeout, withMigrationLock } from "./lock.js";
|
|
7
13
|
|
|
8
|
-
export async function migrateUp(options:
|
|
14
|
+
export async function migrateUp(options: MigrateUpOptions = {}): Promise<MigrateUpResult> {
|
|
9
15
|
const listDir = resolveListDir(options.listDir);
|
|
16
|
+
const target = options.to;
|
|
17
|
+
const lockTimeout = resolveLockTimeout(options.lockTimeout);
|
|
10
18
|
|
|
11
19
|
return runWithDriver(options, async (driver) => {
|
|
12
20
|
await driver.install();
|
|
13
21
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
return withMigrationLock(driver, lockTimeout, async () => {
|
|
23
|
+
const allFiles = await listFiles(listDir, MIGRATION_EXTENSIONS);
|
|
24
|
+
if (target !== undefined && !allFiles.includes(target)) {
|
|
25
|
+
throw new MigrationNotFoundError(target);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const checksums = new Map(
|
|
29
|
+
await Promise.all(
|
|
30
|
+
allFiles.map(
|
|
31
|
+
async (file) => [file, await checksumFile(path.join(listDir, file))] as const,
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const executed = await driver.listExecuted();
|
|
37
|
+
const executedByName = new Map(executed.map((entry) => [entry.name, entry]));
|
|
20
38
|
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
for (const [file, checksum] of checksums) {
|
|
40
|
+
const record = executedByName.get(file);
|
|
41
|
+
if (!record) continue;
|
|
23
42
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
if (record.checksum === null) {
|
|
44
|
+
await driver.setChecksum(file, checksum);
|
|
45
|
+
log({ text: `${file} checksum saved (legacy record)`, type: "info" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
27
48
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
continue;
|
|
49
|
+
if (record.checksum !== checksum) {
|
|
50
|
+
throw new ChecksumDriftError(file);
|
|
51
|
+
}
|
|
32
52
|
}
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
let pending = allFiles.filter((file) => !executedByName.has(file));
|
|
55
|
+
if (target !== undefined) {
|
|
56
|
+
if (executedByName.has(target)) {
|
|
57
|
+
log({ text: `${target} is already applied.`, type: "info" });
|
|
58
|
+
return { applied: [] };
|
|
59
|
+
}
|
|
60
|
+
pending = pending.slice(0, pending.indexOf(target) + 1);
|
|
36
61
|
}
|
|
37
|
-
}
|
|
38
62
|
|
|
39
|
-
|
|
40
|
-
const applied: string[] = [];
|
|
63
|
+
const applied: string[] = [];
|
|
41
64
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
if (pending.length === 0) {
|
|
66
|
+
log({ text: "No pending migrations.", type: "warn" });
|
|
67
|
+
return { applied };
|
|
68
|
+
}
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
for (const file of pending) {
|
|
71
|
+
const checksum = checksums.get(file);
|
|
72
|
+
if (!checksum) continue;
|
|
73
|
+
try {
|
|
74
|
+
const mod = await import(path.join(listDir, file));
|
|
75
|
+
if (typeof mod.up !== "function") {
|
|
76
|
+
log({ text: `${file} has no up() export, skipping`, type: "warn" });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
await runMigrationStep(driver, mod.up);
|
|
80
|
+
await driver.record(file, checksum);
|
|
81
|
+
applied.push(file);
|
|
82
|
+
log({ text: `${file} migrated up`, type: "success" });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
log({ text: `${file} migration failed`, type: "error", error });
|
|
85
|
+
throw error;
|
|
55
86
|
}
|
|
56
|
-
await runMigrationStep(driver, mod.up);
|
|
57
|
-
await driver.record(file, checksum);
|
|
58
|
-
applied.push(file);
|
|
59
|
-
log({ text: `${file} migrated up`, type: "success" });
|
|
60
|
-
} catch (error) {
|
|
61
|
-
log({ text: `${file} migration failed`, type: "error", error });
|
|
62
|
-
throw error;
|
|
63
87
|
}
|
|
64
|
-
}
|
|
65
88
|
|
|
66
|
-
|
|
89
|
+
return { applied };
|
|
90
|
+
});
|
|
67
91
|
});
|
|
68
92
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { migrateUp } from "../api/up.js";
|
|
3
3
|
import { migrateDown } from "../api/down.js";
|
|
4
|
+
import { migrateStatus } from "../api/status.js";
|
|
4
5
|
import { installMigrations } from "../api/install.js";
|
|
5
|
-
import { createMigrationCommand } from "../api/create.js";
|
|
6
|
-
import { ChecksumDriftError } from "../api/options.js";
|
|
6
|
+
import { createMigrationCommand, type MigrationLang } from "../api/create.js";
|
|
7
|
+
import { ChecksumDriftError, MigrationLockError, MigrationNotFoundError } from "../api/options.js";
|
|
7
8
|
import { log } from "../core/console.js";
|
|
8
9
|
|
|
9
10
|
interface CliArgs {
|
|
@@ -11,6 +12,11 @@ interface CliArgs {
|
|
|
11
12
|
positional: string[];
|
|
12
13
|
dir?: string | undefined;
|
|
13
14
|
git: boolean;
|
|
15
|
+
lang?: MigrationLang | undefined;
|
|
16
|
+
to?: string | undefined;
|
|
17
|
+
lockTimeout?: number | undefined;
|
|
18
|
+
all: boolean;
|
|
19
|
+
strict: boolean;
|
|
14
20
|
help: boolean;
|
|
15
21
|
}
|
|
16
22
|
|
|
@@ -18,6 +24,11 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
18
24
|
const positional: string[] = [];
|
|
19
25
|
let dir: string | undefined;
|
|
20
26
|
let git = false;
|
|
27
|
+
let lang: MigrationLang | undefined;
|
|
28
|
+
let to: string | undefined;
|
|
29
|
+
let lockTimeout: number | undefined;
|
|
30
|
+
let all = false;
|
|
31
|
+
let strict = false;
|
|
21
32
|
let help = false;
|
|
22
33
|
for (let i = 0; i < argv.length; i++) {
|
|
23
34
|
const arg = argv[i]!;
|
|
@@ -25,18 +36,60 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
25
36
|
dir = argv[++i];
|
|
26
37
|
} else if (arg === "--git") {
|
|
27
38
|
git = true;
|
|
39
|
+
} else if (arg === "--lang") {
|
|
40
|
+
const value = argv[++i];
|
|
41
|
+
if (value !== "js" && value !== "ts") {
|
|
42
|
+
log({
|
|
43
|
+
text: `Unknown --lang value: ${value ?? "(missing)"} (expected js or ts)`,
|
|
44
|
+
type: "error",
|
|
45
|
+
});
|
|
46
|
+
usage(1);
|
|
47
|
+
}
|
|
48
|
+
lang = value;
|
|
49
|
+
} else if (arg === "--to") {
|
|
50
|
+
to = argv[++i];
|
|
51
|
+
if (to === undefined) {
|
|
52
|
+
log({ text: "--to requires a migration file name", type: "error" });
|
|
53
|
+
usage(1);
|
|
54
|
+
}
|
|
55
|
+
} else if (arg === "--lock-timeout") {
|
|
56
|
+
const value = argv[++i];
|
|
57
|
+
const parsed = Number(value);
|
|
58
|
+
if (value === undefined || !Number.isInteger(parsed) || parsed < 0) {
|
|
59
|
+
log({
|
|
60
|
+
text: `Invalid --lock-timeout: ${value ?? "(missing)"} (expected a non-negative integer of seconds)`,
|
|
61
|
+
type: "error",
|
|
62
|
+
});
|
|
63
|
+
usage(1);
|
|
64
|
+
}
|
|
65
|
+
lockTimeout = parsed;
|
|
66
|
+
} else if (arg === "--all") {
|
|
67
|
+
all = true;
|
|
68
|
+
} else if (arg === "--strict") {
|
|
69
|
+
strict = true;
|
|
28
70
|
} else if (arg === "--help" || arg === "-h") {
|
|
29
71
|
help = true;
|
|
30
72
|
} else {
|
|
31
73
|
positional.push(arg);
|
|
32
74
|
}
|
|
33
75
|
}
|
|
34
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
command: positional.shift(),
|
|
78
|
+
positional,
|
|
79
|
+
dir,
|
|
80
|
+
git,
|
|
81
|
+
lang,
|
|
82
|
+
to,
|
|
83
|
+
lockTimeout,
|
|
84
|
+
all,
|
|
85
|
+
strict,
|
|
86
|
+
help,
|
|
87
|
+
};
|
|
35
88
|
}
|
|
36
89
|
|
|
37
90
|
function usage(exitCode: number): never {
|
|
38
91
|
log({
|
|
39
|
-
text: "Usage: bunsql-native-migrate <up|down|install|create [name]> [--dir <migrations-dir>] [--git] [--help]",
|
|
92
|
+
text: "Usage: 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]",
|
|
40
93
|
type: "info",
|
|
41
94
|
});
|
|
42
95
|
process.exit(exitCode);
|
|
@@ -52,14 +105,40 @@ if (args.help) {
|
|
|
52
105
|
try {
|
|
53
106
|
switch (args.command) {
|
|
54
107
|
case "up": {
|
|
55
|
-
const { applied } = await migrateUp(
|
|
108
|
+
const { applied } = await migrateUp({
|
|
109
|
+
...listDirOptions,
|
|
110
|
+
...(args.to ? { to: args.to } : {}),
|
|
111
|
+
...(args.lockTimeout !== undefined ? { lockTimeout: args.lockTimeout } : {}),
|
|
112
|
+
});
|
|
56
113
|
if (applied.length > 0) {
|
|
57
114
|
log({ text: `Applied ${applied.length} migration(s).`, type: "success" });
|
|
58
115
|
}
|
|
59
116
|
break;
|
|
60
117
|
}
|
|
61
118
|
case "down": {
|
|
62
|
-
|
|
119
|
+
const [stepsArg] = args.positional;
|
|
120
|
+
if (args.all && stepsArg !== undefined) {
|
|
121
|
+
log({ text: "Use either --all or a number of steps, not both.", type: "error" });
|
|
122
|
+
usage(1);
|
|
123
|
+
}
|
|
124
|
+
let steps: number | "all" = 1;
|
|
125
|
+
if (args.all) {
|
|
126
|
+
steps = "all";
|
|
127
|
+
} else if (stepsArg !== undefined) {
|
|
128
|
+
const parsed = Number(stepsArg);
|
|
129
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
130
|
+
log({
|
|
131
|
+
text: `Invalid step count: ${stepsArg} (expected a positive integer)`,
|
|
132
|
+
type: "error",
|
|
133
|
+
});
|
|
134
|
+
usage(1);
|
|
135
|
+
}
|
|
136
|
+
steps = parsed;
|
|
137
|
+
}
|
|
138
|
+
const { reverted } = await migrateDown({ ...listDirOptions, steps });
|
|
139
|
+
if (reverted.length > 0) {
|
|
140
|
+
log({ text: `Reverted ${reverted.length} migration(s).`, type: "success" });
|
|
141
|
+
}
|
|
63
142
|
break;
|
|
64
143
|
}
|
|
65
144
|
case "install": {
|
|
@@ -70,16 +149,36 @@ try {
|
|
|
70
149
|
const [name] = args.positional;
|
|
71
150
|
await createMigrationCommand({
|
|
72
151
|
...(name ? { name } : {}),
|
|
152
|
+
...(args.lang ? { lang: args.lang } : {}),
|
|
73
153
|
git: args.git,
|
|
74
154
|
...listDirOptions,
|
|
75
155
|
});
|
|
76
156
|
break;
|
|
77
157
|
}
|
|
158
|
+
case "status": {
|
|
159
|
+
const { applied, pending } = await migrateStatus(listDirOptions);
|
|
160
|
+
for (const entry of applied) {
|
|
161
|
+
log({ text: `${entry.name} applied`, type: "info" });
|
|
162
|
+
}
|
|
163
|
+
for (const file of pending) {
|
|
164
|
+
log({ text: `${file} pending`, type: "warn" });
|
|
165
|
+
}
|
|
166
|
+
log({ text: `${applied.length} applied, ${pending.length} pending`, type: "info" });
|
|
167
|
+
if (args.strict && pending.length > 0) {
|
|
168
|
+
log({ text: `Strict mode: ${pending.length} pending migration(s).`, type: "warn" });
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
78
173
|
default:
|
|
79
174
|
usage(1);
|
|
80
175
|
}
|
|
81
176
|
} catch (error) {
|
|
82
|
-
if (
|
|
177
|
+
if (
|
|
178
|
+
error instanceof ChecksumDriftError ||
|
|
179
|
+
error instanceof MigrationNotFoundError ||
|
|
180
|
+
error instanceof MigrationLockError
|
|
181
|
+
) {
|
|
83
182
|
log({ text: error.message, type: "error" });
|
|
84
183
|
} else {
|
|
85
184
|
log({ text: "Migration command failed", type: "error", error });
|
package/src/core/driver.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface MigrationDriver {
|
|
|
12
12
|
setChecksum(migration: string, checksum: string): Promise<void>;
|
|
13
13
|
remove(migration: string): Promise<void>;
|
|
14
14
|
transaction<T>(run: (tx: SQL) => Promise<T>): Promise<T>;
|
|
15
|
+
tryLock?(timeoutSeconds: number): Promise<boolean>;
|
|
16
|
+
releaseLock?(): Promise<void>;
|
|
15
17
|
close(): Promise<void>;
|
|
16
18
|
}
|
|
17
19
|
|
package/src/core/fs.ts
CHANGED
|
@@ -3,11 +3,13 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
export const DEFAULT_MIGRATIONS_DIR = "migrations";
|
|
5
5
|
|
|
6
|
-
export
|
|
6
|
+
export const MIGRATION_EXTENSIONS = ["js", "ts"] as const;
|
|
7
|
+
|
|
8
|
+
export async function listFiles(dir: string, extensions: readonly string[]): Promise<string[]> {
|
|
7
9
|
const matchedFiles: string[] = [];
|
|
8
10
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
9
11
|
for (const entry of entries) {
|
|
10
|
-
if (entry.isFile() && entry.name.endsWith(`.${ext}`)) {
|
|
12
|
+
if (entry.isFile() && extensions.some((ext) => entry.name.endsWith(`.${ext}`))) {
|
|
11
13
|
matchedFiles.push(entry.name);
|
|
12
14
|
}
|
|
13
15
|
}
|
package/src/drivers/mariadb.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type SQL } from "bun";
|
|
2
2
|
import type { MigrationDriver } from "../core/driver.js";
|
|
3
|
-
import { createSqlDriver } from "./shared.js";
|
|
3
|
+
import { createReservedLock, createSqlDriver } from "./shared.js";
|
|
4
|
+
|
|
5
|
+
const LOCK_NAME_PREFIX = "bunsql-native-migrate:";
|
|
4
6
|
|
|
5
7
|
async function checksumColumnExists(db: SQL): Promise<boolean> {
|
|
6
8
|
const rows = await db`SELECT column_name FROM information_schema.columns
|
|
@@ -38,5 +40,20 @@ export function create(databaseUrl: string): MigrationDriver {
|
|
|
38
40
|
await db`INSERT IGNORE INTO migrations (migration, checksum)
|
|
39
41
|
VALUES (${migration}, ${checksum})`;
|
|
40
42
|
},
|
|
43
|
+
createLock: (db) =>
|
|
44
|
+
createReservedLock(
|
|
45
|
+
db,
|
|
46
|
+
async (lock) => {
|
|
47
|
+
const rows =
|
|
48
|
+
(await lock`SELECT GET_LOCK(CONCAT(${LOCK_NAME_PREFIX}, MD5(DATABASE())), 0) AS locked`) as Array<{
|
|
49
|
+
locked: number | string | null;
|
|
50
|
+
}>;
|
|
51
|
+
const locked = rows[0]?.locked;
|
|
52
|
+
return locked === 1 || locked === "1";
|
|
53
|
+
},
|
|
54
|
+
async (lock) => {
|
|
55
|
+
await lock`SELECT RELEASE_LOCK(CONCAT(${LOCK_NAME_PREFIX}, MD5(DATABASE())))`;
|
|
56
|
+
},
|
|
57
|
+
),
|
|
41
58
|
});
|
|
42
59
|
}
|
package/src/drivers/postgres.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { type SQL } from "bun";
|
|
1
2
|
import type { MigrationDriver } from "../core/driver.js";
|
|
2
|
-
import { createSqlDriver } from "./shared.js";
|
|
3
|
+
import { createReservedLock, createSqlDriver } from "./shared.js";
|
|
4
|
+
|
|
5
|
+
const LOCK_SCOPE = "bunsql-native-migrate:up";
|
|
6
|
+
|
|
7
|
+
async function advisoryKeyComponents(lock: SQL): Promise<[number, number]> {
|
|
8
|
+
const rows = (await lock`SELECT current_database() AS name`) as Array<{ name: string }>;
|
|
9
|
+
const hash = Bun.hash.wyhash(`${LOCK_SCOPE}:${rows[0]?.name ?? ""}`);
|
|
10
|
+
return [Number((hash >> 32n) & 0x7fffffffn), Number(hash & 0x7fffffffn)];
|
|
11
|
+
}
|
|
3
12
|
|
|
4
13
|
export function create(databaseUrl: string): MigrationDriver {
|
|
5
14
|
return createSqlDriver(databaseUrl, {
|
|
@@ -17,5 +26,21 @@ export function create(databaseUrl: string): MigrationDriver {
|
|
|
17
26
|
VALUES (${migration}, ${checksum})
|
|
18
27
|
ON CONFLICT (migration) DO NOTHING`;
|
|
19
28
|
},
|
|
29
|
+
createLock: (db) =>
|
|
30
|
+
createReservedLock(
|
|
31
|
+
db,
|
|
32
|
+
async (lock) => {
|
|
33
|
+
const [first, second] = await advisoryKeyComponents(lock);
|
|
34
|
+
const rows =
|
|
35
|
+
(await lock`SELECT pg_try_advisory_lock(${first}, ${second}) AS locked`) as Array<{
|
|
36
|
+
locked: boolean;
|
|
37
|
+
}>;
|
|
38
|
+
return rows[0]?.locked === true;
|
|
39
|
+
},
|
|
40
|
+
async (lock) => {
|
|
41
|
+
const [first, second] = await advisoryKeyComponents(lock);
|
|
42
|
+
await lock`SELECT pg_advisory_unlock(${first}, ${second})`;
|
|
43
|
+
},
|
|
44
|
+
),
|
|
20
45
|
});
|
|
21
46
|
}
|
package/src/drivers/shared.ts
CHANGED
|
@@ -1,13 +1,62 @@
|
|
|
1
|
-
import { SQL } from "bun";
|
|
1
|
+
import { SQL, type ReservedSQL } from "bun";
|
|
2
2
|
import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
|
|
3
3
|
|
|
4
|
+
export interface SqlLock {
|
|
5
|
+
tryLock(timeoutSeconds: number): Promise<boolean>;
|
|
6
|
+
releaseLock(): Promise<void>;
|
|
7
|
+
dispose(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
4
10
|
export interface SqlDialect {
|
|
5
11
|
install(db: SQL): Promise<void>;
|
|
6
12
|
record(db: SQL, migration: string, checksum: string): Promise<void>;
|
|
13
|
+
createLock?(db: SQL): SqlLock;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createReservedLock(
|
|
17
|
+
db: SQL,
|
|
18
|
+
acquire: (lock: SQL) => Promise<boolean>,
|
|
19
|
+
release: (lock: SQL) => Promise<void>,
|
|
20
|
+
): SqlLock {
|
|
21
|
+
let lockConnection: ReservedSQL | null = null;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
async tryLock() {
|
|
25
|
+
const connection = await db.reserve();
|
|
26
|
+
lockConnection = connection;
|
|
27
|
+
try {
|
|
28
|
+
const acquired = await acquire(connection);
|
|
29
|
+
if (!acquired) {
|
|
30
|
+
connection.release();
|
|
31
|
+
lockConnection = null;
|
|
32
|
+
}
|
|
33
|
+
return acquired;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
connection.release();
|
|
36
|
+
lockConnection = null;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
async releaseLock() {
|
|
41
|
+
const connection = lockConnection;
|
|
42
|
+
if (!connection) return;
|
|
43
|
+
lockConnection = null;
|
|
44
|
+
try {
|
|
45
|
+
await release(connection);
|
|
46
|
+
} finally {
|
|
47
|
+
connection.release();
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
dispose() {
|
|
51
|
+
lockConnection?.release();
|
|
52
|
+
lockConnection = null;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
7
55
|
}
|
|
8
56
|
|
|
9
57
|
export function createSqlDriver(databaseUrl: string, dialect: SqlDialect): MigrationDriver {
|
|
10
58
|
const db = new SQL(databaseUrl);
|
|
59
|
+
const lock = dialect.createLock?.(db);
|
|
11
60
|
|
|
12
61
|
return {
|
|
13
62
|
install: () => dialect.install(db),
|
|
@@ -28,7 +77,14 @@ export function createSqlDriver(databaseUrl: string, dialect: SqlDialect): Migra
|
|
|
28
77
|
await db`DELETE FROM migrations WHERE migration = ${migration}`;
|
|
29
78
|
},
|
|
30
79
|
transaction: (run) => db.begin(run),
|
|
80
|
+
...(lock
|
|
81
|
+
? {
|
|
82
|
+
tryLock: (timeoutSeconds: number) => lock.tryLock(timeoutSeconds),
|
|
83
|
+
releaseLock: () => lock.releaseLock(),
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
31
86
|
async close() {
|
|
87
|
+
lock?.dispose();
|
|
32
88
|
db.close({ timeout: 0 });
|
|
33
89
|
},
|
|
34
90
|
};
|
package/src/drivers/sqlite.ts
CHANGED
|
@@ -22,5 +22,15 @@ export function create(databaseUrl: string): MigrationDriver {
|
|
|
22
22
|
await db`INSERT OR IGNORE INTO migrations (migration, checksum)
|
|
23
23
|
VALUES (${migration}, ${checksum})`;
|
|
24
24
|
},
|
|
25
|
+
createLock: (db) => ({
|
|
26
|
+
async tryLock(timeoutSeconds: number) {
|
|
27
|
+
await db.unsafe(`PRAGMA busy_timeout = ${timeoutSeconds * 1000}`);
|
|
28
|
+
return true;
|
|
29
|
+
},
|
|
30
|
+
async releaseLock() {
|
|
31
|
+
await db.unsafe("PRAGMA busy_timeout = 0");
|
|
32
|
+
},
|
|
33
|
+
dispose() {},
|
|
34
|
+
}),
|
|
25
35
|
});
|
|
26
36
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,29 +1,41 @@
|
|
|
1
1
|
import { createDriver, type ExecutedMigration, type MigrationDriver } from "./core/driver.js";
|
|
2
2
|
import { migrateUp } from "./api/up.js";
|
|
3
3
|
import { migrateDown } from "./api/down.js";
|
|
4
|
+
import { migrateStatus } from "./api/status.js";
|
|
4
5
|
import { installMigrations } from "./api/install.js";
|
|
5
6
|
import { createMigration } from "./api/create.js";
|
|
6
7
|
import {
|
|
8
|
+
type MigrateDownOptions,
|
|
7
9
|
type MigrateDownResult,
|
|
8
10
|
type MigrateOptions,
|
|
11
|
+
type MigrateStatusResult,
|
|
12
|
+
type MigrateUpOptions,
|
|
9
13
|
type MigrateUpResult,
|
|
10
14
|
ChecksumDriftError,
|
|
11
15
|
GitStageError,
|
|
16
|
+
MigrationLockError,
|
|
17
|
+
MigrationNotFoundError,
|
|
12
18
|
} from "./api/options.js";
|
|
13
19
|
|
|
14
20
|
export {
|
|
15
21
|
createDriver,
|
|
16
22
|
migrateUp,
|
|
17
23
|
migrateDown,
|
|
24
|
+
migrateStatus,
|
|
18
25
|
installMigrations,
|
|
19
26
|
createMigration,
|
|
20
27
|
ChecksumDriftError,
|
|
21
28
|
GitStageError,
|
|
29
|
+
MigrationLockError,
|
|
30
|
+
MigrationNotFoundError,
|
|
22
31
|
};
|
|
23
32
|
export type {
|
|
24
33
|
ExecutedMigration,
|
|
25
34
|
MigrationDriver,
|
|
26
35
|
MigrateOptions,
|
|
36
|
+
MigrateUpOptions,
|
|
27
37
|
MigrateUpResult,
|
|
38
|
+
MigrateDownOptions,
|
|
28
39
|
MigrateDownResult,
|
|
40
|
+
MigrateStatusResult,
|
|
29
41
|
};
|