sealed-migrations 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 +180 -0
- package/dist/index.cjs +709 -0
- package/dist/index.d.cts +151 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.js +663 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stanislav Janů
|
|
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,180 @@
|
|
|
1
|
+
# sealed-migrations
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/sealed-migrations)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
Checksum-tracked SQL migrations for MySQL and MariaDB with a **dev-first lifecycle**: a migration starts life unnumbered as `dev_<slug>/` and gets its number only right before merge (`seal`), so parallel branches never collide on migration numbers.
|
|
7
|
+
|
|
8
|
+
Zero runtime dependencies: you inject the database connection, so the package never bundles a driver.
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
The usual "number the migration when you write it" convention breaks with parallel branches: two feature branches both grab `042_*`, and the shared dev database ends up with a migration one branch has never seen, which most runners reject outright. `sealed-migrations` defers numbering to the last possible moment:
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
create migrations/dev_add_timezone/ (you scaffold it; no number yet)
|
|
16
|
+
run "up" -> applied to the dev DB as dev_add_timezone
|
|
17
|
+
... iterate freely (edit + down/up, or rehash) ...
|
|
18
|
+
run "seal add_timezone" -> renamed to migrations/043_add_timezone/, row updated
|
|
19
|
+
commit + merge
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`up`, `down`, `seal`, `rehash`, `current`, and `status` are the commands. Creating the `dev_<slug>/` folder is not: `sealed-migrations` does not scaffold, so make the folder yourself (see [Creating a migration](#creating-a-migration)).
|
|
23
|
+
|
|
24
|
+
The number is picked as `max(numbered folders in the repo ∪ numbered versions on the dev DB) + 1`, so numbers already taken by other in-flight branches are skipped automatically. The checksum is content-only, so the rename does not invalidate it.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add sealed-migrations
|
|
30
|
+
# plus your driver, e.g.
|
|
31
|
+
pnpm add mysql2
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
Write a thin wrapper that supplies the connection and any project-specific config. See [`examples/basic-wrapper.mjs`](./examples/basic-wrapper.mjs) and [`examples/mariadb-cluster-wrapper.mjs`](./examples/mariadb-cluster-wrapper.mjs).
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import mysql from 'mysql2/promise'
|
|
40
|
+
import { createRunner } from 'sealed-migrations'
|
|
41
|
+
|
|
42
|
+
const runner = createRunner({
|
|
43
|
+
migrationsDir: 'migrations',
|
|
44
|
+
databaseName: process.env.DB_NAME,
|
|
45
|
+
connect: () => mysql.createConnection({
|
|
46
|
+
host: process.env.DB_HOST,
|
|
47
|
+
user: process.env.DB_USER,
|
|
48
|
+
password: process.env.DB_PASS,
|
|
49
|
+
database: process.env.DB_NAME,
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Programmatic:
|
|
54
|
+
await runner.up({ allowDev: true })
|
|
55
|
+
await runner.seal('add_timezone')
|
|
56
|
+
|
|
57
|
+
// Or drive it as a CLI (node wrapper.mjs up --allow-dev):
|
|
58
|
+
await runner.runCli(process.argv.slice(2))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Migration files
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
migrations/
|
|
65
|
+
001_baseline/ <- sealed, immutable once applied
|
|
66
|
+
up.sql
|
|
67
|
+
down.sql
|
|
68
|
+
043_add_timezone/ <- sealed
|
|
69
|
+
up.sql
|
|
70
|
+
down.sql
|
|
71
|
+
dev_new_feature/ <- unsealed dev migration (feature branch only)
|
|
72
|
+
up.sql
|
|
73
|
+
down.sql
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **Sealed**: `NNN_<slug>` (`/^(\d+)_([a-z0-9_]+)$/`). Immutable.
|
|
77
|
+
- **Dev**: `dev_<slug>` (`/^dev_([a-z0-9_]+)$/`). Unnumbered, mutable, sealed before merge.
|
|
78
|
+
- Sealed migrations apply in numeric order; dev migrations apply after all of them, alphabetically. Duplicate numeric prefixes stay deterministic via the folder-name tie-break.
|
|
79
|
+
- Both `up.sql` and `down.sql` are required and non-empty.
|
|
80
|
+
- Transaction mode is auto-detected (DDL runs non-transactional, since MySQL/MariaDB cannot roll back DDL) and can be forced with a first-line directive: `-- migrate: tx`, `-- migrate: no-tx`, or `-- migrate: auto`.
|
|
81
|
+
- Each applied migration stores `SHA-256(up.sql + "\n--down-sql--\n" + down.sql)`; a later edit to a sealed migration is refused.
|
|
82
|
+
|
|
83
|
+
## Creating a migration
|
|
84
|
+
|
|
85
|
+
`sealed-migrations` runs migrations; it does not scaffold them. Create the folder yourself, or add a tiny script to your wrapper:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
slug="add_timezone"
|
|
89
|
+
mkdir -p "migrations/dev_${slug}"
|
|
90
|
+
printf -- '-- migrate: auto\n' > "migrations/dev_${slug}/up.sql"
|
|
91
|
+
printf -- '-- migrate: auto\n' > "migrations/dev_${slug}/down.sql"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Then fill in `up.sql` / `down.sql`, `up` to apply, and `seal ${slug}` right before merge.
|
|
95
|
+
|
|
96
|
+
## Commands
|
|
97
|
+
|
|
98
|
+
| Method | CLI | Purpose |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `up({ to?, allowDev? })` | `up [--to=<v>] [--allow-dev]` | Apply pending migrations |
|
|
101
|
+
| `down({ to })` | `down --to=<v>` (or `--to=none`) | Roll back to a version |
|
|
102
|
+
| `seal(slug)` | `seal <slug>` | Assign the next number, rename, update the row |
|
|
103
|
+
| `rehash(slug)` | `rehash <slug>` | Realign the stored checksum after a manual schema change |
|
|
104
|
+
| `current()` | `current` | Print the highest applied version |
|
|
105
|
+
| `status()` | `status` | List all migrations; foreign dev rows show as `FOREIGN` |
|
|
106
|
+
|
|
107
|
+
## Iterating on a dev migration
|
|
108
|
+
|
|
109
|
+
One feature keeps one migration. Two equivalent loops:
|
|
110
|
+
|
|
111
|
+
- **SQL-first** (the change should really run): edit `up.sql` / `down.sql`, then `down --to=<previous>` and `up`. `down` tolerates the checksum mismatch of an edited dev migration (warning only) and rolls back with the current `down.sql`.
|
|
112
|
+
- **DB-first** (you already changed the schema by hand): mirror the change into the files, then `rehash <slug>`. Nothing runs, only the stored checksum is realigned.
|
|
113
|
+
|
|
114
|
+
`up` and `status` stay strict on a mismatch and point you at both loops.
|
|
115
|
+
|
|
116
|
+
## Parallel branches
|
|
117
|
+
|
|
118
|
+
- An applied `dev_*` version missing from the current worktree (another branch's work) is a **warning only**; `up`, `status`, and `current` proceed, and `status` lists it as `FOREIGN`.
|
|
119
|
+
- An applied **numbered** version missing from the worktree is a hard error: merge or rebase first. Sealed history stays strict.
|
|
120
|
+
|
|
121
|
+
## The production guard
|
|
122
|
+
|
|
123
|
+
`up` refuses to run while any `dev_*` folder exists unless `allowDev` (`--allow-dev`) is set. Wire your local/CI paths to pass it and leave your production migrate path strict, so an unsealed migration that leaks into a release image fails the migrate step instead of applying. Pair it with a branch check (a pre-commit hook and a CI job) that blocks `dev_*` folders on your protected branches.
|
|
124
|
+
|
|
125
|
+
## Config reference
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
interface RunnerConfig {
|
|
129
|
+
migrationsDir: string
|
|
130
|
+
connect: () => Promise<MigrationConnection>
|
|
131
|
+
lockName?: string // advisory GET_LOCK name (default sealed-migrations:lock)
|
|
132
|
+
lockTimeoutSec?: number // default 120
|
|
133
|
+
baselineVersion?: string // default 001_baseline
|
|
134
|
+
databaseName?: string // enables the baseline short-circuit on an existing schema
|
|
135
|
+
assertServer?: (conn) => Promise<void> // e.g. refuse a non-MariaDB server
|
|
136
|
+
withRetry?: (fn, label) => Promise<T> // e.g. a Galera deadlock retry; default identity
|
|
137
|
+
appVersion?: string | null // recorded in schema_migrations.app_version
|
|
138
|
+
executedBy?: string | null // recorded in schema_migrations.executed_by
|
|
139
|
+
logger?: { log, warn, error } // default console
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Connection injection
|
|
144
|
+
|
|
145
|
+
The runner needs only a small connection contract, satisfied by a `mysql2/promise` connection (and by test fakes):
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
interface MigrationConnection {
|
|
149
|
+
query: <T>(sql: string) => Promise<[T, unknown]>
|
|
150
|
+
execute: <T>(sql: string, params?: unknown[]) => Promise<[T, unknown]>
|
|
151
|
+
beginTransaction: () => Promise<void>
|
|
152
|
+
commit: () => Promise<void>
|
|
153
|
+
rollback: () => Promise<void>
|
|
154
|
+
end?: () => Promise<void>
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Because the driver is yours, project-specific behaviour lives in config, not in a fork: a MySQL-locally / MariaDB-in-prod project supplies no `assertServer`, while a MariaDB-Galera project supplies both a MariaDB check and a deadlock `withRetry`.
|
|
159
|
+
|
|
160
|
+
## The `schema_migrations` table
|
|
161
|
+
|
|
162
|
+
Created automatically on first run:
|
|
163
|
+
|
|
164
|
+
```sql
|
|
165
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
166
|
+
version VARCHAR(128) NOT NULL,
|
|
167
|
+
description VARCHAR(255) NOT NULL,
|
|
168
|
+
checksum CHAR(64) NOT NULL,
|
|
169
|
+
applied_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
|
170
|
+
execution_ms INT UNSIGNED NOT NULL,
|
|
171
|
+
tx_mode ENUM('transactional', 'non_transactional', 'baseline_mark') NOT NULL,
|
|
172
|
+
app_version VARCHAR(128) NULL,
|
|
173
|
+
executed_by VARCHAR(128) NULL,
|
|
174
|
+
PRIMARY KEY (version)
|
|
175
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
[MIT](./LICENSE)
|