@happyvertical/smrt-cli 0.40.68 → 0.40.70
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/AGENTS.md +110 -1
- package/README.md +2 -1
- package/dist/{commands-U7gYv14z.js → commands-Df9OZ6tG.js} +1036 -337
- package/dist/index.js +9 -9
- package/package.json +12 -12
|
@@ -5,7 +5,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFile
|
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
-
import { ObjectRegistry, SchemaComparer, createQualifiedName, generateDDLForEngine, getClassName, isQualifiedName, migratePostgresSystemTimestamps, parseQualifiedName, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
|
|
8
|
+
import { ObjectRegistry, SchemaComparer, checkLiveSchemaParity, createQualifiedName, generateDDLForEngine, getClassName, isQualifiedName, migratePostgresSystemTimestamps, parseQualifiedName, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
|
|
9
9
|
import { loadExternalManifestSync } from "@happyvertical/smrt-core/manifest";
|
|
10
10
|
import { access, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
11
11
|
import { createLogger } from "@happyvertical/logger";
|
|
@@ -409,266 +409,12 @@ async function autoDiscoverAndLoad(projectRoot = process.cwd()) {
|
|
|
409
409
|
};
|
|
410
410
|
}
|
|
411
411
|
//#endregion
|
|
412
|
-
//#region src/commands/postgres-timestamp-migration.ts
|
|
413
|
-
/**
|
|
414
|
-
* Fail-closed confirmation shared by PostgreSQL schema preview and migration.
|
|
415
|
-
*
|
|
416
|
-
* A `timestamp without time zone` value cannot establish its own original
|
|
417
|
-
* instant. Callers must therefore make the same explicit UTC provenance
|
|
418
|
-
* confirmation whether they are previewing or applying the conversion.
|
|
419
|
-
*/
|
|
420
|
-
function resolvePostgresTimestampMigration(legacyTimezone) {
|
|
421
|
-
if (legacyTimezone === void 0) return;
|
|
422
|
-
if (legacyTimezone !== "UTC") throw new Error("--postgres-timestamp-legacy-timezone must be exactly UTC; refusing to infer the offset of legacy PostgreSQL timestamps");
|
|
423
|
-
return { legacyTimezone: "UTC" };
|
|
424
|
-
}
|
|
425
|
-
//#endregion
|
|
426
|
-
//#region src/commands/db-diff.ts
|
|
427
|
-
/**
|
|
428
|
-
* db:diff Command
|
|
429
|
-
*
|
|
430
|
-
* Compares manifest schemas to database.
|
|
431
|
-
*/
|
|
432
|
-
var UNSUPPORTED_GENERATE_MESSAGE = "File-backed SMRT migrations are not supported. SMRT schema migrations are manifest-driven; update @smrt object definitions and run smrt db:migrate.";
|
|
433
|
-
var dbDiffCommand = {
|
|
434
|
-
name: "db:diff",
|
|
435
|
-
description: "Compare manifest schema to database",
|
|
436
|
-
aliases: ["diff", "schema-diff"],
|
|
437
|
-
args: [],
|
|
438
|
-
options: {
|
|
439
|
-
generate: {
|
|
440
|
-
type: "boolean",
|
|
441
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
442
|
-
default: false,
|
|
443
|
-
short: "g"
|
|
444
|
-
},
|
|
445
|
-
name: {
|
|
446
|
-
type: "string",
|
|
447
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
448
|
-
short: "n"
|
|
449
|
-
},
|
|
450
|
-
format: {
|
|
451
|
-
type: "string",
|
|
452
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
453
|
-
short: "f"
|
|
454
|
-
},
|
|
455
|
-
"with-down": {
|
|
456
|
-
type: "boolean",
|
|
457
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
458
|
-
default: false
|
|
459
|
-
},
|
|
460
|
-
output: {
|
|
461
|
-
type: "string",
|
|
462
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
463
|
-
short: "o"
|
|
464
|
-
},
|
|
465
|
-
json: {
|
|
466
|
-
type: "boolean",
|
|
467
|
-
description: "Output as JSON",
|
|
468
|
-
default: false,
|
|
469
|
-
short: "j"
|
|
470
|
-
},
|
|
471
|
-
verbose: {
|
|
472
|
-
type: "boolean",
|
|
473
|
-
description: "Show detailed output",
|
|
474
|
-
default: false,
|
|
475
|
-
short: "v"
|
|
476
|
-
},
|
|
477
|
-
"drop-indexes": {
|
|
478
|
-
type: "boolean",
|
|
479
|
-
description: "Include orphan-index drops in the diff (indexes in DB but not in the manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default for safety.",
|
|
480
|
-
default: false
|
|
481
|
-
},
|
|
482
|
-
"postgres-timestamp-legacy-timezone": {
|
|
483
|
-
type: "string",
|
|
484
|
-
description: "Confirm that legacy PostgreSQL timestamp-without-time-zone values are UTC wall times before previewing their conversion to timestamptz. Exact value required: UTC; omitted by default."
|
|
485
|
-
}
|
|
486
|
-
},
|
|
487
|
-
handler: async (_args, options) => {
|
|
488
|
-
let db;
|
|
489
|
-
try {
|
|
490
|
-
const postgresTimestampMigration = resolvePostgresTimestampMigration(options["postgres-timestamp-legacy-timezone"]);
|
|
491
|
-
const unsupportedFileOptions = [
|
|
492
|
-
"generate",
|
|
493
|
-
"name",
|
|
494
|
-
"format",
|
|
495
|
-
"with-down",
|
|
496
|
-
"output"
|
|
497
|
-
].filter((option) => options[option] !== void 0 && options[option] !== false);
|
|
498
|
-
if (unsupportedFileOptions.length > 0) {
|
|
499
|
-
const message = `${UNSUPPORTED_GENERATE_MESSAGE} Unsupported option(s): ${unsupportedFileOptions.map((option) => `--${option}`).join(", ")}.`;
|
|
500
|
-
if (options.json) console.log(JSON.stringify({ error: message }));
|
|
501
|
-
else console.error(`\n❌ ${message}\n`);
|
|
502
|
-
process.exitCode = 1;
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
506
|
-
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
507
|
-
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
508
|
-
if (!config.database?.url || config.database.url === ":memory:") {
|
|
509
|
-
if (options.json) console.log(JSON.stringify({ error: "Database not configured" }));
|
|
510
|
-
else {
|
|
511
|
-
console.error("\n❌ Database configuration required");
|
|
512
|
-
console.error("\nPlease configure database in smrt.config.js\n");
|
|
513
|
-
}
|
|
514
|
-
process.exit(1);
|
|
515
|
-
}
|
|
516
|
-
const dbUrl = config.database.url;
|
|
517
|
-
const dbType = config.database.type || "sqlite";
|
|
518
|
-
if (!options.json) console.log("\n📊 Schema Diff\n");
|
|
519
|
-
const { discovered, totalObjects } = await autoDiscoverAndLoad();
|
|
520
|
-
if (discovered.length === 0) {
|
|
521
|
-
if (options.json) console.log(JSON.stringify({ error: "No manifests found" }));
|
|
522
|
-
else {
|
|
523
|
-
console.error("❌ No SMRT manifests found");
|
|
524
|
-
console.error("\nRun: smrt test --manifest-only (generates manifest)");
|
|
525
|
-
}
|
|
526
|
-
process.exit(1);
|
|
527
|
-
}
|
|
528
|
-
if (!options.json) console.log(`✓ Found ${totalObjects} object(s) in ${discovered.length} manifest(s)\n`);
|
|
529
|
-
const { getDatabase } = await import("@happyvertical/sql");
|
|
530
|
-
db = await getDatabase({
|
|
531
|
-
type: dbType,
|
|
532
|
-
url: dbUrl
|
|
533
|
-
});
|
|
534
|
-
const schemaDefinitions = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
535
|
-
const { SchemaComparer } = await import("@happyvertical/smrt-core/migrations");
|
|
536
|
-
const diff = await new SchemaComparer(db, {
|
|
537
|
-
includeDroppedTables: false,
|
|
538
|
-
includeDroppedColumns: false,
|
|
539
|
-
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
540
|
-
postgresTimestampMigration
|
|
541
|
-
}).compare(schemaDefinitions);
|
|
542
|
-
if (options.json) {
|
|
543
|
-
console.log(JSON.stringify({
|
|
544
|
-
hasChanges: diff.has_changes,
|
|
545
|
-
addedTables: diff.added_tables.map((t) => t.tableName),
|
|
546
|
-
droppedTables: diff.dropped_tables,
|
|
547
|
-
changes: diff.changes
|
|
548
|
-
}, null, 2));
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
if (!diff.has_changes) {
|
|
552
|
-
console.log("✅ Database schema is up to date - no changes detected\n");
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
console.log("📋 Changes Detected:\n");
|
|
556
|
-
if (diff.added_tables.length > 0) {
|
|
557
|
-
console.log(` 📦 New tables (${diff.added_tables.length}):`);
|
|
558
|
-
for (const table of diff.added_tables) console.log(` + ${table.tableName}`);
|
|
559
|
-
console.log();
|
|
560
|
-
}
|
|
561
|
-
const columnChanges = diff.changes.filter((c) => c.type === "add_column");
|
|
562
|
-
const indexChanges = diff.changes.filter((c) => c.type === "add_index");
|
|
563
|
-
const indexDrops = diff.changes.filter((c) => c.type === "drop_index");
|
|
564
|
-
const typeUpgrades = diff.changes.filter((c) => c.type === "type_upgrade");
|
|
565
|
-
const typeMismatches = diff.changes.filter((c) => c.type === "type_mismatch");
|
|
566
|
-
const recreateNames = /* @__PURE__ */ new Set();
|
|
567
|
-
const dropNames = new Set(indexDrops.map((c) => c.name));
|
|
568
|
-
for (const add of indexChanges) if (add.name && dropNames.has(add.name)) recreateNames.add(add.name);
|
|
569
|
-
if (columnChanges.length > 0) {
|
|
570
|
-
console.log(` 📊 New columns (${columnChanges.length}):`);
|
|
571
|
-
for (const change of columnChanges) console.log(` + ${change.table}.${change.name}`);
|
|
572
|
-
console.log();
|
|
573
|
-
}
|
|
574
|
-
if (recreateNames.size > 0) {
|
|
575
|
-
console.log(` ♻️ Indexes to recreate (${recreateNames.size}):`);
|
|
576
|
-
for (const name of recreateNames) {
|
|
577
|
-
const add = indexChanges.find((c) => c.name === name);
|
|
578
|
-
const cols = add?.index?.columns?.join(", ") ?? "?";
|
|
579
|
-
const unique = add?.index?.unique ? "UNIQUE " : "";
|
|
580
|
-
console.log(` ↻ ${name} → ${unique}(${cols})`);
|
|
581
|
-
}
|
|
582
|
-
console.log(" (each recreate is a drop_index followed by add_index)\n");
|
|
583
|
-
}
|
|
584
|
-
const orphanDrops = indexDrops.filter((c) => !(c.name && recreateNames.has(c.name)));
|
|
585
|
-
if (orphanDrops.length > 0) {
|
|
586
|
-
console.log(` 🗑️ Indexes to drop (${orphanDrops.length}):`);
|
|
587
|
-
for (const change of orphanDrops) console.log(` - ${change.name} on ${change.table}`);
|
|
588
|
-
console.log();
|
|
589
|
-
}
|
|
590
|
-
const newIndexes = indexChanges.filter((c) => !(c.name && recreateNames.has(c.name)));
|
|
591
|
-
if (newIndexes.length > 0) {
|
|
592
|
-
console.log(` 🗂️ New indexes (${newIndexes.length}):`);
|
|
593
|
-
for (const change of newIndexes) console.log(` + ${change.name} on ${change.table}`);
|
|
594
|
-
console.log();
|
|
595
|
-
}
|
|
596
|
-
if (typeUpgrades.length > 0) {
|
|
597
|
-
console.log(` 🔁 Type upgrades (${typeUpgrades.length}):`);
|
|
598
|
-
for (const change of typeUpgrades) console.log(` ⤴ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected}`);
|
|
599
|
-
console.log(" (auto-applied by smrt db:migrate)\n");
|
|
600
|
-
}
|
|
601
|
-
if (typeMismatches.length > 0) {
|
|
602
|
-
console.log(` ⚠️ Type mismatches (${typeMismatches.length}):`);
|
|
603
|
-
for (const change of typeMismatches) console.log(` ! ${change.table}.${change.name}: ${change.mismatch?.expected} vs ${change.mismatch?.actual}`);
|
|
604
|
-
console.log(" (Type changes require manual migration)\n");
|
|
605
|
-
}
|
|
606
|
-
console.log("━".repeat(50));
|
|
607
|
-
console.log("\n💡 Schema migrations are manifest-driven.");
|
|
608
|
-
console.log(" Update @smrt object definitions, then run smrt db:migrate.\n");
|
|
609
|
-
} catch (error) {
|
|
610
|
-
if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
611
|
-
else {
|
|
612
|
-
console.error("\n❌ Failed to generate diff:");
|
|
613
|
-
if (error instanceof Error) console.error(` ${error.message}`);
|
|
614
|
-
}
|
|
615
|
-
process.exitCode = 1;
|
|
616
|
-
return;
|
|
617
|
-
} finally {
|
|
618
|
-
await closeDatabaseConnection(db);
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
};
|
|
622
|
-
//#endregion
|
|
623
|
-
//#region src/commands/db-generate.ts
|
|
624
|
-
var UNSUPPORTED_MESSAGE = "File-backed SMRT migrations are not supported. SMRT schema migrations are manifest-driven; update @smrt object definitions and run smrt db:migrate.";
|
|
625
|
-
var dbGenerateCommand = {
|
|
626
|
-
name: "db:generate",
|
|
627
|
-
description: "[Unsupported] File-backed migrations are not supported",
|
|
628
|
-
aliases: [
|
|
629
|
-
"generate",
|
|
630
|
-
"migration-create",
|
|
631
|
-
"db:create"
|
|
632
|
-
],
|
|
633
|
-
args: ["<name>"],
|
|
634
|
-
options: {
|
|
635
|
-
sql: {
|
|
636
|
-
type: "boolean",
|
|
637
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
638
|
-
default: true
|
|
639
|
-
},
|
|
640
|
-
ts: {
|
|
641
|
-
type: "boolean",
|
|
642
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
643
|
-
default: false
|
|
644
|
-
},
|
|
645
|
-
timestamp: {
|
|
646
|
-
type: "boolean",
|
|
647
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
648
|
-
default: false,
|
|
649
|
-
short: "t"
|
|
650
|
-
},
|
|
651
|
-
output: {
|
|
652
|
-
type: "string",
|
|
653
|
-
description: "Unsupported: file-backed migrations are not supported",
|
|
654
|
-
default: "./migrations",
|
|
655
|
-
short: "o"
|
|
656
|
-
},
|
|
657
|
-
json: {
|
|
658
|
-
type: "boolean",
|
|
659
|
-
description: "Output as JSON",
|
|
660
|
-
default: false,
|
|
661
|
-
short: "j"
|
|
662
|
-
}
|
|
663
|
-
},
|
|
664
|
-
handler: async (_args, options) => {
|
|
665
|
-
if (options.json) console.log(JSON.stringify({ error: UNSUPPORTED_MESSAGE }));
|
|
666
|
-
else console.error(`\n❌ ${UNSUPPORTED_MESSAGE}\n`);
|
|
667
|
-
process.exitCode = 1;
|
|
668
|
-
}
|
|
669
|
-
};
|
|
670
|
-
//#endregion
|
|
671
412
|
//#region src/commands/db-migrate-actions.ts
|
|
413
|
+
/** True when a change carries an advisory and no executable statement. */
|
|
414
|
+
function isAdvisoryOnlyChangeLike(change) {
|
|
415
|
+
if (!change.advisory) return false;
|
|
416
|
+
return (change.sqlStatements ?? (change.sql ? [change.sql] : [])).length === 0;
|
|
417
|
+
}
|
|
672
418
|
/**
|
|
673
419
|
* Short stable fingerprint of the SQL we'd execute for an action, used
|
|
674
420
|
* to disambiguate index synthetic ids when an index's shape changes
|
|
@@ -716,9 +462,27 @@ function classifyTypeUpgradeSql(sql) {
|
|
|
716
462
|
if (/no change needed/i.test(trimmed) || /already stores .* as /i.test(trimmed)) return "noop";
|
|
717
463
|
return "manual";
|
|
718
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* `alter_column` identities embed the alteration kind and the SQL shape so a
|
|
467
|
+
* column that is tightened, relaxed, and tightened again over its lifetime
|
|
468
|
+
* never reuses an applied tracker id with a different checksum (#2369).
|
|
469
|
+
*/
|
|
470
|
+
function alterColumnMigrationName(tableName, columnName, alteration, action) {
|
|
471
|
+
const kind = alteration ?? "alter";
|
|
472
|
+
const fingerprint = sqlShapeFingerprint(action);
|
|
473
|
+
return fingerprint ? `alter_column_${tableName}_${columnName}_${kind}_${fingerprint}` : `alter_column_${tableName}_${columnName}_${kind}`;
|
|
474
|
+
}
|
|
719
475
|
function getSyntheticMigrationNameForAction(action) {
|
|
720
476
|
switch (action.type) {
|
|
721
477
|
case "add_column": return action.column ? `add_column_${action.tableName}_${action.column.name}` : null;
|
|
478
|
+
case "drop_column": {
|
|
479
|
+
const columnName = action.columnName ?? action.column?.name;
|
|
480
|
+
return columnName ? `drop_column_${action.tableName}_${columnName}` : null;
|
|
481
|
+
}
|
|
482
|
+
case "alter_column": {
|
|
483
|
+
const columnName = action.columnName ?? action.column?.name;
|
|
484
|
+
return columnName ? alterColumnMigrationName(action.tableName, columnName, action.alteration, action) : null;
|
|
485
|
+
}
|
|
722
486
|
case "add_index": {
|
|
723
487
|
if (!action.index) return null;
|
|
724
488
|
const fingerprint = sqlShapeFingerprint(action);
|
|
@@ -740,6 +504,8 @@ function getSyntheticMigrationNameForAction(action) {
|
|
|
740
504
|
function getSyntheticMigrationNameForChange(change) {
|
|
741
505
|
switch (change.type) {
|
|
742
506
|
case "add_column": return change.name ? `add_column_${change.table}_${change.name}` : null;
|
|
507
|
+
case "drop_column": return change.name ? `drop_column_${change.table}_${change.name}` : null;
|
|
508
|
+
case "alter_column": return change.name && !isAdvisoryOnlyChangeLike(change) ? alterColumnMigrationName(change.table, change.name, change.alteration, change) : null;
|
|
743
509
|
case "add_index": {
|
|
744
510
|
const indexName = change.index?.name ?? change.name;
|
|
745
511
|
if (!indexName) return null;
|
|
@@ -790,14 +556,15 @@ function shouldApplySchemaMigrations(state) {
|
|
|
790
556
|
return !state.dryRun;
|
|
791
557
|
}
|
|
792
558
|
function classifyFailedMigration(migrationName, unresolvedSyntheticMigrationNames) {
|
|
793
|
-
if (!migrationName.startsWith("add_column_") && !migrationName.startsWith("add_index_") && !migrationName.startsWith("drop_index_") && !migrationName.startsWith("type_upgrade_")) return "other";
|
|
559
|
+
if (!migrationName.startsWith("add_column_") && !migrationName.startsWith("drop_column_") && !migrationName.startsWith("alter_column_") && !migrationName.startsWith("add_index_") && !migrationName.startsWith("drop_index_") && !migrationName.startsWith("type_upgrade_")) return "other";
|
|
794
560
|
return unresolvedSyntheticMigrationNames.has(migrationName) ? "unresolved" : "superseded";
|
|
795
561
|
}
|
|
796
562
|
function getUnresolvedGeneratedMigrationNames(changes) {
|
|
797
563
|
const names = /* @__PURE__ */ new Set();
|
|
798
564
|
for (const change of changes) {
|
|
799
|
-
if (change.type !== "add_column" && change.type !== "add_index" && change.type !== "drop_index" && change.type !== "type_upgrade") continue;
|
|
565
|
+
if (change.type !== "add_column" && change.type !== "drop_column" && change.type !== "alter_column" && change.type !== "add_index" && change.type !== "drop_index" && change.type !== "type_upgrade") continue;
|
|
800
566
|
if (change.type === "type_upgrade" && classifyTypeUpgradeSql(change.sql) === "noop") continue;
|
|
567
|
+
if (change.type === "alter_column" && isAdvisoryOnlyChangeLike(change)) continue;
|
|
801
568
|
for (const migrationName of getSyntheticMigrationNamesForChange(change)) names.add(migrationName);
|
|
802
569
|
}
|
|
803
570
|
return names;
|
|
@@ -831,9 +598,77 @@ function summarizeFailedMigrations(failedMigrations, unresolvedSyntheticMigratio
|
|
|
831
598
|
function partitionSchemaChanges(changes, getClassForTable) {
|
|
832
599
|
const migrations = [];
|
|
833
600
|
const manualInterventions = [];
|
|
601
|
+
const advisories = [];
|
|
834
602
|
for (const change of changes) {
|
|
835
603
|
const className = getClassForTable(change.table);
|
|
836
604
|
switch (change.type) {
|
|
605
|
+
case "orphan_column":
|
|
606
|
+
case "orphan_index":
|
|
607
|
+
if (!change.name || !change.advisory) continue;
|
|
608
|
+
advisories.push({
|
|
609
|
+
type: change.type,
|
|
610
|
+
tableName: change.table,
|
|
611
|
+
className,
|
|
612
|
+
name: change.name,
|
|
613
|
+
actual: change.mismatch?.actual,
|
|
614
|
+
advisory: change.advisory
|
|
615
|
+
});
|
|
616
|
+
break;
|
|
617
|
+
case "drop_column":
|
|
618
|
+
if (!change.name) continue;
|
|
619
|
+
migrations.push({
|
|
620
|
+
type: "drop_column",
|
|
621
|
+
tableName: change.table,
|
|
622
|
+
className,
|
|
623
|
+
columnName: change.name,
|
|
624
|
+
mismatch: change.mismatch ? {
|
|
625
|
+
column: change.name,
|
|
626
|
+
expected: change.mismatch.expected,
|
|
627
|
+
actual: change.mismatch.actual
|
|
628
|
+
} : void 0,
|
|
629
|
+
sql: change.sql,
|
|
630
|
+
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
|
|
631
|
+
});
|
|
632
|
+
break;
|
|
633
|
+
case "alter_column": {
|
|
634
|
+
if (!change.name) continue;
|
|
635
|
+
if (isAdvisoryOnlyChangeLike(change) && change.advisory) {
|
|
636
|
+
advisories.push({
|
|
637
|
+
type: "alter_column",
|
|
638
|
+
tableName: change.table,
|
|
639
|
+
className,
|
|
640
|
+
name: change.name,
|
|
641
|
+
alteration: change.alteration,
|
|
642
|
+
actual: change.mismatch?.actual,
|
|
643
|
+
advisory: change.advisory
|
|
644
|
+
});
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
const action = {
|
|
648
|
+
type: "alter_column",
|
|
649
|
+
tableName: change.table,
|
|
650
|
+
className,
|
|
651
|
+
columnName: change.name,
|
|
652
|
+
alteration: change.alteration,
|
|
653
|
+
...change.column ? { column: {
|
|
654
|
+
name: change.name,
|
|
655
|
+
type: change.column.type,
|
|
656
|
+
notNull: change.column.notNull,
|
|
657
|
+
defaultValue: change.column.defaultValue,
|
|
658
|
+
unique: change.column.unique
|
|
659
|
+
} } : {},
|
|
660
|
+
mismatch: change.mismatch ? {
|
|
661
|
+
column: change.name,
|
|
662
|
+
expected: change.mismatch.expected,
|
|
663
|
+
actual: change.mismatch.actual
|
|
664
|
+
} : void 0,
|
|
665
|
+
sql: change.sql,
|
|
666
|
+
...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
|
|
667
|
+
};
|
|
668
|
+
if (classifyTypeUpgradeSql(change.sql) === "executable") migrations.push(action);
|
|
669
|
+
else manualInterventions.push(action);
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
837
672
|
case "add_column": {
|
|
838
673
|
const col = change.column;
|
|
839
674
|
if (!change.name || !col) continue;
|
|
@@ -926,11 +761,367 @@ function partitionSchemaChanges(changes, getClassForTable) {
|
|
|
926
761
|
}
|
|
927
762
|
}
|
|
928
763
|
}
|
|
929
|
-
return {
|
|
930
|
-
migrations,
|
|
931
|
-
manualInterventions
|
|
932
|
-
|
|
933
|
-
}
|
|
764
|
+
return {
|
|
765
|
+
migrations,
|
|
766
|
+
manualInterventions,
|
|
767
|
+
advisories
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Print report-only schema findings (#2369) in a stable order: warnings
|
|
772
|
+
* first (an orphan NOT NULL column that breaks inserts, a stale unique
|
|
773
|
+
* constraint, a relaxation not opted into), then info, then orphan tables
|
|
774
|
+
* (verbose only — shared databases legitimately hold tables from other
|
|
775
|
+
* apps). Shared by db:migrate and db:diff so both commands report the same
|
|
776
|
+
* findings the same way.
|
|
777
|
+
*/
|
|
778
|
+
function printSchemaAdvisories(advisories, options = {}) {
|
|
779
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
780
|
+
const warnings = advisories.filter((a) => a.advisory.severity === "warning");
|
|
781
|
+
const infos = advisories.filter((a) => a.advisory.severity !== "warning");
|
|
782
|
+
if (warnings.length > 0) {
|
|
783
|
+
log("⚠️ Live schema findings that need an operator decision (not applied):\n");
|
|
784
|
+
for (const item of warnings) {
|
|
785
|
+
log(` ${describeAdvisory(item)}`);
|
|
786
|
+
log(` ${item.advisory.message}`);
|
|
787
|
+
for (const suggestion of item.advisory.suggestedSql ?? []) log(` ↳ ${suggestion}`);
|
|
788
|
+
}
|
|
789
|
+
log("");
|
|
790
|
+
}
|
|
791
|
+
if (infos.length > 0) {
|
|
792
|
+
log(`ℹ️ Live schema notes (${infos.length}, not applied):`);
|
|
793
|
+
for (const item of infos) {
|
|
794
|
+
log(` ${describeAdvisory(item)}`);
|
|
795
|
+
if (options.verbose) {
|
|
796
|
+
log(` ${item.advisory.message}`);
|
|
797
|
+
for (const suggestion of item.advisory.suggestedSql ?? []) log(` ↳ ${suggestion}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
log("");
|
|
801
|
+
}
|
|
802
|
+
const orphanTables = options.orphanTables ?? [];
|
|
803
|
+
if (orphanTables.length > 0 && options.verbose) {
|
|
804
|
+
log(`ℹ️ Tables in the database that no loaded manifest declares (${orphanTables.length}): ${orphanTables.join(", ")}`);
|
|
805
|
+
log(" Not dropped; remove them manually if they are stale.\n");
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
function describeAdvisory(item) {
|
|
809
|
+
switch (item.type) {
|
|
810
|
+
case "orphan_column": return `${item.tableName}.${item.name}: orphan column${item.actual ? ` (${item.actual})` : ""}`;
|
|
811
|
+
case "orphan_index": return `${item.tableName}.${item.name}: unique constraint not in manifest${item.actual ? ` (${item.actual})` : ""}`;
|
|
812
|
+
default: return `${item.tableName}.${item.name}: ${item.alteration ?? "alter_column"}${item.actual ? ` (live: ${item.actual})` : ""}`;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/commands/postgres-timestamp-migration.ts
|
|
817
|
+
/**
|
|
818
|
+
* Fail-closed confirmation shared by PostgreSQL schema preview and migration.
|
|
819
|
+
*
|
|
820
|
+
* A `timestamp without time zone` value cannot establish its own original
|
|
821
|
+
* instant. Callers must therefore make the same explicit UTC provenance
|
|
822
|
+
* confirmation whether they are previewing or applying the conversion.
|
|
823
|
+
*/
|
|
824
|
+
function resolvePostgresTimestampMigration(legacyTimezone) {
|
|
825
|
+
if (legacyTimezone === void 0) return;
|
|
826
|
+
if (legacyTimezone !== "UTC") throw new Error("--postgres-timestamp-legacy-timezone must be exactly UTC; refusing to infer the offset of legacy PostgreSQL timestamps");
|
|
827
|
+
return { legacyTimezone: "UTC" };
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region src/commands/db-diff.ts
|
|
831
|
+
/**
|
|
832
|
+
* db:diff Command
|
|
833
|
+
*
|
|
834
|
+
* Compares manifest schemas to database.
|
|
835
|
+
*/
|
|
836
|
+
var UNSUPPORTED_GENERATE_MESSAGE = "File-backed SMRT migrations are not supported. SMRT schema migrations are manifest-driven; update @smrt object definitions and run smrt db:migrate.";
|
|
837
|
+
var dbDiffCommand = {
|
|
838
|
+
name: "db:diff",
|
|
839
|
+
description: "Compare manifest schema to database",
|
|
840
|
+
aliases: ["diff", "schema-diff"],
|
|
841
|
+
args: [],
|
|
842
|
+
options: {
|
|
843
|
+
generate: {
|
|
844
|
+
type: "boolean",
|
|
845
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
846
|
+
default: false,
|
|
847
|
+
short: "g"
|
|
848
|
+
},
|
|
849
|
+
name: {
|
|
850
|
+
type: "string",
|
|
851
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
852
|
+
short: "n"
|
|
853
|
+
},
|
|
854
|
+
format: {
|
|
855
|
+
type: "string",
|
|
856
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
857
|
+
short: "f"
|
|
858
|
+
},
|
|
859
|
+
"with-down": {
|
|
860
|
+
type: "boolean",
|
|
861
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
862
|
+
default: false
|
|
863
|
+
},
|
|
864
|
+
output: {
|
|
865
|
+
type: "string",
|
|
866
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
867
|
+
short: "o"
|
|
868
|
+
},
|
|
869
|
+
json: {
|
|
870
|
+
type: "boolean",
|
|
871
|
+
description: "Output as JSON",
|
|
872
|
+
default: false,
|
|
873
|
+
short: "j"
|
|
874
|
+
},
|
|
875
|
+
verbose: {
|
|
876
|
+
type: "boolean",
|
|
877
|
+
description: "Show detailed output",
|
|
878
|
+
default: false,
|
|
879
|
+
short: "v"
|
|
880
|
+
},
|
|
881
|
+
"drop-indexes": {
|
|
882
|
+
type: "boolean",
|
|
883
|
+
description: "Include orphan-index drops in the diff (indexes in DB but not in the manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default for safety.",
|
|
884
|
+
default: false
|
|
885
|
+
},
|
|
886
|
+
"drop-columns": {
|
|
887
|
+
type: "boolean",
|
|
888
|
+
description: "Include orphan-column drops in the diff (columns in DB but not in the manifest). Off by default; orphans are always reported.",
|
|
889
|
+
default: false
|
|
890
|
+
},
|
|
891
|
+
"relax-columns": {
|
|
892
|
+
type: "boolean",
|
|
893
|
+
description: "Include constraint relaxations in the diff (DROP NOT NULL / DROP DEFAULT on live columns stricter than the manifest, DROP NOT NULL on orphan NOT NULL columns). Off by default; relaxations are always reported.",
|
|
894
|
+
default: false
|
|
895
|
+
},
|
|
896
|
+
"postgres-timestamp-legacy-timezone": {
|
|
897
|
+
type: "string",
|
|
898
|
+
description: "Confirm that legacy PostgreSQL timestamp-without-time-zone values are UTC wall times before previewing their conversion to timestamptz. Exact value required: UTC; omitted by default."
|
|
899
|
+
}
|
|
900
|
+
},
|
|
901
|
+
handler: async (_args, options) => {
|
|
902
|
+
let db;
|
|
903
|
+
try {
|
|
904
|
+
const postgresTimestampMigration = resolvePostgresTimestampMigration(options["postgres-timestamp-legacy-timezone"]);
|
|
905
|
+
const unsupportedFileOptions = [
|
|
906
|
+
"generate",
|
|
907
|
+
"name",
|
|
908
|
+
"format",
|
|
909
|
+
"with-down",
|
|
910
|
+
"output"
|
|
911
|
+
].filter((option) => options[option] !== void 0 && options[option] !== false);
|
|
912
|
+
if (unsupportedFileOptions.length > 0) {
|
|
913
|
+
const message = `${UNSUPPORTED_GENERATE_MESSAGE} Unsupported option(s): ${unsupportedFileOptions.map((option) => `--${option}`).join(", ")}.`;
|
|
914
|
+
if (options.json) console.log(JSON.stringify({ error: message }));
|
|
915
|
+
else console.error(`\n❌ ${message}\n`);
|
|
916
|
+
process.exitCode = 1;
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
920
|
+
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
921
|
+
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
922
|
+
if (!config.database?.url || config.database.url === ":memory:") {
|
|
923
|
+
if (options.json) console.log(JSON.stringify({ error: "Database not configured" }));
|
|
924
|
+
else {
|
|
925
|
+
console.error("\n❌ Database configuration required");
|
|
926
|
+
console.error("\nPlease configure database in smrt.config.js\n");
|
|
927
|
+
}
|
|
928
|
+
process.exit(1);
|
|
929
|
+
}
|
|
930
|
+
const dbUrl = config.database.url;
|
|
931
|
+
const dbType = config.database.type || "sqlite";
|
|
932
|
+
if (!options.json) console.log("\n📊 Schema Diff\n");
|
|
933
|
+
const { discovered, totalObjects } = await autoDiscoverAndLoad();
|
|
934
|
+
if (discovered.length === 0) {
|
|
935
|
+
if (options.json) console.log(JSON.stringify({ error: "No manifests found" }));
|
|
936
|
+
else {
|
|
937
|
+
console.error("❌ No SMRT manifests found");
|
|
938
|
+
console.error("\nRun: smrt test --manifest-only (generates manifest)");
|
|
939
|
+
}
|
|
940
|
+
process.exit(1);
|
|
941
|
+
}
|
|
942
|
+
if (!options.json) console.log(`✓ Found ${totalObjects} object(s) in ${discovered.length} manifest(s)\n`);
|
|
943
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
944
|
+
db = await getDatabase({
|
|
945
|
+
type: dbType,
|
|
946
|
+
url: dbUrl
|
|
947
|
+
});
|
|
948
|
+
const schemaDefinitions = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
949
|
+
const { SchemaComparer } = await import("@happyvertical/smrt-core/migrations");
|
|
950
|
+
const diff = await new SchemaComparer(db, {
|
|
951
|
+
includeDroppedTables: false,
|
|
952
|
+
includeDroppedColumns: Boolean(options["drop-columns"]),
|
|
953
|
+
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
954
|
+
relaxColumns: Boolean(options["relax-columns"]),
|
|
955
|
+
postgresTimestampMigration
|
|
956
|
+
}).compare(schemaDefinitions);
|
|
957
|
+
if (options.json) {
|
|
958
|
+
console.log(JSON.stringify({
|
|
959
|
+
hasChanges: diff.has_changes,
|
|
960
|
+
addedTables: diff.added_tables.map((t) => t.tableName),
|
|
961
|
+
droppedTables: diff.dropped_tables,
|
|
962
|
+
orphanTables: diff.orphan_tables ?? [],
|
|
963
|
+
changes: diff.changes
|
|
964
|
+
}, null, 2));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const { advisories } = partitionSchemaChanges(diff.changes, (tableName) => tableName);
|
|
968
|
+
if (!diff.has_changes) {
|
|
969
|
+
console.log("✅ Database schema is up to date - no changes detected\n");
|
|
970
|
+
printSchemaAdvisories(advisories, {
|
|
971
|
+
orphanTables: diff.orphan_tables,
|
|
972
|
+
verbose: options.verbose
|
|
973
|
+
});
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (diff.changes.filter((c) => !(c.advisory && !c.sql && !c.sqlStatements)).length === 0 && diff.added_tables.length === 0 && diff.dropped_tables.length === 0) {
|
|
977
|
+
console.log("✅ Database schema is up to date - no migrations needed (see notes below)\n");
|
|
978
|
+
printSchemaAdvisories(advisories, {
|
|
979
|
+
orphanTables: diff.orphan_tables,
|
|
980
|
+
verbose: options.verbose
|
|
981
|
+
});
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
console.log("📋 Changes Detected:\n");
|
|
985
|
+
if (diff.added_tables.length > 0) {
|
|
986
|
+
console.log(` 📦 New tables (${diff.added_tables.length}):`);
|
|
987
|
+
for (const table of diff.added_tables) console.log(` + ${table.tableName}`);
|
|
988
|
+
console.log();
|
|
989
|
+
}
|
|
990
|
+
const columnChanges = diff.changes.filter((c) => c.type === "add_column");
|
|
991
|
+
const indexChanges = diff.changes.filter((c) => c.type === "add_index");
|
|
992
|
+
const indexDrops = diff.changes.filter((c) => c.type === "drop_index");
|
|
993
|
+
const typeUpgrades = diff.changes.filter((c) => c.type === "type_upgrade");
|
|
994
|
+
const typeMismatches = diff.changes.filter((c) => c.type === "type_mismatch");
|
|
995
|
+
const recreateNames = /* @__PURE__ */ new Set();
|
|
996
|
+
const dropNames = new Set(indexDrops.map((c) => c.name));
|
|
997
|
+
for (const add of indexChanges) if (add.name && dropNames.has(add.name)) recreateNames.add(add.name);
|
|
998
|
+
if (columnChanges.length > 0) {
|
|
999
|
+
console.log(` 📊 New columns (${columnChanges.length}):`);
|
|
1000
|
+
for (const change of columnChanges) console.log(` + ${change.table}.${change.name}`);
|
|
1001
|
+
console.log();
|
|
1002
|
+
}
|
|
1003
|
+
const columnAlterations = diff.changes.filter((c) => c.type === "alter_column" && (c.sql || c.sqlStatements));
|
|
1004
|
+
const autoAlterations = columnAlterations.filter((c) => classifyTypeUpgradeSql(c.sql) === "executable");
|
|
1005
|
+
const manualAlterations = columnAlterations.filter((c) => classifyTypeUpgradeSql(c.sql) !== "executable");
|
|
1006
|
+
if (autoAlterations.length > 0) {
|
|
1007
|
+
console.log(` 🔧 Column constraint repairs (${autoAlterations.length}):`);
|
|
1008
|
+
for (const change of autoAlterations) console.log(` ~ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected} (${change.alteration})`);
|
|
1009
|
+
console.log(" (auto-applied by smrt db:migrate)\n");
|
|
1010
|
+
}
|
|
1011
|
+
if (manualAlterations.length > 0) {
|
|
1012
|
+
console.log(` ⚠️ Column constraint drift needing a manual step (${manualAlterations.length}):`);
|
|
1013
|
+
for (const change of manualAlterations) {
|
|
1014
|
+
console.log(` ! ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected} (${change.alteration})`);
|
|
1015
|
+
if (options.verbose && change.sql) console.log(` ${change.sql.replace(/^--\s*/, "")}`);
|
|
1016
|
+
}
|
|
1017
|
+
console.log();
|
|
1018
|
+
}
|
|
1019
|
+
const columnDrops = diff.changes.filter((c) => c.type === "drop_column");
|
|
1020
|
+
if (columnDrops.length > 0) {
|
|
1021
|
+
console.log(` 🗑️ Columns to drop (${columnDrops.length}, --drop-columns):`);
|
|
1022
|
+
for (const change of columnDrops) console.log(` - ${change.table}.${change.name}`);
|
|
1023
|
+
console.log();
|
|
1024
|
+
}
|
|
1025
|
+
if (recreateNames.size > 0) {
|
|
1026
|
+
console.log(` ♻️ Indexes to recreate (${recreateNames.size}):`);
|
|
1027
|
+
for (const name of recreateNames) {
|
|
1028
|
+
const add = indexChanges.find((c) => c.name === name);
|
|
1029
|
+
const cols = add?.index?.columns?.join(", ") ?? "?";
|
|
1030
|
+
const unique = add?.index?.unique ? "UNIQUE " : "";
|
|
1031
|
+
console.log(` ↻ ${name} → ${unique}(${cols})`);
|
|
1032
|
+
}
|
|
1033
|
+
console.log(" (each recreate is a drop_index followed by add_index)\n");
|
|
1034
|
+
}
|
|
1035
|
+
const orphanDrops = indexDrops.filter((c) => !(c.name && recreateNames.has(c.name)));
|
|
1036
|
+
if (orphanDrops.length > 0) {
|
|
1037
|
+
console.log(` 🗑️ Indexes to drop (${orphanDrops.length}):`);
|
|
1038
|
+
for (const change of orphanDrops) console.log(` - ${change.name} on ${change.table}`);
|
|
1039
|
+
console.log();
|
|
1040
|
+
}
|
|
1041
|
+
const newIndexes = indexChanges.filter((c) => !(c.name && recreateNames.has(c.name)));
|
|
1042
|
+
if (newIndexes.length > 0) {
|
|
1043
|
+
console.log(` 🗂️ New indexes (${newIndexes.length}):`);
|
|
1044
|
+
for (const change of newIndexes) console.log(` + ${change.name} on ${change.table}`);
|
|
1045
|
+
console.log();
|
|
1046
|
+
}
|
|
1047
|
+
if (typeUpgrades.length > 0) {
|
|
1048
|
+
console.log(` 🔁 Type upgrades (${typeUpgrades.length}):`);
|
|
1049
|
+
for (const change of typeUpgrades) console.log(` ⤴ ${change.table}.${change.name}: ${change.mismatch?.actual} → ${change.mismatch?.expected}`);
|
|
1050
|
+
console.log(" (auto-applied by smrt db:migrate)\n");
|
|
1051
|
+
}
|
|
1052
|
+
if (typeMismatches.length > 0) {
|
|
1053
|
+
console.log(` ⚠️ Type mismatches (${typeMismatches.length}):`);
|
|
1054
|
+
for (const change of typeMismatches) console.log(` ! ${change.table}.${change.name}: ${change.mismatch?.expected} vs ${change.mismatch?.actual}`);
|
|
1055
|
+
console.log(" (Type changes require manual migration)\n");
|
|
1056
|
+
}
|
|
1057
|
+
printSchemaAdvisories(advisories, {
|
|
1058
|
+
orphanTables: diff.orphan_tables,
|
|
1059
|
+
verbose: options.verbose
|
|
1060
|
+
});
|
|
1061
|
+
console.log("━".repeat(50));
|
|
1062
|
+
console.log("\n💡 Schema migrations are manifest-driven.");
|
|
1063
|
+
console.log(" Update @smrt object definitions, then run smrt db:migrate.\n");
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1066
|
+
else {
|
|
1067
|
+
console.error("\n❌ Failed to generate diff:");
|
|
1068
|
+
if (error instanceof Error) console.error(` ${error.message}`);
|
|
1069
|
+
}
|
|
1070
|
+
process.exitCode = 1;
|
|
1071
|
+
return;
|
|
1072
|
+
} finally {
|
|
1073
|
+
await closeDatabaseConnection(db);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/commands/db-generate.ts
|
|
1079
|
+
var UNSUPPORTED_MESSAGE = "File-backed SMRT migrations are not supported. SMRT schema migrations are manifest-driven; update @smrt object definitions and run smrt db:migrate.";
|
|
1080
|
+
var dbGenerateCommand = {
|
|
1081
|
+
name: "db:generate",
|
|
1082
|
+
description: "[Unsupported] File-backed migrations are not supported",
|
|
1083
|
+
aliases: [
|
|
1084
|
+
"generate",
|
|
1085
|
+
"migration-create",
|
|
1086
|
+
"db:create"
|
|
1087
|
+
],
|
|
1088
|
+
args: ["<name>"],
|
|
1089
|
+
options: {
|
|
1090
|
+
sql: {
|
|
1091
|
+
type: "boolean",
|
|
1092
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
1093
|
+
default: true
|
|
1094
|
+
},
|
|
1095
|
+
ts: {
|
|
1096
|
+
type: "boolean",
|
|
1097
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
1098
|
+
default: false
|
|
1099
|
+
},
|
|
1100
|
+
timestamp: {
|
|
1101
|
+
type: "boolean",
|
|
1102
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
1103
|
+
default: false,
|
|
1104
|
+
short: "t"
|
|
1105
|
+
},
|
|
1106
|
+
output: {
|
|
1107
|
+
type: "string",
|
|
1108
|
+
description: "Unsupported: file-backed migrations are not supported",
|
|
1109
|
+
default: "./migrations",
|
|
1110
|
+
short: "o"
|
|
1111
|
+
},
|
|
1112
|
+
json: {
|
|
1113
|
+
type: "boolean",
|
|
1114
|
+
description: "Output as JSON",
|
|
1115
|
+
default: false,
|
|
1116
|
+
short: "j"
|
|
1117
|
+
}
|
|
1118
|
+
},
|
|
1119
|
+
handler: async (_args, options) => {
|
|
1120
|
+
if (options.json) console.log(JSON.stringify({ error: UNSUPPORTED_MESSAGE }));
|
|
1121
|
+
else console.error(`\n❌ ${UNSUPPORTED_MESSAGE}\n`);
|
|
1122
|
+
process.exitCode = 1;
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
934
1125
|
//#endregion
|
|
935
1126
|
//#region src/commands/migration-failure-analysis.ts
|
|
936
1127
|
function getActionableGeneratedMigrationNames(change) {
|
|
@@ -946,6 +1137,8 @@ function getActionableFailedMigrationNames(diff) {
|
|
|
946
1137
|
}
|
|
947
1138
|
function classifyKind(name) {
|
|
948
1139
|
if (name.startsWith("add_column_")) return "add_column";
|
|
1140
|
+
if (name.startsWith("drop_column_")) return "drop_column";
|
|
1141
|
+
if (name.startsWith("alter_column_")) return "alter_column";
|
|
949
1142
|
if (name.startsWith("add_index_")) return "add_index";
|
|
950
1143
|
if (name.startsWith("type_upgrade_")) return "type_upgrade";
|
|
951
1144
|
return "other";
|
|
@@ -1208,9 +1401,69 @@ function formatDateTime(date) {
|
|
|
1208
1401
|
}
|
|
1209
1402
|
//#endregion
|
|
1210
1403
|
//#region src/commands/db-rollback.ts
|
|
1404
|
+
/**
|
|
1405
|
+
* Name prefix of the only migration class `db:migrate` records a DOWN script
|
|
1406
|
+
* for (`utilities.ts`, `diff.added_tables` loop:
|
|
1407
|
+
* `down: ['DROP TABLE IF EXISTS "<table>"']`). Keep the two in step — this
|
|
1408
|
+
* command reconstructs that exact statement from the recorded migration name.
|
|
1409
|
+
*/
|
|
1410
|
+
var CREATE_TABLE_MIGRATION_PREFIX = "create_table_";
|
|
1411
|
+
/**
|
|
1412
|
+
* The suffix of a `create_table_*` row must be a bare SQL identifier before it
|
|
1413
|
+
* is interpolated into the reconstructed `DROP TABLE`. Quotes, whitespace,
|
|
1414
|
+
* semicolons and dots all fail closed (the migration is refused) rather than
|
|
1415
|
+
* producing a statement that drops something other than the recorded table.
|
|
1416
|
+
*
|
|
1417
|
+
* Deliberately not lowercase-only: `classnameToTablename` yields snake_case,
|
|
1418
|
+
* but `@smrt({ tableName })` passes a consumer-supplied name through verbatim,
|
|
1419
|
+
* so a legitimately reversible table can carry capitals. This is a shape and
|
|
1420
|
+
* injection guard, not a naming-convention check.
|
|
1421
|
+
*/
|
|
1422
|
+
var SAFE_TABLE_NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]*$/;
|
|
1423
|
+
/** Human-readable refusal headline, printed above the per-migration reasons. */
|
|
1424
|
+
var REFUSAL_HEADLINE = "Refusing to roll back: no DOWN script is available for the selected migration(s).";
|
|
1425
|
+
/** Single-line refusal used for the JSON payload. */
|
|
1426
|
+
var REFUSAL_ERROR = `${REFUSAL_HEADLINE} Nothing was changed.`;
|
|
1427
|
+
/** Why the refusal happened and the two ways forward, printed with it. */
|
|
1428
|
+
var REFUSAL_GUIDANCE = [
|
|
1429
|
+
"SMRT schema migrations are diff-driven: db:migrate stores no SQL in _smrt_schema_migrations and records a DOWN script only for create_table_<table> migrations.",
|
|
1430
|
+
"Revert these by updating the @smrt object definitions and running smrt db:migrate, or by reverting the schema by hand.",
|
|
1431
|
+
"Re-run with --mark-only to mark them rolled_back in the tracking table WITHOUT changing the schema (record-only)."
|
|
1432
|
+
];
|
|
1433
|
+
/**
|
|
1434
|
+
* Reconstruct the DOWN script for an applied migration, or explain why it
|
|
1435
|
+
* cannot be reconstructed.
|
|
1436
|
+
*
|
|
1437
|
+
* Recoverable only for `create_table_<table>` rows recorded as reversible: the
|
|
1438
|
+
* DOWN `db:migrate` attached to them is the deterministic
|
|
1439
|
+
* `DROP TABLE IF EXISTS "<table>"`. A row recorded reversible under any other
|
|
1440
|
+
* name came from a caller-supplied `MigrationDefinition` whose SQL was never
|
|
1441
|
+
* persisted, so it is refused rather than guessed at.
|
|
1442
|
+
*
|
|
1443
|
+
* Exported for unit testing.
|
|
1444
|
+
*/
|
|
1445
|
+
function recoverDownStatements(migration) {
|
|
1446
|
+
if (!migration.name.startsWith(CREATE_TABLE_MIGRATION_PREFIX)) return {
|
|
1447
|
+
recoverable: false,
|
|
1448
|
+
reason: migration.is_reversible ? "recorded as reversible, but its DOWN SQL is not stored in _smrt_schema_migrations and cannot be reconstructed" : "was applied without a DOWN script"
|
|
1449
|
+
};
|
|
1450
|
+
if (!migration.is_reversible) return {
|
|
1451
|
+
recoverable: false,
|
|
1452
|
+
reason: "is recorded as not reversible"
|
|
1453
|
+
};
|
|
1454
|
+
const tableName = migration.name.slice(13);
|
|
1455
|
+
if (!SAFE_TABLE_NAME_RE.test(tableName)) return {
|
|
1456
|
+
recoverable: false,
|
|
1457
|
+
reason: `names table "${tableName}", which is not a plain identifier db:migrate would have generated`
|
|
1458
|
+
};
|
|
1459
|
+
return {
|
|
1460
|
+
recoverable: true,
|
|
1461
|
+
statements: [`DROP TABLE IF EXISTS "${tableName}"`]
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1211
1464
|
var dbRollbackCommand = {
|
|
1212
1465
|
name: "db:rollback",
|
|
1213
|
-
description: "Rollback applied migrations",
|
|
1466
|
+
description: "Rollback applied migrations by executing their DOWN script",
|
|
1214
1467
|
aliases: ["rollback", "migration-rollback"],
|
|
1215
1468
|
args: [],
|
|
1216
1469
|
options: {
|
|
@@ -1230,6 +1483,12 @@ var dbRollbackCommand = {
|
|
|
1230
1483
|
description: "Preview rollback without executing",
|
|
1231
1484
|
default: false
|
|
1232
1485
|
},
|
|
1486
|
+
"mark-only": {
|
|
1487
|
+
type: "boolean",
|
|
1488
|
+
description: "Record-only: mark migrations rolled back WITHOUT running any DOWN script (schema untouched)",
|
|
1489
|
+
default: false,
|
|
1490
|
+
short: "m"
|
|
1491
|
+
},
|
|
1233
1492
|
force: {
|
|
1234
1493
|
type: "boolean",
|
|
1235
1494
|
description: "Skip confirmation prompt",
|
|
@@ -1252,6 +1511,7 @@ var dbRollbackCommand = {
|
|
|
1252
1511
|
handler: async (_args, options) => {
|
|
1253
1512
|
let db;
|
|
1254
1513
|
const dryRun = options["dry-run"] ?? options.dryRun;
|
|
1514
|
+
const markOnly = options["mark-only"] ?? options.markOnly;
|
|
1255
1515
|
try {
|
|
1256
1516
|
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
1257
1517
|
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
@@ -1297,26 +1557,45 @@ var dbRollbackCommand = {
|
|
|
1297
1557
|
else console.log("✅ Nothing to rollback\n");
|
|
1298
1558
|
return;
|
|
1299
1559
|
}
|
|
1560
|
+
const plan = migrationsToRollback.map((migration) => ({
|
|
1561
|
+
migration,
|
|
1562
|
+
recovery: recoverDownStatements(migration)
|
|
1563
|
+
}));
|
|
1564
|
+
const unrecoverable = plan.flatMap(({ migration, recovery }) => recovery.recoverable ? [] : [{
|
|
1565
|
+
name: migration.name,
|
|
1566
|
+
reason: recovery.reason
|
|
1567
|
+
}]);
|
|
1300
1568
|
if (!options.json) {
|
|
1301
1569
|
console.log(`Migrations to rollback (${migrationsToRollback.length}):\n`);
|
|
1302
|
-
for (const
|
|
1303
|
-
const appliedAt =
|
|
1304
|
-
|
|
1570
|
+
for (const { migration, recovery } of plan) {
|
|
1571
|
+
const appliedAt = migration.applied_at.toISOString().substring(0, 19);
|
|
1572
|
+
const marker = recovery.recoverable ? "↩" : "⊘";
|
|
1573
|
+
console.log(` ${marker} ${migration.name} (${shortChecksum(migration.checksum)} applied ${appliedAt})`);
|
|
1574
|
+
if (recovery.recoverable) for (const sql of recovery.statements) console.log(` ${sql};`);
|
|
1575
|
+
else console.log(` no DOWN script — ${recovery.reason}`);
|
|
1305
1576
|
}
|
|
1306
1577
|
console.log();
|
|
1307
1578
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1579
|
+
if (unrecoverable.length > 0 && !markOnly) {
|
|
1580
|
+
if (options.json) console.log(JSON.stringify({
|
|
1581
|
+
error: REFUSAL_ERROR,
|
|
1582
|
+
dryRun: Boolean(dryRun),
|
|
1583
|
+
unrecoverable,
|
|
1584
|
+
nonReversible: unrecoverable.map((entry) => entry.name),
|
|
1585
|
+
guidance: REFUSAL_GUIDANCE
|
|
1586
|
+
}, null, 2));
|
|
1587
|
+
else {
|
|
1588
|
+
console.error(`❌ ${REFUSAL_HEADLINE}\n`);
|
|
1589
|
+
for (const detail of unrecoverable) console.error(` - ${detail.name} — ${detail.reason}`);
|
|
1590
|
+
console.error();
|
|
1591
|
+
for (const line of REFUSAL_GUIDANCE) console.error(` ${line}`);
|
|
1592
|
+
console.error("\n Nothing was changed.\n");
|
|
1593
|
+
}
|
|
1594
|
+
process.exitCode = 1;
|
|
1595
|
+
return;
|
|
1317
1596
|
}
|
|
1318
1597
|
if (!options.force && !dryRun && !options.json) {
|
|
1319
|
-
console.log("⚠️ WARNING: This will revert database changes!");
|
|
1598
|
+
console.log(markOnly ? "⚠️ WARNING: This marks migrations rolled back WITHOUT changing the schema!" : "⚠️ WARNING: This will revert database changes!");
|
|
1320
1599
|
const rl = (await import("node:readline/promises")).createInterface({
|
|
1321
1600
|
input: process.stdin,
|
|
1322
1601
|
output: process.stdout
|
|
@@ -1332,68 +1611,86 @@ var dbRollbackCommand = {
|
|
|
1332
1611
|
if (dryRun) {
|
|
1333
1612
|
if (options.json) console.log(JSON.stringify({
|
|
1334
1613
|
dryRun: true,
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1614
|
+
markOnly: Boolean(markOnly),
|
|
1615
|
+
migrationsToRollback: plan.map(({ migration, recovery }) => ({
|
|
1616
|
+
name: migration.name,
|
|
1617
|
+
checksum: migration.checksum,
|
|
1618
|
+
isReversible: migration.is_reversible,
|
|
1619
|
+
down: recovery.recoverable ? recovery.statements : []
|
|
1339
1620
|
}))
|
|
1340
1621
|
}));
|
|
1341
1622
|
else {
|
|
1342
1623
|
console.log("📋 Dry-run - no changes made\n");
|
|
1343
|
-
console.log("Would
|
|
1344
|
-
for (const
|
|
1345
|
-
const status =
|
|
1346
|
-
console.log(`
|
|
1624
|
+
console.log(markOnly ? "Would mark the following migrations rolled back (schema untouched):" : "Would execute the DOWN script of the following migrations:");
|
|
1625
|
+
for (const { migration, recovery } of plan) {
|
|
1626
|
+
const status = recovery.recoverable ? `↩ ${migration.name} (${recovery.statements.length} DOWN statement(s))` : `⊘ ${migration.name} (no DOWN script)`;
|
|
1627
|
+
console.log(` ${status}`);
|
|
1347
1628
|
}
|
|
1348
1629
|
console.log();
|
|
1349
1630
|
}
|
|
1350
1631
|
return;
|
|
1351
1632
|
}
|
|
1352
|
-
if (!options.json) console.log("🔨 Rolling back migrations...\n");
|
|
1633
|
+
if (!options.json) console.log(markOnly ? "🔨 Marking migrations rolled back (record-only)...\n" : "🔨 Rolling back migrations...\n");
|
|
1353
1634
|
let successCount = 0;
|
|
1354
1635
|
let errorCount = 0;
|
|
1355
1636
|
const results = [];
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1637
|
+
let stoppedBy = null;
|
|
1638
|
+
for (const { migration, recovery } of plan) {
|
|
1639
|
+
if (stoppedBy) {
|
|
1640
|
+
const message = `Not attempted: rollback stopped after ${stoppedBy} failed`;
|
|
1641
|
+
if (!options.json) console.error(` ⊘ ${migration.name} skipped: ${message}`);
|
|
1642
|
+
results.push({
|
|
1643
|
+
name: migration.name,
|
|
1644
|
+
success: false,
|
|
1645
|
+
error: message
|
|
1646
|
+
});
|
|
1647
|
+
errorCount++;
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
try {
|
|
1651
|
+
if (markOnly) {
|
|
1652
|
+
await db.query(`UPDATE _smrt_schema_migrations SET status = 'rolled_back', rolled_back_at = CURRENT_TIMESTAMP WHERE name = ?`, [migration.name]);
|
|
1653
|
+
if (!options.json) console.log(` ⊙ ${migration.name} marked rolled back (schema untouched)`);
|
|
1368
1654
|
results.push({
|
|
1369
1655
|
name: migration.name,
|
|
1370
|
-
success: true
|
|
1656
|
+
success: true,
|
|
1657
|
+
markedOnly: true
|
|
1371
1658
|
});
|
|
1372
1659
|
successCount++;
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1660
|
+
continue;
|
|
1661
|
+
}
|
|
1662
|
+
if (!recovery.recoverable) throw new Error(`No DOWN script available — ${recovery.reason}`);
|
|
1663
|
+
const result = await tracker.rollback(migration.name, {
|
|
1664
|
+
id: migration.name,
|
|
1665
|
+
description: `Rollback: ${migration.name}`,
|
|
1666
|
+
version: migration.version,
|
|
1667
|
+
up: [],
|
|
1668
|
+
down: recovery.statements
|
|
1669
|
+
}, { dryRun: false });
|
|
1670
|
+
if (!result.success) throw result.error || /* @__PURE__ */ new Error("Rollback failed");
|
|
1671
|
+
if (!options.json) console.log(` ✓ ${migration.name} rolled back`);
|
|
1377
1672
|
results.push({
|
|
1378
1673
|
name: migration.name,
|
|
1379
1674
|
success: true,
|
|
1380
|
-
|
|
1675
|
+
statements: recovery.statements
|
|
1381
1676
|
});
|
|
1382
1677
|
successCount++;
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
errorCount++;
|
|
1680
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1681
|
+
if (!options.json) console.error(` ✗ ${migration.name} failed: ${errorMsg}`);
|
|
1682
|
+
results.push({
|
|
1683
|
+
name: migration.name,
|
|
1684
|
+
success: false,
|
|
1685
|
+
error: errorMsg
|
|
1686
|
+
});
|
|
1687
|
+
stoppedBy = migration.name;
|
|
1688
|
+
if (options.verbose && error instanceof Error && error.stack) console.error(`\n${error.stack}\n`);
|
|
1383
1689
|
}
|
|
1384
|
-
} catch (error) {
|
|
1385
|
-
errorCount++;
|
|
1386
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
1387
|
-
if (!options.json) console.error(` ✗ ${migration.name} failed: ${errorMsg}`);
|
|
1388
|
-
results.push({
|
|
1389
|
-
name: migration.name,
|
|
1390
|
-
success: false,
|
|
1391
|
-
error: errorMsg
|
|
1392
|
-
});
|
|
1393
|
-
if (options.verbose && error instanceof Error && error.stack) console.error(`\n${error.stack}\n`);
|
|
1394
1690
|
}
|
|
1395
1691
|
if (options.json) console.log(JSON.stringify({
|
|
1396
1692
|
success: errorCount === 0,
|
|
1693
|
+
markOnly: Boolean(markOnly),
|
|
1397
1694
|
successCount,
|
|
1398
1695
|
errorCount,
|
|
1399
1696
|
results
|
|
@@ -1401,6 +1698,7 @@ var dbRollbackCommand = {
|
|
|
1401
1698
|
else {
|
|
1402
1699
|
console.log();
|
|
1403
1700
|
if (errorCount > 0) console.log(`⚠️ Rollback completed with errors: ${successCount} succeeded, ${errorCount} failed\n`);
|
|
1701
|
+
else if (markOnly) console.log(`✅ Marked ${successCount} migration(s) rolled back — the schema was NOT changed\n`);
|
|
1404
1702
|
else console.log(`✅ Successfully rolled back ${successCount} migration(s)\n`);
|
|
1405
1703
|
console.log("💡 Commands:");
|
|
1406
1704
|
console.log(" smrt db:status - View current migration status");
|
|
@@ -1408,6 +1706,7 @@ var dbRollbackCommand = {
|
|
|
1408
1706
|
console.log(" smrt db:migrate - Re-apply migrations");
|
|
1409
1707
|
console.log();
|
|
1410
1708
|
}
|
|
1709
|
+
if (errorCount > 0) process.exitCode = 1;
|
|
1411
1710
|
} catch (error) {
|
|
1412
1711
|
if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
1413
1712
|
else {
|
|
@@ -1422,6 +1721,131 @@ var dbRollbackCommand = {
|
|
|
1422
1721
|
}
|
|
1423
1722
|
};
|
|
1424
1723
|
//#endregion
|
|
1724
|
+
//#region src/commands/db-parity.ts
|
|
1725
|
+
/**
|
|
1726
|
+
* Live-schema parity reporting for the CLI (#2368)
|
|
1727
|
+
*
|
|
1728
|
+
* Shared by `smrt doctor --db` and `smrt db:status --parity`. Both surfaces
|
|
1729
|
+
* introspect the configured database and compare it to the shape the model
|
|
1730
|
+
* layer assumes — including the hand-DDL `_smrt_*` system tables and an index
|
|
1731
|
+
* policy that does not consult the manifest, since the manifest is the artifact
|
|
1732
|
+
* that dropped the index in the first place (#2356).
|
|
1733
|
+
*
|
|
1734
|
+
* Everything here fails closed: a database that cannot be reached, or an
|
|
1735
|
+
* adapter that cannot describe its tables, is reported as a failure rather
|
|
1736
|
+
* than as "in sync".
|
|
1737
|
+
*/
|
|
1738
|
+
/**
|
|
1739
|
+
* Collect every declared upsert conflict target, keyed by table name.
|
|
1740
|
+
*
|
|
1741
|
+
* The conflict target is a registry property, not a manifest one: a class may
|
|
1742
|
+
* declare `conflictColumns` explicitly, inherit the CTI `(slug, context)`
|
|
1743
|
+
* default, or get the STI `(slug, context, _meta_type)` triple. Whatever it
|
|
1744
|
+
* resolves to, the live database needs a matching UNIQUE index or every upsert
|
|
1745
|
+
* against that table either errors (PostgreSQL) or duplicates rows.
|
|
1746
|
+
*/
|
|
1747
|
+
function collectRegistryConflictTargets() {
|
|
1748
|
+
const targets = {};
|
|
1749
|
+
for (const className of ObjectRegistry.getQualifiedClassNames()) {
|
|
1750
|
+
const tableName = ObjectRegistry.getTableName(className);
|
|
1751
|
+
if (!tableName) continue;
|
|
1752
|
+
const columns = ObjectRegistry.getConflictColumns(className);
|
|
1753
|
+
if (!columns || columns.length === 0) continue;
|
|
1754
|
+
const bucket = targets[tableName] ?? [];
|
|
1755
|
+
bucket.push({
|
|
1756
|
+
columns,
|
|
1757
|
+
source: className
|
|
1758
|
+
});
|
|
1759
|
+
targets[tableName] = bucket;
|
|
1760
|
+
}
|
|
1761
|
+
return targets;
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Run the parity check against the configured database.
|
|
1765
|
+
*
|
|
1766
|
+
* Never throws for an operational problem: connection and introspection
|
|
1767
|
+
* failures come back as `{ report: null, error }` so both callers can render
|
|
1768
|
+
* them in their own idiom while still failing closed.
|
|
1769
|
+
*/
|
|
1770
|
+
async function runLiveSchemaParity(options = {}) {
|
|
1771
|
+
const { includeSystemTables = true, discover = true } = options;
|
|
1772
|
+
let db;
|
|
1773
|
+
try {
|
|
1774
|
+
const { getPackageConfig } = await import("@happyvertical/smrt-config");
|
|
1775
|
+
const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
|
|
1776
|
+
const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
|
|
1777
|
+
if (!config.database?.url || config.database.url === ":memory:") return {
|
|
1778
|
+
report: null,
|
|
1779
|
+
database: null,
|
|
1780
|
+
error: "No persistent database is configured, so live-schema parity cannot be verified. Set `database.url` in smrt.config.ts (or DATABASE_URL)."
|
|
1781
|
+
};
|
|
1782
|
+
const dbUrl = config.database.url;
|
|
1783
|
+
const dbType = config.database.type || "sqlite";
|
|
1784
|
+
const database = {
|
|
1785
|
+
type: dbType,
|
|
1786
|
+
url: formatDatabaseDisplayUrl(dbType, dbUrl)
|
|
1787
|
+
};
|
|
1788
|
+
if (discover) await autoDiscoverAndLoad();
|
|
1789
|
+
const { getDatabase } = await import("@happyvertical/sql");
|
|
1790
|
+
db = await getDatabase({
|
|
1791
|
+
type: dbType,
|
|
1792
|
+
url: dbUrl
|
|
1793
|
+
});
|
|
1794
|
+
return {
|
|
1795
|
+
report: await checkLiveSchemaParity({
|
|
1796
|
+
db,
|
|
1797
|
+
schemas: ObjectRegistry.getAllSchemasAsDefinitions(),
|
|
1798
|
+
conflictTargets: collectRegistryConflictTargets(),
|
|
1799
|
+
includeSystemTables,
|
|
1800
|
+
engineHint: dbType
|
|
1801
|
+
}),
|
|
1802
|
+
database,
|
|
1803
|
+
error: null
|
|
1804
|
+
};
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
return {
|
|
1807
|
+
report: null,
|
|
1808
|
+
database: null,
|
|
1809
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1810
|
+
};
|
|
1811
|
+
} finally {
|
|
1812
|
+
await closeDatabaseConnection(db);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
var SEVERITY_ICON = {
|
|
1816
|
+
error: "❌",
|
|
1817
|
+
warning: "⚠️",
|
|
1818
|
+
info: "ℹ️"
|
|
1819
|
+
};
|
|
1820
|
+
/** Findings of one severity, ordered by table then target. */
|
|
1821
|
+
function selectFindings(report, severity) {
|
|
1822
|
+
return report.findings.filter((finding) => finding.severity === severity).sort((left, right) => left.table.localeCompare(right.table) || (left.target ?? "").localeCompare(right.target ?? ""));
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* Render a parity report as console lines.
|
|
1826
|
+
*
|
|
1827
|
+
* Errors and warnings always print. Informational findings (undeclared tables,
|
|
1828
|
+
* columns and indexes) print only with `verbose`, because on a shared database
|
|
1829
|
+
* they are numerous and expected.
|
|
1830
|
+
*/
|
|
1831
|
+
function formatParityReport(report, options = {}) {
|
|
1832
|
+
const lines = [];
|
|
1833
|
+
lines.push(` Engine: ${report.engine} · tables checked: ${report.tablesChecked}` + (report.tablesMissing > 0 ? ` · missing: ${report.tablesMissing}` : "") + (report.systemTablesIncluded ? " · system tables included" : ""));
|
|
1834
|
+
if (report.indexIntrospection === "unavailable") lines.push(" ⚠️ Index metadata is not readable on this engine; index, uniqueness and conflict-target checks were skipped.");
|
|
1835
|
+
const severities = options.verbose ? [
|
|
1836
|
+
"error",
|
|
1837
|
+
"warning",
|
|
1838
|
+
"info"
|
|
1839
|
+
] : ["error", "warning"];
|
|
1840
|
+
for (const severity of severities) for (const finding of selectFindings(report, severity)) {
|
|
1841
|
+
lines.push(` ${SEVERITY_ICON[severity]} ${finding.message}`);
|
|
1842
|
+
if (options.verbose) lines.push(` → ${finding.recommendation}`);
|
|
1843
|
+
}
|
|
1844
|
+
if (report.counts.info > 0 && !options.verbose) lines.push(` ℹ️ ${report.counts.info} informational finding(s) hidden; re-run with --verbose.`);
|
|
1845
|
+
if (report.findings.length === 0) lines.push(" ✅ Live schema matches the expected shape.");
|
|
1846
|
+
return lines;
|
|
1847
|
+
}
|
|
1848
|
+
//#endregion
|
|
1425
1849
|
//#region src/commands/schema-contract.ts
|
|
1426
1850
|
var SchemaContractError = class extends Error {
|
|
1427
1851
|
report;
|
|
@@ -1777,9 +2201,76 @@ function summarizeSchemaDiff(diff) {
|
|
|
1777
2201
|
recommendation: change.mismatch ? `Manual intervention required: expected ${change.mismatch.expected}, found ${change.mismatch.actual}.` : "Manual intervention required to reconcile this incompatible live column type."
|
|
1778
2202
|
});
|
|
1779
2203
|
break;
|
|
2204
|
+
case "alter_column": {
|
|
2205
|
+
const name = `${change.table}.${change.name ?? "(unknown)"}`;
|
|
2206
|
+
const driftType = change.alteration === "set_not_null" || change.alteration === "drop_not_null" ? "nullability_drift" : "default_drift";
|
|
2207
|
+
if (!Boolean(change.sql || change.sqlStatements?.length) && change.advisory) {
|
|
2208
|
+
if (change.advisory.severity !== "warning") break;
|
|
2209
|
+
drift.push({
|
|
2210
|
+
name,
|
|
2211
|
+
type: driftType,
|
|
2212
|
+
recommendation: change.advisory.message
|
|
2213
|
+
});
|
|
2214
|
+
} else if (classifyTypeUpgradeSql(change.sql) === "executable") drift.push({
|
|
2215
|
+
name,
|
|
2216
|
+
type: driftType,
|
|
2217
|
+
recommendation: `Run \`smrt db:migrate\` to repair this live column (${change.alteration ?? "alter_column"}: expected ${change.mismatch?.expected ?? "?"}, found ${change.mismatch?.actual ?? "?"}).`
|
|
2218
|
+
});
|
|
2219
|
+
else drift.push({
|
|
2220
|
+
name,
|
|
2221
|
+
type: driftType,
|
|
2222
|
+
recommendation: change.advisory?.message ? `Manual intervention required: ${change.advisory.message}` : `Manual intervention required: expected ${change.mismatch?.expected ?? "?"}, found ${change.mismatch?.actual ?? "?"} (${change.alteration ?? "alter_column"}; cannot auto-apply on this database engine).`
|
|
2223
|
+
});
|
|
2224
|
+
break;
|
|
2225
|
+
}
|
|
2226
|
+
case "orphan_column":
|
|
2227
|
+
if (change.advisory?.severity !== "warning") break;
|
|
2228
|
+
drift.push({
|
|
2229
|
+
name: `${change.table}.${change.name ?? "(unknown)"}`,
|
|
2230
|
+
type: "orphan_column_blocking",
|
|
2231
|
+
recommendation: change.advisory?.message ?? "Column exists in the database but not in the manifest."
|
|
2232
|
+
});
|
|
2233
|
+
break;
|
|
2234
|
+
case "orphan_index":
|
|
2235
|
+
drift.push({
|
|
2236
|
+
name: `${change.table}.${change.name ?? "(unknown)"}`,
|
|
2237
|
+
type: "orphan_unique_constraint",
|
|
2238
|
+
recommendation: change.advisory?.message ?? "Unique constraint exists in the database but not in the manifest."
|
|
2239
|
+
});
|
|
2240
|
+
break;
|
|
2241
|
+
case "drop_column":
|
|
2242
|
+
drift.push({
|
|
2243
|
+
name: `${change.table}.${change.name ?? "(unknown)"}`,
|
|
2244
|
+
type: "orphan_column",
|
|
2245
|
+
recommendation: "Run `smrt db:migrate --drop-columns` to drop this orphan column (destructive)."
|
|
2246
|
+
});
|
|
2247
|
+
break;
|
|
1780
2248
|
}
|
|
1781
2249
|
return drift;
|
|
1782
2250
|
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Info-level live-schema notes (#2369): harmless orphan columns, live
|
|
2253
|
+
* defaults the manifest no longer declares, and tables no loaded manifest
|
|
2254
|
+
* declares. Reported separately from `drift` so a JSON consumer counting
|
|
2255
|
+
* drift entries is not tripped by findings that need no action.
|
|
2256
|
+
*/
|
|
2257
|
+
function summarizeSchemaNotes(diff) {
|
|
2258
|
+
const notes = [];
|
|
2259
|
+
for (const change of diff.changes) {
|
|
2260
|
+
if (Boolean(change.sql || change.sqlStatements?.length) || change.advisory?.severity !== "info") continue;
|
|
2261
|
+
notes.push({
|
|
2262
|
+
name: `${change.table}.${change.name ?? "(unknown)"}`,
|
|
2263
|
+
type: change.type === "orphan_column" ? "orphan_column" : change.type === "alter_column" ? "default_drift" : change.type,
|
|
2264
|
+
recommendation: change.advisory.message
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
for (const table of diff.orphan_tables ?? []) notes.push({
|
|
2268
|
+
name: table,
|
|
2269
|
+
type: "orphan_table",
|
|
2270
|
+
recommendation: "Table exists in the database but no loaded manifest declares it. Not dropped; remove it manually if it is stale."
|
|
2271
|
+
});
|
|
2272
|
+
return notes;
|
|
2273
|
+
}
|
|
1783
2274
|
var dbStatusCommand = {
|
|
1784
2275
|
name: "db:status",
|
|
1785
2276
|
description: "Show migration status (applied, pending, drift)",
|
|
@@ -1797,6 +2288,12 @@ var dbStatusCommand = {
|
|
|
1797
2288
|
description: "Show detailed migration information",
|
|
1798
2289
|
default: false,
|
|
1799
2290
|
short: "v"
|
|
2291
|
+
},
|
|
2292
|
+
parity: {
|
|
2293
|
+
type: "boolean",
|
|
2294
|
+
description: "Also compare the live schema to the expected shape (incl. _smrt_* system tables and index policy)",
|
|
2295
|
+
default: false,
|
|
2296
|
+
short: "p"
|
|
1800
2297
|
}
|
|
1801
2298
|
},
|
|
1802
2299
|
handler: async (_args, options) => {
|
|
@@ -1873,9 +2370,12 @@ var dbStatusCommand = {
|
|
|
1873
2370
|
}))
|
|
1874
2371
|
},
|
|
1875
2372
|
drift: [],
|
|
2373
|
+
notes: [],
|
|
1876
2374
|
preconditions: [],
|
|
1877
2375
|
failedMigrations: summarizeFailedMigrations(failed, null),
|
|
1878
|
-
schemaContract
|
|
2376
|
+
schemaContract,
|
|
2377
|
+
parity: null,
|
|
2378
|
+
parityError: null
|
|
1879
2379
|
};
|
|
1880
2380
|
let diff = {
|
|
1881
2381
|
added_tables: [],
|
|
@@ -1887,6 +2387,7 @@ var dbStatusCommand = {
|
|
|
1887
2387
|
const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
|
|
1888
2388
|
diff = await new SchemaComparer(db).compare(manifestSchemas);
|
|
1889
2389
|
status.drift = summarizeSchemaDiff(diff);
|
|
2390
|
+
status.notes = summarizeSchemaNotes(diff);
|
|
1890
2391
|
status.preconditions = await checkTenantIdUuidPreconditions({
|
|
1891
2392
|
db,
|
|
1892
2393
|
dbType,
|
|
@@ -1894,7 +2395,18 @@ var dbStatusCommand = {
|
|
|
1894
2395
|
manifestSchemas
|
|
1895
2396
|
});
|
|
1896
2397
|
status.failedMigrations = summarizeFailedMigrations(failed, getUnresolvedGeneratedMigrationNames(diff.changes));
|
|
1897
|
-
|
|
2398
|
+
if (options.parity) try {
|
|
2399
|
+
status.parity = await checkLiveSchemaParity({
|
|
2400
|
+
db,
|
|
2401
|
+
schemas: manifestSchemas,
|
|
2402
|
+
conflictTargets: collectRegistryConflictTargets(),
|
|
2403
|
+
includeSystemTables: true,
|
|
2404
|
+
engineHint: dbType
|
|
2405
|
+
});
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
status.parityError = error instanceof Error ? error.message : String(error);
|
|
2408
|
+
}
|
|
2409
|
+
} else if (options.parity) status.parityError = "The configured database adapter cannot describe tables, so live-schema parity cannot be verified.";
|
|
1898
2410
|
const failedAssessments = assessFailedMigrations(failed, typeof db.getTableSchema === "function" ? diff : null);
|
|
1899
2411
|
status.migrations.failed = {
|
|
1900
2412
|
total: failedAssessments.length,
|
|
@@ -1903,9 +2415,10 @@ var dbStatusCommand = {
|
|
|
1903
2415
|
manualReview: failedAssessments.filter((item) => item.resolution === "manual_review").length,
|
|
1904
2416
|
details: failedAssessments
|
|
1905
2417
|
};
|
|
2418
|
+
const parityFailed = status.parityError !== null || status.parity?.ok === false;
|
|
1906
2419
|
if (options.json) {
|
|
1907
2420
|
console.log(JSON.stringify(status, null, 2));
|
|
1908
|
-
if (!schemaContract.ok || status.preconditions.some((item) => item.status === "error")) process.exitCode = 1;
|
|
2421
|
+
if (!schemaContract.ok || parityFailed || status.preconditions.some((item) => item.status === "error")) process.exitCode = 1;
|
|
1909
2422
|
return;
|
|
1910
2423
|
}
|
|
1911
2424
|
console.log(`📦 Manifests: ${status.manifests.count} discovered`);
|
|
@@ -1949,6 +2462,25 @@ var dbStatusCommand = {
|
|
|
1949
2462
|
console.log("✅ Live schema matches current manifests");
|
|
1950
2463
|
console.log();
|
|
1951
2464
|
}
|
|
2465
|
+
if (status.notes.length > 0) {
|
|
2466
|
+
console.log(`ℹ️ Live schema notes (${status.notes.length}, no action required):`);
|
|
2467
|
+
for (const note of status.notes) {
|
|
2468
|
+
console.log(` • ${note.name}: ${note.type}`);
|
|
2469
|
+
if (options.verbose) console.log(` ${note.recommendation}`);
|
|
2470
|
+
}
|
|
2471
|
+
console.log();
|
|
2472
|
+
}
|
|
2473
|
+
if (options.parity) {
|
|
2474
|
+
console.log("🗄️ Live Schema Parity:");
|
|
2475
|
+
if (status.parityError) {
|
|
2476
|
+
console.log(` ❌ ${status.parityError}`);
|
|
2477
|
+
process.exitCode = 1;
|
|
2478
|
+
} else if (status.parity) {
|
|
2479
|
+
for (const line of formatParityReport(status.parity, { verbose: options.verbose })) console.log(line);
|
|
2480
|
+
if (!status.parity.ok) process.exitCode = 1;
|
|
2481
|
+
}
|
|
2482
|
+
console.log();
|
|
2483
|
+
}
|
|
1952
2484
|
if (!schemaContract.ok) {
|
|
1953
2485
|
console.log("❌ Schema Contract Failed:");
|
|
1954
2486
|
console.log(formatSchemaContractFailures(schemaContract));
|
|
@@ -6512,6 +7044,15 @@ async function repairStiDiscriminatorRows(options) {
|
|
|
6512
7044
|
*
|
|
6513
7045
|
* Commands for introspection, testing, and project management
|
|
6514
7046
|
*/
|
|
7047
|
+
/**
|
|
7048
|
+
* Fallbacks for `migrations.postgres.lockTimeout` / `.statementTimeout`.
|
|
7049
|
+
*
|
|
7050
|
+
* These mirror `DEFAULT_CLI_CONFIG.migrations.postgres` ('30s' / '60s') so a
|
|
7051
|
+
* project that never wrote a `migrations` block still gets a bounded
|
|
7052
|
+
* PostgreSQL migration rather than an unbounded lock wait (issue #2362).
|
|
7053
|
+
*/
|
|
7054
|
+
var DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 3e4;
|
|
7055
|
+
var DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 6e4;
|
|
6515
7056
|
function formatSchemaCommandFailureHeader(error, fallback) {
|
|
6516
7057
|
if (error instanceof SchemaContractError) return "\n❌ Schema contract failed:";
|
|
6517
7058
|
if (error instanceof UnsupportedFileMigrationsError) return "\n❌ File-backed migrations are not supported:";
|
|
@@ -6584,6 +7125,79 @@ function assertForceMigrationTargetsExist(forceMigrations, generatedMigrationIds
|
|
|
6584
7125
|
const unknown = forceMigrations.filter((migrationId) => !generated.has(migrationId));
|
|
6585
7126
|
if (unknown.length > 0) throw new Error(`--force-migration target${unknown.length === 1 ? "" : "s"} not found in the current generated migration batch: ${unknown.join(", ")}`);
|
|
6586
7127
|
}
|
|
7128
|
+
/**
|
|
7129
|
+
* Match an actual `oxc: { … decorator: … }` configuration block.
|
|
7130
|
+
*
|
|
7131
|
+
* Deliberately stricter than "the words `oxc` and `decorator` both appear":
|
|
7132
|
+
* a stray mention in a comment or an unrelated import would otherwise mark the
|
|
7133
|
+
* transform configured on a Vite 8 project that throws
|
|
7134
|
+
* `SyntaxError: Invalid or unexpected token` on its first SSR request — the
|
|
7135
|
+
* exact failure this check exists to catch. The lazy body stops at the first
|
|
7136
|
+
* `decorator:` key after the `oxc` object opens.
|
|
7137
|
+
*
|
|
7138
|
+
* A config that assembles `oxc` indirectly (a spread, or an imported base
|
|
7139
|
+
* config) is reported as unconfigured. That direction is the safe one: the
|
|
7140
|
+
* recommendation names the exact key to add, whereas the opposite error is
|
|
7141
|
+
* silent until runtime.
|
|
7142
|
+
*/
|
|
7143
|
+
var OXC_DECORATOR_BLOCK_RE = /\boxc\s*:\s*\{[\s\S]*?\bdecorator\s*:/;
|
|
7144
|
+
/**
|
|
7145
|
+
* Read the declared Vite major version from a project's package.json.
|
|
7146
|
+
*
|
|
7147
|
+
* Returns `null` when Vite is absent or the range is not a simple one whose
|
|
7148
|
+
* major can be read off the front (`workspace:*`, `*`, a git URL, …); callers
|
|
7149
|
+
* treat that as "unknown major" and accept either decorator recipe.
|
|
7150
|
+
*/
|
|
7151
|
+
function resolveDeclaredViteMajor(packageJson) {
|
|
7152
|
+
const range = packageJson.devDependencies?.vite ?? packageJson.dependencies?.vite;
|
|
7153
|
+
if (typeof range !== "string") return null;
|
|
7154
|
+
const match = range.match(/(\d+)\s*\./);
|
|
7155
|
+
if (!match) return null;
|
|
7156
|
+
const major = Number.parseInt(match[1], 10);
|
|
7157
|
+
return Number.isFinite(major) ? major : null;
|
|
7158
|
+
}
|
|
7159
|
+
/**
|
|
7160
|
+
* Assess whether a project's decorator transform is actually configured.
|
|
7161
|
+
*
|
|
7162
|
+
* The doctor used to require `experimentalDecorators` in tsconfig.json
|
|
7163
|
+
* unconditionally, which contradicts the framework's own Vite 8 guidance: the
|
|
7164
|
+
* oxc transform does not honor tsconfig `experimentalDecorators` (nor the
|
|
7165
|
+
* pre-Vite-8 `esbuild.tsconfigRaw` recipe), so a correctly configured Vite 8
|
|
7166
|
+
* project — decorators declared under `oxc.decorator` — was reported broken,
|
|
7167
|
+
* and a Vite 8 project relying on tsconfig alone was reported healthy right up
|
|
7168
|
+
* until the first SSR request threw `SyntaxError: Invalid or unexpected token`.
|
|
7169
|
+
*
|
|
7170
|
+
* The check now follows the documented recipe per Vite major:
|
|
7171
|
+
* - Vite 8+: `oxc.decorator` in vite.config is the only working configuration.
|
|
7172
|
+
* - Vite <8 (or no declared Vite): tsconfig `experimentalDecorators` is the
|
|
7173
|
+
* legacy recipe and remains valid.
|
|
7174
|
+
*/
|
|
7175
|
+
function assessDecoratorSupport(input) {
|
|
7176
|
+
const { viteMajor, viteConfigContent, tsconfigContent } = input;
|
|
7177
|
+
const hasOxcDecorator = viteConfigContent !== null && OXC_DECORATOR_BLOCK_RE.test(viteConfigContent);
|
|
7178
|
+
const hasTsconfigDecorators = tsconfigContent !== null && tsconfigContent.includes("experimentalDecorators");
|
|
7179
|
+
const isVite8Plus = viteMajor !== null && viteMajor >= 8;
|
|
7180
|
+
if (hasOxcDecorator) return {
|
|
7181
|
+
status: "ok",
|
|
7182
|
+
message: "oxc.decorator configured in vite.config"
|
|
7183
|
+
};
|
|
7184
|
+
if (isVite8Plus) return {
|
|
7185
|
+
status: "error",
|
|
7186
|
+
message: hasTsconfigDecorators ? "Vite 8 ignores tsconfig experimentalDecorators - add oxc: { decorator: { legacy: true, emitDecoratorMetadata: true } } to vite.config" : "Add oxc: { decorator: { legacy: true, emitDecoratorMetadata: true } } to vite.config (required for @smrt() under Vite 8+)"
|
|
7187
|
+
};
|
|
7188
|
+
if (hasTsconfigDecorators) return {
|
|
7189
|
+
status: "ok",
|
|
7190
|
+
message: "experimentalDecorators enabled in tsconfig.json"
|
|
7191
|
+
};
|
|
7192
|
+
if (viteConfigContent === null && tsconfigContent === null) return {
|
|
7193
|
+
status: "warning",
|
|
7194
|
+
message: "No vite.config or tsconfig.json found - configure the decorator transform wherever this project compiles @smrt() classes"
|
|
7195
|
+
};
|
|
7196
|
+
return {
|
|
7197
|
+
status: "error",
|
|
7198
|
+
message: "No decorator transform configured - add oxc: { decorator: { legacy: true, emitDecoratorMetadata: true } } to vite.config (Vite 8+), or \"experimentalDecorators\": true to tsconfig.json (vite<8)"
|
|
7199
|
+
};
|
|
7200
|
+
}
|
|
6587
7201
|
function getErrorContext(error) {
|
|
6588
7202
|
if (error && typeof error === "object" && "context" in error) {
|
|
6589
7203
|
const context = error.context;
|
|
@@ -7141,7 +7755,7 @@ export default testManifest;
|
|
|
7141
7755
|
},
|
|
7142
7756
|
"postgres-safe": {
|
|
7143
7757
|
type: "boolean",
|
|
7144
|
-
description: "
|
|
7758
|
+
description: "PostgreSQL concurrent-index mode: commit non-index DDL atomically, then build each index with CREATE INDEX CONCURRENTLY outside that transaction. Trades batch atomicity for availability; lock_timeout/statement_timeout apply either way.",
|
|
7145
7759
|
default: false
|
|
7146
7760
|
},
|
|
7147
7761
|
"postgres-timestamp-legacy-timezone": {
|
|
@@ -7173,6 +7787,16 @@ export default testManifest;
|
|
|
7173
7787
|
description: "Drop orphan indexes (in DB but not in manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default.",
|
|
7174
7788
|
default: false
|
|
7175
7789
|
},
|
|
7790
|
+
"drop-columns": {
|
|
7791
|
+
type: "boolean",
|
|
7792
|
+
description: "DESTRUCTIVE: drop orphan columns (in DB but not in manifest). Off by default; orphans are always reported.",
|
|
7793
|
+
default: false
|
|
7794
|
+
},
|
|
7795
|
+
"relax-columns": {
|
|
7796
|
+
type: "boolean",
|
|
7797
|
+
description: "Apply constraint relaxations the manifest implies: DROP NOT NULL / DROP DEFAULT on live columns stricter than the manifest, and DROP NOT NULL on orphan NOT NULL columns (PostgreSQL/DuckDB). Off by default; relaxations are always reported.",
|
|
7798
|
+
default: false
|
|
7799
|
+
},
|
|
7176
7800
|
verbose: {
|
|
7177
7801
|
type: "boolean",
|
|
7178
7802
|
description: "Show detailed output",
|
|
@@ -7266,16 +7890,26 @@ export default testManifest;
|
|
|
7266
7890
|
console.log(`✓ Connected to ${formatDatabaseDisplayUrl(dbType, dbUrl)}\n`);
|
|
7267
7891
|
const systemTimestampPreview = postgresTimestampMigration && isDryRun ? await planPostgresSystemTimestampMigrations(db, postgresTimestampMigration, dbType) : [];
|
|
7268
7892
|
if (postgresTimestampMigration && !isDryRun) await migratePostgresSystemTimestamps(db, postgresTimestampMigration, dbType);
|
|
7269
|
-
const { MigrationTracker, shortChecksum } = await import("@happyvertical/smrt-core/migrations");
|
|
7893
|
+
const { buildConcurrentIndexPlan, MigrationTracker, parsePostgresTimeoutMs, shortChecksum } = await import("@happyvertical/smrt-core/migrations");
|
|
7894
|
+
const postgresMigrationConfig = config.migrations?.postgres;
|
|
7895
|
+
const lockTimeout = parsePostgresTimeoutMs(postgresMigrationConfig?.lockTimeout, DEFAULT_MIGRATION_LOCK_TIMEOUT_MS);
|
|
7896
|
+
const statementTimeout = parsePostgresTimeoutMs(postgresMigrationConfig?.statementTimeout, DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS);
|
|
7897
|
+
const concurrentIndexMode = Boolean(options["postgres-safe"]) && (postgresMigrationConfig?.useConcurrently ?? true);
|
|
7270
7898
|
const tracker = new MigrationTracker({
|
|
7271
7899
|
db,
|
|
7272
|
-
|
|
7900
|
+
lockTimeout,
|
|
7901
|
+
statementTimeout,
|
|
7902
|
+
useConcurrentIndexes: concurrentIndexMode
|
|
7273
7903
|
});
|
|
7274
7904
|
if (!isDryRun) await tracker.initialize();
|
|
7275
7905
|
if (options.verbose) {
|
|
7276
7906
|
const engine = tracker.getEngine();
|
|
7277
7907
|
console.log(isDryRun ? `Migration tracker preview configured (engine: ${engine})` : `Migration tracker initialized (engine: ${engine})`);
|
|
7278
|
-
if (
|
|
7908
|
+
if (engine === "postgres") {
|
|
7909
|
+
console.log(`PostgreSQL lock_timeout: ${lockTimeout}ms, statement_timeout: ${statementTimeout}ms`);
|
|
7910
|
+
if (concurrentIndexMode) console.log("PostgreSQL concurrent-index mode enabled (CREATE INDEX CONCURRENTLY outside the atomic batch)");
|
|
7911
|
+
else if (options["postgres-safe"]) console.log("PostgreSQL concurrent-index mode disabled by migrations.postgres.useConcurrently = false");
|
|
7912
|
+
}
|
|
7279
7913
|
console.log();
|
|
7280
7914
|
}
|
|
7281
7915
|
const migrations = [];
|
|
@@ -7290,6 +7924,8 @@ export default testManifest;
|
|
|
7290
7924
|
console.log("🔍 Comparing schemas...\n");
|
|
7291
7925
|
const diff = await new SchemaComparer(db, {
|
|
7292
7926
|
includeDroppedIndexes: Boolean(options["drop-indexes"]),
|
|
7927
|
+
includeDroppedColumns: Boolean(options["drop-columns"]),
|
|
7928
|
+
relaxColumns: Boolean(options["relax-columns"]),
|
|
7293
7929
|
postgresTimestampMigration
|
|
7294
7930
|
}).compare(manifestSchemas);
|
|
7295
7931
|
const getClassForTable = (tableName) => {
|
|
@@ -7308,6 +7944,7 @@ export default testManifest;
|
|
|
7308
7944
|
const partitionedChanges = partitionSchemaChanges(diff.changes, getClassForTable);
|
|
7309
7945
|
migrations.push(...partitionedChanges.migrations);
|
|
7310
7946
|
manualInterventions.push(...partitionedChanges.manualInterventions);
|
|
7947
|
+
const advisories = partitionedChanges.advisories;
|
|
7311
7948
|
assertForceMigrationTargetsExist(forceSelection.forceMigrations, [...diff.added_tables.map((schema) => `create_table_${schema.tableName}`), ...migrations.flatMap((migration) => {
|
|
7312
7949
|
const migrationName = getSyntheticMigrationNameForAction(migration);
|
|
7313
7950
|
return migrationName ? [migrationName] : [];
|
|
@@ -7317,17 +7954,22 @@ export default testManifest;
|
|
|
7317
7954
|
console.log("⚠️ Schema drift detected that requires manual intervention:\n");
|
|
7318
7955
|
for (const change of manualInterventions) {
|
|
7319
7956
|
if (!change.mismatch) continue;
|
|
7320
|
-
const detail = change.type === "type_upgrade" ? `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual} (cannot auto-apply on this database engine)` : `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual}`;
|
|
7957
|
+
const detail = change.type === "type_upgrade" ? `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual} (cannot auto-apply on this database engine)` : change.type === "alter_column" ? `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual} (${change.alteration ?? "alter_column"}; ${change.sql?.replace(/^--\s*/, "") ?? "cannot auto-apply on this database engine"})` : `${change.tableName}.${change.mismatch.column}: expected ${change.mismatch.expected}, found ${change.mismatch.actual}`;
|
|
7321
7958
|
console.log(` ${detail}`);
|
|
7322
7959
|
}
|
|
7323
7960
|
console.log();
|
|
7324
7961
|
console.log(" Manual migration required (backup, recreate, restore as needed).\n");
|
|
7325
7962
|
}
|
|
7963
|
+
printSchemaAdvisories(advisories, {
|
|
7964
|
+
orphanTables: diff.orphan_tables ?? [],
|
|
7965
|
+
verbose: Boolean(options.verbose)
|
|
7966
|
+
});
|
|
7326
7967
|
const tablesCreated = diff.added_tables.length > 0;
|
|
7327
7968
|
const schemaUpToDate = migrations.length === 0 && manualInterventions.length === 0 && !tablesCreated && systemTimestampPreview.length === 0;
|
|
7969
|
+
const upToDateMessage = advisories.some((a) => a.advisory.severity === "warning") ? "✅ No migrations needed - see the live schema findings above\n" : "✅ Database schema is up to date - no migrations needed\n";
|
|
7328
7970
|
if (isDryRun) {
|
|
7329
7971
|
if (schemaUpToDate && !repairData) {
|
|
7330
|
-
console.log(
|
|
7972
|
+
console.log(upToDateMessage);
|
|
7331
7973
|
return;
|
|
7332
7974
|
}
|
|
7333
7975
|
if (schemaUpToDate) console.log("✅ Database schema is up to date - no schema migrations needed\n");
|
|
@@ -7341,11 +7983,23 @@ export default testManifest;
|
|
|
7341
7983
|
const columnMigrations = migrations.filter((m) => m.type === "add_column");
|
|
7342
7984
|
const indexMigrations = migrations.filter((m) => m.type === "add_index");
|
|
7343
7985
|
const indexDrops = migrations.filter((m) => m.type === "drop_index");
|
|
7986
|
+
const columnAlterations = migrations.filter((m) => m.type === "alter_column");
|
|
7987
|
+
const columnDrops = migrations.filter((m) => m.type === "drop_column");
|
|
7344
7988
|
if (columnMigrations.length > 0) {
|
|
7345
7989
|
console.log(` 📊 Columns to add: ${columnMigrations.length}`);
|
|
7346
7990
|
for (const m of columnMigrations) console.log(` ${m.tableName}.${m.column?.name} (${m.column?.type})`);
|
|
7347
7991
|
console.log();
|
|
7348
7992
|
}
|
|
7993
|
+
if (columnAlterations.length > 0) {
|
|
7994
|
+
console.log(` 🔧 Columns to alter: ${columnAlterations.length}`);
|
|
7995
|
+
for (const m of columnAlterations) console.log(` ${m.tableName}.${m.columnName ?? m.column?.name} (${m.alteration ?? "alter"}: ${m.mismatch?.actual ?? "?"} → ${m.mismatch?.expected ?? "?"})`);
|
|
7996
|
+
console.log();
|
|
7997
|
+
}
|
|
7998
|
+
if (columnDrops.length > 0) {
|
|
7999
|
+
console.log(` 🗑️ Columns to drop (DESTRUCTIVE, --drop-columns): ${columnDrops.length}`);
|
|
8000
|
+
for (const m of columnDrops) console.log(` ${m.tableName}.${m.columnName}`);
|
|
8001
|
+
console.log();
|
|
8002
|
+
}
|
|
7349
8003
|
if (indexDrops.length > 0) {
|
|
7350
8004
|
console.log(` 🗑️ Indexes to drop: ${indexDrops.length}`);
|
|
7351
8005
|
for (const m of indexDrops) console.log(` ${m.indexName} on ${m.tableName}`);
|
|
@@ -7378,7 +8032,6 @@ export default testManifest;
|
|
|
7378
8032
|
let errorCount = 0;
|
|
7379
8033
|
let stiErrorCount = 0;
|
|
7380
8034
|
const schemaChangeCount = diff.added_tables.length + migrations.length;
|
|
7381
|
-
if (applySchemaMigrations && schemaChangeCount > 0) console.log(`🔨 Applying ${schemaChangeCount} schema change(s) atomically...\n`);
|
|
7382
8035
|
if (applySchemaMigrations && schemaChangeCount > 0) {
|
|
7383
8036
|
const migrationDefs = [];
|
|
7384
8037
|
const migrationLogs = /* @__PURE__ */ new Map();
|
|
@@ -7410,6 +8063,12 @@ export default testManifest;
|
|
|
7410
8063
|
if (migration.type === "add_column" && migration.column) {
|
|
7411
8064
|
migrationSql = migration.sql || "";
|
|
7412
8065
|
actionDesc = `Added column ${migration.tableName}.${migration.column.name}`;
|
|
8066
|
+
} else if (migration.type === "alter_column" && (migration.columnName || migration.column)) {
|
|
8067
|
+
migrationSql = migration.sql || "";
|
|
8068
|
+
actionDesc = `Altered column ${migration.tableName}.${migration.columnName ?? migration.column?.name} (${migration.alteration ?? "alter"})`;
|
|
8069
|
+
} else if (migration.type === "drop_column" && migration.columnName) {
|
|
8070
|
+
migrationSql = migration.sql || "";
|
|
8071
|
+
actionDesc = `Dropped column ${migration.tableName}.${migration.columnName}`;
|
|
7413
8072
|
} else if (migration.type === "type_upgrade" && migration.column) {
|
|
7414
8073
|
migrationSql = migration.sql || "";
|
|
7415
8074
|
actionDesc = `Upgraded column ${migration.tableName}.${migration.column.name} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}`;
|
|
@@ -7423,7 +8082,7 @@ export default testManifest;
|
|
|
7423
8082
|
const migrationSqlStatements = migration.sqlStatements ?? (migrationSql ? [migrationSql] : []);
|
|
7424
8083
|
migrationDefs.push({
|
|
7425
8084
|
id: migrationName,
|
|
7426
|
-
description: migration.type === "add_column" ? `Add column ${migration.column?.name} to ${migration.tableName}` : migration.type === "type_upgrade" ? `Upgrade column ${migration.column?.name} on ${migration.tableName} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}` : migration.type === "drop_index" ? `Drop index ${migration.indexName} on ${migration.tableName}` : `Add index ${migration.index?.name} on ${migration.tableName}`,
|
|
8085
|
+
description: migration.type === "add_column" ? `Add column ${migration.column?.name} to ${migration.tableName}` : migration.type === "alter_column" ? `Alter column ${migration.columnName ?? migration.column?.name} on ${migration.tableName} (${migration.alteration ?? "alter"}: ${migration.mismatch?.actual ?? "?"} → ${migration.mismatch?.expected ?? "?"})` : migration.type === "drop_column" ? `Drop column ${migration.columnName} from ${migration.tableName}` : migration.type === "type_upgrade" ? `Upgrade column ${migration.column?.name} on ${migration.tableName} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}` : migration.type === "drop_index" ? `Drop index ${migration.indexName} on ${migration.tableName}` : `Add index ${migration.index?.name} on ${migration.tableName}`,
|
|
7427
8086
|
version: "1.0.0",
|
|
7428
8087
|
up: migrationSqlStatements,
|
|
7429
8088
|
down: []
|
|
@@ -7433,14 +8092,20 @@ export default testManifest;
|
|
|
7433
8092
|
skippedMessage: `${migrationName} already applied`
|
|
7434
8093
|
});
|
|
7435
8094
|
}
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
8095
|
+
const deferredIndexMigrations = concurrentIndexMode && engine === "postgres" ? buildConcurrentIndexPlan(migrationDefs, true).size : 0;
|
|
8096
|
+
const batchHasIndexDDL = migrations.some((migration) => migration.type === "add_index" || migration.type === "drop_index");
|
|
8097
|
+
console.log(deferredIndexMigrations > 0 ? `🔨 Applying ${schemaChangeCount} schema change(s) (non-index DDL atomically, ${deferredIndexMigrations} index migration(s) CONCURRENTLY)...\n` : `🔨 Applying ${schemaChangeCount} schema change(s) atomically...\n`);
|
|
8098
|
+
if (deferredIndexMigrations > 0) {
|
|
8099
|
+
console.warn("⚠️ PostgreSQL concurrent-index mode: index DDL runs CONCURRENTLY after the non-index batch commits.");
|
|
8100
|
+
console.warn(" This batch is therefore NOT atomic — committed column/table changes survive a later index failure, and unfinished index migrations are recorded failed so the next db:migrate retries them.\n");
|
|
8101
|
+
} else if (engine === "postgres" && batchHasIndexDDL && options["postgres-safe"] && !concurrentIndexMode) {
|
|
8102
|
+
console.warn("⚠️ --postgres-safe requested, but migrations.postgres.useConcurrently is false.");
|
|
8103
|
+
console.warn(" Index DDL in this batch will run inside the atomic transaction (bounded by lock_timeout/statement_timeout).\n");
|
|
7439
8104
|
}
|
|
7440
8105
|
try {
|
|
7441
8106
|
const results = await tracker.applyAll(migrationDefs, {
|
|
7442
8107
|
atomic: true,
|
|
7443
|
-
postgresSafe:
|
|
8108
|
+
postgresSafe: concurrentIndexMode,
|
|
7444
8109
|
force: forceSelection.force,
|
|
7445
8110
|
forceMigrations: forceSelection.forceMigrations,
|
|
7446
8111
|
reconcile: true,
|
|
@@ -7465,10 +8130,10 @@ export default testManifest;
|
|
|
7465
8130
|
console.error(` ✗ atomic schema migration failed: ${errorMsg}`);
|
|
7466
8131
|
if (error instanceof Error && getErrorContext(error)?.originalError) console.error(` Cause: ${getErrorContext(error)?.originalError}`);
|
|
7467
8132
|
if (options.verbose && error instanceof Error && error.stack) console.error(`\n${error.stack}\n`);
|
|
7468
|
-
console.error(" Rolled back all schema changes from this migration batch, including any successful steps shown above.");
|
|
8133
|
+
console.error(deferredIndexMigrations > 0 ? " Non-index changes in this batch were committed; concurrent index builds that did not finish are recorded failed and will be retried on the next db:migrate." : " Rolled back all schema changes from this migration batch, including any successful steps shown above.");
|
|
7469
8134
|
}
|
|
7470
8135
|
}
|
|
7471
|
-
if (schemaUpToDate && !repairData) console.log(
|
|
8136
|
+
if (schemaUpToDate && !repairData) console.log(upToDateMessage);
|
|
7472
8137
|
if (repairData) {
|
|
7473
8138
|
console.log("\n🔄 Repairing STI discriminators to qualified names...\n");
|
|
7474
8139
|
const allTableNames = /* @__PURE__ */ new Set();
|
|
@@ -7607,6 +8272,17 @@ export default testManifest;
|
|
|
7607
8272
|
default: false,
|
|
7608
8273
|
short: "f"
|
|
7609
8274
|
},
|
|
8275
|
+
db: {
|
|
8276
|
+
type: "boolean",
|
|
8277
|
+
description: "Compare the live database to the expected schema shape (incl. _smrt_* system tables)",
|
|
8278
|
+
default: false
|
|
8279
|
+
},
|
|
8280
|
+
verbose: {
|
|
8281
|
+
type: "boolean",
|
|
8282
|
+
description: "Show recommendations and informational findings",
|
|
8283
|
+
default: false,
|
|
8284
|
+
short: "v"
|
|
8285
|
+
},
|
|
7610
8286
|
"generation-snapshot": {
|
|
7611
8287
|
type: "string",
|
|
7612
8288
|
description: "Verify a transported SMRT generation snapshot"
|
|
@@ -7713,14 +8389,22 @@ export default testManifest;
|
|
|
7713
8389
|
const viteConfigJs = resolve(cwd, "vite.config.js");
|
|
7714
8390
|
const hasViteConfig = existsSync(viteConfigTs) || existsSync(viteConfigJs);
|
|
7715
8391
|
check("vite.config.ts/js exists", hasViteConfig, void 0, "Missing vite.config (optional for non-Vite projects)");
|
|
7716
|
-
|
|
8392
|
+
const viteConfigContent = hasViteConfig ? readFileSync(existsSync(viteConfigTs) ? viteConfigTs : viteConfigJs, "utf-8") : null;
|
|
8393
|
+
if (viteConfigContent !== null) check("smrtPlugin in vite.config", viteConfigContent.includes("smrtPlugin"), "Missing smrtPlugin - add: import { smrtPlugin } from \"@happyvertical/smrt-core/vite-plugin\"");
|
|
7717
8394
|
const tsconfigPath = resolve(cwd, "tsconfig.json");
|
|
8395
|
+
let tsconfigContent = null;
|
|
7718
8396
|
if (existsSync(tsconfigPath)) try {
|
|
7719
|
-
|
|
8397
|
+
tsconfigContent = readFileSync(tsconfigPath, "utf-8");
|
|
7720
8398
|
} catch {
|
|
7721
8399
|
check("tsconfig.json readable", false, "Could not read tsconfig.json");
|
|
7722
8400
|
}
|
|
7723
8401
|
else check("tsconfig.json exists", false, void 0, "No tsconfig.json found (optional for JavaScript projects)");
|
|
8402
|
+
const decoratorSupport = assessDecoratorSupport({
|
|
8403
|
+
viteMajor: resolveDeclaredViteMajor(packageJson),
|
|
8404
|
+
viteConfigContent,
|
|
8405
|
+
tsconfigContent
|
|
8406
|
+
});
|
|
8407
|
+
check("Decorator transform configured", decoratorSupport.status === "ok", decoratorSupport.status === "error" ? decoratorSupport.message : void 0, decoratorSupport.status === "warning" ? decoratorSupport.message : void 0);
|
|
7724
8408
|
console.log();
|
|
7725
8409
|
console.log("📦 SMRT Objects\n");
|
|
7726
8410
|
const objectsDir = resolve(cwd, "src/lib/objects");
|
|
@@ -7774,6 +8458,21 @@ export default testManifest;
|
|
|
7774
8458
|
else if (existsSync(envExamplePath)) check(".env file exists", false, void 0, ".env.example exists - copy it to .env");
|
|
7775
8459
|
else check(".env file exists", false, void 0, "No .env file - environment variables may be needed");
|
|
7776
8460
|
console.log();
|
|
8461
|
+
if (options.db) {
|
|
8462
|
+
console.log("🗄️ Live Database Parity\n");
|
|
8463
|
+
const { report, error, database } = await runLiveSchemaParity({ discover: false });
|
|
8464
|
+
if (!report) check("Live schema parity", false, error ?? "Could not verify the live schema");
|
|
8465
|
+
else {
|
|
8466
|
+
if (database) console.log(` Database: ${database.url}`);
|
|
8467
|
+
for (const line of formatParityReport(report, { verbose: options.verbose })) console.log(line);
|
|
8468
|
+
const parityErrors = selectFindings(report, "error");
|
|
8469
|
+
const parityWarnings = selectFindings(report, "warning");
|
|
8470
|
+
for (const finding of parityErrors) issues.push(`live schema: ${finding.table}${finding.target ? `.${finding.target}` : ""} (${finding.kind})`);
|
|
8471
|
+
for (const finding of parityWarnings) warnings.push(`live schema: ${finding.table}${finding.target ? `.${finding.target}` : ""} (${finding.kind})`);
|
|
8472
|
+
if (parityErrors.length === 0 && parityWarnings.length === 0) passed.push("Live schema parity");
|
|
8473
|
+
}
|
|
8474
|
+
console.log();
|
|
8475
|
+
}
|
|
7777
8476
|
console.log("━".repeat(50));
|
|
7778
8477
|
console.log(`\n📊 Summary\n`);
|
|
7779
8478
|
console.log(` ✅ Passed: ${passed.length}`);
|