bunsql-native-migrate 0.1.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/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +57 -0
- package/src/api/create.ts +95 -0
- package/src/api/down.ts +37 -0
- package/src/api/install.ts +15 -0
- package/src/api/options.ts +39 -0
- package/src/api/up.ts +74 -0
- package/src/cli/main.ts +88 -0
- package/src/core/console.ts +47 -0
- package/src/core/driver.ts +46 -0
- package/src/core/env.ts +7 -0
- package/src/core/fs.ts +29 -0
- package/src/core/random-name.ts +111 -0
- package/src/drivers/mariadb.ts +45 -0
- package/src/drivers/postgres.ts +46 -0
- package/src/drivers/sqlite.ts +51 -0
- package/src/index.ts +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tekina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# bunsql-native-migrate
|
|
2
|
+
|
|
3
|
+
[](https://github.com/TekinaLiadon/bunsql-migrate/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/bunsql-native-migrate)
|
|
5
|
+
|
|
6
|
+
Zero-ORM SQL file migrations for [Bun](https://bun.sh): PostgreSQL, MySQL/MariaDB and SQLite through the built-in `Bun.SQL` client.
|
|
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.
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
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.
|
|
14
|
+
- **Zero dependencies**.
|
|
15
|
+
- **Checksums** — every applied migration is checksummed (SHA-256). A modified applied file fails the run instead of silently drifting.
|
|
16
|
+
- **Legacy backfill** — records without a checksum are backfilled automatically on the next `up`.
|
|
17
|
+
- **CLI and library** — use it as `bunx bunsql-native-migrate` or import the functions directly.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bun add bunsql-native-migrate
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires Bun ≥ 1.4.2 — the version this package is developed and tested against. (The unified `Bun.SQL` client it is built on exists since Bun 1.2.21, when MySQL/MariaDB and SQLite support were added.)
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# create migrations/<timestamp>_<name>.js from the stub template
|
|
31
|
+
bunx bunsql-native-migrate create add_users_table
|
|
32
|
+
|
|
33
|
+
# create the tracking table (optional — up() does it automatically)
|
|
34
|
+
bunx bunsql-native-migrate install
|
|
35
|
+
|
|
36
|
+
# apply pending migrations
|
|
37
|
+
bunx bunsql-native-migrate up
|
|
38
|
+
|
|
39
|
+
# roll back the last applied migration
|
|
40
|
+
bunx bunsql-native-migrate down
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The CLI reads `DATABASE_URL` from the environment (or a `.env` file — Bun loads it automatically).
|
|
44
|
+
|
|
45
|
+
### Migration file format
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
import { sql } from "bun";
|
|
49
|
+
|
|
50
|
+
const up = async () => {
|
|
51
|
+
await sql`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const down = async () => {
|
|
55
|
+
await sql`DROP TABLE users`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export { up, down };
|
|
59
|
+
```
|
|
60
|
+
|
|
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 names with an inverted timestamp prefix so newer migrations sort first:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
9999999999999_2026_09_13_add_users_table.js
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Migrations directory path resolution
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
Absolute paths are passed through unchanged, which is the safe choice for CI/CD and other automation where the working directory is not guaranteed to be the repository root:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
DATABASE_URL=$SECRET_URL bunx bunsql-native-migrate up --dir "$CI_WORKSPACE/migrations"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This resolution is part of the library contract: `resolveListDir` (and therefore every API function) always returns a fully qualified absolute path, so programmatic callers can pass either form and get identical behavior from any working directory.
|
|
78
|
+
|
|
79
|
+
## CLI reference
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
bunsql-native-migrate <up|down|install|create [name]> [--dir <migrations-dir>] [--git] [--help]
|
|
83
|
+
```
|
|
84
|
+
|
|
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; prints `No migrations to rollback.` when there is none. |
|
|
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
|
+
| Flag | Applies to | Meaning |
|
|
93
|
+
| ------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
94
|
+
| `--dir <migrations-dir>` | all | Migrations directory (default `./migrations`, or the `MIGRATION_LIST_DIR` env var). |
|
|
95
|
+
| `--git` | `create` | `git add` the created file. When staging fails, the CLI prints an error and exits 1 — the file itself stays on disk. |
|
|
96
|
+
| `--help`, `-h` | — | Prints the usage line and exits with code 0. |
|
|
97
|
+
|
|
98
|
+
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
|
+
|
|
100
|
+
## Programmatic API
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import {
|
|
104
|
+
migrateUp,
|
|
105
|
+
migrateDown,
|
|
106
|
+
installMigrations,
|
|
107
|
+
createMigration,
|
|
108
|
+
createDriver,
|
|
109
|
+
ChecksumDriftError,
|
|
110
|
+
GitStageError,
|
|
111
|
+
} from "bunsql-native-migrate";
|
|
112
|
+
|
|
113
|
+
const { applied } = await migrateUp({
|
|
114
|
+
databaseUrl: "postgres://user:pass@localhost:5432/app", // default: DATABASE_URL env
|
|
115
|
+
listDir: "./migrations", // default: MIGRATION_LIST_DIR env or ./migrations
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const { reverted } = await migrateDown(); // reverted: string | null
|
|
119
|
+
|
|
120
|
+
await installMigrations(); // creates the tracking table
|
|
121
|
+
|
|
122
|
+
const filename = await createMigration({ name: "add_users_table", listDir: "./migrations" });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
All options are optional unless stated otherwise:
|
|
126
|
+
|
|
127
|
+
| Option | Where | Default |
|
|
128
|
+
| ------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
129
|
+
| `databaseUrl` | `migrateUp`, `migrateDown`, `installMigrations` | `DATABASE_URL` env var |
|
|
130
|
+
| `listDir` | all functions | `MIGRATION_LIST_DIR` env var, then `./migrations` (relative paths resolve against the process cwd) |
|
|
131
|
+
| `name` | `createMigration` | random `adjective_noun` name |
|
|
132
|
+
| `git` | `createMigration` | `false` — `git add` the new file |
|
|
133
|
+
|
|
134
|
+
### Drivers
|
|
135
|
+
|
|
136
|
+
The driver is picked from the URL protocol:
|
|
137
|
+
|
|
138
|
+
| Protocol | Client | Notes |
|
|
139
|
+
| ------------------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
140
|
+
| `postgres://`, `postgresql://` | `Bun.SQL` (PostgreSQL) | Full-featured backend of `Bun.SQL`. |
|
|
141
|
+
| `mariadb://`, `mysql://` | `Bun.SQL` (MySQL/MariaDB) | MySQL 8's `caching_sha2_password` over plain TCP requires TLS or `allowPublicKeyRetrieval: true` on the server config side. |
|
|
142
|
+
| `sqlite://`, `sqlite:` | `Bun.SQL` (SQLite) | Single connection, no pooling — fine for migrations. |
|
|
143
|
+
|
|
144
|
+
All three adapters share the same `Bun.SQL` tagged-template API, so migration files (`import { sql } from "bun"`) work identically regardless of the database.
|
|
145
|
+
|
|
146
|
+
Known MySQL/MariaDB limitation of `Bun.SQL` (not used by this package, good to know for your own migrations): no `RETURNING` clause; use `result.lastInsertRowid` or a follow-up `SELECT` instead.
|
|
147
|
+
|
|
148
|
+
Bun's client also recognizes `mysql2://` and `file://` URLs — `createDriver` deliberately rejects them. Stick to the protocols listed above.
|
|
149
|
+
|
|
150
|
+
`createDriver(url)` returns a `MigrationDriver` (`install`/`listExecuted`/`record`/`setChecksum`/`remove`/`close`) if you need custom tracking logic; `listExecuted` yields `ExecutedMigration` records (`name`, `checksum`).
|
|
151
|
+
|
|
152
|
+
### Tracking table
|
|
153
|
+
|
|
154
|
+
Applied migrations are recorded in a `migrations` table with a unique name and a SHA-256 checksum. The table lives in the database you connect to: two projects pointed at the same database share one history and one `down` stack — give each project its own database. `migrate:up` aborts when an applied file has been modified after the fact:
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
1_first.js was modified after it was applied — restore the file or resolve the drift manually
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Records created before checksums existed (checksum `NULL`) are backfilled on the next `up`.
|
|
161
|
+
|
|
162
|
+
### Error handling
|
|
163
|
+
|
|
164
|
+
The library throws instead of exiting: connection errors, failing migrations and [`ChecksumDriftError`](#tracking-table) propagate to the caller, and the driver connection is always closed. The CLI catches these and exits with code 1.
|
|
165
|
+
|
|
166
|
+
`createMigration` with `git: true` throws `GitStageError` when `git add` fails (no repository, ignored path, …) — the migration file itself is still created on disk.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
[MIT](./LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bunsql-native-migrate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-ORM SQL file migrations for Bun: PostgreSQL, MySQL/MariaDB and SQLite through the built-in Bun.SQL client",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"cli",
|
|
8
|
+
"mariadb",
|
|
9
|
+
"migrations",
|
|
10
|
+
"mysql",
|
|
11
|
+
"postgres",
|
|
12
|
+
"sql",
|
|
13
|
+
"sqlite"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/TekinaLiadon/bunsql-migrate#readme",
|
|
16
|
+
"bugs": "https://github.com/TekinaLiadon/bunsql-migrate/issues",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Tekina",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/TekinaLiadon/bunsql-migrate.git"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"bunsql-native-migrate": "src/cli/main.ts"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./src/index.ts",
|
|
36
|
+
"default": "./src/index.ts"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"typecheck": "tsgo --noEmit",
|
|
42
|
+
"lint": "oxlint",
|
|
43
|
+
"fmt": "oxfmt",
|
|
44
|
+
"fmt:check": "oxfmt --check"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.20.2",
|
|
48
|
+
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
|
|
49
|
+
"bun-types": "1.4.2",
|
|
50
|
+
"oxfmt": "^0.46.0",
|
|
51
|
+
"oxlint": "^1.80.0",
|
|
52
|
+
"typescript": "^6.0.3"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"bun": ">=1.4.2"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { mkdir, open, type FileHandle } from "node:fs/promises";
|
|
3
|
+
import { randomName } from "../core/random-name.js";
|
|
4
|
+
import { resolveListDir } from "../core/fs.js";
|
|
5
|
+
import { log } from "../core/console.js";
|
|
6
|
+
import { GitStageError } from "./options.js";
|
|
7
|
+
|
|
8
|
+
export interface CreateOptions {
|
|
9
|
+
name?: string;
|
|
10
|
+
git?: boolean;
|
|
11
|
+
listDir: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const STUB_TEMPLATE = `import { sql } from "bun";
|
|
15
|
+
// Write your migration SQL here
|
|
16
|
+
const up = async () => {};
|
|
17
|
+
|
|
18
|
+
// Write your rollback SQL here
|
|
19
|
+
const down = async () => {};
|
|
20
|
+
|
|
21
|
+
export { up, down };
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
async function stageInGit(filePath: string): Promise<void> {
|
|
25
|
+
const add = Bun.spawn({
|
|
26
|
+
cmd: ["git", "add", filePath],
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
});
|
|
30
|
+
const stderr = await new Response(add.stderr).text();
|
|
31
|
+
const exitCode = await add.exited;
|
|
32
|
+
if (exitCode !== 0) {
|
|
33
|
+
throw new GitStageError(filePath, exitCode, stderr.trim());
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function migrationFilename(name: string, date: Date): string {
|
|
38
|
+
const pad = (value: number) => (value <= 9 ? `0${value}` : `${value}`);
|
|
39
|
+
const MAX_TIME = 9999999999999;
|
|
40
|
+
const invertedTime = (MAX_TIME - date.getTime()).toString().padStart(13, "0");
|
|
41
|
+
const timestamp = [
|
|
42
|
+
invertedTime,
|
|
43
|
+
date.getUTCFullYear(),
|
|
44
|
+
pad(date.getUTCMonth() + 1),
|
|
45
|
+
pad(date.getUTCDate()),
|
|
46
|
+
].join("_");
|
|
47
|
+
return `${timestamp}_${name}.js`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function writeStubExclusively(filePath: string): Promise<boolean> {
|
|
51
|
+
let handle: FileHandle;
|
|
52
|
+
try {
|
|
53
|
+
handle = await open(filePath, "wx");
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await handle.writeFile(STUB_TEMPLATE);
|
|
62
|
+
} finally {
|
|
63
|
+
await handle.close();
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function createMigration(options: CreateOptions): Promise<string> {
|
|
69
|
+
const name = options.name ?? randomName();
|
|
70
|
+
await mkdir(options.listDir, { recursive: true });
|
|
71
|
+
let filename = migrationFilename(name, new Date());
|
|
72
|
+
let filePath = path.join(options.listDir, filename);
|
|
73
|
+
while (!(await writeStubExclusively(filePath))) {
|
|
74
|
+
await Bun.sleep(1);
|
|
75
|
+
filename = migrationFilename(name, new Date());
|
|
76
|
+
filePath = path.join(options.listDir, filename);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (options.git) {
|
|
80
|
+
await stageInGit(filePath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return filename;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function createMigrationCommand(
|
|
87
|
+
options: Omit<CreateOptions, "listDir"> & { listDir?: string },
|
|
88
|
+
): Promise<string> {
|
|
89
|
+
const filename = await createMigration({ ...options, listDir: resolveListDir(options.listDir) });
|
|
90
|
+
log({ text: `Migration created: ${filename}`, type: "success" });
|
|
91
|
+
if (options.git) {
|
|
92
|
+
log({ text: `Staged in git: ${filename}`, type: "success" });
|
|
93
|
+
}
|
|
94
|
+
return filename;
|
|
95
|
+
}
|
package/src/api/down.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getDatabaseUrl } from "../core/env.js";
|
|
3
|
+
import { createDriver } from "../core/driver.js";
|
|
4
|
+
import { resolveListDir } from "../core/fs.js";
|
|
5
|
+
import { log } from "../core/console.js";
|
|
6
|
+
import type { MigrateDownResult, MigrateOptions } from "./options.js";
|
|
7
|
+
|
|
8
|
+
export async function migrateDown(options: MigrateOptions = {}): Promise<MigrateDownResult> {
|
|
9
|
+
const url = getDatabaseUrl(options.databaseUrl);
|
|
10
|
+
const listDir = resolveListDir(options.listDir);
|
|
11
|
+
const driver = await createDriver(url);
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const executed = await driver.listExecuted();
|
|
15
|
+
if (executed.length === 0) {
|
|
16
|
+
log({ text: "No migrations to rollback.", type: "warn" });
|
|
17
|
+
return { reverted: null };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const file = executed[executed.length - 1]!.name;
|
|
21
|
+
const mod = await import(path.join(listDir, file));
|
|
22
|
+
|
|
23
|
+
if (typeof mod.down !== "function") {
|
|
24
|
+
log({ text: `${file} has no down() export, removing tracking record`, type: "warn" });
|
|
25
|
+
await driver.remove(file);
|
|
26
|
+
log({ text: `${file} tracking record removed`, type: "success" });
|
|
27
|
+
return { reverted: file };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await mod.down();
|
|
31
|
+
await driver.remove(file);
|
|
32
|
+
log({ text: `${file} rolled back`, type: "success" });
|
|
33
|
+
return { reverted: file };
|
|
34
|
+
} finally {
|
|
35
|
+
await driver.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getDatabaseUrl } from "../core/env.js";
|
|
2
|
+
import { createDriver } from "../core/driver.js";
|
|
3
|
+
import { log } from "../core/console.js";
|
|
4
|
+
import type { MigrateOptions } from "./options.js";
|
|
5
|
+
|
|
6
|
+
export async function installMigrations(options: MigrateOptions = {}): Promise<void> {
|
|
7
|
+
const url = getDatabaseUrl(options.databaseUrl);
|
|
8
|
+
const driver = await createDriver(url);
|
|
9
|
+
try {
|
|
10
|
+
await driver.install();
|
|
11
|
+
log({ text: "Migration table created!", type: "success" });
|
|
12
|
+
} finally {
|
|
13
|
+
await driver.close();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface MigrateOptions {
|
|
2
|
+
databaseUrl?: string;
|
|
3
|
+
listDir?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface MigrateUpResult {
|
|
7
|
+
applied: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MigrateDownResult {
|
|
11
|
+
reverted: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ChecksumDriftError extends Error {
|
|
15
|
+
readonly file: string;
|
|
16
|
+
|
|
17
|
+
constructor(file: string) {
|
|
18
|
+
super(
|
|
19
|
+
`${file} was modified after it was applied — restore the file or resolve the drift manually`,
|
|
20
|
+
);
|
|
21
|
+
this.name = "ChecksumDriftError";
|
|
22
|
+
this.file = file;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class GitStageError extends Error {
|
|
27
|
+
readonly file: string;
|
|
28
|
+
readonly exitCode: number;
|
|
29
|
+
|
|
30
|
+
constructor(file: string, exitCode: number, reason: string) {
|
|
31
|
+
super(
|
|
32
|
+
`git add failed for ${file} (exit code ${exitCode})` +
|
|
33
|
+
`${reason ? `: ${reason}` : ""} — the file was created but is not staged`,
|
|
34
|
+
);
|
|
35
|
+
this.name = "GitStageError";
|
|
36
|
+
this.file = file;
|
|
37
|
+
this.exitCode = exitCode;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/api/up.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getDatabaseUrl } from "../core/env.js";
|
|
3
|
+
import { createDriver } from "../core/driver.js";
|
|
4
|
+
import { checksumFile, listFiles, resolveListDir } from "../core/fs.js";
|
|
5
|
+
import { log } from "../core/console.js";
|
|
6
|
+
import { type MigrateOptions, type MigrateUpResult, ChecksumDriftError } from "./options.js";
|
|
7
|
+
|
|
8
|
+
export async function migrateUp(options: MigrateOptions = {}): Promise<MigrateUpResult> {
|
|
9
|
+
const url = getDatabaseUrl(options.databaseUrl);
|
|
10
|
+
const listDir = resolveListDir(options.listDir);
|
|
11
|
+
const driver = await createDriver(url);
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await driver.install();
|
|
15
|
+
|
|
16
|
+
const allFiles = await listFiles(listDir, "js");
|
|
17
|
+
const checksums = new Map<string, string>();
|
|
18
|
+
for (const file of allFiles) {
|
|
19
|
+
checksums.set(file, await checksumFile(path.join(listDir, file)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const executed = await driver.listExecuted();
|
|
23
|
+
const executedByName = new Map(executed.map((entry) => [entry.name, entry]));
|
|
24
|
+
|
|
25
|
+
for (const file of allFiles) {
|
|
26
|
+
const record = executedByName.get(file);
|
|
27
|
+
if (!record) continue;
|
|
28
|
+
|
|
29
|
+
const checksum = checksums.get(file);
|
|
30
|
+
if (!checksum) continue;
|
|
31
|
+
|
|
32
|
+
if (record.checksum === null) {
|
|
33
|
+
await driver.setChecksum(file, checksum);
|
|
34
|
+
log({ text: `${file} checksum saved (legacy record)`, type: "info" });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (record.checksum !== checksum) {
|
|
39
|
+
throw new ChecksumDriftError(file);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pending = allFiles.filter((file) => !executedByName.has(file));
|
|
44
|
+
const applied: string[] = [];
|
|
45
|
+
|
|
46
|
+
if (pending.length === 0) {
|
|
47
|
+
log({ text: "No pending migrations.", type: "warn" });
|
|
48
|
+
return { applied };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const file of pending) {
|
|
52
|
+
const checksum = checksums.get(file);
|
|
53
|
+
if (!checksum) continue;
|
|
54
|
+
try {
|
|
55
|
+
const mod = await import(path.join(listDir, file));
|
|
56
|
+
if (typeof mod.up !== "function") {
|
|
57
|
+
log({ text: `${file} has no up() export, skipping`, type: "warn" });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
await mod.up();
|
|
61
|
+
await driver.record(file, checksum);
|
|
62
|
+
applied.push(file);
|
|
63
|
+
log({ text: `${file} migrated up`, type: "success" });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
log({ text: `${file} migration failed`, type: "error", error });
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { applied };
|
|
71
|
+
} finally {
|
|
72
|
+
await driver.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { migrateUp } from "../api/up.js";
|
|
3
|
+
import { migrateDown } from "../api/down.js";
|
|
4
|
+
import { installMigrations } from "../api/install.js";
|
|
5
|
+
import { createMigrationCommand } from "../api/create.js";
|
|
6
|
+
import { ChecksumDriftError } from "../api/options.js";
|
|
7
|
+
import { log } from "../core/console.js";
|
|
8
|
+
|
|
9
|
+
interface CliArgs {
|
|
10
|
+
command: string | undefined;
|
|
11
|
+
positional: string[];
|
|
12
|
+
dir?: string | undefined;
|
|
13
|
+
git: boolean;
|
|
14
|
+
help: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(argv: string[]): CliArgs {
|
|
18
|
+
const positional: string[] = [];
|
|
19
|
+
let dir: string | undefined;
|
|
20
|
+
let git = false;
|
|
21
|
+
let help = false;
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const arg = argv[i]!;
|
|
24
|
+
if (arg === "--dir") {
|
|
25
|
+
dir = argv[++i];
|
|
26
|
+
} else if (arg === "--git") {
|
|
27
|
+
git = true;
|
|
28
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
29
|
+
help = true;
|
|
30
|
+
} else {
|
|
31
|
+
positional.push(arg);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { command: positional.shift(), positional, dir, git, help };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function usage(exitCode: number): never {
|
|
38
|
+
log({
|
|
39
|
+
text: "Usage: bunsql-native-migrate <up|down|install|create [name]> [--dir <migrations-dir>] [--git] [--help]",
|
|
40
|
+
type: "info",
|
|
41
|
+
});
|
|
42
|
+
process.exit(exitCode);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const args = parseArgs(process.argv.slice(2));
|
|
46
|
+
const listDirOptions = args.dir ? { listDir: args.dir } : {};
|
|
47
|
+
|
|
48
|
+
if (args.help) {
|
|
49
|
+
usage(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
switch (args.command) {
|
|
54
|
+
case "up": {
|
|
55
|
+
const { applied } = await migrateUp(listDirOptions);
|
|
56
|
+
if (applied.length > 0) {
|
|
57
|
+
log({ text: `Applied ${applied.length} migration(s).`, type: "success" });
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case "down": {
|
|
62
|
+
await migrateDown(listDirOptions);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "install": {
|
|
66
|
+
await installMigrations(listDirOptions);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case "create": {
|
|
70
|
+
const [name] = args.positional;
|
|
71
|
+
await createMigrationCommand({
|
|
72
|
+
...(name ? { name } : {}),
|
|
73
|
+
git: args.git,
|
|
74
|
+
...listDirOptions,
|
|
75
|
+
});
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
usage(1);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof ChecksumDriftError) {
|
|
83
|
+
log({ text: error.message, type: "error" });
|
|
84
|
+
} else {
|
|
85
|
+
log({ text: "Migration command failed", type: "error", error });
|
|
86
|
+
}
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const colors = {
|
|
2
|
+
success: "\x1b[32m%s\x1b[0m",
|
|
3
|
+
warn: "\x1b[33m%s\x1b[0m",
|
|
4
|
+
error: "\x1b[31m%s\x1b[0m",
|
|
5
|
+
info: "",
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
type LogLevel = keyof typeof colors;
|
|
9
|
+
|
|
10
|
+
interface LogOptions {
|
|
11
|
+
text: string;
|
|
12
|
+
type: LogLevel;
|
|
13
|
+
error?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
17
|
+
function formatError(error: any): void {
|
|
18
|
+
if (error instanceof Error) {
|
|
19
|
+
console.log(error.message);
|
|
20
|
+
if (error.stack) console.log(error.stack);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (typeof error === "object" && error !== null) {
|
|
24
|
+
if ("code" in error && "detail" in error) {
|
|
25
|
+
console.table(error);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if ("code" in error && "errno" in error) {
|
|
29
|
+
console.log(error.code);
|
|
30
|
+
console.log(error.errno);
|
|
31
|
+
if ("byteOffset" in error) console.log(error.byteOffset);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if ("message" in error) {
|
|
35
|
+
console.log(error.message);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
console.log(String(error));
|
|
40
|
+
}
|
|
41
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
42
|
+
|
|
43
|
+
export function log({ text, type, error = null }: LogOptions): void {
|
|
44
|
+
console.log(colors[type], text);
|
|
45
|
+
if (!error) return;
|
|
46
|
+
formatError(error);
|
|
47
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface ExecutedMigration {
|
|
2
|
+
name: string;
|
|
3
|
+
checksum: string | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface MigrationDriver {
|
|
7
|
+
install(): Promise<void>;
|
|
8
|
+
listExecuted(): Promise<ExecutedMigration[]>;
|
|
9
|
+
record(migration: string, checksum: string): Promise<void>;
|
|
10
|
+
setChecksum(migration: string, checksum: string): Promise<void>;
|
|
11
|
+
remove(migration: string): Promise<void>;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function createDriver(databaseUrl: string): Promise<MigrationDriver> {
|
|
16
|
+
let protocol: string;
|
|
17
|
+
try {
|
|
18
|
+
protocol = new URL(databaseUrl).protocol.replace(":", "");
|
|
19
|
+
} catch {
|
|
20
|
+
if (databaseUrl.startsWith("sqlite:")) {
|
|
21
|
+
protocol = "sqlite";
|
|
22
|
+
} else {
|
|
23
|
+
throw new Error(`Cannot parse database URL: ${databaseUrl}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
switch (protocol) {
|
|
27
|
+
case "postgres":
|
|
28
|
+
case "postgresql": {
|
|
29
|
+
const mod = await import("./../drivers/postgres.js");
|
|
30
|
+
return mod.create(databaseUrl);
|
|
31
|
+
}
|
|
32
|
+
case "sqlite": {
|
|
33
|
+
const mod = await import("./../drivers/sqlite.js");
|
|
34
|
+
return mod.create(databaseUrl);
|
|
35
|
+
}
|
|
36
|
+
case "mariadb":
|
|
37
|
+
case "mysql": {
|
|
38
|
+
const mod = await import("./../drivers/mariadb.js");
|
|
39
|
+
return mod.create(databaseUrl);
|
|
40
|
+
}
|
|
41
|
+
default:
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Unsupported database URL protocol: ${protocol}. Supported: postgres, mariadb/mysql, sqlite.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/core/env.ts
ADDED
package/src/core/fs.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_MIGRATIONS_DIR = "migrations";
|
|
5
|
+
|
|
6
|
+
export async function listFiles(dir: string, ext: string): Promise<string[]> {
|
|
7
|
+
const matchedFiles: string[] = [];
|
|
8
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
if (entry.isFile() && entry.name.endsWith(`.${ext}`)) {
|
|
11
|
+
matchedFiles.push(entry.name);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return matchedFiles.sort().reverse();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function checksumFile(filePath: string): Promise<string> {
|
|
18
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
19
|
+
hasher.update(await Bun.file(filePath).arrayBuffer());
|
|
20
|
+
return hasher.digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveListDir(override?: string): string {
|
|
24
|
+
return path.resolve(
|
|
25
|
+
override ??
|
|
26
|
+
process.env["MIGRATION_LIST_DIR"] ??
|
|
27
|
+
path.join(process.cwd(), DEFAULT_MIGRATIONS_DIR),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const adjectives = [
|
|
2
|
+
"brave",
|
|
3
|
+
"calm",
|
|
4
|
+
"dusty",
|
|
5
|
+
"eager",
|
|
6
|
+
"fair",
|
|
7
|
+
"golden",
|
|
8
|
+
"hasty",
|
|
9
|
+
"jolly",
|
|
10
|
+
"keen",
|
|
11
|
+
"lucky",
|
|
12
|
+
"merry",
|
|
13
|
+
"noble",
|
|
14
|
+
"proud",
|
|
15
|
+
"quick",
|
|
16
|
+
"sharp",
|
|
17
|
+
"swift",
|
|
18
|
+
"tall",
|
|
19
|
+
"vivid",
|
|
20
|
+
"warm",
|
|
21
|
+
"young",
|
|
22
|
+
"bold",
|
|
23
|
+
"crisp",
|
|
24
|
+
"dry",
|
|
25
|
+
"fine",
|
|
26
|
+
"glad",
|
|
27
|
+
"kind",
|
|
28
|
+
"light",
|
|
29
|
+
"mild",
|
|
30
|
+
"neat",
|
|
31
|
+
"prime",
|
|
32
|
+
"rare",
|
|
33
|
+
"safe",
|
|
34
|
+
"true",
|
|
35
|
+
"vast",
|
|
36
|
+
"wise",
|
|
37
|
+
"apt",
|
|
38
|
+
"bright",
|
|
39
|
+
"deep",
|
|
40
|
+
"free",
|
|
41
|
+
"grand",
|
|
42
|
+
"honest",
|
|
43
|
+
"just",
|
|
44
|
+
"lean",
|
|
45
|
+
"open",
|
|
46
|
+
"plain",
|
|
47
|
+
"still",
|
|
48
|
+
"wild",
|
|
49
|
+
"cool",
|
|
50
|
+
"soft",
|
|
51
|
+
"dark",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const nouns = [
|
|
55
|
+
"comet",
|
|
56
|
+
"river",
|
|
57
|
+
"stone",
|
|
58
|
+
"falcon",
|
|
59
|
+
"oak",
|
|
60
|
+
"summit",
|
|
61
|
+
"breeze",
|
|
62
|
+
"creek",
|
|
63
|
+
"ember",
|
|
64
|
+
"glacier",
|
|
65
|
+
"harbor",
|
|
66
|
+
"island",
|
|
67
|
+
"jasper",
|
|
68
|
+
"kettle",
|
|
69
|
+
"lantern",
|
|
70
|
+
"meadow",
|
|
71
|
+
"nectar",
|
|
72
|
+
"orchid",
|
|
73
|
+
"prism",
|
|
74
|
+
"quartz",
|
|
75
|
+
"ridge",
|
|
76
|
+
"tide",
|
|
77
|
+
"valley",
|
|
78
|
+
"willow",
|
|
79
|
+
"zephyr",
|
|
80
|
+
"bolt",
|
|
81
|
+
"cliff",
|
|
82
|
+
"dawn",
|
|
83
|
+
"fern",
|
|
84
|
+
"grove",
|
|
85
|
+
"haze",
|
|
86
|
+
"iris",
|
|
87
|
+
"jet",
|
|
88
|
+
"kite",
|
|
89
|
+
"lark",
|
|
90
|
+
"marsh",
|
|
91
|
+
"nest",
|
|
92
|
+
"opal",
|
|
93
|
+
"peak",
|
|
94
|
+
"reed",
|
|
95
|
+
"sage",
|
|
96
|
+
"thorn",
|
|
97
|
+
"vine",
|
|
98
|
+
"wolf",
|
|
99
|
+
"ash",
|
|
100
|
+
"bay",
|
|
101
|
+
"cape",
|
|
102
|
+
"dune",
|
|
103
|
+
"flint",
|
|
104
|
+
"gleam",
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
export function randomName(): string {
|
|
108
|
+
const adj = adjectives[Math.floor(Math.random() * adjectives.length)]!;
|
|
109
|
+
const noun = nouns[Math.floor(Math.random() * nouns.length)]!;
|
|
110
|
+
return `${adj}_${noun}`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
|
|
3
|
+
|
|
4
|
+
export function create(databaseUrl: string): MigrationDriver {
|
|
5
|
+
const db = new SQL(databaseUrl);
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
async install() {
|
|
9
|
+
await db`CREATE TABLE IF NOT EXISTS migrations (
|
|
10
|
+
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
|
11
|
+
migration VARCHAR(255) NOT NULL,
|
|
12
|
+
checksum VARCHAR(64)
|
|
13
|
+
)`;
|
|
14
|
+
await db`ALTER TABLE migrations ADD COLUMN IF NOT EXISTS checksum VARCHAR(64)`;
|
|
15
|
+
await db`CREATE UNIQUE INDEX IF NOT EXISTS migrations_migration_unique ON migrations (migration)`;
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
async listExecuted() {
|
|
19
|
+
const rows = await db`SELECT migration, checksum FROM migrations ORDER BY id ASC`;
|
|
20
|
+
return rows.map(
|
|
21
|
+
(r: { migration: string; checksum: string | null }): ExecutedMigration => ({
|
|
22
|
+
name: r.migration,
|
|
23
|
+
checksum: r.checksum ?? null,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async record(migration: string, checksum: string) {
|
|
29
|
+
await db`INSERT IGNORE INTO migrations (migration, checksum)
|
|
30
|
+
VALUES (${migration}, ${checksum})`;
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async setChecksum(migration: string, checksum: string) {
|
|
34
|
+
await db`UPDATE migrations SET checksum = ${checksum} WHERE migration = ${migration}`;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async remove(migration: string) {
|
|
38
|
+
await db`DELETE FROM migrations WHERE migration = ${migration}`;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async close() {
|
|
42
|
+
db.close({ timeout: 0 });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
|
|
3
|
+
|
|
4
|
+
export function create(databaseUrl: string): MigrationDriver {
|
|
5
|
+
const db = new SQL(databaseUrl);
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
async install() {
|
|
9
|
+
await db`CREATE TABLE IF NOT EXISTS migrations (
|
|
10
|
+
id SERIAL PRIMARY KEY,
|
|
11
|
+
migration VARCHAR(255) NOT NULL,
|
|
12
|
+
checksum VARCHAR(64)
|
|
13
|
+
)`;
|
|
14
|
+
await db`ALTER TABLE migrations ADD COLUMN IF NOT EXISTS checksum VARCHAR(64)`;
|
|
15
|
+
await db`CREATE UNIQUE INDEX IF NOT EXISTS migrations_migration_unique ON migrations (migration)`;
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
async listExecuted() {
|
|
19
|
+
const rows = await db`SELECT migration, checksum FROM migrations ORDER BY id ASC`;
|
|
20
|
+
return rows.map(
|
|
21
|
+
(r: { migration: string; checksum: string | null }): ExecutedMigration => ({
|
|
22
|
+
name: r.migration,
|
|
23
|
+
checksum: r.checksum ?? null,
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async record(migration: string, checksum: string) {
|
|
29
|
+
await db`INSERT INTO migrations (migration, checksum)
|
|
30
|
+
VALUES (${migration}, ${checksum})
|
|
31
|
+
ON CONFLICT (migration) DO NOTHING`;
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async setChecksum(migration: string, checksum: string) {
|
|
35
|
+
await db`UPDATE migrations SET checksum = ${checksum} WHERE migration = ${migration}`;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async remove(migration: string) {
|
|
39
|
+
await db`DELETE FROM migrations WHERE migration = ${migration}`;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async close() {
|
|
43
|
+
db.close({ timeout: 0 });
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SQL } from "bun";
|
|
2
|
+
import type { ExecutedMigration, MigrationDriver } from "../core/driver.js";
|
|
3
|
+
|
|
4
|
+
export function create(databaseUrl: string): MigrationDriver {
|
|
5
|
+
const db = new SQL(databaseUrl);
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
async install() {
|
|
9
|
+
await db`CREATE TABLE IF NOT EXISTS migrations (
|
|
10
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
migration TEXT NOT NULL,
|
|
12
|
+
checksum TEXT
|
|
13
|
+
)`;
|
|
14
|
+
const columns = (await db`PRAGMA table_info(migrations)`) as Array<{
|
|
15
|
+
name: string;
|
|
16
|
+
}>;
|
|
17
|
+
const hasChecksum = columns.some((column) => column.name === "checksum");
|
|
18
|
+
if (!hasChecksum) {
|
|
19
|
+
await db`ALTER TABLE migrations ADD COLUMN checksum TEXT`;
|
|
20
|
+
}
|
|
21
|
+
await db`CREATE UNIQUE INDEX IF NOT EXISTS migrations_migration_unique ON migrations (migration)`;
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async listExecuted() {
|
|
25
|
+
const rows = await db`SELECT migration, checksum FROM migrations ORDER BY id ASC`;
|
|
26
|
+
return rows.map(
|
|
27
|
+
(r: { migration: string; checksum: string | null }): ExecutedMigration => ({
|
|
28
|
+
name: r.migration,
|
|
29
|
+
checksum: r.checksum ?? null,
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async record(migration: string, checksum: string) {
|
|
35
|
+
await db`INSERT OR IGNORE INTO migrations (migration, checksum)
|
|
36
|
+
VALUES (${migration}, ${checksum})`;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async setChecksum(migration: string, checksum: string) {
|
|
40
|
+
await db`UPDATE migrations SET checksum = ${checksum} WHERE migration = ${migration}`;
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async remove(migration: string) {
|
|
44
|
+
await db`DELETE FROM migrations WHERE migration = ${migration}`;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async close() {
|
|
48
|
+
db.close({ timeout: 0 });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createDriver, type ExecutedMigration, type MigrationDriver } from "./core/driver.js";
|
|
2
|
+
import { migrateUp } from "./api/up.js";
|
|
3
|
+
import { migrateDown } from "./api/down.js";
|
|
4
|
+
import { installMigrations } from "./api/install.js";
|
|
5
|
+
import { createMigration } from "./api/create.js";
|
|
6
|
+
import {
|
|
7
|
+
type MigrateDownResult,
|
|
8
|
+
type MigrateOptions,
|
|
9
|
+
type MigrateUpResult,
|
|
10
|
+
ChecksumDriftError,
|
|
11
|
+
GitStageError,
|
|
12
|
+
} from "./api/options.js";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
createDriver,
|
|
16
|
+
migrateUp,
|
|
17
|
+
migrateDown,
|
|
18
|
+
installMigrations,
|
|
19
|
+
createMigration,
|
|
20
|
+
ChecksumDriftError,
|
|
21
|
+
GitStageError,
|
|
22
|
+
};
|
|
23
|
+
export type {
|
|
24
|
+
ExecutedMigration,
|
|
25
|
+
MigrationDriver,
|
|
26
|
+
MigrateOptions,
|
|
27
|
+
MigrateUpResult,
|
|
28
|
+
MigrateDownResult,
|
|
29
|
+
};
|