@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.
@@ -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 m of migrationsToRollback) {
1303
- const appliedAt = m.applied_at.toISOString().substring(0, 19);
1304
- console.log(` ↩ ${m.name} (${shortChecksum(m.checksum)} applied ${appliedAt})`);
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
- const nonReversible = migrationsToRollback.filter((m) => !m.is_reversible);
1309
- if (nonReversible.length > 0) if (options.json) console.log(JSON.stringify({
1310
- error: "Some migrations are not reversible",
1311
- nonReversible: nonReversible.map((m) => m.name)
1312
- }));
1313
- else {
1314
- console.log("⚠️ Warning: Some migrations are not reversible:\n");
1315
- for (const m of nonReversible) console.log(` - ${m.name}`);
1316
- console.log("\n These migrations will be marked as rolled back but no DOWN script will run.\n");
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
- migrationsToRollback: migrationsToRollback.map((m) => ({
1336
- name: m.name,
1337
- checksum: m.checksum,
1338
- isReversible: m.is_reversible
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 rollback the following migrations:");
1344
- for (const m of migrationsToRollback) {
1345
- const status = m.is_reversible ? "(has DOWN script)" : "(no DOWN script)";
1346
- console.log(` ${m.name} ${status}`);
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
- for (const migration of migrationsToRollback) try {
1357
- const definition = {
1358
- id: migration.name,
1359
- description: `Rollback: ${migration.name}`,
1360
- version: migration.version,
1361
- up: [],
1362
- down: []
1363
- };
1364
- if (migration.is_reversible) {
1365
- const result = await tracker.rollback(migration.name, definition, { dryRun: false });
1366
- if (result.success) {
1367
- if (!options.json) console.log(` ✓ ${migration.name} rolled back`);
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
- } else throw result.error || /* @__PURE__ */ new Error("Rollback failed");
1374
- } else {
1375
- await db.query(`UPDATE _smrt_schema_migrations SET status = 'rolled_back', rolled_back_at = CURRENT_TIMESTAMP WHERE name = ?`, [migration.name]);
1376
- if (!options.json) console.log(` ⊙ ${migration.name} marked as rolled back (no DOWN script)`);
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
- noDownScript: true
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: "Use PostgreSQL-safe operations (CONCURRENTLY for indexes, lock_timeout)",
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
- useConcurrentIndexes: options["postgres-safe"] ?? false
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 (options["postgres-safe"] && engine === "postgres") console.log("PostgreSQL-safe mode enabled (CONCURRENTLY, lock_timeout)");
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("✅ Database schema is up to date - no migrations needed\n");
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
- if (options["postgres-safe"] && engine === "postgres" && migrations.some((migration) => migration.type === "add_index" || migration.type === "drop_index")) {
7437
- console.warn("⚠️ --postgres-safe requested, but db:migrate applies generated schema changes atomically.");
7438
- console.warn(" Index DDL in this batch will run without CONCURRENTLY so PostgreSQL can roll back the full batch on failure.\n");
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: false,
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("✅ Database schema is up to date - no migrations needed\n");
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
- if (hasViteConfig) check("smrtPlugin in vite.config", readFileSync(existsSync(viteConfigTs) ? viteConfigTs : viteConfigJs, "utf-8").includes("smrtPlugin"), "Missing smrtPlugin - add: import { smrtPlugin } from \"@happyvertical/smrt-core/vite-plugin\"");
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
- check("experimentalDecorators enabled", readFileSync(tsconfigPath, "utf-8").includes("experimentalDecorators"), "Add \"experimentalDecorators\": true to tsconfig.json");
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}`);