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/dist/index.js ADDED
@@ -0,0 +1,663 @@
1
+ import { promises } from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import process from "node:process";
5
+
6
+ //#region src/sql.ts
7
+ /**
8
+ * A tiny SQL splitter aware of string/identifier quoting and comments, so a
9
+ * migration file can hold multiple statements. It is deliberately conservative:
10
+ * it never tries to understand SQL, only where one statement ends.
11
+ */
12
+ function stripLeadingComments(statement) {
13
+ let sql = statement.trimStart();
14
+ while (sql.length > 0) {
15
+ if (sql.startsWith("--")) {
16
+ const nextLine = sql.indexOf("\n");
17
+ if (nextLine === -1) return "";
18
+ sql = sql.slice(nextLine + 1).trimStart();
19
+ continue;
20
+ }
21
+ if (sql.startsWith("#")) {
22
+ const nextLine = sql.indexOf("\n");
23
+ if (nextLine === -1) return "";
24
+ sql = sql.slice(nextLine + 1).trimStart();
25
+ continue;
26
+ }
27
+ if (sql.startsWith("/*")) {
28
+ if (sql.startsWith("/*!")) return sql;
29
+ const commentEnd = sql.indexOf("*/");
30
+ if (commentEnd === -1) return "";
31
+ sql = sql.slice(commentEnd + 2).trimStart();
32
+ continue;
33
+ }
34
+ return sql;
35
+ }
36
+ return "";
37
+ }
38
+ function splitSqlStatements(sqlFileContent) {
39
+ const statements = [];
40
+ let current = "";
41
+ let inSingle = false;
42
+ let inDouble = false;
43
+ let inBacktick = false;
44
+ let inLineComment = false;
45
+ let inBlockComment = false;
46
+ for (let i = 0; i < sqlFileContent.length; i += 1) {
47
+ const char = sqlFileContent[i];
48
+ const next = sqlFileContent[i + 1];
49
+ if (inLineComment) {
50
+ current += char;
51
+ if (char === "\n") inLineComment = false;
52
+ continue;
53
+ }
54
+ if (inBlockComment) {
55
+ current += char;
56
+ if (char === "*" && next === "/") {
57
+ current += "/";
58
+ i += 1;
59
+ inBlockComment = false;
60
+ }
61
+ continue;
62
+ }
63
+ if (!inSingle && !inDouble && !inBacktick) {
64
+ if (char === "-" && next === "-" && /\s/.test(sqlFileContent[i + 2] ?? "")) {
65
+ inLineComment = true;
66
+ current += char;
67
+ current += next;
68
+ i += 1;
69
+ continue;
70
+ }
71
+ if (char === "#") {
72
+ inLineComment = true;
73
+ current += char;
74
+ continue;
75
+ }
76
+ if (char === "/" && next === "*") {
77
+ inBlockComment = true;
78
+ current += char;
79
+ current += next;
80
+ i += 1;
81
+ continue;
82
+ }
83
+ if (char === ";") {
84
+ const trimmed = current.trim();
85
+ if (trimmed) statements.push(trimmed);
86
+ current = "";
87
+ continue;
88
+ }
89
+ }
90
+ if (!inDouble && !inBacktick && char === "'") {
91
+ if (inSingle && next === "'") {
92
+ current += "''";
93
+ i += 1;
94
+ continue;
95
+ }
96
+ inSingle = !inSingle;
97
+ current += char;
98
+ continue;
99
+ }
100
+ if (!inSingle && !inBacktick && char === "\"") {
101
+ if (inDouble && next === "\"") {
102
+ current += "\"\"";
103
+ i += 1;
104
+ continue;
105
+ }
106
+ inDouble = !inDouble;
107
+ current += char;
108
+ continue;
109
+ }
110
+ if (!inSingle && !inDouble && char === "`") {
111
+ inBacktick = !inBacktick;
112
+ current += char;
113
+ continue;
114
+ }
115
+ current += char;
116
+ }
117
+ const last = current.trim();
118
+ if (last) statements.push(last);
119
+ return statements;
120
+ }
121
+ /**
122
+ * Picks the transaction mode for a migration file. An explicit
123
+ * `-- migrate: tx | no-tx | auto` directive wins; otherwise DDL forces the
124
+ * non-transactional path (MySQL/MariaDB cannot roll back DDL).
125
+ */
126
+ function resolveTxMode(sql, statements) {
127
+ const directive = sql.match(/--\s*migrate:\s*(tx|no-tx|auto)/i)?.[1]?.toLowerCase();
128
+ if (directive === "tx") return "transactional";
129
+ if (directive === "no-tx") return "non_transactional";
130
+ const ddlRegex = /^(?:ALTER|CREATE|DROP|TRUNCATE|RENAME|LOCK|UNLOCK)\b/i;
131
+ return statements.some((statement) => ddlRegex.test(stripLeadingComments(statement))) ? "non_transactional" : "transactional";
132
+ }
133
+
134
+ //#endregion
135
+ //#region src/migrations.ts
136
+ const MIGRATION_DIR_PATTERN = /^(\d+)_([a-z0-9_]+)$/;
137
+ const DEV_MIGRATION_DIR_PATTERN = /^dev_([a-z0-9_]+)$/;
138
+ /** SHA-256 of `up.sql + "\n--down-sql--\n" + down.sql`, content only. */
139
+ function hashMigration(upSql, downSql) {
140
+ return createHash("sha256").update(upSql).update("\n--down-sql--\n").update(downSql).digest("hex");
141
+ }
142
+ function isDevVersion(version) {
143
+ return DEV_MIGRATION_DIR_PATTERN.test(String(version));
144
+ }
145
+ function parseMigrationDirName(dirName) {
146
+ const devMatch = DEV_MIGRATION_DIR_PATTERN.exec(dirName);
147
+ if (devMatch) return {
148
+ isDev: true,
149
+ sortNumber: Number.POSITIVE_INFINITY,
150
+ version: dirName,
151
+ description: (devMatch[1] ?? "").replaceAll("_", " ")
152
+ };
153
+ const match = MIGRATION_DIR_PATTERN.exec(dirName);
154
+ if (!match) throw new Error(`Invalid migration directory name "${dirName}". Expected format: NNN_description (example: 002_add_column_x) or dev_description (example: dev_add_column_x)`);
155
+ return {
156
+ isDev: false,
157
+ sortNumber: Number.parseInt(match[1] ?? "0", 10),
158
+ version: dirName,
159
+ description: (match[2] ?? "").replaceAll("_", " ")
160
+ };
161
+ }
162
+ /**
163
+ * Normalises a `seal`/`rehash` argument into a `dev_<slug>` version. Accepts
164
+ * both `foo` and `dev_foo`, and refuses sealed (numbered) versions, which are
165
+ * immutable.
166
+ */
167
+ function normalizeDevVersion(input) {
168
+ const raw = (input ?? "").trim();
169
+ if (!raw) throw new Error("Missing dev migration slug. Usage: seal|rehash <slug> (slug or dev_<slug>)");
170
+ if (MIGRATION_DIR_PATTERN.test(raw)) throw new Error(`${raw} is a sealed (numbered) migration. Sealed migrations are immutable; this command only operates on dev migrations.`);
171
+ const version = raw.startsWith("dev_") ? raw : `dev_${raw}`;
172
+ if (!DEV_MIGRATION_DIR_PATTERN.test(version)) throw new Error(`Invalid dev migration slug "${raw}". Expected [a-z0-9_]+ (example: album_sharing)`);
173
+ return version;
174
+ }
175
+ async function fileExists(filePath) {
176
+ try {
177
+ await promises.access(filePath);
178
+ return true;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+ /**
184
+ * Reads every migration folder, sorted numbered-ascending then dev
185
+ * alphabetical. Duplicate numeric prefixes stay deterministic via the folder
186
+ * name tie-break.
187
+ */
188
+ async function readMigrations(migrationsDir) {
189
+ const dirs = (await promises.readdir(migrationsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((a, b) => {
190
+ const left = parseMigrationDirName(a);
191
+ const right = parseMigrationDirName(b);
192
+ if (left.sortNumber !== right.sortNumber) return left.sortNumber - right.sortNumber;
193
+ return left.version.localeCompare(right.version);
194
+ });
195
+ const migrations = [];
196
+ for (const dirName of dirs) {
197
+ const parsed = parseMigrationDirName(dirName);
198
+ const migrationPath = path.join(migrationsDir, dirName);
199
+ const upPath = path.join(migrationPath, "up.sql");
200
+ const downPath = path.join(migrationPath, "down.sql");
201
+ const [upExists, downExists] = await Promise.all([fileExists(upPath), fileExists(downPath)]);
202
+ if (!upExists || !downExists) throw new Error(`Migration ${dirName} must contain up.sql and down.sql`);
203
+ const [upSql, downSql] = await Promise.all([promises.readFile(upPath, "utf8"), promises.readFile(downPath, "utf8")]);
204
+ migrations.push({
205
+ ...parsed,
206
+ upSql,
207
+ downSql,
208
+ checksum: hashMigration(upSql, downSql)
209
+ });
210
+ }
211
+ return migrations;
212
+ }
213
+
214
+ //#endregion
215
+ //#region src/state.ts
216
+ async function ensureSchemaMigrationsTable(conn) {
217
+ await conn.query(`
218
+ CREATE TABLE IF NOT EXISTS schema_migrations (
219
+ version VARCHAR(128) NOT NULL,
220
+ description VARCHAR(255) NOT NULL,
221
+ checksum CHAR(64) NOT NULL,
222
+ applied_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
223
+ execution_ms INT UNSIGNED NOT NULL,
224
+ tx_mode ENUM('transactional', 'non_transactional', 'baseline_mark') NOT NULL,
225
+ app_version VARCHAR(128) NULL,
226
+ executed_by VARCHAR(128) NULL,
227
+ PRIMARY KEY (version)
228
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
229
+ `);
230
+ }
231
+ async function getAppliedMigrations(conn) {
232
+ const [rows] = await conn.query(`SELECT version, checksum, applied_at FROM schema_migrations ORDER BY applied_at ASC, version ASC`);
233
+ const byVersion = /* @__PURE__ */ new Map();
234
+ for (const row of rows) byVersion.set(row.version, {
235
+ checksum: row.checksum,
236
+ appliedAt: row.applied_at
237
+ });
238
+ return byVersion;
239
+ }
240
+ async function getSchemaHasUserTables(conn, dbName) {
241
+ const [rows] = await conn.execute(`
242
+ SELECT COUNT(*) AS cnt
243
+ FROM information_schema.tables
244
+ WHERE table_schema = ?
245
+ AND table_type = 'BASE TABLE'
246
+ AND table_name <> 'schema_migrations'
247
+ `, [dbName]);
248
+ return Number(rows[0]?.cnt ?? 0) > 0;
249
+ }
250
+ async function acquireLock(conn, lockName, timeoutSec) {
251
+ const [rows] = await conn.execute("SELECT GET_LOCK(?, ?) AS lock_acquired", [lockName, timeoutSec]);
252
+ if (Number(rows[0]?.lock_acquired) !== 1) throw new Error(`Failed to acquire migration lock "${lockName}" within ${timeoutSec}s`);
253
+ }
254
+ async function releaseLock(conn, lockName) {
255
+ try {
256
+ await conn.execute("SELECT RELEASE_LOCK(?)", [lockName]);
257
+ } catch {}
258
+ }
259
+ async function insertAppliedMigration(conn, migration, txMode, executionMs, config) {
260
+ await conn.execute(`
261
+ INSERT INTO schema_migrations
262
+ (version, description, checksum, execution_ms, tx_mode, app_version, executed_by)
263
+ VALUES
264
+ (?, ?, ?, ?, ?, ?, ?)
265
+ ON DUPLICATE KEY UPDATE version = version
266
+ `, [
267
+ migration.version,
268
+ migration.description,
269
+ migration.checksum,
270
+ executionMs,
271
+ txMode,
272
+ config.appVersion ?? null,
273
+ config.executedBy ?? null
274
+ ]);
275
+ }
276
+ async function deleteAppliedMigration(conn, version) {
277
+ await conn.execute("DELETE FROM schema_migrations WHERE version = ?", [version]);
278
+ }
279
+ function makeMigrationMap(migrations) {
280
+ return new Map(migrations.map((migration) => [migration.version, migration]));
281
+ }
282
+ /**
283
+ * Checks applied state against the repo:
284
+ * - a dev migration applied elsewhere but missing here is only a warning
285
+ * (the normal state of a shared dev DB with parallel branches),
286
+ * - a numbered migration missing here is a hard error (sealed history is strict),
287
+ * - a checksum mismatch is a hard error, except a dev mismatch in `warn` mode
288
+ * (the `down` path, so the SQL-first edit loop can start).
289
+ */
290
+ function validateAppliedState(migrations, appliedByVersion, options = {}, logger = console) {
291
+ const devChecksumMismatch = options.devChecksumMismatch ?? "error";
292
+ const migrationsByVersion = makeMigrationMap(migrations);
293
+ for (const [version] of appliedByVersion) {
294
+ if (migrationsByVersion.has(version)) continue;
295
+ if (isDevVersion(version)) {
296
+ logger.warn(`WARNING: applied dev migration ${version} is missing in this worktree (parallel branch), skipping`);
297
+ continue;
298
+ }
299
+ throw new Error(`Applied migration ${version} is missing in repository`);
300
+ }
301
+ for (const migration of migrations) {
302
+ const applied = appliedByVersion.get(migration.version);
303
+ if (!applied) continue;
304
+ if (applied.checksum !== migration.checksum) {
305
+ if (migration.isDev) {
306
+ if (devChecksumMismatch === "warn") {
307
+ logger.warn(`WARNING: checksum mismatch for dev migration ${migration.version} (files changed since apply), continuing with the current SQL`);
308
+ continue;
309
+ }
310
+ const slug = migration.version.slice(4);
311
+ throw new Error(`Checksum mismatch for dev migration ${migration.version}. Re-apply it (down + up), or run "seal|rehash ${slug}" if you already applied the schema change manually.`);
312
+ }
313
+ throw new Error(`Checksum mismatch for migration ${migration.version}. Applied migration files were modified.`);
314
+ }
315
+ }
316
+ }
317
+
318
+ //#endregion
319
+ //#region src/apply.ts
320
+ /** Identity wrapper used when the consumer does not supply a retry strategy. */
321
+ function passthroughRetry(fn) {
322
+ return fn();
323
+ }
324
+ async function executeStatements(conn, statements, context) {
325
+ for (let i = 0; i < statements.length; i += 1) {
326
+ const executableSql = stripLeadingComments(statements[i] ?? "").trim();
327
+ if (!executableSql) continue;
328
+ try {
329
+ await conn.query(executableSql);
330
+ } catch (error) {
331
+ const err = error;
332
+ const message = err?.sqlMessage ?? err?.message ?? "Unknown SQL error";
333
+ throw new Error(`${context} failed at statement #${i + 1}: ${message}`, { cause: error });
334
+ }
335
+ }
336
+ }
337
+ async function applyUpMigration(conn, migration, config, logger) {
338
+ const statements = splitSqlStatements(migration.upSql);
339
+ if (statements.length === 0) throw new Error(`Migration ${migration.version} has empty up.sql`);
340
+ const withRetry = config.withRetry ?? passthroughRetry;
341
+ const txMode = resolveTxMode(migration.upSql, statements);
342
+ const start = Date.now();
343
+ if (txMode === "transactional") await withRetry(async () => {
344
+ await conn.beginTransaction();
345
+ try {
346
+ await executeStatements(conn, statements, `UP migration ${migration.version}`);
347
+ await insertAppliedMigration(conn, migration, txMode, Date.now() - start, config);
348
+ await conn.commit();
349
+ } catch (error) {
350
+ await conn.rollback();
351
+ throw error;
352
+ }
353
+ }, `UP migration ${migration.version}`);
354
+ else {
355
+ await executeStatements(conn, statements, `UP migration ${migration.version}`);
356
+ await withRetry(() => insertAppliedMigration(conn, migration, txMode, Date.now() - start, config), `record migration ${migration.version}`);
357
+ }
358
+ logger.log(`Applied ${migration.version}`);
359
+ }
360
+ async function markBaselineAsApplied(conn, migration, config, logger) {
361
+ await insertAppliedMigration(conn, migration, "baseline_mark", 0, config);
362
+ logger.log(`Baseline ${migration.version} marked as applied (existing schema detected)`);
363
+ }
364
+ async function applyDownMigration(conn, migration, logger) {
365
+ const statements = splitSqlStatements(migration.downSql);
366
+ if (statements.length === 0) throw new Error(`Migration ${migration.version} has empty down.sql`);
367
+ if (resolveTxMode(migration.downSql, statements) === "transactional") {
368
+ await conn.beginTransaction();
369
+ try {
370
+ await executeStatements(conn, statements, `DOWN migration ${migration.version}`);
371
+ await deleteAppliedMigration(conn, migration.version);
372
+ await conn.commit();
373
+ } catch (error) {
374
+ await conn.rollback();
375
+ throw error;
376
+ }
377
+ } else {
378
+ await executeStatements(conn, statements, `DOWN migration ${migration.version}`);
379
+ await deleteAppliedMigration(conn, migration.version);
380
+ }
381
+ logger.log(`Rolled back ${migration.version}`);
382
+ }
383
+
384
+ //#endregion
385
+ //#region src/commands.ts
386
+ const DEFAULT_BASELINE_VERSION = "001_baseline";
387
+ function assertDevMigrationsAllowed(migrations, allowDev) {
388
+ if (allowDev) return;
389
+ const devVersions = migrations.filter((migration) => migration.isDev).map((migration) => migration.version);
390
+ if (devVersions.length === 0) return;
391
+ throw new Error(`Refusing to run "up" with unsealed dev migrations present: ${devVersions.join(", ")}. Only sealed (numbered) migrations may reach production. Seal them first. For local development pass allowDev / --allow-dev.`);
392
+ }
393
+ /** Next free number = max(numbered repo folders ∪ numbered applied versions) + 1. */
394
+ function computeNextSealNumber(migrations, appliedVersions) {
395
+ let max = 0;
396
+ for (const migration of migrations) if (!migration.isDev && migration.sortNumber > max) max = migration.sortNumber;
397
+ for (const version of appliedVersions) {
398
+ const match = MIGRATION_DIR_PATTERN.exec(version);
399
+ if (match) {
400
+ const value = Number.parseInt(match[1] ?? "0", 10);
401
+ if (value > max) max = value;
402
+ }
403
+ }
404
+ return String(max + 1).padStart(3, "0");
405
+ }
406
+ async function commandCurrent(conn, migrations, logger) {
407
+ const applied = await getAppliedMigrations(conn);
408
+ validateAppliedState(migrations, applied, {}, logger);
409
+ const latestApplied = [...migrations].reverse().find((migration) => applied.has(migration.version));
410
+ logger.log(latestApplied?.version ?? "none");
411
+ }
412
+ async function commandStatus(conn, migrations, logger) {
413
+ const applied = await getAppliedMigrations(conn);
414
+ validateAppliedState(migrations, applied, {}, logger);
415
+ for (const migration of migrations) {
416
+ const status = applied.has(migration.version) ? "APPLIED" : "PENDING";
417
+ logger.log(`${status.padEnd(8, " ")} ${migration.version} ${migration.description}`);
418
+ }
419
+ const repoVersions = new Set(migrations.map((migration) => migration.version));
420
+ for (const [version] of applied) if (!repoVersions.has(version) && isDevVersion(version)) logger.log(`FOREIGN ${version} (applied on this DB, missing in this worktree)`);
421
+ }
422
+ async function commandUp(conn, migrations, config, options, logger) {
423
+ if (migrations.length === 0) {
424
+ logger.log("No migrations found");
425
+ return;
426
+ }
427
+ const baselineVersion = config.baselineVersion ?? DEFAULT_BASELINE_VERSION;
428
+ if (baselineVersion && migrations[0]?.version !== baselineVersion) throw new Error(`Baseline migration must be the first migration (${baselineVersion})`);
429
+ const targetVersion = (options.to ?? "").trim();
430
+ const targetIndex = targetVersion ? migrations.findIndex((migration) => migration.version === targetVersion) : migrations.length - 1;
431
+ if (targetVersion && targetIndex < 0) throw new Error(`Target version not found: ${targetVersion}`);
432
+ const applied = await getAppliedMigrations(conn);
433
+ validateAppliedState(migrations, applied, {}, logger);
434
+ for (let index = 0; index <= targetIndex; index += 1) {
435
+ const migration = migrations[index];
436
+ if (!migration || applied.has(migration.version)) continue;
437
+ if (migration.version === baselineVersion) {
438
+ if (applied.size > 0) throw new Error("Baseline migration is pending but newer migrations are already applied. State is inconsistent.");
439
+ if (config.databaseName) {
440
+ if (await getSchemaHasUserTables(conn, config.databaseName)) {
441
+ await markBaselineAsApplied(conn, migration, config, logger);
442
+ applied.set(migration.version, { checksum: migration.checksum });
443
+ continue;
444
+ }
445
+ }
446
+ }
447
+ await applyUpMigration(conn, migration, config, logger);
448
+ applied.set(migration.version, { checksum: migration.checksum });
449
+ }
450
+ const latestApplied = [...migrations].reverse().find((migration) => applied.has(migration.version));
451
+ logger.log(`Current DB version: ${latestApplied?.version ?? "none"}`);
452
+ }
453
+ async function commandDown(conn, migrations, options, logger) {
454
+ const targetVersion = (options.to ?? "").trim();
455
+ if (!targetVersion) throw new Error("Rollback requires a target version (to / --to=<version>)");
456
+ const applied = await getAppliedMigrations(conn);
457
+ validateAppliedState(migrations, applied, { devChecksumMismatch: "warn" }, logger);
458
+ if (targetVersion !== "none" && !applied.has(targetVersion)) throw new Error(`Target version ${targetVersion} is not currently applied`);
459
+ const targetIndex = targetVersion === "none" ? -1 : migrations.findIndex((migration) => migration.version === targetVersion);
460
+ if (targetVersion !== "none" && targetIndex < 0) throw new Error(`Target version not found in repository: ${targetVersion}`);
461
+ const rollbackList = migrations.filter((migration, index) => index > targetIndex && applied.has(migration.version)).reverse();
462
+ if (rollbackList.length === 0) {
463
+ logger.log(`Nothing to rollback. Current DB already at ${targetVersion}`);
464
+ return;
465
+ }
466
+ for (const migration of rollbackList) await applyDownMigration(conn, migration, logger);
467
+ logger.log(`Rollback complete. Target DB version: ${targetVersion}`);
468
+ }
469
+ async function commandSeal(conn, migrations, migrationsDir, slug, logger) {
470
+ const devVersion = normalizeDevVersion(slug);
471
+ const migration = migrations.find((candidate) => candidate.version === devVersion);
472
+ if (!migration) throw new Error(`Dev migration not found: ${path.join(migrationsDir, devVersion)}`);
473
+ const applied = await getAppliedMigrations(conn);
474
+ const appliedRow = applied.get(devVersion);
475
+ const bareSlug = devVersion.slice(4);
476
+ if (appliedRow && appliedRow.checksum !== migration.checksum) throw new Error(`Checksum mismatch for ${devVersion}: the files differ from what was applied. Re-apply it (down + up) or rehash ${bareSlug} first.`);
477
+ const sealedVersion = `${computeNextSealNumber(migrations, [...applied.keys()])}_${bareSlug}`;
478
+ const devDir = path.join(migrationsDir, devVersion);
479
+ const sealedDir = path.join(migrationsDir, sealedVersion);
480
+ if (await fileExists(sealedDir)) throw new Error(`Cannot seal ${devVersion}: target directory already exists: ${sealedDir}`);
481
+ if (!appliedRow) {
482
+ await promises.rename(devDir, sealedDir);
483
+ logger.log(`Sealed ${devVersion} as ${sealedVersion} (not applied on this DB, folder renamed only)`);
484
+ logger.log("Commit the renamed folder before merging.");
485
+ return;
486
+ }
487
+ await conn.execute("UPDATE schema_migrations SET version = ?, description = ? WHERE version = ?", [
488
+ sealedVersion,
489
+ migration.description,
490
+ devVersion
491
+ ]);
492
+ try {
493
+ await promises.rename(devDir, sealedDir);
494
+ } catch (renameError) {
495
+ const rename = renameError;
496
+ try {
497
+ await conn.execute("UPDATE schema_migrations SET version = ?, description = ? WHERE version = ?", [
498
+ devVersion,
499
+ migration.description,
500
+ sealedVersion
501
+ ]);
502
+ } catch (revertError) {
503
+ const revert = revertError;
504
+ throw new Error(`Failed to rename ${devDir} to ${sealedDir} (${rename?.message}) AND failed to revert schema_migrations (${revert?.message}). Fix manually: UPDATE schema_migrations SET version = '${devVersion}' WHERE version = '${sealedVersion}'`, { cause: revertError });
505
+ }
506
+ throw new Error(`Failed to rename ${devDir} to ${sealedDir}: ${rename?.message}. schema_migrations was reverted to ${devVersion}.`, { cause: renameError });
507
+ }
508
+ logger.log(`Sealed ${devVersion} as ${sealedVersion} (schema_migrations updated)`);
509
+ logger.log("Commit the renamed folder before merging.");
510
+ }
511
+ async function commandRehash(conn, migrations, slug, logger) {
512
+ const devVersion = normalizeDevVersion(slug);
513
+ const migration = migrations.find((candidate) => candidate.version === devVersion);
514
+ if (!migration) throw new Error(`Dev migration not found in repository: ${devVersion}`);
515
+ const [rows] = await conn.execute("SELECT version, checksum FROM schema_migrations WHERE version = ?", [devVersion]);
516
+ if (!rows || rows.length === 0) throw new Error(`Dev migration ${devVersion} is not applied on this database. Nothing to rehash, run "up" instead.`);
517
+ if (rows[0]?.checksum === migration.checksum) {
518
+ logger.log(`Checksum for ${devVersion} is already up to date`);
519
+ return;
520
+ }
521
+ await conn.execute("UPDATE schema_migrations SET checksum = ? WHERE version = ?", [migration.checksum, devVersion]);
522
+ logger.log(`Updated checksum for ${devVersion} to match the current up.sql + down.sql content`);
523
+ }
524
+
525
+ //#endregion
526
+ //#region src/runner.ts
527
+ const DEFAULT_LOCK_NAME = "sealed-migrations:lock";
528
+ const DEFAULT_LOCK_TIMEOUT_SEC = 120;
529
+ /** Minimal `--flag`, `--key=value`, `--key value` and positional parser. */
530
+ function parseArgs(argv) {
531
+ const args = {};
532
+ const positionals = [];
533
+ for (let i = 0; i < argv.length; i += 1) {
534
+ const token = argv[i] ?? "";
535
+ if (!token.startsWith("--")) {
536
+ positionals.push(token);
537
+ continue;
538
+ }
539
+ const eqIndex = token.indexOf("=");
540
+ if (eqIndex >= 0) {
541
+ args[token.slice(2, eqIndex)] = token.slice(eqIndex + 1);
542
+ continue;
543
+ }
544
+ const key = token.slice(2);
545
+ const next = argv[i + 1];
546
+ if (next && !next.startsWith("--")) {
547
+ args[key] = next;
548
+ i += 1;
549
+ } else args[key] = true;
550
+ }
551
+ return {
552
+ args,
553
+ positionals
554
+ };
555
+ }
556
+ function usage() {
557
+ return [
558
+ "Usage:",
559
+ " up [--allow-dev] [--to=<version>]",
560
+ " down --to=<version>",
561
+ " seal <slug>",
562
+ " rehash <slug>",
563
+ " current",
564
+ " status",
565
+ "",
566
+ "Dev migrations (dev_<slug>/) apply after all numbered migrations,",
567
+ "alphabetically. \"up\" refuses them without --allow-dev.",
568
+ "\"seal <slug>\" assigns the next free number right before merge.",
569
+ "\"rehash <slug>\" aligns the stored checksum after a manual schema change."
570
+ ].join("\n");
571
+ }
572
+ function strOpt(value) {
573
+ return typeof value === "string" ? value : void 0;
574
+ }
575
+ function createRunner(config) {
576
+ const logger = config.logger ?? console;
577
+ const lockName = config.lockName ?? DEFAULT_LOCK_NAME;
578
+ const lockTimeoutSec = config.lockTimeoutSec ?? DEFAULT_LOCK_TIMEOUT_SEC;
579
+ const migrationsDir = path.resolve(process.cwd(), config.migrationsDir);
580
+ async function withConnection(useLock, fn) {
581
+ const conn = await config.connect();
582
+ let lockAcquired = false;
583
+ try {
584
+ if (config.assertServer) await config.assertServer(conn);
585
+ await ensureSchemaMigrationsTable(conn);
586
+ if (useLock) {
587
+ await acquireLock(conn, lockName, lockTimeoutSec);
588
+ lockAcquired = true;
589
+ }
590
+ return await fn(conn);
591
+ } finally {
592
+ if (lockAcquired) await releaseLock(conn, lockName);
593
+ if (conn.end) await conn.end();
594
+ }
595
+ }
596
+ const runner = {
597
+ async up(options = {}) {
598
+ const migrations = await readMigrations(migrationsDir);
599
+ assertDevMigrationsAllowed(migrations, Boolean(options.allowDev));
600
+ await withConnection(true, (conn) => commandUp(conn, migrations, config, options, logger));
601
+ },
602
+ async down(options) {
603
+ const migrations = await readMigrations(migrationsDir);
604
+ await withConnection(true, (conn) => commandDown(conn, migrations, options, logger));
605
+ },
606
+ async seal(slug) {
607
+ const migrations = await readMigrations(migrationsDir);
608
+ await withConnection(true, (conn) => commandSeal(conn, migrations, migrationsDir, slug, logger));
609
+ },
610
+ async rehash(slug) {
611
+ const migrations = await readMigrations(migrationsDir);
612
+ await withConnection(true, (conn) => commandRehash(conn, migrations, slug, logger));
613
+ },
614
+ async current() {
615
+ const migrations = await readMigrations(migrationsDir);
616
+ await withConnection(false, (conn) => commandCurrent(conn, migrations, logger));
617
+ },
618
+ async status() {
619
+ const migrations = await readMigrations(migrationsDir);
620
+ await withConnection(false, (conn) => commandStatus(conn, migrations, logger));
621
+ },
622
+ async runCli(argv) {
623
+ const { args, positionals } = parseArgs(argv);
624
+ const command = positionals[0] ?? "";
625
+ if (args.help || args.h || command === "help" || command === "-h") {
626
+ logger.log(usage());
627
+ return;
628
+ }
629
+ if (!command) {
630
+ logger.log(usage());
631
+ throw new Error("No command given");
632
+ }
633
+ switch (command) {
634
+ case "up":
635
+ await runner.up({
636
+ to: strOpt(args.to),
637
+ allowDev: Boolean(args["allow-dev"])
638
+ });
639
+ return;
640
+ case "down":
641
+ await runner.down({ to: strOpt(args.to) });
642
+ return;
643
+ case "seal":
644
+ await runner.seal(strOpt(args.slug) ?? positionals[1] ?? "");
645
+ return;
646
+ case "rehash":
647
+ await runner.rehash(strOpt(args.slug) ?? positionals[1] ?? "");
648
+ return;
649
+ case "current":
650
+ await runner.current();
651
+ return;
652
+ case "status":
653
+ await runner.status();
654
+ return;
655
+ default: throw new Error(`Unknown command: ${command}`);
656
+ }
657
+ }
658
+ };
659
+ return runner;
660
+ }
661
+
662
+ //#endregion
663
+ export { DEV_MIGRATION_DIR_PATTERN, MIGRATION_DIR_PATTERN, assertDevMigrationsAllowed, commandCurrent, commandDown, commandRehash, commandSeal, commandStatus, commandUp, computeNextSealNumber, createRunner, hashMigration, isDevVersion, normalizeDevVersion, parseArgs, parseMigrationDirName, readMigrations, resolveTxMode, splitSqlStatements, validateAppliedState };