@onreza/sqlx-js 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,17 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { createHash } from "node:crypto";
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { createHash, randomBytes } from "node:crypto";
4
6
  import { PgClient, parseDatabaseUrl, decodeText } from "../pg/wire.js";
7
+ import { openSession, prepareOnce } from "./prepare.js";
8
+ import { introspectConnected, schemaSnapshotEqual, } from "../schema-snapshot.js";
9
+ const SQUASH_PREFIX = "-- sqlx-js-squash:";
5
10
  export const DEFAULT_MIGRATE_LOCK_KEY = 18750938867203960n;
6
11
  const FILE_RE = /^(\d+)_(.+)\.up\.sql$/;
12
+ const DOWN_FILE_RE = /^(\d+)_(.+)\.down\.sql$/;
7
13
  const SAFE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
14
+ const MIGRATIONS_TABLE = "_sqlx_js_migrations";
8
15
  function readMigrations(dir) {
9
16
  if (!existsSync(dir))
10
17
  return [];
@@ -28,58 +35,829 @@ function readMigrations(dir) {
28
35
  downPath: existsSync(downPath) ? downPath : null,
29
36
  upSql,
30
37
  upHash: createHash("sha256").update(upSql).digest("hex"),
38
+ squash: parseSquashMetadata(upSql),
31
39
  });
32
40
  }
33
41
  return out;
34
42
  }
43
+ function parseSquashMetadata(sql) {
44
+ const line = sql.split(/\r?\n/).find((l) => l.startsWith(SQUASH_PREFIX));
45
+ if (!line)
46
+ return null;
47
+ const raw = line.slice(SQUASH_PREFIX.length).trim();
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ }
52
+ catch (e) {
53
+ throw new Error(`sqlx-js.migrate: invalid squash metadata JSON: ${e.message}`);
54
+ }
55
+ if (!parsed || typeof parsed !== "object") {
56
+ throw new Error("sqlx-js.migrate: invalid squash metadata");
57
+ }
58
+ const obj = parsed;
59
+ if (obj.format !== 1 || !Array.isArray(obj.replaces) || obj.replaces.length === 0) {
60
+ throw new Error("sqlx-js.migrate: invalid squash metadata");
61
+ }
62
+ const replaces = [];
63
+ for (const r of obj.replaces) {
64
+ if (!r || typeof r !== "object")
65
+ throw new Error("sqlx-js.migrate: invalid squash replacement metadata");
66
+ const item = r;
67
+ if (typeof item.version !== "number" || !Number.isSafeInteger(item.version) || item.version <= 0) {
68
+ throw new Error("sqlx-js.migrate: invalid squash replacement version");
69
+ }
70
+ if (typeof item.name !== "string" || !SAFE_NAME_RE.test(item.name)) {
71
+ throw new Error("sqlx-js.migrate: invalid squash replacement name");
72
+ }
73
+ if (typeof item.upHash !== "string" || !/^[a-f0-9]{64}$/.test(item.upHash)) {
74
+ throw new Error("sqlx-js.migrate: invalid squash replacement hash");
75
+ }
76
+ replaces.push({ version: item.version, name: item.name, upHash: item.upHash });
77
+ }
78
+ return { format: 1, replaces };
79
+ }
80
+ function quoteIdent(ident) {
81
+ return `"${ident.replace(/"/g, '""')}"`;
82
+ }
83
+ function databaseUrlWithDatabase(databaseUrl, database) {
84
+ const url = new URL(databaseUrl);
85
+ url.pathname = `/${database}`;
86
+ return url.toString();
87
+ }
88
+ function maintenanceDatabaseUrl(databaseUrl) {
89
+ return databaseUrlWithDatabase(databaseUrl, "postgres");
90
+ }
91
+ function generatedShadowDatabaseName() {
92
+ return `sqlx_js_shadow_${process.pid}_${Date.now().toString(36)}_${randomBytes(4).toString("hex")}`;
93
+ }
94
+ async function findMigrationStore(c) {
95
+ const r = await c.simpleQuery(`
96
+ SELECT n.nspname, cls.relname
97
+ FROM pg_class cls
98
+ JOIN pg_namespace n ON n.oid = cls.relnamespace
99
+ WHERE cls.oid = to_regclass('${MIGRATIONS_TABLE}')
100
+ `);
101
+ const row = r.rows[0];
102
+ if (!row)
103
+ return null;
104
+ const schema = decodeText(row[0]);
105
+ const table = decodeText(row[1]);
106
+ if (!schema || !table) {
107
+ throw new Error(`sqlx-js.migrate: failed to resolve ${MIGRATIONS_TABLE} identifier`);
108
+ }
109
+ return { table: `${quoteIdent(schema)}.${quoteIdent(table)}` };
110
+ }
111
+ async function resolveMigrationStore(c) {
112
+ const store = await findMigrationStore(c);
113
+ if (!store) {
114
+ throw new Error(`sqlx-js.migrate: failed to resolve ${MIGRATIONS_TABLE} in current search_path`);
115
+ }
116
+ return store;
117
+ }
35
118
  export async function ensureTable(c) {
119
+ const existing = await findMigrationStore(c);
120
+ if (existing)
121
+ return existing;
36
122
  await c.simpleQuery(`
37
- CREATE TABLE IF NOT EXISTS _sqlx_js_migrations (
123
+ CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
38
124
  version BIGINT PRIMARY KEY,
39
125
  name TEXT NOT NULL,
40
126
  up_hash TEXT NOT NULL,
41
127
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
42
128
  )
43
129
  `);
130
+ return resolveMigrationStore(c);
44
131
  }
45
- export async function listApplied(c) {
46
- const r = await c.simpleQuery("SELECT version, name, up_hash FROM _sqlx_js_migrations ORDER BY version");
132
+ export async function listApplied(c, store) {
133
+ const s = store ?? await resolveMigrationStore(c);
134
+ const r = await c.simpleQuery(`SELECT version, name, up_hash FROM ${s.table} ORDER BY version`);
47
135
  const out = new Map();
48
136
  for (const row of r.rows) {
49
137
  out.set(Number(decodeText(row[0])), { name: decodeText(row[1]), hash: decodeText(row[2]) });
50
138
  }
51
139
  return out;
52
140
  }
53
- export async function applyPending(c, migrationsDir, onEvent) {
54
- await ensureTable(c);
55
- const applied = await listApplied(c);
56
- const all = readMigrations(migrationsDir);
57
- const counts = { applied: 0, tampered: 0, failed: 0 };
141
+ function appliedSquashSupersededVersions(all, applied, onEvent) {
142
+ const superseded = new Set();
143
+ const byVersion = new Map(all.map((m) => [m.version, m]));
144
+ const visitedSquashes = new Set();
145
+ const visitReplacements = (m) => {
146
+ if (!m.squash || visitedSquashes.has(m.version))
147
+ return true;
148
+ visitedSquashes.add(m.version);
149
+ for (const r of m.squash.replaces) {
150
+ superseded.add(r.version);
151
+ const current = byVersion.get(r.version);
152
+ if (!current?.squash || current.version >= m.version)
153
+ continue;
154
+ if (current.name !== r.name || current.upHash !== r.upHash) {
155
+ onEvent?.({ kind: "tampered", version: r.version, name: r.name, applied: r.upHash, current: current.upHash });
156
+ return false;
157
+ }
158
+ if (!visitReplacements(current))
159
+ return false;
160
+ }
161
+ return true;
162
+ };
58
163
  for (const m of all) {
164
+ if (!m.squash)
165
+ continue;
59
166
  const a = applied.get(m.version);
167
+ if (!a)
168
+ continue;
169
+ if (a.hash !== m.upHash) {
170
+ onEvent?.({ kind: "tampered", version: m.version, name: m.name, applied: a.hash, current: m.upHash });
171
+ return null;
172
+ }
173
+ if (!visitReplacements(m))
174
+ return null;
175
+ }
176
+ return superseded;
177
+ }
178
+ function squashCoveredVersions(all) {
179
+ const covered = new Set();
180
+ for (const m of all) {
181
+ if (!m.squash)
182
+ continue;
183
+ for (const r of m.squash.replaces) {
184
+ if (r.version >= m.version) {
185
+ throw new Error(`sqlx-js.migrate: squash replacement ${r.version}_${r.name} must be older than ${m.version}_${m.name}`);
186
+ }
187
+ covered.add(r.version);
188
+ }
189
+ }
190
+ return covered;
191
+ }
192
+ function migrationCheckIssue(code, message, details = {}) {
193
+ return { severity: "error", code, message, ...details };
194
+ }
195
+ function parseMigrationVersion(raw) {
196
+ const version = Number(raw);
197
+ if (!Number.isSafeInteger(version) || version <= 0)
198
+ return null;
199
+ return version;
200
+ }
201
+ export function checkMigrationFiles(migrationsDir) {
202
+ const issues = [];
203
+ const upByVersion = new Map();
204
+ const downFiles = [];
205
+ let migrations = 0;
206
+ if (existsSync(migrationsDir)) {
207
+ for (const file of readdirSync(migrationsDir).sort()) {
208
+ const up = FILE_RE.exec(file);
209
+ const down = DOWN_FILE_RE.exec(file);
210
+ if (!up && !down) {
211
+ if (file.endsWith(".up.sql") || file.endsWith(".down.sql")) {
212
+ issues.push(migrationCheckIssue("invalid-file-name", `migration file ${file} must be named <version>_<name>.up.sql or <version>_<name>.down.sql`, { file }));
213
+ }
214
+ continue;
215
+ }
216
+ const match = up ?? down;
217
+ const version = parseMigrationVersion(match[1]);
218
+ const name = match[2];
219
+ const stem = `${match[1]}_${name}`;
220
+ if (version === null) {
221
+ issues.push(migrationCheckIssue("invalid-version", `migration file ${file} has an invalid version`, { file }));
222
+ continue;
223
+ }
224
+ if (!SAFE_NAME_RE.test(name)) {
225
+ issues.push(migrationCheckIssue("invalid-file-name", `migration file ${file} has an unsafe name`, { file, version, name }));
226
+ continue;
227
+ }
228
+ if (down) {
229
+ downFiles.push({ file, stem, version, name });
230
+ continue;
231
+ }
232
+ migrations++;
233
+ const upSql = readFileSync(join(migrationsDir, file), "utf8");
234
+ let squash = null;
235
+ try {
236
+ squash = parseSquashMetadata(upSql);
237
+ }
238
+ catch (e) {
239
+ issues.push(migrationCheckIssue("invalid-squash-metadata", `${file}: ${e.message}`, { file, version, name }));
240
+ }
241
+ const upHash = createHash("sha256").update(upSql).digest("hex");
242
+ const existing = upByVersion.get(version);
243
+ if (existing) {
244
+ issues.push(migrationCheckIssue("duplicate-version", `migration version ${version} is used by both ${existing.file} and ${file}`, { file, version, name }));
245
+ continue;
246
+ }
247
+ upByVersion.set(version, { file, stem, name, upHash, squash });
248
+ }
249
+ }
250
+ for (const down of downFiles) {
251
+ const up = upByVersion.get(down.version);
252
+ if (!up || up.stem !== down.stem) {
253
+ issues.push(migrationCheckIssue("orphan-down", `down migration ${down.file} does not have a matching up migration`, { file: down.file, version: down.version, name: down.name }));
254
+ }
255
+ }
256
+ for (const [version, migration] of upByVersion) {
257
+ if (!migration.squash)
258
+ continue;
259
+ for (const r of migration.squash.replaces) {
260
+ if (r.version >= version) {
261
+ issues.push(migrationCheckIssue("invalid-squash-replacement", `squash replacement ${r.version}_${r.name} must be older than ${version}_${migration.name}`, { file: migration.file, version, name: migration.name }));
262
+ continue;
263
+ }
264
+ const current = upByVersion.get(r.version);
265
+ if (current && (current.name !== r.name || current.upHash !== r.upHash)) {
266
+ issues.push(migrationCheckIssue("tampered-squash-replacement", `squash replacement ${r.version}_${r.name} does not match current migration file ${current.file}`, { file: current.file, version: r.version, name: r.name }));
267
+ }
268
+ }
269
+ }
270
+ return {
271
+ ok: issues.length === 0,
272
+ migrations,
273
+ archives: listMigrationArchives(migrationsDir).length,
274
+ issues,
275
+ };
276
+ }
277
+ function diffByKey(before, after, key) {
278
+ const beforeMap = new Map(before.map((item) => [key(item), JSON.stringify(item)]));
279
+ const afterMap = new Map(after.map((item) => [key(item), JSON.stringify(item)]));
280
+ const added = [];
281
+ const removed = [];
282
+ const changed = [];
283
+ for (const k of afterMap.keys()) {
284
+ if (!beforeMap.has(k))
285
+ added.push(k);
286
+ else if (beforeMap.get(k) !== afterMap.get(k))
287
+ changed.push(k);
288
+ }
289
+ for (const k of beforeMap.keys()) {
290
+ if (!afterMap.has(k))
291
+ removed.push(k);
292
+ }
293
+ return { added: added.sort(), removed: removed.sort(), changed: changed.sort() };
294
+ }
295
+ function schemaTypeKey(type) {
296
+ return `${type.kind}:${type.schema}.${type.name}`;
297
+ }
298
+ function schemaFunctionKey(fn) {
299
+ return `${fn.schema}.${fn.name}(${fn.identityArguments})`;
300
+ }
301
+ function schemaDiffSummary(before, after) {
302
+ return {
303
+ relations: diffByKey(before.relations, after.relations, (r) => `${r.schema}.${r.name}`),
304
+ types: diffByKey(before.types, after.types, schemaTypeKey),
305
+ functions: diffByKey(before.functions, after.functions, schemaFunctionKey),
306
+ };
307
+ }
308
+ async function listUserSchemas(c) {
309
+ const r = await c.simpleQuery(`
310
+ SELECT nspname
311
+ FROM pg_namespace
312
+ WHERE nspname <> 'information_schema'
313
+ AND nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
314
+ ORDER BY nspname
315
+ `);
316
+ return r.rows.map((row) => decodeText(row[0])).filter((schema) => schema.length > 0);
317
+ }
318
+ async function isolateShadowSchemaState(c) {
319
+ for (const schema of await listUserSchemas(c)) {
320
+ await c.simpleQuery(`DROP SCHEMA IF EXISTS ${quoteIdent(schema)} CASCADE`);
321
+ }
322
+ await c.simpleQuery("CREATE SCHEMA IF NOT EXISTS public");
323
+ await resetMigrationSession(c);
324
+ }
325
+ async function useExplicitShadowDatabase(databaseUrl) {
326
+ const c = new PgClient(parseDatabaseUrl(databaseUrl));
327
+ await c.connect();
328
+ try {
329
+ await isolateShadowSchemaState(c);
330
+ }
331
+ finally {
332
+ await c.end();
333
+ }
334
+ return { databaseUrl, cleanup: async () => { } };
335
+ }
336
+ async function createDisposableShadowDatabase(databaseUrl, shadowAdminUrl, log = console.log) {
337
+ if (!databaseUrl) {
338
+ throw new Error("DATABASE_URL is required to create an automatic shadow database (or pass --shadow-url)");
339
+ }
340
+ const name = generatedShadowDatabaseName();
341
+ const adminUrl = shadowAdminUrl ?? maintenanceDatabaseUrl(databaseUrl);
342
+ const shadowUrl = databaseUrlWithDatabase(databaseUrl, name);
343
+ const owner = parseDatabaseUrl(databaseUrl).user;
344
+ const admin = new PgClient(parseDatabaseUrl(adminUrl));
345
+ await admin.connect();
346
+ try {
347
+ await admin.simpleQuery(`CREATE DATABASE ${quoteIdent(name)} OWNER ${quoteIdent(owner)}`);
348
+ }
349
+ catch (err) {
350
+ throw new Error(`sqlx-js.migrate: failed to create shadow database ${name}: ${err.message}. ` +
351
+ "Grant CREATEDB, pass --shadow-admin-url, or pass --shadow-url.");
352
+ }
353
+ finally {
354
+ await admin.end();
355
+ }
356
+ log(`shadow: created ${name}`);
357
+ let dropped = false;
358
+ return {
359
+ databaseUrl: shadowUrl,
360
+ cleanup: async () => {
361
+ if (dropped)
362
+ return;
363
+ dropped = true;
364
+ const dropAdmin = new PgClient(parseDatabaseUrl(adminUrl));
365
+ await dropAdmin.connect();
366
+ try {
367
+ await dropAdmin.simpleQuery(`DROP DATABASE IF EXISTS ${quoteIdent(name)}`);
368
+ log(`shadow: dropped ${name}`);
369
+ }
370
+ finally {
371
+ await dropAdmin.end();
372
+ }
373
+ },
374
+ };
375
+ }
376
+ async function withWorkflowShadowDatabase(opts, fn) {
377
+ const handle = opts.shadowUrl
378
+ ? await useExplicitShadowDatabase(opts.shadowUrl)
379
+ : await createDisposableShadowDatabase(opts.databaseUrl, opts.shadowAdminUrl);
380
+ let fnError;
381
+ try {
382
+ return await fn(handle.databaseUrl);
383
+ }
384
+ catch (err) {
385
+ fnError = err;
386
+ throw err;
387
+ }
388
+ finally {
389
+ try {
390
+ await handle.cleanup();
391
+ }
392
+ catch (cleanupErr) {
393
+ if (fnError)
394
+ console.warn(`shadow: cleanup failed after command error: ${cleanupErr.message}`);
395
+ else
396
+ throw cleanupErr;
397
+ }
398
+ }
399
+ }
400
+ async function withDryRunShadowDatabase(opts, fn, log) {
401
+ if (opts.shadowUrl)
402
+ return fn(opts.shadowUrl);
403
+ const handle = await createDisposableShadowDatabase(opts.databaseUrl, opts.shadowAdminUrl, log);
404
+ let fnError;
405
+ try {
406
+ return await fn(handle.databaseUrl);
407
+ }
408
+ catch (err) {
409
+ fnError = err;
410
+ throw err;
411
+ }
412
+ finally {
413
+ try {
414
+ await handle.cleanup();
415
+ }
416
+ catch (cleanupErr) {
417
+ if (fnError)
418
+ console.warn(`shadow: cleanup failed after command error: ${cleanupErr.message}`);
419
+ else
420
+ throw cleanupErr;
421
+ }
422
+ }
423
+ }
424
+ async function applyMigrationsForWorkflow(databaseUrl, migrationsDir, lockKey, lockTimeoutMs) {
425
+ const c = new PgClient(parseDatabaseUrl(databaseUrl));
426
+ await c.connect();
427
+ let locked = false;
428
+ try {
429
+ await acquireMigrateLock(c, lockKey ?? DEFAULT_MIGRATE_LOCK_KEY, lockTimeoutMs);
430
+ locked = true;
431
+ const result = await applyPending(c, migrationsDir, (e) => {
432
+ if (e.kind === "applied")
433
+ console.log(`shadow: applied ${String(e.version).padStart(4, "0")}_${e.name}`);
434
+ else if (e.kind === "adopted")
435
+ console.log(`shadow: adopted ${String(e.version).padStart(4, "0")}_${e.name} (${e.replaced} replaced)`);
436
+ else if (e.kind === "tampered") {
437
+ throw new Error(`sqlx-js shadow: ${e.version}_${e.name} hash mismatch (applied ${e.applied.slice(0, 16)} vs current ${e.current.slice(0, 16)})`);
438
+ }
439
+ else {
440
+ throw new Error(`sqlx-js shadow: ${e.version}_${e.name} failed — ${e.error}`);
441
+ }
442
+ });
443
+ if (result.applied === 0 && result.tampered === 0 && result.failed === 0)
444
+ console.log("shadow: migrations up-to-date");
445
+ }
446
+ finally {
447
+ if (locked) {
448
+ try {
449
+ await releaseMigrateLock(c, lockKey ?? DEFAULT_MIGRATE_LOCK_KEY);
450
+ }
451
+ catch (e) {
452
+ console.warn(`shadow: failed to release advisory lock: ${e.message}`);
453
+ }
454
+ }
455
+ await c.end();
456
+ }
457
+ }
458
+ function latestMigrationIsSquash(migrationsDir) {
459
+ const all = readMigrations(migrationsDir);
460
+ return all[all.length - 1]?.squash != null;
461
+ }
462
+ async function validateLatestDownForWorkflow(databaseUrl, migrationsDir) {
463
+ const c = new PgClient(parseDatabaseUrl(databaseUrl));
464
+ await c.connect();
465
+ try {
466
+ const outcome = await checkLastDownMigration(c, migrationsDir);
467
+ if (outcome.kind === "noop") {
468
+ console.log("shadow: no migrations to validate down");
469
+ return;
470
+ }
471
+ if (outcome.kind === "passed") {
472
+ console.log(`shadow: latest down restores schema (${String(outcome.version).padStart(4, "0")}_${outcome.name})`);
473
+ return;
474
+ }
475
+ if (outcome.kind === "no-down" && latestMigrationIsSquash(migrationsDir)) {
476
+ console.log(`shadow: latest migration has no down (expected for squash baseline ${String(outcome.version).padStart(4, "0")}_${outcome.name})`);
477
+ return;
478
+ }
479
+ if (outcome.kind === "no-down") {
480
+ throw new Error(`latest migration ${String(outcome.version).padStart(4, "0")}_${outcome.name} has no .down.sql`);
481
+ }
482
+ if (outcome.kind === "schema-mismatch") {
483
+ throw new Error(`latest migration ${String(outcome.version).padStart(4, "0")}_${outcome.name} down did not restore schema`);
484
+ }
485
+ throw new Error(`latest down validation failed during ${outcome.phase}: ${outcome.error}`);
486
+ }
487
+ finally {
488
+ await c.end();
489
+ }
490
+ }
491
+ async function prepareWorkflowArtifacts(opts, databaseUrl) {
492
+ const prepareOpts = {
493
+ root: opts.root,
494
+ databaseUrl,
495
+ cacheDir: opts.cacheDir,
496
+ dtsPath: opts.dtsPath,
497
+ check: false,
498
+ prune: opts.prune,
499
+ };
500
+ const session = await openSession(prepareOpts);
501
+ try {
502
+ const r = await prepareOnce(prepareOpts, session);
503
+ if (r.failures > 0) {
504
+ console.error(`\n${r.failures} query/queries failed to prepare`);
505
+ return false;
506
+ }
507
+ console.log(`\nprepared ${r.entries} unique query/queries → ${opts.dtsPath}`);
508
+ return true;
509
+ }
510
+ finally {
511
+ await session.client.end();
512
+ }
513
+ }
514
+ async function prepareInTemporaryArtifacts(opts, databaseUrl) {
515
+ const tmp = mkdtempSync(join(tmpdir(), "sqlx-js-verify-"));
516
+ const cacheDir = join(tmp, "cache");
517
+ const dtsPath = join(tmp, "sqlx-js-env.d.ts");
518
+ const prepareOpts = {
519
+ root: opts.root,
520
+ databaseUrl,
521
+ cacheDir,
522
+ dtsPath,
523
+ check: false,
524
+ prune: true,
525
+ };
526
+ const session = await openSession(prepareOpts);
527
+ try {
528
+ const r = await prepareOnce(prepareOpts, session);
529
+ if (r.failures > 0) {
530
+ console.error(`\n${r.failures} query/queries failed to prepare`);
531
+ return false;
532
+ }
533
+ console.log(`shadow: prepared ${r.entries} unique query/queries`);
534
+ return true;
535
+ }
536
+ finally {
537
+ await session.client.end();
538
+ rmSync(tmp, { recursive: true, force: true });
539
+ }
540
+ }
541
+ function effectiveSquashReplacements(all) {
542
+ const covered = squashCoveredVersions(all);
543
+ return all
544
+ .filter((m) => !covered.has(m.version))
545
+ .map((m) => ({ version: m.version, name: m.name, upHash: m.upHash }));
546
+ }
547
+ function preflightSquashMigrations(all, applied, superseded, onEvent) {
548
+ const byVersion = new Map(all.map((m) => [m.version, m]));
549
+ for (const m of all) {
550
+ if (!m.squash)
551
+ continue;
552
+ let present = 0;
553
+ for (const r of m.squash.replaces) {
554
+ if (r.version >= m.version) {
555
+ onEvent?.({
556
+ kind: "failed",
557
+ version: m.version,
558
+ name: m.name,
559
+ error: `squash replacement ${r.version}_${r.name} must be older than ${m.version}_${m.name}`,
560
+ });
561
+ return "failed";
562
+ }
563
+ const current = byVersion.get(r.version);
564
+ if (current && !superseded.has(r.version) && (current.name !== r.name || current.upHash !== r.upHash)) {
565
+ onEvent?.({ kind: "tampered", version: r.version, name: r.name, applied: r.upHash, current: current.upHash });
566
+ return "tampered";
567
+ }
568
+ const a = applied.get(r.version);
569
+ if (!a)
570
+ continue;
571
+ present++;
572
+ if (a.hash !== r.upHash || a.name !== r.name) {
573
+ onEvent?.({ kind: "tampered", version: r.version, name: r.name, applied: a.hash, current: r.upHash });
574
+ return "tampered";
575
+ }
576
+ }
577
+ if (present > 0 && present !== m.squash.replaces.length) {
578
+ onEvent?.({
579
+ kind: "failed",
580
+ version: m.version,
581
+ name: m.name,
582
+ error: `squash migration replaces ${m.squash.replaces.length} migration(s), but only ${present} matching row(s) are applied`,
583
+ });
584
+ return "failed";
585
+ }
586
+ }
587
+ return "ok";
588
+ }
589
+ function planSquashAdoption(m, applied, onEvent) {
590
+ if (!m.squash)
591
+ return { kind: "none" };
592
+ let present = 0;
593
+ for (const r of m.squash.replaces) {
594
+ if (r.version >= m.version) {
595
+ onEvent?.({
596
+ kind: "failed",
597
+ version: m.version,
598
+ name: m.name,
599
+ error: `squash replacement ${r.version}_${r.name} must be older than ${m.version}_${m.name}`,
600
+ });
601
+ return { kind: "failed" };
602
+ }
603
+ const a = applied.get(r.version);
604
+ if (!a)
605
+ continue;
606
+ present++;
607
+ if (a.hash !== r.upHash || a.name !== r.name) {
608
+ onEvent?.({ kind: "tampered", version: r.version, name: r.name, applied: a.hash, current: r.upHash });
609
+ return { kind: "tampered" };
610
+ }
611
+ }
612
+ if (present === 0)
613
+ return { kind: "none" };
614
+ if (present !== m.squash.replaces.length) {
615
+ onEvent?.({
616
+ kind: "failed",
617
+ version: m.version,
618
+ name: m.name,
619
+ error: `squash migration replaces ${m.squash.replaces.length} migration(s), but only ${present} matching row(s) are applied`,
620
+ });
621
+ return { kind: "failed" };
622
+ }
623
+ return { kind: "adopt", replaced: m.squash.replaces.length };
624
+ }
625
+ function buildMigrationPlan(all, applied, onEvent) {
626
+ const plannedApplied = new Map(applied);
627
+ const superseded = appliedSquashSupersededVersions(all, plannedApplied, onEvent);
628
+ if (!superseded)
629
+ return { kind: "tampered" };
630
+ const preflight = preflightSquashMigrations(all, plannedApplied, superseded, onEvent);
631
+ if (preflight !== "ok")
632
+ return { kind: preflight };
633
+ const steps = [];
634
+ for (const m of all) {
635
+ if (superseded.has(m.version))
636
+ continue;
637
+ const a = plannedApplied.get(m.version);
60
638
  if (a) {
61
639
  if (a.hash !== m.upHash) {
62
- counts.tampered++;
63
640
  onEvent?.({ kind: "tampered", version: m.version, name: m.name, applied: a.hash, current: m.upHash });
641
+ return { kind: "tampered" };
642
+ }
643
+ continue;
644
+ }
645
+ const adoption = planSquashAdoption(m, plannedApplied, onEvent);
646
+ if (adoption.kind === "adopt") {
647
+ steps.push({ kind: "adopt", migration: m, replaced: adoption.replaced });
648
+ for (const r of m.squash.replaces)
649
+ plannedApplied.delete(r.version);
650
+ plannedApplied.set(m.version, { name: m.name, hash: m.upHash });
651
+ continue;
652
+ }
653
+ if (adoption.kind === "tampered" || adoption.kind === "failed")
654
+ return { kind: adoption.kind };
655
+ steps.push({ kind: "apply", migration: m });
656
+ plannedApplied.set(m.version, { name: m.name, hash: m.upHash });
657
+ }
658
+ return { kind: "ok", steps };
659
+ }
660
+ function publicPlanItem(step) {
661
+ if (step.kind === "apply") {
662
+ return { kind: "apply", version: step.migration.version, name: step.migration.name };
663
+ }
664
+ return {
665
+ kind: "adopt",
666
+ version: step.migration.version,
667
+ name: step.migration.name,
668
+ replaced: step.replaced,
669
+ };
670
+ }
671
+ async function resetMigrationSession(c) {
672
+ await c.simpleQuery("RESET ALL");
673
+ }
674
+ async function executeSquashAdoption(c, store, m, applied, replaced, onEvent) {
675
+ if (!m.squash)
676
+ return "failed";
677
+ let committed = false;
678
+ await c.simpleQuery("BEGIN");
679
+ try {
680
+ for (const r of m.squash.replaces) {
681
+ await c.execParamsText(`DELETE FROM ${store.table} WHERE version = $1`, [String(r.version)]);
682
+ }
683
+ await c.execParamsText(`INSERT INTO ${store.table} (version, name, up_hash) VALUES ($1, $2, $3)`, [String(m.version), m.name, m.upHash]);
684
+ await c.simpleQuery("COMMIT");
685
+ committed = true;
686
+ }
687
+ catch (err) {
688
+ let rollbackErr;
689
+ if (!committed) {
690
+ try {
691
+ await c.simpleQuery("ROLLBACK");
692
+ }
693
+ catch (rb) {
694
+ rollbackErr = rb.message;
695
+ }
696
+ }
697
+ const message = rollbackErr
698
+ ? `${err.message} (rollback also failed: ${rollbackErr})`
699
+ : err.message;
700
+ onEvent?.({ kind: "failed", version: m.version, name: m.name, error: message });
701
+ return "failed";
702
+ }
703
+ try {
704
+ await resetMigrationSession(c);
705
+ }
706
+ catch (err) {
707
+ onEvent?.({ kind: "failed", version: m.version, name: m.name, error: `failed to reset migration session: ${err.message}` });
708
+ return "failed";
709
+ }
710
+ for (const r of m.squash.replaces)
711
+ applied.delete(r.version);
712
+ applied.set(m.version, { name: m.name, hash: m.upHash });
713
+ onEvent?.({ kind: "adopted", version: m.version, name: m.name, replaced });
714
+ return "done";
715
+ }
716
+ export async function planPending(c, migrationsDir, onEvent) {
717
+ const store = await findMigrationStore(c);
718
+ const applied = store ? await listApplied(c, store) : new Map();
719
+ const all = readMigrations(migrationsDir);
720
+ const counts = { pending: 0, adoptable: 0, tampered: 0, failed: 0 };
721
+ const plan = buildMigrationPlan(all, applied, onEvent);
722
+ if (plan.kind === "tampered") {
723
+ counts.tampered++;
724
+ return { ...counts, steps: [] };
725
+ }
726
+ if (plan.kind === "failed") {
727
+ counts.failed++;
728
+ return { ...counts, steps: [] };
729
+ }
730
+ const steps = plan.steps.map(publicPlanItem);
731
+ for (const step of steps) {
732
+ if (step.kind === "apply") {
733
+ counts.pending++;
734
+ onEvent?.({ kind: "pending", version: step.version, name: step.name });
735
+ }
736
+ else {
737
+ counts.adoptable++;
738
+ onEvent?.({ kind: "adoptable", version: step.version, name: step.name, replaced: step.replaced });
739
+ }
740
+ }
741
+ return { ...counts, steps };
742
+ }
743
+ export async function inspectMigrationPlan(c, migrationsDir) {
744
+ const diagnostics = [];
745
+ const result = await planPending(c, migrationsDir, (e) => {
746
+ if (e.kind === "tampered" || e.kind === "failed")
747
+ diagnostics.push(e);
748
+ });
749
+ return {
750
+ ok: result.tampered === 0 && result.failed === 0,
751
+ ...result,
752
+ diagnostics,
753
+ };
754
+ }
755
+ export async function inspectMigrations(c, migrationsDir) {
756
+ const store = await findMigrationStore(c);
757
+ const applied = store ? await listApplied(c, store) : new Map();
758
+ const all = readMigrations(migrationsDir);
759
+ const validation = [];
760
+ const plan = buildMigrationPlan(all, applied, (e) => validation.push(e));
761
+ const planned = new Map();
762
+ if (plan.kind === "ok") {
763
+ for (const step of plan.steps)
764
+ planned.set(step.migration.version, step);
765
+ }
766
+ const validationByVersion = new Map(validation.map((e) => [e.version, e]));
767
+ const superseded = appliedSquashSupersededVersions(all, applied) ?? new Set();
768
+ const summary = {
769
+ applied: 0,
770
+ pending: 0,
771
+ adoptable: 0,
772
+ superseded: 0,
773
+ tampered: 0,
774
+ failed: 0,
775
+ };
776
+ const items = all.map((m) => {
777
+ const validationEvent = validationByVersion.get(m.version);
778
+ if (validationEvent?.kind === "tampered") {
779
+ summary.tampered++;
780
+ return {
781
+ version: m.version,
782
+ name: m.name,
783
+ status: "tampered",
784
+ detail: `hash mismatch: applied ${validationEvent.applied.slice(0, 16)} vs current ${validationEvent.current.slice(0, 16)}`,
785
+ };
786
+ }
787
+ if (validationEvent?.kind === "failed") {
788
+ summary.failed++;
789
+ return { version: m.version, name: m.name, status: "failed", detail: validationEvent.error };
790
+ }
791
+ if (superseded.has(m.version)) {
792
+ summary.superseded++;
793
+ return { version: m.version, name: m.name, status: "superseded" };
794
+ }
795
+ const a = applied.get(m.version);
796
+ if (a) {
797
+ if (a.hash !== m.upHash) {
798
+ summary.tampered++;
799
+ return {
800
+ version: m.version,
801
+ name: m.name,
802
+ status: "tampered",
803
+ detail: `hash mismatch: applied ${a.hash.slice(0, 16)} vs current ${m.upHash.slice(0, 16)}`,
804
+ };
805
+ }
806
+ summary.applied++;
807
+ return { version: m.version, name: m.name, status: "applied" };
808
+ }
809
+ const plannedStep = planned.get(m.version);
810
+ if (plannedStep?.kind === "adopt") {
811
+ summary.adoptable++;
812
+ return { version: m.version, name: m.name, status: "adoptable", detail: `${plannedStep.replaced} replaced` };
813
+ }
814
+ summary.pending++;
815
+ return { version: m.version, name: m.name, status: "pending" };
816
+ });
817
+ return { historyTable: store?.table ?? null, summary, items };
818
+ }
819
+ export async function applyPending(c, migrationsDir, onEvent) {
820
+ const store = await ensureTable(c);
821
+ const applied = await listApplied(c, store);
822
+ const all = readMigrations(migrationsDir);
823
+ const counts = { applied: 0, tampered: 0, failed: 0 };
824
+ const plan = buildMigrationPlan(all, applied, onEvent);
825
+ if (plan.kind === "tampered") {
826
+ counts.tampered++;
827
+ return counts;
828
+ }
829
+ if (plan.kind === "failed") {
830
+ counts.failed++;
831
+ return counts;
832
+ }
833
+ for (const step of plan.steps) {
834
+ const m = step.migration;
835
+ if (step.kind === "adopt") {
836
+ const adoption = await executeSquashAdoption(c, store, m, applied, step.replaced, onEvent);
837
+ if (adoption === "failed") {
838
+ counts.failed++;
64
839
  return counts;
65
840
  }
841
+ counts.applied++;
66
842
  continue;
67
843
  }
844
+ let committed = false;
68
845
  await c.simpleQuery("BEGIN");
69
846
  try {
70
847
  await c.simpleQuery(m.upSql);
71
- await c.execParamsText("INSERT INTO _sqlx_js_migrations (version, name, up_hash) VALUES ($1, $2, $3)", [String(m.version), m.name, m.upHash]);
848
+ await c.execParamsText(`INSERT INTO ${store.table} (version, name, up_hash) VALUES ($1, $2, $3)`, [String(m.version), m.name, m.upHash]);
72
849
  await c.simpleQuery("COMMIT");
73
- counts.applied++;
74
- onEvent?.({ kind: "applied", version: m.version, name: m.name });
850
+ committed = true;
75
851
  }
76
852
  catch (err) {
77
853
  let rollbackErr;
78
- try {
79
- await c.simpleQuery("ROLLBACK");
80
- }
81
- catch (rb) {
82
- rollbackErr = rb.message;
854
+ if (!committed) {
855
+ try {
856
+ await c.simpleQuery("ROLLBACK");
857
+ }
858
+ catch (rb) {
859
+ rollbackErr = rb.message;
860
+ }
83
861
  }
84
862
  counts.failed++;
85
863
  const message = rollbackErr
@@ -88,6 +866,17 @@ export async function applyPending(c, migrationsDir, onEvent) {
88
866
  onEvent?.({ kind: "failed", version: m.version, name: m.name, error: message });
89
867
  return counts;
90
868
  }
869
+ try {
870
+ await resetMigrationSession(c);
871
+ }
872
+ catch (err) {
873
+ counts.failed++;
874
+ onEvent?.({ kind: "failed", version: m.version, name: m.name, error: `failed to reset migration session: ${err.message}` });
875
+ return counts;
876
+ }
877
+ counts.applied++;
878
+ applied.set(m.version, { name: m.name, hash: m.upHash });
879
+ onEvent?.({ kind: "applied", version: m.version, name: m.name });
91
880
  }
92
881
  return counts;
93
882
  }
@@ -128,7 +917,275 @@ export async function releaseMigrateLock(c, lockKey = DEFAULT_MIGRATE_LOCK_KEY)
128
917
  const key = lockKeyToString(lockKey);
129
918
  await c.simpleQuery(`SELECT pg_advisory_unlock(${key})`);
130
919
  }
920
+ function safeMigrationName(name) {
921
+ const safe = name.replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^[^a-zA-Z0-9]+/, "");
922
+ if (!safe)
923
+ throw new Error(`sqlx-js.migrate: invalid migration name "${name}"`);
924
+ return safe;
925
+ }
926
+ function renderSquashMigration(replaces, schemaSql) {
927
+ const metadata = { format: 1, replaces };
928
+ return [
929
+ `${SQUASH_PREFIX} ${JSON.stringify(metadata)}`,
930
+ "-- generated by sqlx-js migrate squash",
931
+ "-- This baseline can be applied to an empty database or adopted by a database",
932
+ "-- that has all replaced migrations recorded with matching hashes.",
933
+ "",
934
+ schemaSql.trimEnd(),
935
+ "",
936
+ ].join("\n");
937
+ }
938
+ export function sanitizePgDumpSchema(sql) {
939
+ const out = [];
940
+ let state = { dollarQuote: null, blockComment: false, singleQuote: null };
941
+ for (const line of sql.split(/\r?\n/)) {
942
+ if (!state.dollarQuote && !state.blockComment && !state.singleQuote && line.trimStart().startsWith("\\"))
943
+ continue;
944
+ out.push(line);
945
+ state = scanPgDumpLine(line, state);
946
+ }
947
+ return out.join("\n").trimEnd() + "\n";
948
+ }
949
+ function matchDollarQuote(line, index) {
950
+ const m = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(line.slice(index));
951
+ return m?.[0] ?? null;
952
+ }
953
+ function scanPgDumpLine(line, current) {
954
+ const state = { ...current };
955
+ let i = 0;
956
+ while (i < line.length) {
957
+ if (state.singleQuote) {
958
+ while (i < line.length) {
959
+ if (state.singleQuote === "escape" && line[i] === "\\") {
960
+ i += 2;
961
+ continue;
962
+ }
963
+ if (line[i] !== "'") {
964
+ i++;
965
+ continue;
966
+ }
967
+ if (line[i + 1] === "'") {
968
+ i += 2;
969
+ continue;
970
+ }
971
+ i++;
972
+ state.singleQuote = null;
973
+ break;
974
+ }
975
+ continue;
976
+ }
977
+ if (state.dollarQuote) {
978
+ const end = line.indexOf(state.dollarQuote, i);
979
+ if (end === -1)
980
+ return state;
981
+ i = end + state.dollarQuote.length;
982
+ state.dollarQuote = null;
983
+ continue;
984
+ }
985
+ if (state.blockComment) {
986
+ const end = line.indexOf("*/", i);
987
+ if (end === -1)
988
+ return state;
989
+ i = end + 2;
990
+ state.blockComment = false;
991
+ continue;
992
+ }
993
+ const ch = line[i];
994
+ const next = line[i + 1];
995
+ if (ch === "-" && next === "-")
996
+ break;
997
+ if (ch === "/" && next === "*") {
998
+ state.blockComment = true;
999
+ i += 2;
1000
+ continue;
1001
+ }
1002
+ if (ch === "'") {
1003
+ const escapeString = i > 0 && (line[i - 1] === "E" || line[i - 1] === "e");
1004
+ state.singleQuote = escapeString ? "escape" : "standard";
1005
+ i++;
1006
+ continue;
1007
+ }
1008
+ if (ch === "$") {
1009
+ const tag = matchDollarQuote(line, i);
1010
+ if (tag) {
1011
+ state.dollarQuote = tag;
1012
+ i += tag.length;
1013
+ continue;
1014
+ }
1015
+ }
1016
+ i++;
1017
+ }
1018
+ return state;
1019
+ }
1020
+ function archiveMigrations(migrationsDir, archiveName, migrations) {
1021
+ const archiveDir = join(migrationsDir, ".archive", archiveName);
1022
+ if (existsSync(archiveDir)) {
1023
+ throw new Error(`sqlx-js.migrate: archive already exists: ${archiveDir}`);
1024
+ }
1025
+ mkdirSync(archiveDir, { recursive: true });
1026
+ for (const m of migrations) {
1027
+ renameSync(m.upPath, join(archiveDir, basename(m.upPath)));
1028
+ if (m.downPath)
1029
+ renameSync(m.downPath, join(archiveDir, basename(m.downPath)));
1030
+ }
1031
+ return archiveDir;
1032
+ }
1033
+ function validateArchiveName(name) {
1034
+ if (!SAFE_NAME_RE.test(name)) {
1035
+ throw new Error(`sqlx-js.migrate: unsafe archive name ${name}`);
1036
+ }
1037
+ }
1038
+ function isMigrationSqlFile(file) {
1039
+ const up = FILE_RE.exec(file);
1040
+ const down = DOWN_FILE_RE.exec(file);
1041
+ const m = up ?? down;
1042
+ return !!m && SAFE_NAME_RE.test(m[2]);
1043
+ }
1044
+ export function listMigrationArchives(migrationsDir) {
1045
+ const archiveRoot = join(migrationsDir, ".archive");
1046
+ if (!existsSync(archiveRoot))
1047
+ return [];
1048
+ return readdirSync(archiveRoot, { withFileTypes: true })
1049
+ .filter((entry) => entry.isDirectory() && SAFE_NAME_RE.test(entry.name))
1050
+ .map((entry) => {
1051
+ const path = join(archiveRoot, entry.name);
1052
+ const files = readdirSync(path)
1053
+ .filter((file) => isMigrationSqlFile(file))
1054
+ .sort();
1055
+ return { name: entry.name, path, files };
1056
+ })
1057
+ .sort((a, b) => a.name.localeCompare(b.name));
1058
+ }
1059
+ export function restoreMigrationArchive(migrationsDir, archiveName, opts = {}) {
1060
+ validateArchiveName(archiveName);
1061
+ const archiveDir = join(migrationsDir, ".archive", archiveName);
1062
+ if (!existsSync(archiveDir)) {
1063
+ throw new Error(`sqlx-js.migrate: archive not found: ${archiveName}`);
1064
+ }
1065
+ const files = readdirSync(archiveDir)
1066
+ .filter((file) => isMigrationSqlFile(file))
1067
+ .sort();
1068
+ if (files.length === 0)
1069
+ throw new Error(`sqlx-js.migrate: archive is empty: ${archiveName}`);
1070
+ const conflicts = files.filter((file) => existsSync(join(migrationsDir, file)));
1071
+ if (conflicts.length > 0 && !opts.force) {
1072
+ throw new Error(`sqlx-js.migrate: restore would overwrite existing migration file(s): ${conflicts.join(", ")}`);
1073
+ }
1074
+ for (const file of files) {
1075
+ renameSync(join(archiveDir, file), join(migrationsDir, file));
1076
+ }
1077
+ return { archiveName, restored: files };
1078
+ }
1079
+ export function createSquashMigration(opts) {
1080
+ if (!existsSync(opts.migrationsDir))
1081
+ mkdirSync(opts.migrationsDir, { recursive: true });
1082
+ const existing = readMigrations(opts.migrationsDir);
1083
+ if (existing.length === 0)
1084
+ throw new Error("sqlx-js.migrate: no migrations to squash");
1085
+ if (opts.schemaSql.trim() === "")
1086
+ throw new Error("sqlx-js.migrate: squash schema SQL is empty");
1087
+ const safe = safeMigrationName(opts.name);
1088
+ const nextVersion = (existing[existing.length - 1]?.version ?? 0) + 1;
1089
+ const padded = String(nextVersion).padStart(4, "0");
1090
+ const upPath = join(opts.migrationsDir, `${padded}_${safe}.up.sql`);
1091
+ if (existsSync(upPath))
1092
+ throw new Error(`sqlx-js.migrate: migration already exists: ${upPath}`);
1093
+ const replaces = effectiveSquashReplacements(existing);
1094
+ writeFileSync(upPath, renderSquashMigration(replaces, opts.schemaSql));
1095
+ let archiveDir;
1096
+ if (opts.replace) {
1097
+ archiveDir = archiveMigrations(opts.migrationsDir, `${padded}_${safe}`, existing);
1098
+ }
1099
+ return { version: nextVersion, name: safe, upPath, replaced: replaces.length, archiveDir };
1100
+ }
1101
+ function pgDumpEnv(databaseUrl) {
1102
+ const cfg = parseDatabaseUrl(databaseUrl);
1103
+ const env = { ...process.env };
1104
+ env.PGHOST = cfg.host;
1105
+ env.PGPORT = String(cfg.port);
1106
+ env.PGUSER = cfg.user;
1107
+ env.PGDATABASE = cfg.database;
1108
+ if (cfg.password)
1109
+ env.PGPASSWORD = cfg.password;
1110
+ else
1111
+ delete env.PGPASSWORD;
1112
+ if (cfg.sslmode)
1113
+ env.PGSSLMODE = cfg.sslmode;
1114
+ else
1115
+ delete env.PGSSLMODE;
1116
+ if (cfg.applicationName)
1117
+ env.PGAPPNAME = cfg.applicationName;
1118
+ else
1119
+ delete env.PGAPPNAME;
1120
+ if (cfg.connectTimeoutMs)
1121
+ env.PGCONNECT_TIMEOUT = String(cfg.connectTimeoutMs / 1000);
1122
+ else
1123
+ delete env.PGCONNECT_TIMEOUT;
1124
+ delete env.PGSERVICE;
1125
+ delete env.PGSERVICEFILE;
1126
+ delete env.PGPASSFILE;
1127
+ return env;
1128
+ }
1129
+ export function dumpSchema(databaseUrl, pgDumpPath = "pg_dump") {
1130
+ const tmp = mkdtempSync(join(tmpdir(), "sqlx-js-pgdump-"));
1131
+ const outPath = join(tmp, "schema.sql");
1132
+ const args = [
1133
+ "--schema-only",
1134
+ "--no-owner",
1135
+ "--no-privileges",
1136
+ "--exclude-table=*._sqlx_js_migrations",
1137
+ "--exclude-table=_sqlx_js_migrations",
1138
+ "--exclude-table=public._sqlx_js_migrations",
1139
+ `--file=${outPath}`,
1140
+ "-w",
1141
+ ];
1142
+ try {
1143
+ const r = spawnSync(pgDumpPath, args, {
1144
+ encoding: "utf8",
1145
+ env: pgDumpEnv(databaseUrl),
1146
+ maxBuffer: 16 * 1024 * 1024,
1147
+ });
1148
+ if (r.error) {
1149
+ throw new Error(`sqlx-js.migrate: failed to run ${pgDumpPath}: ${r.error.message}`);
1150
+ }
1151
+ if (r.status !== 0) {
1152
+ throw new Error(`sqlx-js.migrate: ${pgDumpPath} failed: ${r.stderr.trim() || `exit ${r.status}`}`);
1153
+ }
1154
+ const schema = sanitizePgDumpSchema(readFileSync(outPath, "utf8"));
1155
+ if (schema.trim() === "")
1156
+ throw new Error("sqlx-js.migrate: pg_dump returned empty schema");
1157
+ return schema;
1158
+ }
1159
+ finally {
1160
+ rmSync(tmp, { recursive: true, force: true });
1161
+ }
1162
+ }
1163
+ export async function migrateSquash(opts) {
1164
+ let schemaSql = "";
1165
+ await withWorkflowShadowDatabase({
1166
+ databaseUrl: opts.databaseUrl ?? "",
1167
+ shadowUrl: opts.shadowUrl,
1168
+ shadowAdminUrl: opts.shadowAdminUrl,
1169
+ }, async (shadowDatabaseUrl) => {
1170
+ await applyMigrationsForWorkflow(shadowDatabaseUrl, opts.migrationsDir, opts.lockKey, opts.lockTimeoutMs);
1171
+ schemaSql = dumpSchema(shadowDatabaseUrl, opts.pgDumpPath);
1172
+ });
1173
+ const result = createSquashMigration({
1174
+ migrationsDir: opts.migrationsDir,
1175
+ name: opts.name,
1176
+ schemaSql,
1177
+ replace: opts.replace,
1178
+ });
1179
+ console.log(`created ${result.upPath}`);
1180
+ console.log(`squash: replaced ${result.replaced} migration(s) with ${String(result.version).padStart(4, "0")}_${result.name}`);
1181
+ if (result.archiveDir)
1182
+ console.log(`squash: archived replaced migrations in ${result.archiveDir}`);
1183
+ }
131
1184
  export async function migrateRun(opts) {
1185
+ if (opts.json && !opts.dryRun) {
1186
+ console.error("--json for migrate run requires --dry-run");
1187
+ process.exit(2);
1188
+ }
132
1189
  const cfg = parseDatabaseUrl(opts.databaseUrl);
133
1190
  const c = new PgClient(cfg);
134
1191
  await c.connect();
@@ -138,20 +1195,54 @@ export async function migrateRun(opts) {
138
1195
  try {
139
1196
  await acquireMigrateLock(c, lockKey, opts.lockTimeoutMs);
140
1197
  locked = true;
141
- await applyPending(c, opts.migrationsDir, (e) => {
142
- if (e.kind === "applied")
143
- console.log(`applying ${e.version}_${e.name}…\n ✓ applied`);
144
- else if (e.kind === "tampered") {
145
- console.error(`migration ${e.version}_${e.name} was tampered with (hash mismatch)`);
146
- console.error(` applied: ${e.applied.slice(0, 16)}…`);
147
- console.error(` current: ${e.current.slice(0, 16)}…`);
148
- exitCode = 1;
1198
+ if (opts.dryRun) {
1199
+ if (opts.json) {
1200
+ const result = await inspectMigrationPlan(c, opts.migrationsDir);
1201
+ console.log(JSON.stringify(result, null, 2));
1202
+ if (!result.ok)
1203
+ exitCode = 1;
149
1204
  }
150
1205
  else {
151
- console.error(`applying ${e.version}_${e.name}…\n ✗ ${e.error}`);
152
- exitCode = 1;
1206
+ const result = await planPending(c, opts.migrationsDir, (e) => {
1207
+ if (e.kind === "pending")
1208
+ console.log(`would apply ${e.version}_${e.name}`);
1209
+ else if (e.kind === "adoptable")
1210
+ console.log(`would adopt ${e.version}_${e.name} (${e.replaced} replaced)`);
1211
+ else if (e.kind === "tampered") {
1212
+ console.error(`migration ${e.version}_${e.name} was tampered with (hash mismatch)`);
1213
+ console.error(` applied: ${e.applied.slice(0, 16)}…`);
1214
+ console.error(` current: ${e.current.slice(0, 16)}…`);
1215
+ exitCode = 1;
1216
+ }
1217
+ else {
1218
+ console.error(`planning ${e.version}_${e.name}…\n ✗ ${e.error}`);
1219
+ exitCode = 1;
1220
+ }
1221
+ });
1222
+ if (exitCode === 0 && result.steps.length === 0)
1223
+ console.log("migrations up-to-date");
1224
+ else if (exitCode === 0)
1225
+ console.log(`dry-run: ${result.steps.length} pending action(s)`);
153
1226
  }
154
- });
1227
+ }
1228
+ else {
1229
+ await applyPending(c, opts.migrationsDir, (e) => {
1230
+ if (e.kind === "applied")
1231
+ console.log(`applying ${e.version}_${e.name}…\n ✓ applied`);
1232
+ else if (e.kind === "adopted")
1233
+ console.log(`adopting ${e.version}_${e.name}…\n ✓ replaced ${e.replaced} migration rows`);
1234
+ else if (e.kind === "tampered") {
1235
+ console.error(`migration ${e.version}_${e.name} was tampered with (hash mismatch)`);
1236
+ console.error(` applied: ${e.applied.slice(0, 16)}…`);
1237
+ console.error(` current: ${e.current.slice(0, 16)}…`);
1238
+ exitCode = 1;
1239
+ }
1240
+ else {
1241
+ console.error(`applying ${e.version}_${e.name}…\n ✗ ${e.error}`);
1242
+ exitCode = 1;
1243
+ }
1244
+ });
1245
+ }
155
1246
  }
156
1247
  finally {
157
1248
  if (locked) {
@@ -171,20 +1262,82 @@ export async function migrateInfo(opts) {
171
1262
  const cfg = parseDatabaseUrl(opts.databaseUrl);
172
1263
  const c = new PgClient(cfg);
173
1264
  await c.connect();
174
- await ensureTable(c);
175
- const applied = await listApplied(c);
176
- const all = readMigrations(opts.migrationsDir);
177
- console.log(`migrations in ${opts.migrationsDir}:`);
178
- for (const m of all) {
179
- const a = applied.get(m.version);
180
- const status = !a ? "pending" : a.hash === m.upHash ? "applied" : "applied (tampered!)";
181
- console.log(` ${String(m.version).padStart(4, "0")}_${m.name}: ${status}`);
1265
+ try {
1266
+ const info = await inspectMigrations(c, opts.migrationsDir);
1267
+ if (opts.json) {
1268
+ console.log(JSON.stringify(info, null, 2));
1269
+ return;
1270
+ }
1271
+ console.log(`migrations in ${opts.migrationsDir}:`);
1272
+ console.log(`history table: ${info.historyTable ?? "not created"}`);
1273
+ console.log(`summary: ${info.summary.applied} applied, ${info.summary.pending} pending, ` +
1274
+ `${info.summary.adoptable} adoptable, ${info.summary.superseded} superseded, ` +
1275
+ `${info.summary.tampered} tampered, ${info.summary.failed} failed`);
1276
+ for (const item of info.items) {
1277
+ const detail = item.detail ? ` (${item.detail})` : "";
1278
+ console.log(` ${String(item.version).padStart(4, "0")}_${item.name}: ${item.status}${detail}`);
1279
+ }
1280
+ }
1281
+ finally {
1282
+ await c.end();
182
1283
  }
183
- await c.end();
1284
+ }
1285
+ export function migrateCheck(opts) {
1286
+ const report = checkMigrationFiles(opts.migrationsDir);
1287
+ if (opts.json) {
1288
+ console.log(JSON.stringify(report, null, 2));
1289
+ }
1290
+ else if (report.ok) {
1291
+ console.log(`migration files ok: ${report.migrations} migration(s), ${report.archives} archive(s)`);
1292
+ }
1293
+ else {
1294
+ console.error(`migration check failed: ${report.issues.length} issue(s)`);
1295
+ for (const issue of report.issues) {
1296
+ const file = issue.file ? `${issue.file}: ` : "";
1297
+ console.error(` [${issue.severity}] ${issue.code}: ${file}${issue.message}`);
1298
+ }
1299
+ }
1300
+ if (!report.ok)
1301
+ process.exit(1);
1302
+ }
1303
+ function assertMigrationCheckOk(migrationsDir) {
1304
+ const report = checkMigrationFiles(migrationsDir);
1305
+ if (report.ok) {
1306
+ console.log(`migration files ok: ${report.migrations} migration(s), ${report.archives} archive(s)`);
1307
+ return;
1308
+ }
1309
+ console.error(`migration check failed: ${report.issues.length} issue(s)`);
1310
+ for (const issue of report.issues) {
1311
+ const file = issue.file ? `${issue.file}: ` : "";
1312
+ console.error(` [${issue.severity}] ${issue.code}: ${file}${issue.message}`);
1313
+ }
1314
+ process.exit(1);
1315
+ }
1316
+ export async function migrateDev(opts) {
1317
+ assertMigrationCheckOk(opts.migrationsDir);
1318
+ let ok = true;
1319
+ await withWorkflowShadowDatabase(opts, async (shadowDatabaseUrl) => {
1320
+ await applyMigrationsForWorkflow(shadowDatabaseUrl, opts.migrationsDir, opts.lockKey, opts.lockTimeoutMs);
1321
+ await validateLatestDownForWorkflow(shadowDatabaseUrl, opts.migrationsDir);
1322
+ ok = await prepareWorkflowArtifacts(opts, shadowDatabaseUrl);
1323
+ });
1324
+ if (!ok)
1325
+ process.exit(1);
1326
+ }
1327
+ export async function migrateVerify(opts) {
1328
+ assertMigrationCheckOk(opts.migrationsDir);
1329
+ let ok = true;
1330
+ await withWorkflowShadowDatabase(opts, async (shadowDatabaseUrl) => {
1331
+ await applyMigrationsForWorkflow(shadowDatabaseUrl, opts.migrationsDir, opts.lockKey, opts.lockTimeoutMs);
1332
+ await validateLatestDownForWorkflow(shadowDatabaseUrl, opts.migrationsDir);
1333
+ ok = await prepareInTemporaryArtifacts(opts, shadowDatabaseUrl);
1334
+ });
1335
+ if (!ok)
1336
+ process.exit(1);
184
1337
  }
185
1338
  export async function revertLast(c, migrationsDir) {
186
- await ensureTable(c);
187
- const applied = await listApplied(c);
1339
+ const store = await ensureTable(c);
1340
+ const applied = await listApplied(c, store);
188
1341
  const all = readMigrations(migrationsDir);
189
1342
  let last = null;
190
1343
  for (let i = all.length - 1; i >= 0; i--) {
@@ -201,7 +1354,7 @@ export async function revertLast(c, migrationsDir) {
201
1354
  await c.simpleQuery("BEGIN");
202
1355
  try {
203
1356
  await c.simpleQuery(downSql);
204
- await c.execParamsText("DELETE FROM _sqlx_js_migrations WHERE version = $1", [String(last.version)]);
1357
+ await c.execParamsText(`DELETE FROM ${store.table} WHERE version = $1`, [String(last.version)]);
205
1358
  await c.simpleQuery("COMMIT");
206
1359
  return { kind: "reverted", version: last.version, name: last.name };
207
1360
  }
@@ -219,7 +1372,175 @@ export async function revertLast(c, migrationsDir) {
219
1372
  return { kind: "failed", version: last.version, name: last.name, error: msg };
220
1373
  }
221
1374
  }
1375
+ export async function checkLastDownMigration(c, migrationsDir) {
1376
+ const localCheck = checkMigrationFiles(migrationsDir);
1377
+ if (!localCheck.ok) {
1378
+ return {
1379
+ kind: "failed",
1380
+ phase: "validate",
1381
+ error: localCheck.issues.map((i) => `${i.code}: ${i.message}`).join("; "),
1382
+ };
1383
+ }
1384
+ const all = readMigrations(migrationsDir);
1385
+ const target = all[all.length - 1];
1386
+ if (!target)
1387
+ return { kind: "noop" };
1388
+ if (!target.downPath)
1389
+ return { kind: "no-down", version: target.version, name: target.name };
1390
+ const downSql = readFileSync(target.downPath, "utf8");
1391
+ const validation = [];
1392
+ const prefixPlan = buildMigrationPlan(all.slice(0, -1), new Map(), (e) => {
1393
+ validation.push(e);
1394
+ });
1395
+ if (prefixPlan.kind !== "ok") {
1396
+ return {
1397
+ kind: "failed",
1398
+ version: target.version,
1399
+ name: target.name,
1400
+ phase: "validate",
1401
+ error: validation.map((e) => e.kind === "failed" ? e.error : `${e.version}_${e.name} hash mismatch`).join("; "),
1402
+ };
1403
+ }
1404
+ let phase = "begin";
1405
+ let outcome;
1406
+ let inTransaction = false;
1407
+ try {
1408
+ await c.simpleQuery("BEGIN");
1409
+ inTransaction = true;
1410
+ phase = "isolate";
1411
+ await isolateShadowSchemaState(c);
1412
+ phase = "previous-up";
1413
+ for (const step of prefixPlan.steps) {
1414
+ if (step.kind === "apply") {
1415
+ await c.simpleQuery(step.migration.upSql);
1416
+ await resetMigrationSession(c);
1417
+ }
1418
+ }
1419
+ phase = "snapshot-before";
1420
+ const before = await introspectConnected(c);
1421
+ phase = "target-up";
1422
+ await c.simpleQuery(target.upSql);
1423
+ await resetMigrationSession(c);
1424
+ phase = "down";
1425
+ await c.simpleQuery(downSql);
1426
+ await resetMigrationSession(c);
1427
+ phase = "snapshot-after";
1428
+ const after = await introspectConnected(c);
1429
+ outcome = schemaSnapshotEqual(before, after)
1430
+ ? { kind: "passed", version: target.version, name: target.name }
1431
+ : {
1432
+ kind: "schema-mismatch",
1433
+ version: target.version,
1434
+ name: target.name,
1435
+ diff: schemaDiffSummary(before, after),
1436
+ };
1437
+ }
1438
+ catch (err) {
1439
+ outcome = {
1440
+ kind: "failed",
1441
+ version: target.version,
1442
+ name: target.name,
1443
+ phase,
1444
+ error: err.message,
1445
+ };
1446
+ }
1447
+ if (inTransaction) {
1448
+ try {
1449
+ phase = "rollback";
1450
+ await c.simpleQuery("ROLLBACK");
1451
+ }
1452
+ catch (err) {
1453
+ const rollbackError = err.message;
1454
+ if (outcome.kind === "failed") {
1455
+ return { ...outcome, error: `${outcome.error} (rollback also failed: ${rollbackError})` };
1456
+ }
1457
+ return {
1458
+ kind: "failed",
1459
+ version: target.version,
1460
+ name: target.name,
1461
+ phase,
1462
+ error: rollbackError,
1463
+ };
1464
+ }
1465
+ }
1466
+ return outcome;
1467
+ }
1468
+ function migrationLabel(version, name) {
1469
+ return `${String(version).padStart(4, "0")}_${name}`;
1470
+ }
1471
+ function printSchemaObjectDiff(label, diff) {
1472
+ if (diff.added.length > 0)
1473
+ console.error(` ${label} added: ${diff.added.join(", ")}`);
1474
+ if (diff.removed.length > 0)
1475
+ console.error(` ${label} removed: ${diff.removed.join(", ")}`);
1476
+ if (diff.changed.length > 0)
1477
+ console.error(` ${label} changed: ${diff.changed.join(", ")}`);
1478
+ }
1479
+ async function migrateRevertDryRun(opts) {
1480
+ let exitCode = 0;
1481
+ await withDryRunShadowDatabase(opts, async (shadowDatabaseUrl) => {
1482
+ const cfg = parseDatabaseUrl(shadowDatabaseUrl);
1483
+ const c = new PgClient(cfg);
1484
+ await c.connect();
1485
+ const lockKey = opts.lockKey ?? DEFAULT_MIGRATE_LOCK_KEY;
1486
+ let locked = false;
1487
+ try {
1488
+ await acquireMigrateLock(c, lockKey, opts.lockTimeoutMs);
1489
+ locked = true;
1490
+ const outcome = await checkLastDownMigration(c, opts.migrationsDir);
1491
+ if (opts.json) {
1492
+ console.log(JSON.stringify(outcome, null, 2));
1493
+ }
1494
+ else if (outcome.kind === "noop") {
1495
+ console.log("revert dry-run: no migrations");
1496
+ }
1497
+ else if (outcome.kind === "no-down") {
1498
+ console.error(`migration ${migrationLabel(outcome.version, outcome.name)} has no .down.sql`);
1499
+ exitCode = 1;
1500
+ }
1501
+ else if (outcome.kind === "passed") {
1502
+ console.log(`revert dry-run: ${migrationLabel(outcome.version, outcome.name)} restores schema`);
1503
+ }
1504
+ else if (outcome.kind === "schema-mismatch") {
1505
+ console.error(`revert dry-run: ${migrationLabel(outcome.version, outcome.name)} down did not restore schema`);
1506
+ printSchemaObjectDiff("relations", outcome.diff.relations);
1507
+ printSchemaObjectDiff("types", outcome.diff.types);
1508
+ printSchemaObjectDiff("functions", outcome.diff.functions);
1509
+ exitCode = 1;
1510
+ }
1511
+ else {
1512
+ const label = outcome.version && outcome.name ? `${migrationLabel(outcome.version, outcome.name)} ` : "";
1513
+ console.error(`revert dry-run: ${label}failed during ${outcome.phase}`);
1514
+ console.error(` ✗ ${outcome.error}`);
1515
+ exitCode = 1;
1516
+ }
1517
+ if (opts.json && outcome.kind !== "noop" && outcome.kind !== "passed")
1518
+ exitCode = 1;
1519
+ }
1520
+ finally {
1521
+ if (locked) {
1522
+ try {
1523
+ await releaseMigrateLock(c, lockKey);
1524
+ }
1525
+ catch (e) {
1526
+ console.warn(`sqlx-js.migrate: failed to release advisory lock: ${e.message}`);
1527
+ }
1528
+ }
1529
+ await c.end();
1530
+ }
1531
+ }, opts.json ? () => { } : console.log);
1532
+ if (exitCode !== 0)
1533
+ process.exit(exitCode);
1534
+ }
222
1535
  export async function migrateRevert(opts) {
1536
+ if (opts.json && !opts.dryRun) {
1537
+ console.error("--json for migrate revert requires --dry-run");
1538
+ process.exit(2);
1539
+ }
1540
+ if (opts.dryRun) {
1541
+ await migrateRevertDryRun(opts);
1542
+ return;
1543
+ }
223
1544
  const cfg = parseDatabaseUrl(opts.databaseUrl);
224
1545
  const c = new PgClient(cfg);
225
1546
  await c.connect();
@@ -261,14 +1582,30 @@ export async function migrateRevert(opts) {
261
1582
  if (exitCode !== 0)
262
1583
  process.exit(exitCode);
263
1584
  }
1585
+ export function migrateArchiveList(opts) {
1586
+ const archives = listMigrationArchives(opts.migrationsDir);
1587
+ if (archives.length === 0) {
1588
+ console.log("no migration archives");
1589
+ return;
1590
+ }
1591
+ for (const archive of archives) {
1592
+ console.log(`${archive.name}: ${archive.files.length} file(s)`);
1593
+ for (const file of archive.files)
1594
+ console.log(` ${file}`);
1595
+ }
1596
+ }
1597
+ export function migrateArchiveRestore(opts) {
1598
+ const result = restoreMigrationArchive(opts.migrationsDir, opts.name, { force: opts.force });
1599
+ console.log(`restored ${result.restored.length} file(s) from ${result.archiveName}`);
1600
+ for (const file of result.restored)
1601
+ console.log(` ${file}`);
1602
+ }
264
1603
  export function migrateAdd(opts) {
265
1604
  if (!existsSync(opts.migrationsDir))
266
1605
  mkdirSync(opts.migrationsDir, { recursive: true });
267
1606
  const existing = readMigrations(opts.migrationsDir);
268
1607
  const nextVersion = (existing[existing.length - 1]?.version ?? 0) + 1;
269
- const safe = opts.name.replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^[^a-zA-Z0-9]+/, "");
270
- if (!safe)
271
- throw new Error(`sqlx-js.migrate: invalid migration name "${opts.name}"`);
1608
+ const safe = safeMigrationName(opts.name);
272
1609
  const padded = String(nextVersion).padStart(4, "0");
273
1610
  const upFname = `${padded}_${safe}.up.sql`;
274
1611
  const downFname = `${padded}_${safe}.down.sql`;