@dbsp/cli 2.0.0 → 2.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/{chunk-T4ODDHZQ.js → chunk-EO3GHEFW.js} +12 -2
- package/dist/chunk-EO3GHEFW.js.map +1 -0
- package/dist/{chunk-VUNVYYOZ.js → chunk-G6NS4BAV.js} +3 -3
- package/dist/{chunk-VUNVYYOZ.js.map → chunk-G6NS4BAV.js.map} +1 -1
- package/dist/generators/schema-codegen.js +1 -1
- package/dist/index.js +342 -239
- package/dist/index.js.map +1 -1
- package/dist/repl/batch.d.ts +3 -2
- package/dist/utils/schema-loader.d.ts +5 -3
- package/dist/utils/schema-loader.js +1 -1
- package/package.json +5 -5
- package/dist/chunk-T4ODDHZQ.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -3,11 +3,11 @@ import {
|
|
|
3
3
|
createDbConnection,
|
|
4
4
|
generateSchemaFileWithDiagnostics,
|
|
5
5
|
redactDbUrl
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-G6NS4BAV.js";
|
|
7
7
|
import {
|
|
8
8
|
loadSchema,
|
|
9
9
|
loadSchemaFromCwd
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-EO3GHEFW.js";
|
|
11
11
|
import {
|
|
12
12
|
config,
|
|
13
13
|
validateIdentifier
|
|
@@ -52,16 +52,16 @@ var generateCommand = new Command("generate").description("Generate code from sc
|
|
|
52
52
|
const log = useStdout ? console.error : console.log;
|
|
53
53
|
log(`\u{1F4C4} Loaded schema from: ${schemaPath}`);
|
|
54
54
|
const dialect = options.dialect ?? "postgresql";
|
|
55
|
-
if (dialect
|
|
55
|
+
if (!isDialectSupported(dialect)) {
|
|
56
56
|
console.error(
|
|
57
57
|
`\u26A0\uFE0F Warning: Only 'postgresql' dialect is currently supported. Using postgresql.`
|
|
58
58
|
);
|
|
59
59
|
}
|
|
60
60
|
switch (target) {
|
|
61
61
|
case "ddl": {
|
|
62
|
-
const
|
|
62
|
+
const dbCasing = mapCasingToDbCasing(options.casing) ?? schema.dbCasing ?? "snake_case";
|
|
63
|
+
const casingLabel = formatGenerateCasingLabel(dbCasing);
|
|
63
64
|
const { createPgsqlCompileOnlyAdapter } = await import("@dbsp/adapter-pgsql");
|
|
64
|
-
const dbCasing = casing === "snake" ? "snake_case" : "preserve";
|
|
65
65
|
const adapter = createPgsqlCompileOnlyAdapter({
|
|
66
66
|
dbCasing,
|
|
67
67
|
...options.schemaName ? { schemaName: options.schemaName } : {}
|
|
@@ -81,7 +81,7 @@ var generateCommand = new Command("generate").description("Generate code from sc
|
|
|
81
81
|
console.log(`\u2705 Generated DDL: ${outPath}`);
|
|
82
82
|
console.log(` Tables: ${schema.tableNames.length}`);
|
|
83
83
|
console.log(` Statements: ${ddlStatements.length}`);
|
|
84
|
-
console.log(` Casing: ${
|
|
84
|
+
console.log(` Casing: ${casingLabel}`);
|
|
85
85
|
if (options.drop) {
|
|
86
86
|
console.log(` Includes DROP statements`);
|
|
87
87
|
}
|
|
@@ -102,6 +102,31 @@ var generateCommand = new Command("generate").description("Generate code from sc
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
);
|
|
105
|
+
function isDialectSupported(dialect) {
|
|
106
|
+
return dialect === "postgresql";
|
|
107
|
+
}
|
|
108
|
+
function mapCasingToDbCasing(casing) {
|
|
109
|
+
switch (casing) {
|
|
110
|
+
case "snake":
|
|
111
|
+
return "snake_case";
|
|
112
|
+
case "camel":
|
|
113
|
+
return "camelCase";
|
|
114
|
+
case "none":
|
|
115
|
+
return "preserve";
|
|
116
|
+
default:
|
|
117
|
+
return void 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function formatGenerateCasingLabel(dbCasing) {
|
|
121
|
+
switch (dbCasing) {
|
|
122
|
+
case "snake_case":
|
|
123
|
+
return "snake";
|
|
124
|
+
case "camelCase":
|
|
125
|
+
return "camel";
|
|
126
|
+
case "preserve":
|
|
127
|
+
return "none";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
105
130
|
|
|
106
131
|
// src/commands/introspect.ts
|
|
107
132
|
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
@@ -142,10 +167,10 @@ var introspectCommand = new Command2("introspect").description("Generate schema.
|
|
|
142
167
|
try {
|
|
143
168
|
const { pool } = await createDbConnection(options.db);
|
|
144
169
|
try {
|
|
145
|
-
const { introspect
|
|
170
|
+
const { introspect } = await import("@dbsp/adapter-pgsql");
|
|
146
171
|
const excludePatterns = options.exclude ? options.exclude.split(",").map((s) => s.trim()) : void 0;
|
|
147
172
|
const includePatterns = options.include ? options.include.split(",").map((s) => s.trim()) : void 0;
|
|
148
|
-
const model = await
|
|
173
|
+
const model = await introspect(pool, {
|
|
149
174
|
schema: options.schemaName,
|
|
150
175
|
...excludePatterns ? { exclude: excludePatterns } : {},
|
|
151
176
|
...includePatterns ? { include: includePatterns } : {}
|
|
@@ -190,13 +215,13 @@ var introspectCommand = new Command2("introspect").description("Generate schema.
|
|
|
190
215
|
|
|
191
216
|
// src/commands/migrate.ts
|
|
192
217
|
import {
|
|
193
|
-
|
|
218
|
+
comparePgsqlDatabaseSchema,
|
|
219
|
+
createPgsqlAdapter,
|
|
194
220
|
ensureMigrationsTable,
|
|
195
221
|
generateMigrationFile,
|
|
196
222
|
generateMigrationSQL,
|
|
197
223
|
getAppliedMigrations,
|
|
198
224
|
getNextSchemaVersion,
|
|
199
|
-
introspect,
|
|
200
225
|
isDestructiveDown,
|
|
201
226
|
parseMigrationFile,
|
|
202
227
|
recordMigration,
|
|
@@ -321,6 +346,218 @@ function hasExecutableSql(stmt) {
|
|
|
321
346
|
return t.length > 0 && !t.startsWith("--");
|
|
322
347
|
});
|
|
323
348
|
}
|
|
349
|
+
var defaultMigrateDeps = {
|
|
350
|
+
withMigratePool,
|
|
351
|
+
ensureMigrationsTable,
|
|
352
|
+
withMigrationLock,
|
|
353
|
+
getAppliedMigrations,
|
|
354
|
+
scanMigrationFiles,
|
|
355
|
+
parseMigrationFile,
|
|
356
|
+
getNextSchemaVersion,
|
|
357
|
+
recordMigration,
|
|
358
|
+
removeMigrationRecord,
|
|
359
|
+
isDestructiveDown
|
|
360
|
+
};
|
|
361
|
+
function resolveMigrateDeps(deps) {
|
|
362
|
+
return { ...defaultMigrateDeps, ...deps };
|
|
363
|
+
}
|
|
364
|
+
async function runApply(options, deps) {
|
|
365
|
+
const d = resolveMigrateDeps(deps);
|
|
366
|
+
console.log("\u{1F504} Applying migrations");
|
|
367
|
+
console.log(` Database: ${redactDbUrl(options.db)}`);
|
|
368
|
+
console.log(` Directory: ${options.dir}`);
|
|
369
|
+
console.log("");
|
|
370
|
+
await d.withMigratePool(options.db, async (pool) => {
|
|
371
|
+
await d.ensureMigrationsTable(pool);
|
|
372
|
+
await d.withMigrationLock(pool, async (client) => {
|
|
373
|
+
const applied = await d.getAppliedMigrations(client);
|
|
374
|
+
const appliedMap = new Map(applied.map((m) => [m.name, m.checksum]));
|
|
375
|
+
const files = d.scanMigrationFiles(options.dir);
|
|
376
|
+
if (files.length === 0) {
|
|
377
|
+
console.log(`No migration files found in ${options.dir}`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
for (const file of files) {
|
|
381
|
+
const existingChecksum = appliedMap.get(file.name);
|
|
382
|
+
if (existingChecksum !== void 0 && existingChecksum !== file.checksum) {
|
|
383
|
+
throw new MigrationError(
|
|
384
|
+
`Checksum mismatch for ${file.name}
|
|
385
|
+
Expected: ${existingChecksum}
|
|
386
|
+
Got: ${file.checksum}
|
|
387
|
+
|
|
388
|
+
Migration file has been tampered with after being applied.`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const pending = files.filter((f) => !appliedMap.has(f.name));
|
|
393
|
+
if (pending.length === 0) {
|
|
394
|
+
console.log("\u2705 All migrations already applied.");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (options.dryRun) {
|
|
398
|
+
console.log(`${pending.length} pending migration(s):`);
|
|
399
|
+
for (const file of pending) {
|
|
400
|
+
console.log(` - ${file.name}`);
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
let appliedCount = 0;
|
|
405
|
+
for (const file of pending) {
|
|
406
|
+
console.log(` Applying: ${file.name}...`);
|
|
407
|
+
const parsed = d.parseMigrationFile(file.content);
|
|
408
|
+
const statements = parsed.upStatements.filter(hasExecutableSql);
|
|
409
|
+
const version = await d.getNextSchemaVersion(client);
|
|
410
|
+
const destructive = parsed.destructive === true;
|
|
411
|
+
try {
|
|
412
|
+
await client.query("BEGIN");
|
|
413
|
+
for (const stmt of statements) {
|
|
414
|
+
await client.query(stmt);
|
|
415
|
+
}
|
|
416
|
+
await d.recordMigration(
|
|
417
|
+
client,
|
|
418
|
+
file.name,
|
|
419
|
+
file.checksum,
|
|
420
|
+
version,
|
|
421
|
+
destructive
|
|
422
|
+
);
|
|
423
|
+
await client.query("COMMIT");
|
|
424
|
+
} catch (applyError) {
|
|
425
|
+
let rollbackError;
|
|
426
|
+
try {
|
|
427
|
+
await client.query("ROLLBACK");
|
|
428
|
+
} catch (e) {
|
|
429
|
+
rollbackError = e;
|
|
430
|
+
}
|
|
431
|
+
const primary = sanitizePgError(applyError);
|
|
432
|
+
if (rollbackError !== void 0) {
|
|
433
|
+
console.error(
|
|
434
|
+
` Note: rollback also failed: ${sanitizePgError(rollbackError).message}`
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
throw primary;
|
|
438
|
+
}
|
|
439
|
+
appliedCount++;
|
|
440
|
+
console.log(` \u2705 Applied: ${file.name}`);
|
|
441
|
+
}
|
|
442
|
+
console.log(`
|
|
443
|
+
\u2705 ${appliedCount} migration(s) applied successfully.`);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
async function runRollback(options, deps) {
|
|
448
|
+
const d = resolveMigrateDeps(deps);
|
|
449
|
+
console.log(`\u23EA Rolling back ${options.count} migration(s)`);
|
|
450
|
+
console.log(` Database: ${redactDbUrl(options.db)}`);
|
|
451
|
+
console.log(` Directory: ${options.dir}`);
|
|
452
|
+
console.log("");
|
|
453
|
+
await d.withMigratePool(options.db, async (pool) => {
|
|
454
|
+
await d.ensureMigrationsTable(pool);
|
|
455
|
+
await d.withMigrationLock(pool, async (client) => {
|
|
456
|
+
const applied = await d.getAppliedMigrations(client);
|
|
457
|
+
const sortedDesc = [...applied].sort(
|
|
458
|
+
(a, b) => b.name.localeCompare(a.name)
|
|
459
|
+
);
|
|
460
|
+
if (options.count > sortedDesc.length) {
|
|
461
|
+
throw new MigrationError(
|
|
462
|
+
`Cannot roll back ${options.count} migration(s) \u2014 only ${sortedDesc.length} applied`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
const toRollback = sortedDesc.slice(0, options.count);
|
|
466
|
+
const files = d.scanMigrationFiles(options.dir);
|
|
467
|
+
const fileMap = new Map(files.map((f) => [f.name, f]));
|
|
468
|
+
let rolledBack = 0;
|
|
469
|
+
for (const record of toRollback) {
|
|
470
|
+
const file = fileMap.get(record.name);
|
|
471
|
+
if (!file) {
|
|
472
|
+
throw new MigrationError(
|
|
473
|
+
`Migration file not found on disk: ${record.name}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
if (file.checksum !== record.checksum) {
|
|
477
|
+
throw new MigrationError(
|
|
478
|
+
`Checksum mismatch for ${record.name}
|
|
479
|
+
Expected: ${record.checksum}
|
|
480
|
+
Got: ${file.checksum}
|
|
481
|
+
|
|
482
|
+
Migration file has been modified since it was applied.`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const parsed = d.parseMigrationFile(file.content);
|
|
486
|
+
if (!parsed.hasDown) {
|
|
487
|
+
throw new MigrationError(
|
|
488
|
+
`Migration ${record.name} has no DOWN section
|
|
489
|
+
Cannot roll back a migration without a DOWN section.`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const downStmts = parsed.downStatements.filter(hasExecutableSql);
|
|
493
|
+
if (downStmts.length === 0 && !options.force) {
|
|
494
|
+
throw new MigrationError(
|
|
495
|
+
`Migration ${record.name} has an empty DOWN section
|
|
496
|
+
Use --force to roll back anyway.`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
const safeHeaderContradictedByDown = parsed.destructive === false && d.isDestructiveDown(downStmts);
|
|
500
|
+
if ((parsed.destructive !== false || safeHeaderContradictedByDown) && !options.force) {
|
|
501
|
+
if (parsed.destructive === true) {
|
|
502
|
+
throw new MigrationError(
|
|
503
|
+
`Migration ${record.name} has destructive DOWN operations
|
|
504
|
+
Use --force to proceed with destructive rollback.`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (safeHeaderContradictedByDown) {
|
|
508
|
+
throw new MigrationError(
|
|
509
|
+
`Migration ${record.name} is marked non-destructive but its DOWN section contains an obvious destructive statement
|
|
510
|
+
Re-generate the migration with accurate dbsp metadata or pass --force.`
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
throw new MigrationError(
|
|
514
|
+
`Migration ${record.name} is unmarked or legacy
|
|
515
|
+
Re-generate the migration with dbsp metadata or pass --force.`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (downStmts.length > 0 || options.force) {
|
|
519
|
+
console.log(` Rolling back: ${record.name}...`);
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
await client.query("BEGIN");
|
|
523
|
+
for (const stmt of downStmts) {
|
|
524
|
+
await client.query(stmt);
|
|
525
|
+
}
|
|
526
|
+
await d.removeMigrationRecord(client, record.name);
|
|
527
|
+
await client.query("COMMIT");
|
|
528
|
+
} catch (rollbackError) {
|
|
529
|
+
let rollbackCleanupError;
|
|
530
|
+
try {
|
|
531
|
+
await client.query("ROLLBACK");
|
|
532
|
+
} catch (e) {
|
|
533
|
+
rollbackCleanupError = e;
|
|
534
|
+
}
|
|
535
|
+
const primary = sanitizePgError(rollbackError);
|
|
536
|
+
if (rollbackCleanupError !== void 0) {
|
|
537
|
+
console.error(
|
|
538
|
+
` Note: rollback also failed: ${sanitizePgError(rollbackCleanupError).message}`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
throw primary;
|
|
542
|
+
}
|
|
543
|
+
rolledBack++;
|
|
544
|
+
console.log(` \u2705 Rolled back: ${record.name}`);
|
|
545
|
+
}
|
|
546
|
+
console.log(`
|
|
547
|
+
\u2705 ${rolledBack} migration(s) rolled back successfully.`);
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
function runRollbackCommand(countArg, options) {
|
|
552
|
+
const count = Number.parseInt(countArg, 10);
|
|
553
|
+
if (Number.isNaN(count) || count < 1) {
|
|
554
|
+
console.error("\u274C Count must be a positive integer");
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
return runMigrateAction(async () => {
|
|
558
|
+
await runRollback({ count, ...options });
|
|
559
|
+
});
|
|
560
|
+
}
|
|
324
561
|
var devCommand = new Command3("dev").description("Generate a migration from schema changes").option(
|
|
325
562
|
"-s, --schema <path>",
|
|
326
563
|
"Path to schema file (default: dbsp.schema.ts)"
|
|
@@ -333,10 +570,12 @@ var devCommand = new Command3("dev").description("Generate a migration from sche
|
|
|
333
570
|
const loaded = await loadSchema(schemaPath);
|
|
334
571
|
const schemaModel = loaded.model;
|
|
335
572
|
await withMigratePool(options.db, async (pool) => {
|
|
336
|
-
const
|
|
337
|
-
|
|
573
|
+
const adapter = createPgsqlAdapter(pool);
|
|
574
|
+
const diff = await comparePgsqlDatabaseSchema(adapter, schemaModel, {
|
|
575
|
+
...options.schemaName ? { schema: options.schemaName } : {},
|
|
576
|
+
...loaded.dbCasing ? { dbCasing: loaded.dbCasing } : {},
|
|
577
|
+
onWarning: (message) => console.warn(`\u26A0\uFE0F ${message}`)
|
|
338
578
|
});
|
|
339
|
-
const diff = compareSchemata(schemaModel, dbModel);
|
|
340
579
|
if (diff.changes.length === 0) {
|
|
341
580
|
console.log("\u2705 No changes detected \u2014 database matches schema.");
|
|
342
581
|
return;
|
|
@@ -379,211 +618,10 @@ var devCommand = new Command3("dev").description("Generate a migration from sche
|
|
|
379
618
|
);
|
|
380
619
|
var applyCommand = new Command3("apply").description("Apply pending migrations").requiredOption("-d, --db <url>", "Database connection URL (required)").option("--dir <path>", "Migrations directory", DEFAULT_MIGRATIONS_DIR).option("--dry-run", "Show pending migrations without applying").action(
|
|
381
620
|
(options) => runMigrateAction(async () => {
|
|
382
|
-
|
|
383
|
-
console.log(` Database: ${redactDbUrl(options.db)}`);
|
|
384
|
-
console.log(` Directory: ${options.dir}`);
|
|
385
|
-
console.log("");
|
|
386
|
-
await withMigratePool(options.db, async (pool) => {
|
|
387
|
-
await ensureMigrationsTable(pool);
|
|
388
|
-
await withMigrationLock(pool, async (client) => {
|
|
389
|
-
const applied = await getAppliedMigrations(client);
|
|
390
|
-
const appliedMap = new Map(applied.map((m) => [m.name, m.checksum]));
|
|
391
|
-
const files = scanMigrationFiles(options.dir);
|
|
392
|
-
if (files.length === 0) {
|
|
393
|
-
console.log(`No migration files found in ${options.dir}`);
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
for (const file of files) {
|
|
397
|
-
const existingChecksum = appliedMap.get(file.name);
|
|
398
|
-
if (existingChecksum !== void 0 && existingChecksum !== file.checksum) {
|
|
399
|
-
throw new MigrationError(
|
|
400
|
-
`Checksum mismatch for ${file.name}
|
|
401
|
-
Expected: ${existingChecksum}
|
|
402
|
-
Got: ${file.checksum}
|
|
403
|
-
|
|
404
|
-
Migration file has been tampered with after being applied.`
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
const pending = files.filter((f) => !appliedMap.has(f.name));
|
|
409
|
-
if (pending.length === 0) {
|
|
410
|
-
console.log("\u2705 All migrations already applied.");
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
if (options.dryRun) {
|
|
414
|
-
console.log(`${pending.length} pending migration(s):`);
|
|
415
|
-
for (const file of pending) {
|
|
416
|
-
console.log(` - ${file.name}`);
|
|
417
|
-
}
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
let appliedCount = 0;
|
|
421
|
-
for (const file of pending) {
|
|
422
|
-
console.log(` Applying: ${file.name}...`);
|
|
423
|
-
const parsed = parseMigrationFile(file.content);
|
|
424
|
-
const statements = parsed.upStatements.filter(hasExecutableSql);
|
|
425
|
-
const version = await getNextSchemaVersion(
|
|
426
|
-
client
|
|
427
|
-
);
|
|
428
|
-
const destructive = parsed.destructive === true;
|
|
429
|
-
try {
|
|
430
|
-
await client.query("BEGIN");
|
|
431
|
-
for (const stmt of statements) {
|
|
432
|
-
await client.query(stmt);
|
|
433
|
-
}
|
|
434
|
-
await recordMigration(
|
|
435
|
-
client,
|
|
436
|
-
file.name,
|
|
437
|
-
file.checksum,
|
|
438
|
-
version,
|
|
439
|
-
destructive
|
|
440
|
-
);
|
|
441
|
-
await client.query("COMMIT");
|
|
442
|
-
} catch (applyError) {
|
|
443
|
-
let rollbackError;
|
|
444
|
-
try {
|
|
445
|
-
await client.query("ROLLBACK");
|
|
446
|
-
} catch (e) {
|
|
447
|
-
rollbackError = e;
|
|
448
|
-
}
|
|
449
|
-
const primary = sanitizePgError(applyError);
|
|
450
|
-
if (rollbackError !== void 0) {
|
|
451
|
-
console.error(
|
|
452
|
-
` Note: rollback also failed: ${sanitizePgError(rollbackError).message}`
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
throw primary;
|
|
456
|
-
}
|
|
457
|
-
appliedCount++;
|
|
458
|
-
console.log(` \u2705 Applied: ${file.name}`);
|
|
459
|
-
}
|
|
460
|
-
console.log(
|
|
461
|
-
`
|
|
462
|
-
\u2705 ${appliedCount} migration(s) applied successfully.`
|
|
463
|
-
);
|
|
464
|
-
});
|
|
465
|
-
});
|
|
621
|
+
await runApply(options);
|
|
466
622
|
})
|
|
467
623
|
);
|
|
468
|
-
var rollbackCommand = new Command3("rollback").description("Roll back applied migrations").argument("<count>", "Number of migrations to roll back").requiredOption("-d, --db <url>", "Database connection URL (required)").option("--dir <path>", "Migrations directory", DEFAULT_MIGRATIONS_DIR).option("--force", "Force rollback of destructive or empty DOWN migrations").action(
|
|
469
|
-
(countArg, options) => {
|
|
470
|
-
const count = Number.parseInt(countArg, 10);
|
|
471
|
-
if (Number.isNaN(count) || count < 1) {
|
|
472
|
-
console.error("\u274C Count must be a positive integer");
|
|
473
|
-
process.exit(1);
|
|
474
|
-
}
|
|
475
|
-
return runMigrateAction(async () => {
|
|
476
|
-
console.log(`\u23EA Rolling back ${count} migration(s)`);
|
|
477
|
-
console.log(` Database: ${redactDbUrl(options.db)}`);
|
|
478
|
-
console.log(` Directory: ${options.dir}`);
|
|
479
|
-
console.log("");
|
|
480
|
-
await withMigratePool(options.db, async (pool) => {
|
|
481
|
-
await ensureMigrationsTable(pool);
|
|
482
|
-
await withMigrationLock(pool, async (client) => {
|
|
483
|
-
const applied = await getAppliedMigrations(
|
|
484
|
-
client
|
|
485
|
-
);
|
|
486
|
-
const sortedDesc = [...applied].sort(
|
|
487
|
-
(a, b) => b.name.localeCompare(a.name)
|
|
488
|
-
);
|
|
489
|
-
if (count > sortedDesc.length) {
|
|
490
|
-
throw new MigrationError(
|
|
491
|
-
`Cannot roll back ${count} migration(s) \u2014 only ${sortedDesc.length} applied`
|
|
492
|
-
);
|
|
493
|
-
}
|
|
494
|
-
const toRollback = sortedDesc.slice(0, count);
|
|
495
|
-
const files = scanMigrationFiles(options.dir);
|
|
496
|
-
const fileMap = new Map(files.map((f) => [f.name, f]));
|
|
497
|
-
let rolledBack = 0;
|
|
498
|
-
for (const record of toRollback) {
|
|
499
|
-
const file = fileMap.get(record.name);
|
|
500
|
-
if (!file) {
|
|
501
|
-
throw new MigrationError(
|
|
502
|
-
`Migration file not found on disk: ${record.name}`
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
if (file.checksum !== record.checksum) {
|
|
506
|
-
throw new MigrationError(
|
|
507
|
-
`Checksum mismatch for ${record.name}
|
|
508
|
-
Expected: ${record.checksum}
|
|
509
|
-
Got: ${file.checksum}
|
|
510
|
-
|
|
511
|
-
Migration file has been modified since it was applied.`
|
|
512
|
-
);
|
|
513
|
-
}
|
|
514
|
-
const parsed = parseMigrationFile(file.content);
|
|
515
|
-
if (!parsed.hasDown) {
|
|
516
|
-
throw new MigrationError(
|
|
517
|
-
`Migration ${record.name} has no DOWN section
|
|
518
|
-
Cannot roll back a migration without a DOWN section.`
|
|
519
|
-
);
|
|
520
|
-
}
|
|
521
|
-
const downStmts = parsed.downStatements.filter(hasExecutableSql);
|
|
522
|
-
if (downStmts.length === 0 && !options.force) {
|
|
523
|
-
throw new MigrationError(
|
|
524
|
-
`Migration ${record.name} has an empty DOWN section
|
|
525
|
-
Use --force to roll back anyway.`
|
|
526
|
-
);
|
|
527
|
-
}
|
|
528
|
-
const safeHeaderContradictedByDown = parsed.destructive === false && isDestructiveDown(downStmts);
|
|
529
|
-
if ((parsed.destructive !== false || safeHeaderContradictedByDown) && !options.force) {
|
|
530
|
-
if (parsed.destructive === true) {
|
|
531
|
-
throw new MigrationError(
|
|
532
|
-
`Migration ${record.name} has destructive DOWN operations
|
|
533
|
-
Use --force to proceed with destructive rollback.`
|
|
534
|
-
);
|
|
535
|
-
}
|
|
536
|
-
if (safeHeaderContradictedByDown) {
|
|
537
|
-
throw new MigrationError(
|
|
538
|
-
`Migration ${record.name} is marked non-destructive but its DOWN section contains an obvious destructive statement
|
|
539
|
-
Re-generate the migration with accurate dbsp metadata or pass --force.`
|
|
540
|
-
);
|
|
541
|
-
}
|
|
542
|
-
throw new MigrationError(
|
|
543
|
-
`Migration ${record.name} is unmarked or legacy
|
|
544
|
-
Re-generate the migration with dbsp metadata or pass --force.`
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
if (downStmts.length > 0 || options.force) {
|
|
548
|
-
console.log(` Rolling back: ${record.name}...`);
|
|
549
|
-
}
|
|
550
|
-
try {
|
|
551
|
-
await client.query("BEGIN");
|
|
552
|
-
for (const stmt of downStmts) {
|
|
553
|
-
await client.query(stmt);
|
|
554
|
-
}
|
|
555
|
-
await removeMigrationRecord(
|
|
556
|
-
client,
|
|
557
|
-
record.name
|
|
558
|
-
);
|
|
559
|
-
await client.query("COMMIT");
|
|
560
|
-
} catch (rollbackError) {
|
|
561
|
-
let rollbackCleanupError;
|
|
562
|
-
try {
|
|
563
|
-
await client.query("ROLLBACK");
|
|
564
|
-
} catch (e) {
|
|
565
|
-
rollbackCleanupError = e;
|
|
566
|
-
}
|
|
567
|
-
const primary = sanitizePgError(rollbackError);
|
|
568
|
-
if (rollbackCleanupError !== void 0) {
|
|
569
|
-
console.error(
|
|
570
|
-
` Note: rollback also failed: ${sanitizePgError(rollbackCleanupError).message}`
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
throw primary;
|
|
574
|
-
}
|
|
575
|
-
rolledBack++;
|
|
576
|
-
console.log(` \u2705 Rolled back: ${record.name}`);
|
|
577
|
-
}
|
|
578
|
-
console.log(
|
|
579
|
-
`
|
|
580
|
-
\u2705 ${rolledBack} migration(s) rolled back successfully.`
|
|
581
|
-
);
|
|
582
|
-
});
|
|
583
|
-
});
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
);
|
|
624
|
+
var rollbackCommand = new Command3("rollback").description("Roll back applied migrations").argument("<count>", "Number of migrations to roll back").requiredOption("-d, --db <url>", "Database connection URL (required)").option("--dir <path>", "Migrations directory", DEFAULT_MIGRATIONS_DIR).option("--force", "Force rollback of destructive or empty DOWN migrations").action(runRollbackCommand);
|
|
587
625
|
var statusCommand = new Command3("status").description("Show migration status").requiredOption("-d, --db <url>", "Database connection URL (required)").option("--dir <path>", "Migrations directory", DEFAULT_MIGRATIONS_DIR).option("--json", "Output as JSON").action(
|
|
588
626
|
(options) => runMigrateAction(async () => {
|
|
589
627
|
await withMigratePool(options.db, async (pool) => {
|
|
@@ -655,10 +693,11 @@ var migrateCommand = new Command3("migrate").description("Database migration man
|
|
|
655
693
|
|
|
656
694
|
// src/commands/push.ts
|
|
657
695
|
import {
|
|
658
|
-
|
|
696
|
+
comparePgsqlDatabaseSchema as comparePgsqlDatabaseSchema2,
|
|
697
|
+
createPgsqlAdapter as createPgsqlAdapter2,
|
|
659
698
|
generateDDL,
|
|
660
699
|
generateMigrationSQL as generateMigrationSQL2,
|
|
661
|
-
|
|
700
|
+
getNamingPluginForDbCasing
|
|
662
701
|
} from "@dbsp/adapter-pgsql";
|
|
663
702
|
import { Command as Command4 } from "commander";
|
|
664
703
|
|
|
@@ -721,16 +760,10 @@ var pushCommand = new Command4("push").description("Push schema to database (add
|
|
|
721
760
|
if (options.drop) {
|
|
722
761
|
const statements = generateDDL(schemaModel, {
|
|
723
762
|
includeDropStatements: true,
|
|
724
|
-
...options.schemaName ? { schemaName: options.schemaName } : {}
|
|
763
|
+
...options.schemaName ? { schemaName: options.schemaName } : {},
|
|
764
|
+
...loaded.dbCasing !== void 0 ? { naming: getNamingPluginForDbCasing(loaded.dbCasing) } : {}
|
|
725
765
|
});
|
|
726
|
-
const
|
|
727
|
-
/[.*+?^${}()|[\]\\]/g,
|
|
728
|
-
"\\$&"
|
|
729
|
-
);
|
|
730
|
-
const migrationsPattern = new RegExp(
|
|
731
|
-
`DROP\\s+TABLE(?:\\s+IF\\s+EXISTS)?(?:\\s+"[^"]*"\\s*\\.)?\\s*"${escapedTable}"`,
|
|
732
|
-
"i"
|
|
733
|
-
);
|
|
766
|
+
const migrationsPattern = buildMigrationsTableDropPattern(MIGRATIONS_TABLE);
|
|
734
767
|
const filtered = statements.filter(
|
|
735
768
|
(stmt) => !migrationsPattern.test(stmt)
|
|
736
769
|
);
|
|
@@ -739,10 +772,7 @@ var pushCommand = new Command4("push").description("Push schema to database (add
|
|
|
739
772
|
...options.dryRun !== void 0 ? { dryRun: options.dryRun } : {}
|
|
740
773
|
});
|
|
741
774
|
if (options.json) {
|
|
742
|
-
const droppedTables = statements.filter((s) => /DROP\s+TABLE/i.test(s)).filter((s) => !migrationsPattern.test(s)).map((
|
|
743
|
-
const m = s.match(/"([^"]+)"\s*(?:CASCADE\s*)?;?\s*$/i);
|
|
744
|
-
return m ? m[1] : s;
|
|
745
|
-
}).filter((t) => t !== void 0);
|
|
775
|
+
const droppedTables = statements.filter((s) => /DROP\s+TABLE/i.test(s)).filter((s) => !migrationsPattern.test(s)).map(extractDroppedTableName).filter((t) => t !== void 0);
|
|
746
776
|
console.log(
|
|
747
777
|
JSON.stringify(
|
|
748
778
|
{
|
|
@@ -762,10 +792,17 @@ var pushCommand = new Command4("push").description("Push schema to database (add
|
|
|
762
792
|
);
|
|
763
793
|
}
|
|
764
794
|
} else {
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
795
|
+
const adapter = createPgsqlAdapter2(pool);
|
|
796
|
+
const compareOptions = {
|
|
797
|
+
...options.schemaName ? { schema: options.schemaName } : {},
|
|
798
|
+
...loaded.dbCasing ? { dbCasing: loaded.dbCasing } : {},
|
|
799
|
+
onWarning: (message) => console.warn(`\u26A0\uFE0F ${message}`)
|
|
800
|
+
};
|
|
801
|
+
const diff = await comparePgsqlDatabaseSchema2(
|
|
802
|
+
adapter,
|
|
803
|
+
schemaModel,
|
|
804
|
+
compareOptions
|
|
805
|
+
);
|
|
769
806
|
const statements = generateMigrationSQL2(diff, {
|
|
770
807
|
includeDestructive: false,
|
|
771
808
|
...options.schemaName ? { schemaName: options.schemaName } : {}
|
|
@@ -802,6 +839,17 @@ var pushCommand = new Command4("push").description("Push schema to database (add
|
|
|
802
839
|
const result = await executeDdl(pool, statements, {
|
|
803
840
|
...options.dryRun !== void 0 ? { dryRun: options.dryRun } : {}
|
|
804
841
|
});
|
|
842
|
+
if (!options.dryRun) {
|
|
843
|
+
const postApplyDiff = await comparePgsqlDatabaseSchema2(
|
|
844
|
+
adapter,
|
|
845
|
+
schemaModel,
|
|
846
|
+
{
|
|
847
|
+
...compareOptions,
|
|
848
|
+
previouslyAppliedDiff: diff
|
|
849
|
+
}
|
|
850
|
+
);
|
|
851
|
+
assertNoUnexpectedResidualDrift(postApplyDiff, skippedChanges);
|
|
852
|
+
}
|
|
805
853
|
if (options.json) {
|
|
806
854
|
console.log(
|
|
807
855
|
JSON.stringify(
|
|
@@ -837,6 +885,17 @@ var pushCommand = new Command4("push").description("Push schema to database (add
|
|
|
837
885
|
}
|
|
838
886
|
}
|
|
839
887
|
);
|
|
888
|
+
function buildMigrationsTableDropPattern(tableName) {
|
|
889
|
+
const escaped = tableName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
890
|
+
return new RegExp(
|
|
891
|
+
`DROP\\s+TABLE(?:\\s+IF\\s+EXISTS)?(?:\\s+"[^"]*"\\s*\\.)?\\s*"${escaped}"`,
|
|
892
|
+
"i"
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
function extractDroppedTableName(statement) {
|
|
896
|
+
const match = statement.match(/"([^"]+)"\s*(?:CASCADE\s*)?;?\s*$/iu);
|
|
897
|
+
return match?.[1] ?? statement;
|
|
898
|
+
}
|
|
840
899
|
function outputResult(statements, options) {
|
|
841
900
|
if (options.dryRun && !options.json) {
|
|
842
901
|
console.log(`-- Dry run: ${statements.length} statement(s)
|
|
@@ -847,6 +906,30 @@ function outputResult(statements, options) {
|
|
|
847
906
|
}
|
|
848
907
|
}
|
|
849
908
|
}
|
|
909
|
+
function assertNoUnexpectedResidualDrift(postApplyDiff, skippedChanges) {
|
|
910
|
+
const skippedKeys = new Set(skippedChanges.map(changeIdentity));
|
|
911
|
+
const unexpected = postApplyDiff.changes.filter(
|
|
912
|
+
(change) => !(change.destructive && skippedKeys.has(changeIdentity(change)))
|
|
913
|
+
);
|
|
914
|
+
if (unexpected.length === 0) return;
|
|
915
|
+
throw new Error(
|
|
916
|
+
`Push did not converge after applying DDL; ${unexpected.length} outstanding change(s) remain:
|
|
917
|
+
` + unexpected.map(formatOutstandingChange).join("\n")
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
function changeIdentity(change) {
|
|
921
|
+
return JSON.stringify([
|
|
922
|
+
change.kind,
|
|
923
|
+
change.table,
|
|
924
|
+
change.column ?? null,
|
|
925
|
+
change.details,
|
|
926
|
+
change.destructive
|
|
927
|
+
]);
|
|
928
|
+
}
|
|
929
|
+
function formatOutstandingChange(change) {
|
|
930
|
+
const target = change.column ? `${change.table}.${change.column}` : change.table || "schema";
|
|
931
|
+
return ` - ${change.details} (${change.kind}: ${target})`;
|
|
932
|
+
}
|
|
850
933
|
|
|
851
934
|
// src/commands/repl.ts
|
|
852
935
|
import { readFileSync as readFileSync2 } from "fs";
|
|
@@ -903,7 +986,7 @@ var replCommand = new Command5("repl").description("Launch interactive REPL for
|
|
|
903
986
|
if (options.use) {
|
|
904
987
|
validateIdentifier(options.use, "schema");
|
|
905
988
|
}
|
|
906
|
-
const dbCasing = options.casing
|
|
989
|
+
const dbCasing = mapReplCasingOption(options.casing) ?? schema.dbCasing;
|
|
907
990
|
if (options.eval || options.input) {
|
|
908
991
|
const { runBatchMode } = await import("./repl/batch.js");
|
|
909
992
|
const queries = [];
|
|
@@ -957,9 +1040,24 @@ var replCommand = new Command5("repl").description("Launch interactive REPL for
|
|
|
957
1040
|
process.exit(1);
|
|
958
1041
|
}
|
|
959
1042
|
});
|
|
1043
|
+
function mapReplCasingOption(casing) {
|
|
1044
|
+
switch (casing) {
|
|
1045
|
+
case "snake":
|
|
1046
|
+
return "snake_case";
|
|
1047
|
+
case "camel":
|
|
1048
|
+
return "camelCase";
|
|
1049
|
+
case "none":
|
|
1050
|
+
return "preserve";
|
|
1051
|
+
default:
|
|
1052
|
+
return void 0;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
960
1055
|
|
|
961
1056
|
// src/commands/verify.ts
|
|
962
|
-
import {
|
|
1057
|
+
import {
|
|
1058
|
+
comparePgsqlDatabaseSchema as comparePgsqlDatabaseSchema3,
|
|
1059
|
+
createPgsqlAdapter as createPgsqlAdapter3
|
|
1060
|
+
} from "@dbsp/adapter-pgsql";
|
|
963
1061
|
import { Command as Command6 } from "commander";
|
|
964
1062
|
|
|
965
1063
|
// src/verifier.ts
|
|
@@ -1104,10 +1202,15 @@ var verifyCommand = new Command6("verify").description("Compare schema vs real d
|
|
|
1104
1202
|
const schemaModel = loaded.model;
|
|
1105
1203
|
const { pool } = await createDbConnection(options.db);
|
|
1106
1204
|
try {
|
|
1107
|
-
const
|
|
1205
|
+
const adapter = createPgsqlAdapter3(pool);
|
|
1206
|
+
const diff = await comparePgsqlDatabaseSchema3(adapter, schemaModel, {
|
|
1207
|
+
...options.schemaName ? { schema: options.schemaName } : {},
|
|
1208
|
+
...loaded.dbCasing !== void 0 ? { dbCasing: loaded.dbCasing } : {},
|
|
1209
|
+
onWarning: (message) => console.warn(`\u26A0\uFE0F ${message}`)
|
|
1210
|
+
});
|
|
1211
|
+
const dbModel = await adapter.introspect({
|
|
1108
1212
|
...options.schemaName ? { schema: options.schemaName } : {}
|
|
1109
1213
|
});
|
|
1110
|
-
const diff = compareSchemata3(schemaModel, dbModel);
|
|
1111
1214
|
const schemaTables = Array.from(schemaModel.tables.keys());
|
|
1112
1215
|
const dbTables = Array.from(dbModel.tables.keys());
|
|
1113
1216
|
const result = verifyFromDiff(diff, schemaTables, dbTables);
|