@happyvertical/smrt-cli 0.40.69 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, ensureDeferredSystemTableCompatibility, 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;
@@ -880,57 +715,413 @@ function partitionSchemaChanges(changes, getClassForTable) {
880
715
  sql: change.sql,
881
716
  ...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
882
717
  });
883
- break;
884
- case "type_mismatch": {
885
- const mm = change.mismatch;
886
- if (!change.name || !mm) continue;
887
- manualInterventions.push({
888
- type: "type_mismatch",
889
- tableName: change.table,
890
- className,
891
- mismatch: {
892
- column: change.name,
893
- expected: mm.expected,
894
- actual: mm.actual
895
- }
718
+ break;
719
+ case "type_mismatch": {
720
+ const mm = change.mismatch;
721
+ if (!change.name || !mm) continue;
722
+ manualInterventions.push({
723
+ type: "type_mismatch",
724
+ tableName: change.table,
725
+ className,
726
+ mismatch: {
727
+ column: change.name,
728
+ expected: mm.expected,
729
+ actual: mm.actual
730
+ }
731
+ });
732
+ break;
733
+ }
734
+ case "type_upgrade": {
735
+ const mm = change.mismatch;
736
+ const col = change.column;
737
+ if (!change.name || !mm || !col) continue;
738
+ const action = {
739
+ type: "type_upgrade",
740
+ tableName: change.table,
741
+ className,
742
+ column: {
743
+ name: change.name,
744
+ type: col.type,
745
+ notNull: col.notNull,
746
+ defaultValue: col.defaultValue,
747
+ unique: col.unique
748
+ },
749
+ mismatch: {
750
+ column: change.name,
751
+ expected: mm.expected,
752
+ actual: mm.actual
753
+ },
754
+ sql: change.sql,
755
+ ...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
756
+ };
757
+ const executionKind = classifyTypeUpgradeSql(change.sql);
758
+ if (executionKind === "executable") migrations.push(action);
759
+ else if (executionKind === "manual") manualInterventions.push(action);
760
+ break;
761
+ }
762
+ }
763
+ }
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
896
981
  });
897
- break;
982
+ return;
898
983
  }
899
- case "type_upgrade": {
900
- const mm = change.mismatch;
901
- const col = change.column;
902
- if (!change.name || !mm || !col) continue;
903
- const action = {
904
- type: "type_upgrade",
905
- tableName: change.table,
906
- className,
907
- column: {
908
- name: change.name,
909
- type: col.type,
910
- notNull: col.notNull,
911
- defaultValue: col.defaultValue,
912
- unique: col.unique
913
- },
914
- mismatch: {
915
- column: change.name,
916
- expected: mm.expected,
917
- actual: mm.actual
918
- },
919
- sql: change.sql,
920
- ...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {}
921
- };
922
- const executionKind = classifyTypeUpgradeSql(change.sql);
923
- if (executionKind === "executable") migrations.push(action);
924
- else if (executionKind === "manual") manualInterventions.push(action);
925
- break;
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");
926
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);
927
1074
  }
928
1075
  }
929
- return {
930
- migrations,
931
- manualInterventions
932
- };
933
- }
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";
@@ -1157,60 +1350,305 @@ var dbHistoryCommand = {
1157
1350
  }
1158
1351
  console.log();
1159
1352
  }
1160
- const completed = annotatedHistory.filter((m) => m.status === "completed").length;
1161
- const failed = annotatedHistory.filter((m) => m.status === "failed").length;
1162
- const failedUnresolved = annotatedHistory.filter((m) => m.classification === "unresolved").length;
1163
- const failedSuperseded = annotatedHistory.filter((m) => m.classification === "superseded").length;
1164
- const failedOther = annotatedHistory.filter((m) => m.classification === "other").length;
1165
- const rolledBack = annotatedHistory.filter((m) => m.status === "rolled_back").length;
1166
- const actionRequired = failedAssessments.filter((item) => item.resolution === "action_required").length;
1167
- const superseded = failedAssessments.filter((item) => item.resolution === "superseded").length;
1168
- const manualReview = failedAssessments.filter((item) => item.resolution === "manual_review").length;
1169
- console.log("━".repeat(50));
1170
- console.log(`Summary: ${completed} completed, ${failed} failed (${failedUnresolved} unresolved, ${failedSuperseded} superseded, ${failedOther} other), ${rolledBack} rolled back`);
1171
- if (failed > 0) console.log(` ${actionRequired} failed still require action, ${superseded} are superseded history, ${manualReview} need manual review`);
1172
- console.log();
1173
- console.log("💡 Commands:");
1174
- console.log(" smrt db:status - Current migration status");
1175
- console.log(" smrt db:rollback - Rollback migrations");
1176
- console.log();
1177
- } catch (error) {
1178
- if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1353
+ const completed = annotatedHistory.filter((m) => m.status === "completed").length;
1354
+ const failed = annotatedHistory.filter((m) => m.status === "failed").length;
1355
+ const failedUnresolved = annotatedHistory.filter((m) => m.classification === "unresolved").length;
1356
+ const failedSuperseded = annotatedHistory.filter((m) => m.classification === "superseded").length;
1357
+ const failedOther = annotatedHistory.filter((m) => m.classification === "other").length;
1358
+ const rolledBack = annotatedHistory.filter((m) => m.status === "rolled_back").length;
1359
+ const actionRequired = failedAssessments.filter((item) => item.resolution === "action_required").length;
1360
+ const superseded = failedAssessments.filter((item) => item.resolution === "superseded").length;
1361
+ const manualReview = failedAssessments.filter((item) => item.resolution === "manual_review").length;
1362
+ console.log("━".repeat(50));
1363
+ console.log(`Summary: ${completed} completed, ${failed} failed (${failedUnresolved} unresolved, ${failedSuperseded} superseded, ${failedOther} other), ${rolledBack} rolled back`);
1364
+ if (failed > 0) console.log(` ${actionRequired} failed still require action, ${superseded} are superseded history, ${manualReview} need manual review`);
1365
+ console.log();
1366
+ console.log("💡 Commands:");
1367
+ console.log(" smrt db:status - Current migration status");
1368
+ console.log(" smrt db:rollback - Rollback migrations");
1369
+ console.log();
1370
+ } catch (error) {
1371
+ if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1372
+ else {
1373
+ console.error("\n❌ Failed to get migration history:");
1374
+ if (error instanceof Error) console.error(` ${error.message}`);
1375
+ }
1376
+ process.exitCode = 1;
1377
+ return;
1378
+ } finally {
1379
+ await closeDatabaseConnection(db);
1380
+ }
1381
+ }
1382
+ };
1383
+ /**
1384
+ * Get status icon for migration status
1385
+ */
1386
+ function getStatusIcon(status) {
1387
+ switch (status) {
1388
+ case "completed": return "✓";
1389
+ case "failed": return "✗";
1390
+ case "rolled_back": return "↩";
1391
+ case "running": return "⟳";
1392
+ case "pending": return "○";
1393
+ default: return "?";
1394
+ }
1395
+ }
1396
+ /**
1397
+ * Format date/time for display
1398
+ */
1399
+ function formatDateTime(date) {
1400
+ return date.toISOString().replace("T", " ").substring(0, 19);
1401
+ }
1402
+ //#endregion
1403
+ //#region src/commands/db-prune.ts
1404
+ /**
1405
+ * Packages that contribute retention tasks by registering them on import.
1406
+ *
1407
+ * `db:prune` runs in the CLI's own process, so a task only reaches the sweep
1408
+ * if this process actually loaded the package that registers it. These are
1409
+ * imported optionally — a project that does not depend on jobs or users simply
1410
+ * has no jobs or users tasks, which is the correct outcome, not an error.
1411
+ */
1412
+ var RETENTION_TASK_PACKAGES = ["@happyvertical/smrt-jobs", "@happyvertical/smrt-users"];
1413
+ /**
1414
+ * Load every installed package that contributes retention tasks.
1415
+ *
1416
+ * A rejected import is always treated as "not installed" — this function
1417
+ * cannot reliably tell a genuine module-resolution miss apart from a package
1418
+ * that resolved but threw during its own top-level evaluation (error shapes
1419
+ * differ across bundlers and runtimes, and `importPackage` is caller-supplied
1420
+ * for exactly that flexibility). It still logs the message on `stderr`
1421
+ * rather than swallowing it outright, so an operator can tell "this project
1422
+ * doesn't depend on jobs/users" apart from "smrt-jobs is installed but broke
1423
+ * on import" without the sweep itself needing to fail over an optional
1424
+ * dependency.
1425
+ *
1426
+ * @returns The specifiers that loaded, in declaration order.
1427
+ */
1428
+ async function loadRetentionTaskPackages(importPackage) {
1429
+ const loaded = [];
1430
+ for (const specifier of RETENTION_TASK_PACKAGES) try {
1431
+ await importPackage(specifier);
1432
+ loaded.push(specifier);
1433
+ } catch (error) {
1434
+ const message = error instanceof Error ? error.message : String(error);
1435
+ console.warn(`⚠️ Could not load ${specifier} (${message}). Treating its retention tasks as not contributed — if the package is installed, this may be a real import failure rather than a missing dependency.`);
1436
+ }
1437
+ return loaded;
1438
+ }
1439
+ /**
1440
+ * Merge command-line overrides onto the configured retention policy.
1441
+ *
1442
+ * `--skip` names tasks, not tables, so it covers both the built-in tables and
1443
+ * anything a package registered — the same names `db:prune --json` reports.
1444
+ */
1445
+ function buildPrunePolicy(configured, options) {
1446
+ const policy = { ...configured ?? {} };
1447
+ policy.dryRun = options["dry-run"] || (configured?.dryRun ?? false);
1448
+ if (options["changes-days"] !== void 0) policy.changes = {
1449
+ ...policy.changes === false ? {} : policy.changes ?? {},
1450
+ maxAgeDays: options["changes-days"]
1451
+ };
1452
+ if (options["usage-days"] !== void 0) policy.aiUsage = {
1453
+ ...policy.aiUsage === false ? {} : policy.aiUsage ?? {},
1454
+ maxAgeDays: options["usage-days"]
1455
+ };
1456
+ if (options["dispatch-days"] !== void 0) policy.dispatch = {
1457
+ ...policy.dispatch === false ? {} : policy.dispatch ?? {},
1458
+ completedOlderThanDays: options["dispatch-days"]
1459
+ };
1460
+ const skipped = (options.skip ?? "").split(",").map((name) => name.trim()).filter((name) => name.length > 0);
1461
+ for (const name of skipped) switch (name) {
1462
+ case "changes":
1463
+ policy.changes = false;
1464
+ break;
1465
+ case "ai-usage":
1466
+ policy.aiUsage = false;
1467
+ break;
1468
+ case "contexts":
1469
+ policy.contexts = false;
1470
+ break;
1471
+ case "dispatch":
1472
+ policy.dispatch = false;
1473
+ break;
1474
+ default: policy.tasks = {
1475
+ ...policy.tasks ?? {},
1476
+ [name]: false
1477
+ };
1478
+ }
1479
+ return policy;
1480
+ }
1481
+ /** Render a completed sweep as an operator-readable table. */
1482
+ function formatSweepResult(result) {
1483
+ const lines = [];
1484
+ const verb = result.dryRun ? "would prune" : "pruned";
1485
+ for (const task of result.tasks) {
1486
+ const status = task.error ? `error: ${task.error}` : task.skipped ? task.skipped : `${verb} ${task.pruned}`;
1487
+ const detail = task.details ? ` (${Object.entries(task.details).map(([key, value]) => `${key}=${value}`).join(", ")})` : "";
1488
+ lines.push(` ${task.task.padEnd(24)}${status}${detail}`);
1489
+ }
1490
+ lines.push("");
1491
+ lines.push(` ${"total".padEnd(24)}${verb} ${result.pruned} row(s) in ${result.durationMs}ms`);
1492
+ return lines.join("\n");
1493
+ }
1494
+ /**
1495
+ * Names passed to `--skip` that no task in the completed sweep answered to.
1496
+ *
1497
+ * A typo and a package that was never installed look identical on the command
1498
+ * line, so the command says which names it did not recognize rather than
1499
+ * silently doing less than the operator asked for.
1500
+ */
1501
+ function unmatchedSkipNames(skip, result) {
1502
+ const known = new Set(result.tasks.map((task) => task.task));
1503
+ return (skip ?? "").split(",").map((name) => name.trim()).filter((name) => name.length > 0 && !known.has(name));
1504
+ }
1505
+ var dbPruneCommand = {
1506
+ name: "db:prune",
1507
+ description: "Prune framework-owned system tables to their retention windows",
1508
+ args: [],
1509
+ options: {
1510
+ "dry-run": {
1511
+ type: "boolean",
1512
+ description: "Report what would be deleted without deleting it",
1513
+ default: false
1514
+ },
1515
+ json: {
1516
+ type: "boolean",
1517
+ description: "Output as JSON (for CI/cron integration)",
1518
+ default: false,
1519
+ short: "j"
1520
+ },
1521
+ "changes-days": {
1522
+ type: "number",
1523
+ description: "Retention window for _smrt_changes, in days"
1524
+ },
1525
+ "usage-days": {
1526
+ type: "number",
1527
+ description: "Retention window for _smrt_ai_usage, in days"
1528
+ },
1529
+ "dispatch-days": {
1530
+ type: "number",
1531
+ description: "Retention window for completed _smrt_dispatch rows, in days"
1532
+ },
1533
+ skip: {
1534
+ type: "string",
1535
+ description: "Comma-separated task names to skip (changes, ai-usage, contexts, dispatch, …)"
1536
+ }
1537
+ },
1538
+ handler: async (_args, options) => {
1539
+ let db;
1540
+ try {
1541
+ const { getPackageConfig } = await import("@happyvertical/smrt-config");
1542
+ const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
1543
+ const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
1544
+ if (!config.database?.url) {
1545
+ const message = "Database not configured. Set database.url in smrt.config.ts.";
1546
+ if (options.json) console.log(JSON.stringify({ error: message }));
1547
+ else console.error(`\n❌ ${message}\n`);
1548
+ process.exitCode = 1;
1549
+ return;
1550
+ }
1551
+ const dbUrl = config.database.url;
1552
+ const dbType = config.database.type || "sqlite";
1553
+ const { getDatabase } = await import("@happyvertical/sql");
1554
+ db = await getDatabase({
1555
+ type: dbType,
1556
+ url: dbUrl
1557
+ });
1558
+ const { config: smrtConfig, importOptionalDependency, runRetentionSweep } = await import("@happyvertical/smrt-core");
1559
+ const loaded = await loadRetentionTaskPackages((specifier) => importOptionalDependency(specifier, "Install it in the project to include its retention tasks."));
1560
+ const policy = buildPrunePolicy(smrtConfig.toJSON().retention, options);
1561
+ const result = await runRetentionSweep(db, policy);
1562
+ const unmatched = unmatchedSkipNames(options.skip, result);
1563
+ if (options.json) console.log(JSON.stringify({
1564
+ ...result,
1565
+ contributors: loaded,
1566
+ unmatched
1567
+ }, null, 2));
1179
1568
  else {
1180
- console.error("\n Failed to get migration history:");
1181
- if (error instanceof Error) console.error(` ${error.message}`);
1569
+ console.log(`\n🧹 Retention sweep${result.dryRun ? " (dry run)" : ""}\n`);
1570
+ console.log(`Database: ${formatDatabaseDisplayUrl(dbType, dbUrl)}\n`);
1571
+ console.log(formatSweepResult(result));
1572
+ console.log();
1573
+ if (unmatched.length > 0) console.warn(`⚠️ --skip named no known task: ${unmatched.join(", ")}. Check the spelling, or the package that registers it may not be installed.
1574
+ `);
1182
1575
  }
1576
+ if (result.failed) process.exitCode = 1;
1577
+ } catch (error) {
1578
+ const message = error instanceof Error ? error.message : String(error);
1579
+ if (options.json) console.log(JSON.stringify({ error: message }));
1580
+ else console.error(`\n❌ Retention sweep failed: ${message}\n`);
1183
1581
  process.exitCode = 1;
1184
- return;
1185
1582
  } finally {
1186
1583
  await closeDatabaseConnection(db);
1187
1584
  }
1188
1585
  }
1189
1586
  };
1587
+ //#endregion
1588
+ //#region src/commands/db-rollback.ts
1190
1589
  /**
1191
- * Get status icon for migration status
1590
+ * Name prefix of the only migration class `db:migrate` records a DOWN script
1591
+ * for (`utilities.ts`, `diff.added_tables` loop:
1592
+ * `down: ['DROP TABLE IF EXISTS "<table>"']`). Keep the two in step — this
1593
+ * command reconstructs that exact statement from the recorded migration name.
1192
1594
  */
1193
- function getStatusIcon(status) {
1194
- switch (status) {
1195
- case "completed": return "✓";
1196
- case "failed": return "✗";
1197
- case "rolled_back": return "↩";
1198
- case "running": return "⟳";
1199
- case "pending": return "○";
1200
- default: return "?";
1201
- }
1202
- }
1595
+ var CREATE_TABLE_MIGRATION_PREFIX = "create_table_";
1203
1596
  /**
1204
- * Format date/time for display
1597
+ * The suffix of a `create_table_*` row must be a bare SQL identifier before it
1598
+ * is interpolated into the reconstructed `DROP TABLE`. Quotes, whitespace,
1599
+ * semicolons and dots all fail closed (the migration is refused) rather than
1600
+ * producing a statement that drops something other than the recorded table.
1601
+ *
1602
+ * Deliberately not lowercase-only: `classnameToTablename` yields snake_case,
1603
+ * but `@smrt({ tableName })` passes a consumer-supplied name through verbatim,
1604
+ * so a legitimately reversible table can carry capitals. This is a shape and
1605
+ * injection guard, not a naming-convention check.
1205
1606
  */
1206
- function formatDateTime(date) {
1207
- return date.toISOString().replace("T", " ").substring(0, 19);
1607
+ var SAFE_TABLE_NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]*$/;
1608
+ /** Human-readable refusal headline, printed above the per-migration reasons. */
1609
+ var REFUSAL_HEADLINE = "Refusing to roll back: no DOWN script is available for the selected migration(s).";
1610
+ /** Single-line refusal used for the JSON payload. */
1611
+ var REFUSAL_ERROR = `${REFUSAL_HEADLINE} Nothing was changed.`;
1612
+ /** Why the refusal happened and the two ways forward, printed with it. */
1613
+ var REFUSAL_GUIDANCE = [
1614
+ "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.",
1615
+ "Revert these by updating the @smrt object definitions and running smrt db:migrate, or by reverting the schema by hand.",
1616
+ "Re-run with --mark-only to mark them rolled_back in the tracking table WITHOUT changing the schema (record-only)."
1617
+ ];
1618
+ /**
1619
+ * Reconstruct the DOWN script for an applied migration, or explain why it
1620
+ * cannot be reconstructed.
1621
+ *
1622
+ * Recoverable only for `create_table_<table>` rows recorded as reversible: the
1623
+ * DOWN `db:migrate` attached to them is the deterministic
1624
+ * `DROP TABLE IF EXISTS "<table>"`. A row recorded reversible under any other
1625
+ * name came from a caller-supplied `MigrationDefinition` whose SQL was never
1626
+ * persisted, so it is refused rather than guessed at.
1627
+ *
1628
+ * Exported for unit testing.
1629
+ */
1630
+ function recoverDownStatements(migration) {
1631
+ if (!migration.name.startsWith(CREATE_TABLE_MIGRATION_PREFIX)) return {
1632
+ recoverable: false,
1633
+ 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"
1634
+ };
1635
+ if (!migration.is_reversible) return {
1636
+ recoverable: false,
1637
+ reason: "is recorded as not reversible"
1638
+ };
1639
+ const tableName = migration.name.slice(13);
1640
+ if (!SAFE_TABLE_NAME_RE.test(tableName)) return {
1641
+ recoverable: false,
1642
+ reason: `names table "${tableName}", which is not a plain identifier db:migrate would have generated`
1643
+ };
1644
+ return {
1645
+ recoverable: true,
1646
+ statements: [`DROP TABLE IF EXISTS "${tableName}"`]
1647
+ };
1208
1648
  }
1209
- //#endregion
1210
- //#region src/commands/db-rollback.ts
1211
1649
  var dbRollbackCommand = {
1212
1650
  name: "db:rollback",
1213
- description: "Rollback applied migrations",
1651
+ description: "Rollback applied migrations by executing their DOWN script",
1214
1652
  aliases: ["rollback", "migration-rollback"],
1215
1653
  args: [],
1216
1654
  options: {
@@ -1230,6 +1668,12 @@ var dbRollbackCommand = {
1230
1668
  description: "Preview rollback without executing",
1231
1669
  default: false
1232
1670
  },
1671
+ "mark-only": {
1672
+ type: "boolean",
1673
+ description: "Record-only: mark migrations rolled back WITHOUT running any DOWN script (schema untouched)",
1674
+ default: false,
1675
+ short: "m"
1676
+ },
1233
1677
  force: {
1234
1678
  type: "boolean",
1235
1679
  description: "Skip confirmation prompt",
@@ -1252,6 +1696,7 @@ var dbRollbackCommand = {
1252
1696
  handler: async (_args, options) => {
1253
1697
  let db;
1254
1698
  const dryRun = options["dry-run"] ?? options.dryRun;
1699
+ const markOnly = options["mark-only"] ?? options.markOnly;
1255
1700
  try {
1256
1701
  const { getPackageConfig } = await import("@happyvertical/smrt-config");
1257
1702
  const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
@@ -1297,26 +1742,45 @@ var dbRollbackCommand = {
1297
1742
  else console.log("✅ Nothing to rollback\n");
1298
1743
  return;
1299
1744
  }
1745
+ const plan = migrationsToRollback.map((migration) => ({
1746
+ migration,
1747
+ recovery: recoverDownStatements(migration)
1748
+ }));
1749
+ const unrecoverable = plan.flatMap(({ migration, recovery }) => recovery.recoverable ? [] : [{
1750
+ name: migration.name,
1751
+ reason: recovery.reason
1752
+ }]);
1300
1753
  if (!options.json) {
1301
1754
  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})`);
1755
+ for (const { migration, recovery } of plan) {
1756
+ const appliedAt = migration.applied_at.toISOString().substring(0, 19);
1757
+ const marker = recovery.recoverable ? "↩" : "⊘";
1758
+ console.log(` ${marker} ${migration.name} (${shortChecksum(migration.checksum)} applied ${appliedAt})`);
1759
+ if (recovery.recoverable) for (const sql of recovery.statements) console.log(` ${sql};`);
1760
+ else console.log(` no DOWN script — ${recovery.reason}`);
1305
1761
  }
1306
1762
  console.log();
1307
1763
  }
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");
1764
+ if (unrecoverable.length > 0 && !markOnly) {
1765
+ if (options.json) console.log(JSON.stringify({
1766
+ error: REFUSAL_ERROR,
1767
+ dryRun: Boolean(dryRun),
1768
+ unrecoverable,
1769
+ nonReversible: unrecoverable.map((entry) => entry.name),
1770
+ guidance: REFUSAL_GUIDANCE
1771
+ }, null, 2));
1772
+ else {
1773
+ console.error(`❌ ${REFUSAL_HEADLINE}\n`);
1774
+ for (const detail of unrecoverable) console.error(` - ${detail.name} — ${detail.reason}`);
1775
+ console.error();
1776
+ for (const line of REFUSAL_GUIDANCE) console.error(` ${line}`);
1777
+ console.error("\n Nothing was changed.\n");
1778
+ }
1779
+ process.exitCode = 1;
1780
+ return;
1317
1781
  }
1318
1782
  if (!options.force && !dryRun && !options.json) {
1319
- console.log("⚠️ WARNING: This will revert database changes!");
1783
+ console.log(markOnly ? "⚠️ WARNING: This marks migrations rolled back WITHOUT changing the schema!" : "⚠️ WARNING: This will revert database changes!");
1320
1784
  const rl = (await import("node:readline/promises")).createInterface({
1321
1785
  input: process.stdin,
1322
1786
  output: process.stdout
@@ -1332,68 +1796,86 @@ var dbRollbackCommand = {
1332
1796
  if (dryRun) {
1333
1797
  if (options.json) console.log(JSON.stringify({
1334
1798
  dryRun: true,
1335
- migrationsToRollback: migrationsToRollback.map((m) => ({
1336
- name: m.name,
1337
- checksum: m.checksum,
1338
- isReversible: m.is_reversible
1799
+ markOnly: Boolean(markOnly),
1800
+ migrationsToRollback: plan.map(({ migration, recovery }) => ({
1801
+ name: migration.name,
1802
+ checksum: migration.checksum,
1803
+ isReversible: migration.is_reversible,
1804
+ down: recovery.recoverable ? recovery.statements : []
1339
1805
  }))
1340
1806
  }));
1341
1807
  else {
1342
1808
  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}`);
1809
+ console.log(markOnly ? "Would mark the following migrations rolled back (schema untouched):" : "Would execute the DOWN script of the following migrations:");
1810
+ for (const { migration, recovery } of plan) {
1811
+ const status = recovery.recoverable ? `↩ ${migration.name} (${recovery.statements.length} DOWN statement(s))` : `⊘ ${migration.name} (no DOWN script)`;
1812
+ console.log(` ${status}`);
1347
1813
  }
1348
1814
  console.log();
1349
1815
  }
1350
1816
  return;
1351
1817
  }
1352
- if (!options.json) console.log("🔨 Rolling back migrations...\n");
1818
+ if (!options.json) console.log(markOnly ? "🔨 Marking migrations rolled back (record-only)...\n" : "🔨 Rolling back migrations...\n");
1353
1819
  let successCount = 0;
1354
1820
  let errorCount = 0;
1355
1821
  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`);
1822
+ let stoppedBy = null;
1823
+ for (const { migration, recovery } of plan) {
1824
+ if (stoppedBy) {
1825
+ const message = `Not attempted: rollback stopped after ${stoppedBy} failed`;
1826
+ if (!options.json) console.error(` ⊘ ${migration.name} skipped: ${message}`);
1827
+ results.push({
1828
+ name: migration.name,
1829
+ success: false,
1830
+ error: message
1831
+ });
1832
+ errorCount++;
1833
+ continue;
1834
+ }
1835
+ try {
1836
+ if (markOnly) {
1837
+ await db.query(`UPDATE _smrt_schema_migrations SET status = 'rolled_back', rolled_back_at = CURRENT_TIMESTAMP WHERE name = ?`, [migration.name]);
1838
+ if (!options.json) console.log(` ⊙ ${migration.name} marked rolled back (schema untouched)`);
1368
1839
  results.push({
1369
1840
  name: migration.name,
1370
- success: true
1841
+ success: true,
1842
+ markedOnly: true
1371
1843
  });
1372
1844
  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)`);
1845
+ continue;
1846
+ }
1847
+ if (!recovery.recoverable) throw new Error(`No DOWN script available ${recovery.reason}`);
1848
+ const result = await tracker.rollback(migration.name, {
1849
+ id: migration.name,
1850
+ description: `Rollback: ${migration.name}`,
1851
+ version: migration.version,
1852
+ up: [],
1853
+ down: recovery.statements
1854
+ }, { dryRun: false });
1855
+ if (!result.success) throw result.error || /* @__PURE__ */ new Error("Rollback failed");
1856
+ if (!options.json) console.log(` ✓ ${migration.name} rolled back`);
1377
1857
  results.push({
1378
1858
  name: migration.name,
1379
1859
  success: true,
1380
- noDownScript: true
1860
+ statements: recovery.statements
1381
1861
  });
1382
1862
  successCount++;
1863
+ } catch (error) {
1864
+ errorCount++;
1865
+ const errorMsg = error instanceof Error ? error.message : String(error);
1866
+ if (!options.json) console.error(` ✗ ${migration.name} failed: ${errorMsg}`);
1867
+ results.push({
1868
+ name: migration.name,
1869
+ success: false,
1870
+ error: errorMsg
1871
+ });
1872
+ stoppedBy = migration.name;
1873
+ if (options.verbose && error instanceof Error && error.stack) console.error(`\n${error.stack}\n`);
1383
1874
  }
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
1875
  }
1395
1876
  if (options.json) console.log(JSON.stringify({
1396
1877
  success: errorCount === 0,
1878
+ markOnly: Boolean(markOnly),
1397
1879
  successCount,
1398
1880
  errorCount,
1399
1881
  results
@@ -1401,6 +1883,7 @@ var dbRollbackCommand = {
1401
1883
  else {
1402
1884
  console.log();
1403
1885
  if (errorCount > 0) console.log(`⚠️ Rollback completed with errors: ${successCount} succeeded, ${errorCount} failed\n`);
1886
+ else if (markOnly) console.log(`✅ Marked ${successCount} migration(s) rolled back — the schema was NOT changed\n`);
1404
1887
  else console.log(`✅ Successfully rolled back ${successCount} migration(s)\n`);
1405
1888
  console.log("💡 Commands:");
1406
1889
  console.log(" smrt db:status - View current migration status");
@@ -1408,6 +1891,7 @@ var dbRollbackCommand = {
1408
1891
  console.log(" smrt db:migrate - Re-apply migrations");
1409
1892
  console.log();
1410
1893
  }
1894
+ if (errorCount > 0) process.exitCode = 1;
1411
1895
  } catch (error) {
1412
1896
  if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1413
1897
  else {
@@ -1422,6 +1906,131 @@ var dbRollbackCommand = {
1422
1906
  }
1423
1907
  };
1424
1908
  //#endregion
1909
+ //#region src/commands/db-parity.ts
1910
+ /**
1911
+ * Live-schema parity reporting for the CLI (#2368)
1912
+ *
1913
+ * Shared by `smrt doctor --db` and `smrt db:status --parity`. Both surfaces
1914
+ * introspect the configured database and compare it to the shape the model
1915
+ * layer assumes — including the hand-DDL `_smrt_*` system tables and an index
1916
+ * policy that does not consult the manifest, since the manifest is the artifact
1917
+ * that dropped the index in the first place (#2356).
1918
+ *
1919
+ * Everything here fails closed: a database that cannot be reached, or an
1920
+ * adapter that cannot describe its tables, is reported as a failure rather
1921
+ * than as "in sync".
1922
+ */
1923
+ /**
1924
+ * Collect every declared upsert conflict target, keyed by table name.
1925
+ *
1926
+ * The conflict target is a registry property, not a manifest one: a class may
1927
+ * declare `conflictColumns` explicitly, inherit the CTI `(slug, context)`
1928
+ * default, or get the STI `(slug, context, _meta_type)` triple. Whatever it
1929
+ * resolves to, the live database needs a matching UNIQUE index or every upsert
1930
+ * against that table either errors (PostgreSQL) or duplicates rows.
1931
+ */
1932
+ function collectRegistryConflictTargets() {
1933
+ const targets = {};
1934
+ for (const className of ObjectRegistry.getQualifiedClassNames()) {
1935
+ const tableName = ObjectRegistry.getTableName(className);
1936
+ if (!tableName) continue;
1937
+ const columns = ObjectRegistry.getConflictColumns(className);
1938
+ if (!columns || columns.length === 0) continue;
1939
+ const bucket = targets[tableName] ?? [];
1940
+ bucket.push({
1941
+ columns,
1942
+ source: className
1943
+ });
1944
+ targets[tableName] = bucket;
1945
+ }
1946
+ return targets;
1947
+ }
1948
+ /**
1949
+ * Run the parity check against the configured database.
1950
+ *
1951
+ * Never throws for an operational problem: connection and introspection
1952
+ * failures come back as `{ report: null, error }` so both callers can render
1953
+ * them in their own idiom while still failing closed.
1954
+ */
1955
+ async function runLiveSchemaParity(options = {}) {
1956
+ const { includeSystemTables = true, discover = true } = options;
1957
+ let db;
1958
+ try {
1959
+ const { getPackageConfig } = await import("@happyvertical/smrt-config");
1960
+ const { DEFAULT_CLI_CONFIG } = await import("./config-BwrFRL8L.js");
1961
+ const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
1962
+ if (!config.database?.url || config.database.url === ":memory:") return {
1963
+ report: null,
1964
+ database: null,
1965
+ error: "No persistent database is configured, so live-schema parity cannot be verified. Set `database.url` in smrt.config.ts (or DATABASE_URL)."
1966
+ };
1967
+ const dbUrl = config.database.url;
1968
+ const dbType = config.database.type || "sqlite";
1969
+ const database = {
1970
+ type: dbType,
1971
+ url: formatDatabaseDisplayUrl(dbType, dbUrl)
1972
+ };
1973
+ if (discover) await autoDiscoverAndLoad();
1974
+ const { getDatabase } = await import("@happyvertical/sql");
1975
+ db = await getDatabase({
1976
+ type: dbType,
1977
+ url: dbUrl
1978
+ });
1979
+ return {
1980
+ report: await checkLiveSchemaParity({
1981
+ db,
1982
+ schemas: ObjectRegistry.getAllSchemasAsDefinitions(),
1983
+ conflictTargets: collectRegistryConflictTargets(),
1984
+ includeSystemTables,
1985
+ engineHint: dbType
1986
+ }),
1987
+ database,
1988
+ error: null
1989
+ };
1990
+ } catch (error) {
1991
+ return {
1992
+ report: null,
1993
+ database: null,
1994
+ error: error instanceof Error ? error.message : String(error)
1995
+ };
1996
+ } finally {
1997
+ await closeDatabaseConnection(db);
1998
+ }
1999
+ }
2000
+ var SEVERITY_ICON = {
2001
+ error: "❌",
2002
+ warning: "⚠️",
2003
+ info: "ℹ️"
2004
+ };
2005
+ /** Findings of one severity, ordered by table then target. */
2006
+ function selectFindings(report, severity) {
2007
+ return report.findings.filter((finding) => finding.severity === severity).sort((left, right) => left.table.localeCompare(right.table) || (left.target ?? "").localeCompare(right.target ?? ""));
2008
+ }
2009
+ /**
2010
+ * Render a parity report as console lines.
2011
+ *
2012
+ * Errors and warnings always print. Informational findings (undeclared tables,
2013
+ * columns and indexes) print only with `verbose`, because on a shared database
2014
+ * they are numerous and expected.
2015
+ */
2016
+ function formatParityReport(report, options = {}) {
2017
+ const lines = [];
2018
+ lines.push(` Engine: ${report.engine} · tables checked: ${report.tablesChecked}` + (report.tablesMissing > 0 ? ` · missing: ${report.tablesMissing}` : "") + (report.systemTablesIncluded ? " · system tables included" : ""));
2019
+ if (report.indexIntrospection === "unavailable") lines.push(" ⚠️ Index metadata is not readable on this engine; index, uniqueness and conflict-target checks were skipped.");
2020
+ const severities = options.verbose ? [
2021
+ "error",
2022
+ "warning",
2023
+ "info"
2024
+ ] : ["error", "warning"];
2025
+ for (const severity of severities) for (const finding of selectFindings(report, severity)) {
2026
+ lines.push(` ${SEVERITY_ICON[severity]} ${finding.message}`);
2027
+ if (options.verbose) lines.push(` → ${finding.recommendation}`);
2028
+ }
2029
+ if (report.counts.info > 0 && !options.verbose) lines.push(` ℹ️ ${report.counts.info} informational finding(s) hidden; re-run with --verbose.`);
2030
+ if (report.findings.length === 0) lines.push(" ✅ Live schema matches the expected shape.");
2031
+ return lines;
2032
+ }
2033
+ //#endregion
1425
2034
  //#region src/commands/schema-contract.ts
1426
2035
  var SchemaContractError = class extends Error {
1427
2036
  report;
@@ -1777,9 +2386,76 @@ function summarizeSchemaDiff(diff) {
1777
2386
  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
2387
  });
1779
2388
  break;
2389
+ case "alter_column": {
2390
+ const name = `${change.table}.${change.name ?? "(unknown)"}`;
2391
+ const driftType = change.alteration === "set_not_null" || change.alteration === "drop_not_null" ? "nullability_drift" : "default_drift";
2392
+ if (!Boolean(change.sql || change.sqlStatements?.length) && change.advisory) {
2393
+ if (change.advisory.severity !== "warning") break;
2394
+ drift.push({
2395
+ name,
2396
+ type: driftType,
2397
+ recommendation: change.advisory.message
2398
+ });
2399
+ } else if (classifyTypeUpgradeSql(change.sql) === "executable") drift.push({
2400
+ name,
2401
+ type: driftType,
2402
+ recommendation: `Run \`smrt db:migrate\` to repair this live column (${change.alteration ?? "alter_column"}: expected ${change.mismatch?.expected ?? "?"}, found ${change.mismatch?.actual ?? "?"}).`
2403
+ });
2404
+ else drift.push({
2405
+ name,
2406
+ type: driftType,
2407
+ 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).`
2408
+ });
2409
+ break;
2410
+ }
2411
+ case "orphan_column":
2412
+ if (change.advisory?.severity !== "warning") break;
2413
+ drift.push({
2414
+ name: `${change.table}.${change.name ?? "(unknown)"}`,
2415
+ type: "orphan_column_blocking",
2416
+ recommendation: change.advisory?.message ?? "Column exists in the database but not in the manifest."
2417
+ });
2418
+ break;
2419
+ case "orphan_index":
2420
+ drift.push({
2421
+ name: `${change.table}.${change.name ?? "(unknown)"}`,
2422
+ type: "orphan_unique_constraint",
2423
+ recommendation: change.advisory?.message ?? "Unique constraint exists in the database but not in the manifest."
2424
+ });
2425
+ break;
2426
+ case "drop_column":
2427
+ drift.push({
2428
+ name: `${change.table}.${change.name ?? "(unknown)"}`,
2429
+ type: "orphan_column",
2430
+ recommendation: "Run `smrt db:migrate --drop-columns` to drop this orphan column (destructive)."
2431
+ });
2432
+ break;
1780
2433
  }
1781
2434
  return drift;
1782
2435
  }
2436
+ /**
2437
+ * Info-level live-schema notes (#2369): harmless orphan columns, live
2438
+ * defaults the manifest no longer declares, and tables no loaded manifest
2439
+ * declares. Reported separately from `drift` so a JSON consumer counting
2440
+ * drift entries is not tripped by findings that need no action.
2441
+ */
2442
+ function summarizeSchemaNotes(diff) {
2443
+ const notes = [];
2444
+ for (const change of diff.changes) {
2445
+ if (Boolean(change.sql || change.sqlStatements?.length) || change.advisory?.severity !== "info") continue;
2446
+ notes.push({
2447
+ name: `${change.table}.${change.name ?? "(unknown)"}`,
2448
+ type: change.type === "orphan_column" ? "orphan_column" : change.type === "alter_column" ? "default_drift" : change.type,
2449
+ recommendation: change.advisory.message
2450
+ });
2451
+ }
2452
+ for (const table of diff.orphan_tables ?? []) notes.push({
2453
+ name: table,
2454
+ type: "orphan_table",
2455
+ recommendation: "Table exists in the database but no loaded manifest declares it. Not dropped; remove it manually if it is stale."
2456
+ });
2457
+ return notes;
2458
+ }
1783
2459
  var dbStatusCommand = {
1784
2460
  name: "db:status",
1785
2461
  description: "Show migration status (applied, pending, drift)",
@@ -1797,6 +2473,12 @@ var dbStatusCommand = {
1797
2473
  description: "Show detailed migration information",
1798
2474
  default: false,
1799
2475
  short: "v"
2476
+ },
2477
+ parity: {
2478
+ type: "boolean",
2479
+ description: "Also compare the live schema to the expected shape (incl. _smrt_* system tables and index policy)",
2480
+ default: false,
2481
+ short: "p"
1800
2482
  }
1801
2483
  },
1802
2484
  handler: async (_args, options) => {
@@ -1873,9 +2555,12 @@ var dbStatusCommand = {
1873
2555
  }))
1874
2556
  },
1875
2557
  drift: [],
2558
+ notes: [],
1876
2559
  preconditions: [],
1877
2560
  failedMigrations: summarizeFailedMigrations(failed, null),
1878
- schemaContract
2561
+ schemaContract,
2562
+ parity: null,
2563
+ parityError: null
1879
2564
  };
1880
2565
  let diff = {
1881
2566
  added_tables: [],
@@ -1887,6 +2572,7 @@ var dbStatusCommand = {
1887
2572
  const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
1888
2573
  diff = await new SchemaComparer(db).compare(manifestSchemas);
1889
2574
  status.drift = summarizeSchemaDiff(diff);
2575
+ status.notes = summarizeSchemaNotes(diff);
1890
2576
  status.preconditions = await checkTenantIdUuidPreconditions({
1891
2577
  db,
1892
2578
  dbType,
@@ -1894,7 +2580,18 @@ var dbStatusCommand = {
1894
2580
  manifestSchemas
1895
2581
  });
1896
2582
  status.failedMigrations = summarizeFailedMigrations(failed, getUnresolvedGeneratedMigrationNames(diff.changes));
1897
- }
2583
+ if (options.parity) try {
2584
+ status.parity = await checkLiveSchemaParity({
2585
+ db,
2586
+ schemas: manifestSchemas,
2587
+ conflictTargets: collectRegistryConflictTargets(),
2588
+ includeSystemTables: true,
2589
+ engineHint: dbType
2590
+ });
2591
+ } catch (error) {
2592
+ status.parityError = error instanceof Error ? error.message : String(error);
2593
+ }
2594
+ } else if (options.parity) status.parityError = "The configured database adapter cannot describe tables, so live-schema parity cannot be verified.";
1898
2595
  const failedAssessments = assessFailedMigrations(failed, typeof db.getTableSchema === "function" ? diff : null);
1899
2596
  status.migrations.failed = {
1900
2597
  total: failedAssessments.length,
@@ -1903,9 +2600,10 @@ var dbStatusCommand = {
1903
2600
  manualReview: failedAssessments.filter((item) => item.resolution === "manual_review").length,
1904
2601
  details: failedAssessments
1905
2602
  };
2603
+ const parityFailed = status.parityError !== null || status.parity?.ok === false;
1906
2604
  if (options.json) {
1907
2605
  console.log(JSON.stringify(status, null, 2));
1908
- if (!schemaContract.ok || status.preconditions.some((item) => item.status === "error")) process.exitCode = 1;
2606
+ if (!schemaContract.ok || parityFailed || status.preconditions.some((item) => item.status === "error")) process.exitCode = 1;
1909
2607
  return;
1910
2608
  }
1911
2609
  console.log(`📦 Manifests: ${status.manifests.count} discovered`);
@@ -1949,6 +2647,25 @@ var dbStatusCommand = {
1949
2647
  console.log("✅ Live schema matches current manifests");
1950
2648
  console.log();
1951
2649
  }
2650
+ if (status.notes.length > 0) {
2651
+ console.log(`ℹ️ Live schema notes (${status.notes.length}, no action required):`);
2652
+ for (const note of status.notes) {
2653
+ console.log(` • ${note.name}: ${note.type}`);
2654
+ if (options.verbose) console.log(` ${note.recommendation}`);
2655
+ }
2656
+ console.log();
2657
+ }
2658
+ if (options.parity) {
2659
+ console.log("🗄️ Live Schema Parity:");
2660
+ if (status.parityError) {
2661
+ console.log(` ❌ ${status.parityError}`);
2662
+ process.exitCode = 1;
2663
+ } else if (status.parity) {
2664
+ for (const line of formatParityReport(status.parity, { verbose: options.verbose })) console.log(line);
2665
+ if (!status.parity.ok) process.exitCode = 1;
2666
+ }
2667
+ console.log();
2668
+ }
1952
2669
  if (!schemaContract.ok) {
1953
2670
  console.log("❌ Schema Contract Failed:");
1954
2671
  console.log(formatSchemaContractFailures(schemaContract));
@@ -6512,6 +7229,15 @@ async function repairStiDiscriminatorRows(options) {
6512
7229
  *
6513
7230
  * Commands for introspection, testing, and project management
6514
7231
  */
7232
+ /**
7233
+ * Fallbacks for `migrations.postgres.lockTimeout` / `.statementTimeout`.
7234
+ *
7235
+ * These mirror `DEFAULT_CLI_CONFIG.migrations.postgres` ('30s' / '60s') so a
7236
+ * project that never wrote a `migrations` block still gets a bounded
7237
+ * PostgreSQL migration rather than an unbounded lock wait (issue #2362).
7238
+ */
7239
+ var DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 3e4;
7240
+ var DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 6e4;
6515
7241
  function formatSchemaCommandFailureHeader(error, fallback) {
6516
7242
  if (error instanceof SchemaContractError) return "\n❌ Schema contract failed:";
6517
7243
  if (error instanceof UnsupportedFileMigrationsError) return "\n❌ File-backed migrations are not supported:";
@@ -6543,6 +7269,28 @@ function resolveDDLPreviewEngine(dbType) {
6543
7269
  default: return "sqlite";
6544
7270
  }
6545
7271
  }
7272
+ /**
7273
+ * Run the framework's deferred system-table compatibility pass after
7274
+ * `db:migrate` has created the tables it reshapes (issue #2376).
7275
+ *
7276
+ * `_smrt_jobs` and `_smrt_job_events` are dual-owned — created here from the
7277
+ * jobs manifest, then given their compatibility columns and indexes by
7278
+ * `@happyvertical/smrt-core`. On a fresh install the framework bootstrap runs
7279
+ * before those tables exist, so without this call the pass would first become
7280
+ * reachable at the next process start.
7281
+ *
7282
+ * Best-effort: the pass is idempotent and re-runs on the next boot, so a
7283
+ * failure here must not fail an otherwise-successful migration.
7284
+ */
7285
+ async function settleDeferredCompatibilityAfterMigrate(db, dbType, { verbose }) {
7286
+ try {
7287
+ const { settled } = await ensureDeferredSystemTableCompatibility(db, dbType);
7288
+ if (verbose) console.log(settled ? "Deferred system-table compatibility settled (_smrt_jobs, _smrt_job_events)\n" : "Deferred system-table compatibility pending (jobs tables not present)\n");
7289
+ } catch (error) {
7290
+ console.warn(`⚠️ Deferred system-table compatibility did not complete: ${error instanceof Error ? error.message : String(error)}`);
7291
+ console.warn(" It will be retried the next time the framework starts.\n");
7292
+ }
7293
+ }
6546
7294
  function formatStiConflictIdentity(conflict) {
6547
7295
  const entries = Object.entries(conflict.conflictIdentity);
6548
7296
  return `${entries.length > 0 ? entries.map(([column, value]) => `${column}=${JSON.stringify(value)}`).join(", ") : "no non-_meta_type conflict columns"}${conflict.legacyId || conflict.qualifiedId ? ` (legacy id: ${conflict.legacyId ?? "unknown"}, qualified id: ${conflict.qualifiedId ?? "unknown"})` : ""}`;
@@ -6584,6 +7332,79 @@ function assertForceMigrationTargetsExist(forceMigrations, generatedMigrationIds
6584
7332
  const unknown = forceMigrations.filter((migrationId) => !generated.has(migrationId));
6585
7333
  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
7334
  }
7335
+ /**
7336
+ * Match an actual `oxc: { … decorator: … }` configuration block.
7337
+ *
7338
+ * Deliberately stricter than "the words `oxc` and `decorator` both appear":
7339
+ * a stray mention in a comment or an unrelated import would otherwise mark the
7340
+ * transform configured on a Vite 8 project that throws
7341
+ * `SyntaxError: Invalid or unexpected token` on its first SSR request — the
7342
+ * exact failure this check exists to catch. The lazy body stops at the first
7343
+ * `decorator:` key after the `oxc` object opens.
7344
+ *
7345
+ * A config that assembles `oxc` indirectly (a spread, or an imported base
7346
+ * config) is reported as unconfigured. That direction is the safe one: the
7347
+ * recommendation names the exact key to add, whereas the opposite error is
7348
+ * silent until runtime.
7349
+ */
7350
+ var OXC_DECORATOR_BLOCK_RE = /\boxc\s*:\s*\{[\s\S]*?\bdecorator\s*:/;
7351
+ /**
7352
+ * Read the declared Vite major version from a project's package.json.
7353
+ *
7354
+ * Returns `null` when Vite is absent or the range is not a simple one whose
7355
+ * major can be read off the front (`workspace:*`, `*`, a git URL, …); callers
7356
+ * treat that as "unknown major" and accept either decorator recipe.
7357
+ */
7358
+ function resolveDeclaredViteMajor(packageJson) {
7359
+ const range = packageJson.devDependencies?.vite ?? packageJson.dependencies?.vite;
7360
+ if (typeof range !== "string") return null;
7361
+ const match = range.match(/(\d+)\s*\./);
7362
+ if (!match) return null;
7363
+ const major = Number.parseInt(match[1], 10);
7364
+ return Number.isFinite(major) ? major : null;
7365
+ }
7366
+ /**
7367
+ * Assess whether a project's decorator transform is actually configured.
7368
+ *
7369
+ * The doctor used to require `experimentalDecorators` in tsconfig.json
7370
+ * unconditionally, which contradicts the framework's own Vite 8 guidance: the
7371
+ * oxc transform does not honor tsconfig `experimentalDecorators` (nor the
7372
+ * pre-Vite-8 `esbuild.tsconfigRaw` recipe), so a correctly configured Vite 8
7373
+ * project — decorators declared under `oxc.decorator` — was reported broken,
7374
+ * and a Vite 8 project relying on tsconfig alone was reported healthy right up
7375
+ * until the first SSR request threw `SyntaxError: Invalid or unexpected token`.
7376
+ *
7377
+ * The check now follows the documented recipe per Vite major:
7378
+ * - Vite 8+: `oxc.decorator` in vite.config is the only working configuration.
7379
+ * - Vite <8 (or no declared Vite): tsconfig `experimentalDecorators` is the
7380
+ * legacy recipe and remains valid.
7381
+ */
7382
+ function assessDecoratorSupport(input) {
7383
+ const { viteMajor, viteConfigContent, tsconfigContent } = input;
7384
+ const hasOxcDecorator = viteConfigContent !== null && OXC_DECORATOR_BLOCK_RE.test(viteConfigContent);
7385
+ const hasTsconfigDecorators = tsconfigContent?.includes("experimentalDecorators");
7386
+ const isVite8Plus = viteMajor !== null && viteMajor >= 8;
7387
+ if (hasOxcDecorator) return {
7388
+ status: "ok",
7389
+ message: "oxc.decorator configured in vite.config"
7390
+ };
7391
+ if (isVite8Plus) return {
7392
+ status: "error",
7393
+ 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+)"
7394
+ };
7395
+ if (hasTsconfigDecorators) return {
7396
+ status: "ok",
7397
+ message: "experimentalDecorators enabled in tsconfig.json"
7398
+ };
7399
+ if (viteConfigContent === null && tsconfigContent === null) return {
7400
+ status: "warning",
7401
+ message: "No vite.config or tsconfig.json found - configure the decorator transform wherever this project compiles @smrt() classes"
7402
+ };
7403
+ return {
7404
+ status: "error",
7405
+ 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)"
7406
+ };
7407
+ }
6587
7408
  function getErrorContext(error) {
6588
7409
  if (error && typeof error === "object" && "context" in error) {
6589
7410
  const context = error.context;
@@ -7141,7 +7962,7 @@ export default testManifest;
7141
7962
  },
7142
7963
  "postgres-safe": {
7143
7964
  type: "boolean",
7144
- description: "Use PostgreSQL-safe operations (CONCURRENTLY for indexes, lock_timeout)",
7965
+ 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
7966
  default: false
7146
7967
  },
7147
7968
  "postgres-timestamp-legacy-timezone": {
@@ -7173,6 +7994,16 @@ export default testManifest;
7173
7994
  description: "Drop orphan indexes (in DB but not in manifest, excluding *_pkey/*_key implicit-from-constraint indexes). Off by default.",
7174
7995
  default: false
7175
7996
  },
7997
+ "drop-columns": {
7998
+ type: "boolean",
7999
+ description: "DESTRUCTIVE: drop orphan columns (in DB but not in manifest). Off by default; orphans are always reported.",
8000
+ default: false
8001
+ },
8002
+ "relax-columns": {
8003
+ type: "boolean",
8004
+ 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.",
8005
+ default: false
8006
+ },
7176
8007
  verbose: {
7177
8008
  type: "boolean",
7178
8009
  description: "Show detailed output",
@@ -7266,16 +8097,26 @@ export default testManifest;
7266
8097
  console.log(`✓ Connected to ${formatDatabaseDisplayUrl(dbType, dbUrl)}\n`);
7267
8098
  const systemTimestampPreview = postgresTimestampMigration && isDryRun ? await planPostgresSystemTimestampMigrations(db, postgresTimestampMigration, dbType) : [];
7268
8099
  if (postgresTimestampMigration && !isDryRun) await migratePostgresSystemTimestamps(db, postgresTimestampMigration, dbType);
7269
- const { MigrationTracker, shortChecksum } = await import("@happyvertical/smrt-core/migrations");
8100
+ const { buildConcurrentIndexPlan, MigrationTracker, parsePostgresTimeoutMs, shortChecksum } = await import("@happyvertical/smrt-core/migrations");
8101
+ const postgresMigrationConfig = config.migrations?.postgres;
8102
+ const lockTimeout = parsePostgresTimeoutMs(postgresMigrationConfig?.lockTimeout, DEFAULT_MIGRATION_LOCK_TIMEOUT_MS);
8103
+ const statementTimeout = parsePostgresTimeoutMs(postgresMigrationConfig?.statementTimeout, DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS);
8104
+ const concurrentIndexMode = Boolean(options["postgres-safe"]) && (postgresMigrationConfig?.useConcurrently ?? true);
7270
8105
  const tracker = new MigrationTracker({
7271
8106
  db,
7272
- useConcurrentIndexes: options["postgres-safe"] ?? false
8107
+ lockTimeout,
8108
+ statementTimeout,
8109
+ useConcurrentIndexes: concurrentIndexMode
7273
8110
  });
7274
8111
  if (!isDryRun) await tracker.initialize();
7275
8112
  if (options.verbose) {
7276
8113
  const engine = tracker.getEngine();
7277
8114
  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)");
8115
+ if (engine === "postgres") {
8116
+ console.log(`PostgreSQL lock_timeout: ${lockTimeout}ms, statement_timeout: ${statementTimeout}ms`);
8117
+ if (concurrentIndexMode) console.log("PostgreSQL concurrent-index mode enabled (CREATE INDEX CONCURRENTLY outside the atomic batch)");
8118
+ else if (options["postgres-safe"]) console.log("PostgreSQL concurrent-index mode disabled by migrations.postgres.useConcurrently = false");
8119
+ }
7279
8120
  console.log();
7280
8121
  }
7281
8122
  const migrations = [];
@@ -7290,6 +8131,8 @@ export default testManifest;
7290
8131
  console.log("🔍 Comparing schemas...\n");
7291
8132
  const diff = await new SchemaComparer(db, {
7292
8133
  includeDroppedIndexes: Boolean(options["drop-indexes"]),
8134
+ includeDroppedColumns: Boolean(options["drop-columns"]),
8135
+ relaxColumns: Boolean(options["relax-columns"]),
7293
8136
  postgresTimestampMigration
7294
8137
  }).compare(manifestSchemas);
7295
8138
  const getClassForTable = (tableName) => {
@@ -7308,6 +8151,7 @@ export default testManifest;
7308
8151
  const partitionedChanges = partitionSchemaChanges(diff.changes, getClassForTable);
7309
8152
  migrations.push(...partitionedChanges.migrations);
7310
8153
  manualInterventions.push(...partitionedChanges.manualInterventions);
8154
+ const advisories = partitionedChanges.advisories;
7311
8155
  assertForceMigrationTargetsExist(forceSelection.forceMigrations, [...diff.added_tables.map((schema) => `create_table_${schema.tableName}`), ...migrations.flatMap((migration) => {
7312
8156
  const migrationName = getSyntheticMigrationNameForAction(migration);
7313
8157
  return migrationName ? [migrationName] : [];
@@ -7317,17 +8161,22 @@ export default testManifest;
7317
8161
  console.log("⚠️ Schema drift detected that requires manual intervention:\n");
7318
8162
  for (const change of manualInterventions) {
7319
8163
  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}`;
8164
+ 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
8165
  console.log(` ${detail}`);
7322
8166
  }
7323
8167
  console.log();
7324
8168
  console.log(" Manual migration required (backup, recreate, restore as needed).\n");
7325
8169
  }
8170
+ printSchemaAdvisories(advisories, {
8171
+ orphanTables: diff.orphan_tables ?? [],
8172
+ verbose: Boolean(options.verbose)
8173
+ });
7326
8174
  const tablesCreated = diff.added_tables.length > 0;
7327
8175
  const schemaUpToDate = migrations.length === 0 && manualInterventions.length === 0 && !tablesCreated && systemTimestampPreview.length === 0;
8176
+ 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
8177
  if (isDryRun) {
7329
8178
  if (schemaUpToDate && !repairData) {
7330
- console.log("✅ Database schema is up to date - no migrations needed\n");
8179
+ console.log(upToDateMessage);
7331
8180
  return;
7332
8181
  }
7333
8182
  if (schemaUpToDate) console.log("✅ Database schema is up to date - no schema migrations needed\n");
@@ -7341,11 +8190,23 @@ export default testManifest;
7341
8190
  const columnMigrations = migrations.filter((m) => m.type === "add_column");
7342
8191
  const indexMigrations = migrations.filter((m) => m.type === "add_index");
7343
8192
  const indexDrops = migrations.filter((m) => m.type === "drop_index");
8193
+ const columnAlterations = migrations.filter((m) => m.type === "alter_column");
8194
+ const columnDrops = migrations.filter((m) => m.type === "drop_column");
7344
8195
  if (columnMigrations.length > 0) {
7345
8196
  console.log(` 📊 Columns to add: ${columnMigrations.length}`);
7346
8197
  for (const m of columnMigrations) console.log(` ${m.tableName}.${m.column?.name} (${m.column?.type})`);
7347
8198
  console.log();
7348
8199
  }
8200
+ if (columnAlterations.length > 0) {
8201
+ console.log(` 🔧 Columns to alter: ${columnAlterations.length}`);
8202
+ for (const m of columnAlterations) console.log(` ${m.tableName}.${m.columnName ?? m.column?.name} (${m.alteration ?? "alter"}: ${m.mismatch?.actual ?? "?"} → ${m.mismatch?.expected ?? "?"})`);
8203
+ console.log();
8204
+ }
8205
+ if (columnDrops.length > 0) {
8206
+ console.log(` 🗑️ Columns to drop (DESTRUCTIVE, --drop-columns): ${columnDrops.length}`);
8207
+ for (const m of columnDrops) console.log(` ${m.tableName}.${m.columnName}`);
8208
+ console.log();
8209
+ }
7349
8210
  if (indexDrops.length > 0) {
7350
8211
  console.log(` 🗑️ Indexes to drop: ${indexDrops.length}`);
7351
8212
  for (const m of indexDrops) console.log(` ${m.indexName} on ${m.tableName}`);
@@ -7378,7 +8239,6 @@ export default testManifest;
7378
8239
  let errorCount = 0;
7379
8240
  let stiErrorCount = 0;
7380
8241
  const schemaChangeCount = diff.added_tables.length + migrations.length;
7381
- if (applySchemaMigrations && schemaChangeCount > 0) console.log(`🔨 Applying ${schemaChangeCount} schema change(s) atomically...\n`);
7382
8242
  if (applySchemaMigrations && schemaChangeCount > 0) {
7383
8243
  const migrationDefs = [];
7384
8244
  const migrationLogs = /* @__PURE__ */ new Map();
@@ -7410,6 +8270,12 @@ export default testManifest;
7410
8270
  if (migration.type === "add_column" && migration.column) {
7411
8271
  migrationSql = migration.sql || "";
7412
8272
  actionDesc = `Added column ${migration.tableName}.${migration.column.name}`;
8273
+ } else if (migration.type === "alter_column" && (migration.columnName || migration.column)) {
8274
+ migrationSql = migration.sql || "";
8275
+ actionDesc = `Altered column ${migration.tableName}.${migration.columnName ?? migration.column?.name} (${migration.alteration ?? "alter"})`;
8276
+ } else if (migration.type === "drop_column" && migration.columnName) {
8277
+ migrationSql = migration.sql || "";
8278
+ actionDesc = `Dropped column ${migration.tableName}.${migration.columnName}`;
7413
8279
  } else if (migration.type === "type_upgrade" && migration.column) {
7414
8280
  migrationSql = migration.sql || "";
7415
8281
  actionDesc = `Upgraded column ${migration.tableName}.${migration.column.name} from ${migration.mismatch?.actual} to ${migration.mismatch?.expected}`;
@@ -7423,7 +8289,7 @@ export default testManifest;
7423
8289
  const migrationSqlStatements = migration.sqlStatements ?? (migrationSql ? [migrationSql] : []);
7424
8290
  migrationDefs.push({
7425
8291
  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}`,
8292
+ 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
8293
  version: "1.0.0",
7428
8294
  up: migrationSqlStatements,
7429
8295
  down: []
@@ -7433,14 +8299,20 @@ export default testManifest;
7433
8299
  skippedMessage: `${migrationName} already applied`
7434
8300
  });
7435
8301
  }
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");
8302
+ const deferredIndexMigrations = concurrentIndexMode && engine === "postgres" ? buildConcurrentIndexPlan(migrationDefs, true).size : 0;
8303
+ const batchHasIndexDDL = migrations.some((migration) => migration.type === "add_index" || migration.type === "drop_index");
8304
+ 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`);
8305
+ if (deferredIndexMigrations > 0) {
8306
+ console.warn("⚠️ PostgreSQL concurrent-index mode: index DDL runs CONCURRENTLY after the non-index batch commits.");
8307
+ 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");
8308
+ } else if (engine === "postgres" && batchHasIndexDDL && options["postgres-safe"] && !concurrentIndexMode) {
8309
+ console.warn("⚠️ --postgres-safe requested, but migrations.postgres.useConcurrently is false.");
8310
+ console.warn(" Index DDL in this batch will run inside the atomic transaction (bounded by lock_timeout/statement_timeout).\n");
7439
8311
  }
7440
8312
  try {
7441
8313
  const results = await tracker.applyAll(migrationDefs, {
7442
8314
  atomic: true,
7443
- postgresSafe: false,
8315
+ postgresSafe: concurrentIndexMode,
7444
8316
  force: forceSelection.force,
7445
8317
  forceMigrations: forceSelection.forceMigrations,
7446
8318
  reconcile: true,
@@ -7465,10 +8337,10 @@ export default testManifest;
7465
8337
  console.error(` ✗ atomic schema migration failed: ${errorMsg}`);
7466
8338
  if (error instanceof Error && getErrorContext(error)?.originalError) console.error(` Cause: ${getErrorContext(error)?.originalError}`);
7467
8339
  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.");
8340
+ 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
8341
  }
7470
8342
  }
7471
- if (schemaUpToDate && !repairData) console.log("✅ Database schema is up to date - no migrations needed\n");
8343
+ if (schemaUpToDate && !repairData) console.log(upToDateMessage);
7472
8344
  if (repairData) {
7473
8345
  console.log("\n🔄 Repairing STI discriminators to qualified names...\n");
7474
8346
  const allTableNames = /* @__PURE__ */ new Set();
@@ -7564,11 +8436,14 @@ export default testManifest;
7564
8436
  stiErrorCount,
7565
8437
  dryRun: isDryRun
7566
8438
  })) process.exitCode = 1;
7567
- if (!isDryRun) assertSchemaContract(await evaluateSchemaContract({
7568
- discovered,
7569
- schemaContract: config.schemaContract,
7570
- db
7571
- }));
8439
+ if (!isDryRun) {
8440
+ await settleDeferredCompatibilityAfterMigrate(db, dbType, { verbose: Boolean(options.verbose) });
8441
+ assertSchemaContract(await evaluateSchemaContract({
8442
+ discovered,
8443
+ schemaContract: config.schemaContract,
8444
+ db
8445
+ }));
8446
+ }
7572
8447
  console.log("💡 Next steps:");
7573
8448
  console.log(" - Run: smrt db:status (view migration status)");
7574
8449
  console.log(" - Run: smrt db:history (view migration history)");
@@ -7607,6 +8482,17 @@ export default testManifest;
7607
8482
  default: false,
7608
8483
  short: "f"
7609
8484
  },
8485
+ db: {
8486
+ type: "boolean",
8487
+ description: "Compare the live database to the expected schema shape (incl. _smrt_* system tables)",
8488
+ default: false
8489
+ },
8490
+ verbose: {
8491
+ type: "boolean",
8492
+ description: "Show recommendations and informational findings",
8493
+ default: false,
8494
+ short: "v"
8495
+ },
7610
8496
  "generation-snapshot": {
7611
8497
  type: "string",
7612
8498
  description: "Verify a transported SMRT generation snapshot"
@@ -7713,14 +8599,22 @@ export default testManifest;
7713
8599
  const viteConfigJs = resolve(cwd, "vite.config.js");
7714
8600
  const hasViteConfig = existsSync(viteConfigTs) || existsSync(viteConfigJs);
7715
8601
  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\"");
8602
+ const viteConfigContent = hasViteConfig ? readFileSync(existsSync(viteConfigTs) ? viteConfigTs : viteConfigJs, "utf-8") : null;
8603
+ if (viteConfigContent !== null) check("smrtPlugin in vite.config", viteConfigContent.includes("smrtPlugin"), "Missing smrtPlugin - add: import { smrtPlugin } from \"@happyvertical/smrt-core/vite-plugin\"");
7717
8604
  const tsconfigPath = resolve(cwd, "tsconfig.json");
8605
+ let tsconfigContent = null;
7718
8606
  if (existsSync(tsconfigPath)) try {
7719
- check("experimentalDecorators enabled", readFileSync(tsconfigPath, "utf-8").includes("experimentalDecorators"), "Add \"experimentalDecorators\": true to tsconfig.json");
8607
+ tsconfigContent = readFileSync(tsconfigPath, "utf-8");
7720
8608
  } catch {
7721
8609
  check("tsconfig.json readable", false, "Could not read tsconfig.json");
7722
8610
  }
7723
8611
  else check("tsconfig.json exists", false, void 0, "No tsconfig.json found (optional for JavaScript projects)");
8612
+ const decoratorSupport = assessDecoratorSupport({
8613
+ viteMajor: resolveDeclaredViteMajor(packageJson),
8614
+ viteConfigContent,
8615
+ tsconfigContent
8616
+ });
8617
+ check("Decorator transform configured", decoratorSupport.status === "ok", decoratorSupport.status === "error" ? decoratorSupport.message : void 0, decoratorSupport.status === "warning" ? decoratorSupport.message : void 0);
7724
8618
  console.log();
7725
8619
  console.log("📦 SMRT Objects\n");
7726
8620
  const objectsDir = resolve(cwd, "src/lib/objects");
@@ -7774,6 +8668,21 @@ export default testManifest;
7774
8668
  else if (existsSync(envExamplePath)) check(".env file exists", false, void 0, ".env.example exists - copy it to .env");
7775
8669
  else check(".env file exists", false, void 0, "No .env file - environment variables may be needed");
7776
8670
  console.log();
8671
+ if (options.db) {
8672
+ console.log("🗄️ Live Database Parity\n");
8673
+ const { report, error, database } = await runLiveSchemaParity({ discover: false });
8674
+ if (!report) check("Live schema parity", false, error ?? "Could not verify the live schema");
8675
+ else {
8676
+ if (database) console.log(` Database: ${database.url}`);
8677
+ for (const line of formatParityReport(report, { verbose: options.verbose })) console.log(line);
8678
+ const parityErrors = selectFindings(report, "error");
8679
+ const parityWarnings = selectFindings(report, "warning");
8680
+ for (const finding of parityErrors) issues.push(`live schema: ${finding.table}${finding.target ? `.${finding.target}` : ""} (${finding.kind})`);
8681
+ for (const finding of parityWarnings) warnings.push(`live schema: ${finding.table}${finding.target ? `.${finding.target}` : ""} (${finding.kind})`);
8682
+ if (parityErrors.length === 0 && parityWarnings.length === 0) passed.push("Live schema parity");
8683
+ }
8684
+ console.log();
8685
+ }
7777
8686
  console.log("━".repeat(50));
7778
8687
  console.log(`\n📊 Summary\n`);
7779
8688
  console.log(` ✅ Passed: ${passed.length}`);
@@ -7805,6 +8714,7 @@ export default testManifest;
7805
8714
  "db:rollback": dbRollbackCommand,
7806
8715
  "db:generate": dbGenerateCommand,
7807
8716
  "db:migrate-uuid": dbMigrateUuidCommand,
8717
+ "db:prune": dbPruneCommand,
7808
8718
  "config:export": configExportCommand,
7809
8719
  export: exportCommand
7810
8720
  };