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 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
+ [![npm version](https://img.shields.io/npm/v/sealed-migrations.svg)](https://www.npmjs.com/package/sealed-migrations)
4
+ [![license](https://img.shields.io/npm/l/sealed-migrations.svg)](./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)