@happyvertical/smrt-cli 0.47.1 → 0.48.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.
@@ -6,7 +6,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
6
6
  import * as path from "node:path";
7
7
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
- import { ObjectRegistry, SchemaComparer, checkLiveSchemaParity, createQualifiedName, ensureDeferredSystemTableCompatibility, generateDDLForEngine, getClassName, isQualifiedName, migratePostgresSystemTimestamps, parseQualifiedName, planForeignKeyCreation, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
9
+ import { ObjectRegistry, SchemaComparer, checkLiveSchemaParity, collectForeignKeyOrphanCounts, createQualifiedName, ensureDeferredSystemTableCompatibility, generateDDLForEngine, getClassName, isQualifiedName, isSmrtCollectionExtendsName, migratePostgresSystemTimestamps, parseQualifiedName, planForeignKeyCreation, planPostgresSystemTimestampMigrations } from "@happyvertical/smrt-core";
10
10
  import { loadExternalManifestSync } from "@happyvertical/smrt-core/manifest";
11
11
  import { access, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
12
12
  import { createLogger } from "@happyvertical/logger";
@@ -22,8 +22,10 @@ import https from "node:https";
22
22
  import { homedir, tmpdir } from "node:os";
23
23
  import { extract } from "tar";
24
24
  import { importWorkspaceModule } from "@happyvertical/smrt-core/utils/import-workspace-module";
25
+ import { renderForeignKeyAddStatements } from "@happyvertical/smrt-core/schema";
25
26
  import { FRAMEWORK_BASE_TABLE_NAMES, buildIntegerWidthTableStatements, collectIntegerWidthTargets, dropFrameworkBaseTables, parsePostgresTimeoutMs, planFrameworkBaseTableDrop, preflightIntegerWidthWidening, widenIntegerColumnsToBigInt } from "@happyvertical/smrt-core/migrations";
26
27
  import { buildArchitectureContext, buildKnowledgeIndex, buildReviewContext, checkKnowledgeFreshness, compactContextResult, diffKnowledgeIndex, renderFreshnessResult, renderKnowledgeIndexMarkdown } from "@happyvertical/smrt-dev-mcp/knowledge";
28
+ import { RUNTIME_TOOLS } from "@happyvertical/smrt-dev-mcp/runtime";
27
29
  //#region src/commands/db-command-utils.ts
28
30
  var SENSITIVE_QUERY_PARAMS = /* @__PURE__ */ new Set([
29
31
  "access_token",
@@ -623,6 +625,7 @@ function partitionSchemaChanges(changes, getClassForTable) {
623
625
  switch (change.type) {
624
626
  case "orphan_column":
625
627
  case "orphan_index":
628
+ case "rename_data_pending":
626
629
  if (!change.name || !change.advisory) continue;
627
630
  advisories.push({
628
631
  type: change.type,
@@ -732,7 +735,11 @@ function partitionSchemaChanges(changes, getClassForTable) {
732
735
  className,
733
736
  sql: change.sql,
734
737
  ...change.sqlStatements ? { sqlStatements: change.sqlStatements } : {},
735
- advisory: change.advisory
738
+ advisory: change.advisory,
739
+ ...change.foreignKey ? { foreignKey: change.foreignKey } : {},
740
+ ...change.orphanBlocked !== void 0 ? { orphanBlocked: change.orphanBlocked } : {},
741
+ ...change.orphanNullable !== void 0 ? { orphanNullable: change.orphanNullable } : {},
742
+ ...change.engineUnsupported !== void 0 ? { engineUnsupported: change.engineUnsupported } : {}
736
743
  };
737
744
  if (isAdvisoryOnlyChangeLike(change)) manualInterventions.push(action);
738
745
  else migrations.push(action);
@@ -856,9 +863,274 @@ function describeAdvisory(item) {
856
863
  case "orphan_column": return `${item.tableName}.${item.name}: orphan column${item.actual ? ` (${item.actual})` : ""}`;
857
864
  case "orphan_index": return `${item.tableName}.${item.name}: unique constraint not in manifest${item.actual ? ` (${item.actual})` : ""}`;
858
865
  case "type_upgrade": return `${item.tableName}.${item.name}: type upgrade blocked${item.actual ? ` (live: ${item.actual})` : ""}`;
866
+ case "rename_data_pending": return `${item.tableName}.${item.name}: rename data pending${item.actual ? ` (${item.actual})` : ""}`;
859
867
  default: return `${item.tableName}.${item.name}: ${item.alteration ?? "alter_column"}${item.actual ? ` (live: ${item.actual})` : ""}`;
860
868
  }
861
869
  }
870
+ /** `table.column` identity used to key a blocked column across actions. */
871
+ function blockedColumnKey(tableName, columnName) {
872
+ return `${tableName}.${columnName}`;
873
+ }
874
+ /**
875
+ * Column that a manual-intervention action blocks, if any. `type_mismatch`,
876
+ * a manual `type_upgrade`, and a manual `alter_column` each name exactly one
877
+ * live column via `mismatch.column`; a blocked `add_foreign_key` names its
878
+ * child column via `foreignKey.column` — unless the block reason is
879
+ * `engineUnsupported` (this engine cannot express `ALTER TABLE ADD
880
+ * CONSTRAINT` at all, e.g. SQLite/DuckDB): that says nothing about the
881
+ * column's own state, no rerun on this engine ever resolves it, and
882
+ * treating it as a blocked column would withhold unrelated dependent DDL
883
+ * on that column permanently (review finding, #2748). That case blocks no
884
+ * column identity and is excluded from dependency analysis.
885
+ */
886
+ function blockedColumnForAction(action) {
887
+ if (action.type === "add_foreign_key" && action.foreignKey && !action.engineUnsupported) return blockedColumnKey(action.tableName, action.foreignKey.column);
888
+ const column = action.mismatch?.column ?? action.columnName ?? action.column?.name;
889
+ return column ? blockedColumnKey(action.tableName, column) : void 0;
890
+ }
891
+ /**
892
+ * Every column a manual intervention blocks, keyed `table.column`, with the
893
+ * human-readable reason (the action's advisory message, or a description of
894
+ * the type mismatch) a dependent change is withheld for. `advisories`
895
+ * includes only report-only `type_upgrade` findings (the #2608 refused
896
+ * uuid convergence) — that shape is a genuine, permanent column-state block
897
+ * that names a column just as concretely as a manual intervention does, so
898
+ * omitting it let `--apply-unblocked` apply an index/alter/drop against a
899
+ * column whose type convergence is itself blocked (review finding, #2748).
900
+ *
901
+ * `alter_column` advisories are deliberately excluded even though the type
902
+ * exists on `SchemaAdvisory`: an advisory-only `alter_column` is produced
903
+ * only by an un-opted-into relaxation (`drop_default`/`drop_not_null` when
904
+ * `--relax-columns` was not passed) — it says the live column is *stricter*
905
+ * than the manifest, not that the column's state blocks anything, and it
906
+ * reappears on every run regardless of `--apply-unblocked`. Treating it as
907
+ * a blocked column reproduced the exact defect this function's
908
+ * `engineUnsupported` exclusion fixed for `add_foreign_key`: permanently
909
+ * withholding unrelated executable DDL on that column (review finding,
910
+ * #2748, second pass). A genuinely blocked `alter_column` (e.g. NOT NULL
911
+ * required with live NULLs and no default) carries executable-looking SQL
912
+ * as a comment and already reaches `manualInterventions` instead, where it
913
+ * is covered by the loop above via `columnName`.
914
+ */
915
+ function computeBlockedColumns(manualInterventions, advisories = []) {
916
+ const blocked = /* @__PURE__ */ new Map();
917
+ for (const action of manualInterventions) {
918
+ const key = blockedColumnForAction(action);
919
+ if (!key || blocked.has(key)) continue;
920
+ blocked.set(key, describeBlockedReason(action));
921
+ }
922
+ for (const advisory of advisories) {
923
+ if (advisory.type !== "type_upgrade") continue;
924
+ const key = blockedColumnKey(advisory.tableName, advisory.name);
925
+ if (blocked.has(key)) continue;
926
+ blocked.set(key, advisory.advisory.message ?? `${advisory.type} requires manual intervention`);
927
+ }
928
+ return blocked;
929
+ }
930
+ function describeBlockedReason(action) {
931
+ if (action.advisory?.message) return action.advisory.message;
932
+ if (action.mismatch) return `expected ${action.mismatch.expected}, found ${action.mismatch.actual}`;
933
+ return `${action.type} requires manual intervention`;
934
+ }
935
+ /**
936
+ * `table.column` keys whose only manual-intervention block is an
937
+ * orphan-blocked `add_foreign_key` on that same (child) column — a
938
+ * row-data condition (existing rows don't match a parent), not a
939
+ * column-state one. An un-opted-into `--relax-columns` relaxation on that
940
+ * column (`DROP NOT NULL`/`DROP DEFAULT`) is not unsafe against orphan
941
+ * rows; it is in fact the exact remediation that makes a NOT-NULL-child
942
+ * orphan block eventually resolvable by `--null-orphans` on a later run.
943
+ * Gating that `alter_column` on the orphan block made `--apply-unblocked
944
+ * --relax-columns` withhold it forever — the relaxation never applies, the
945
+ * live column never becomes nullable, the orphan block never clears, in a
946
+ * stable non-converging loop (review finding, #2748). Every other
947
+ * dependent shape on that column (an index, an unrelated foreign key, a
948
+ * `drop_column`) still correctly stays gated by the orphan block; this set
949
+ * narrows the exclusion to `alter_column` alone at the call site.
950
+ */
951
+ function computeOrphanOnlyBlockedColumns(manualInterventions) {
952
+ const orphanOnly = /* @__PURE__ */ new Set();
953
+ const otherwiseBlocked = /* @__PURE__ */ new Set();
954
+ for (const action of manualInterventions) {
955
+ const key = blockedColumnForAction(action);
956
+ if (!key) continue;
957
+ if (action.type === "add_foreign_key" && action.orphanBlocked) orphanOnly.add(key);
958
+ else otherwiseBlocked.add(key);
959
+ }
960
+ for (const key of otherwiseBlocked) orphanOnly.delete(key);
961
+ return orphanOnly;
962
+ }
963
+ /**
964
+ * Column(s) an executable migration action reads or writes, for dependency
965
+ * analysis against `computeBlockedColumns()`. An `add_index` depends on
966
+ * every indexed column; an `add_foreign_key` depends on its own child
967
+ * column AND the parent column it references (a parent whose type upgrade
968
+ * is blocked is just as unsafe to reference); `alter_column`/`drop_column`
969
+ * depend on the single column they touch. `add_column`/`type_upgrade`/
970
+ * `drop_index` never depend on another column's state — they are either the
971
+ * fix itself or fully self-contained.
972
+ */
973
+ function actionColumnDependencies(action) {
974
+ switch (action.type) {
975
+ case "add_index": return (action.index?.columns ?? []).map((columnName) => ({
976
+ tableName: action.tableName,
977
+ columnName
978
+ }));
979
+ case "add_foreign_key":
980
+ if (!action.foreignKey) return [];
981
+ return [{
982
+ tableName: action.tableName,
983
+ columnName: action.foreignKey.column
984
+ }, {
985
+ tableName: action.foreignKey.referencesTable,
986
+ columnName: action.foreignKey.referencesColumn
987
+ }];
988
+ case "alter_column":
989
+ case "drop_column": {
990
+ const columnName = action.columnName ?? action.column?.name;
991
+ return columnName ? [{
992
+ tableName: action.tableName,
993
+ columnName
994
+ }] : [];
995
+ }
996
+ default: return [];
997
+ }
998
+ }
999
+ /**
1000
+ * Partition executable migrations (the differ's "safe DDL" bucket) into
1001
+ * those independent of every blocked column and those that depend on one
1002
+ * (#2748's dependency rule): an index or foreign key on a column whose type
1003
+ * upgrade is blocked, or a foreign key whose orphan rows block it, stays
1004
+ * withheld; everything else is applied. Pure and order-preserving so a
1005
+ * dry-run preview and a real apply partition identically.
1006
+ *
1007
+ * A `drop_index` carries no column list of its own (a DB-side index being
1008
+ * removed; see `MigrationAction.indexName`'s doc comment), so it is never
1009
+ * itself a dependency target — EXCEPT when it is the drop half of the
1010
+ * #1165 shape-drift recreate pair (`drop_index` then `add_index` for the
1011
+ * SAME name, in that order, in the same batch). Withholding the `add_index`
1012
+ * half alone while letting its paired `drop_index` proceed would remove the
1013
+ * existing index/uniqueness enforcement with no replacement — reachable and
1014
+ * unsafe (review, #2748) — so a `drop_index` withholds together with any
1015
+ * `add_index` of the same name that this partition withheld.
1016
+ */
1017
+ function partitionUnblockedMigrations(migrations, blockedColumns, orphanOnlyBlockedColumns = /* @__PURE__ */ new Set()) {
1018
+ const directDependency = (action) => {
1019
+ const dependency = actionColumnDependencies(action).find((dep) => {
1020
+ const key = blockedColumnKey(dep.tableName, dep.columnName);
1021
+ if (!blockedColumns.has(key)) return false;
1022
+ if (action.type === "alter_column" && orphanOnlyBlockedColumns.has(key)) return false;
1023
+ return true;
1024
+ });
1025
+ if (!dependency) return void 0;
1026
+ const dependsOn = blockedColumnKey(dependency.tableName, dependency.columnName);
1027
+ return {
1028
+ dependsOn,
1029
+ reason: blockedColumns.get(dependsOn) ?? "depends on a blocked change"
1030
+ };
1031
+ };
1032
+ const withheldAddIndexDependency = /* @__PURE__ */ new Map();
1033
+ const direct = migrations.map((action) => {
1034
+ const dependency = directDependency(action);
1035
+ if (dependency && action.type === "add_index" && action.index?.name) withheldAddIndexDependency.set(action.index.name, dependency);
1036
+ return {
1037
+ action,
1038
+ dependency
1039
+ };
1040
+ });
1041
+ const applied = [];
1042
+ const withheld = [];
1043
+ for (const { action, dependency } of direct) {
1044
+ if (dependency) {
1045
+ withheld.push({
1046
+ action,
1047
+ ...dependency
1048
+ });
1049
+ continue;
1050
+ }
1051
+ const paired = action.type === "drop_index" && action.indexName ? withheldAddIndexDependency.get(action.indexName) : void 0;
1052
+ if (paired) {
1053
+ withheld.push({
1054
+ action,
1055
+ dependsOn: paired.dependsOn,
1056
+ reason: `paired with the withheld rebuild of index ${action.indexName} (${paired.reason})`
1057
+ });
1058
+ continue;
1059
+ }
1060
+ applied.push(action);
1061
+ }
1062
+ return {
1063
+ applied,
1064
+ withheld
1065
+ };
1066
+ }
1067
+ /**
1068
+ * Drop any pending post-apply report entry whose `action` reference was
1069
+ * withheld by `partitionUnblockedMigrations()` (review finding, #2748): a
1070
+ * `--null-orphans` combined null+add-FK migration can itself depend on a
1071
+ * separately blocked *parent* column and get withheld under
1072
+ * `--apply-unblocked` even though it already resolved out of
1073
+ * `manualInterventions`. Without this filter, the post-apply report printed
1074
+ * a `✓ ... resolved` line for a disposition that never executed — the same
1075
+ * relationship the `🔒 Withheld` listing (from `withheld`) already names.
1076
+ * Matches by object identity, not by table/column, since the caller pushed
1077
+ * the exact `MigrationAction` reference into both `migrations` and the
1078
+ * pending entry.
1079
+ */
1080
+ function filterUnresolvedOrphanDispositions(pending, withheld) {
1081
+ if (withheld.length === 0) return [...pending];
1082
+ const withheldActions = new Set(withheld.map((item) => item.action));
1083
+ return pending.filter((item) => !withheldActions.has(item.action));
1084
+ }
1085
+ /**
1086
+ * Split `manualInterventions` into orphan-blocked `add_foreign_key` actions
1087
+ * whose child column is nullable (an opt-in `--null-orphans` disposition can
1088
+ * null the references and proceed) and every other manual intervention,
1089
+ * including orphan-blocked FKs on a NOT NULL child column, which keep the
1090
+ * existing "Manual repair required" refusal (#2748) unconditionally — a
1091
+ * caller must never delete rows to make room for one of these.
1092
+ *
1093
+ * Reuses the differ's own rendered `advisory.suggestedSql` (detector at
1094
+ * index 0, repair at index 1) rather than re-deriving the orphan probe's
1095
+ * uuid-cast options, so the disposition always runs exactly the SQL the
1096
+ * differ already decided on.
1097
+ */
1098
+ function planOrphanDispositions(manualInterventions) {
1099
+ const nullable = [];
1100
+ const notNullable = [];
1101
+ for (const action of manualInterventions) {
1102
+ if (action.type !== "add_foreign_key" || !action.orphanBlocked) continue;
1103
+ if (!action.orphanNullable) {
1104
+ notNullable.push(action);
1105
+ continue;
1106
+ }
1107
+ const [detectorSql, repairSql] = action.advisory?.suggestedSql ?? [];
1108
+ if (!action.foreignKey || !detectorSql || !repairSql) {
1109
+ notNullable.push(action);
1110
+ continue;
1111
+ }
1112
+ nullable.push({
1113
+ action,
1114
+ tableName: action.tableName,
1115
+ column: action.foreignKey.column,
1116
+ detectorSql,
1117
+ repairSql
1118
+ });
1119
+ }
1120
+ return {
1121
+ nullable,
1122
+ notNullable
1123
+ };
1124
+ }
1125
+ /**
1126
+ * Wrap an orphan-probe `SELECT` (the differ's `renderForeignKeyOrphanDetector`
1127
+ * output, a bare `SELECT ... orphan_key ...` with no `LIMIT`) as a
1128
+ * `COUNT(*)` so `--null-orphans` can report before/after counts without
1129
+ * re-deriving the probe's own uuid-cast options.
1130
+ */
1131
+ function orphanCountSql(detectorSql) {
1132
+ return `SELECT COUNT(*) AS orphan_count FROM (${detectorSql}) AS smrt_orphan_probe`;
1133
+ }
862
1134
  //#endregion
863
1135
  //#region src/commands/postgres-timestamp-migration.ts
864
1136
  /**
@@ -1458,6 +1730,128 @@ function formatDateTime(date) {
1458
1730
  return date.toISOString().replace("T", " ").substring(0, 19);
1459
1731
  }
1460
1732
  //#endregion
1733
+ //#region src/commands/db-orphans.ts
1734
+ /**
1735
+ * db:orphans Command (#2753)
1736
+ *
1737
+ * Read-only per-foreign-key orphan count report. `db:migrate`'s orphan probe
1738
+ * already gates `ADD CONSTRAINT` with a `LIMIT 1` existence check and prints a
1739
+ * suggested repair; this command runs the identical probe as `COUNT(*)` for
1740
+ * every manifest-declared foreign key, so planning a repair no longer starts
1741
+ * with hand-writing the same SQL against the live database.
1742
+ *
1743
+ * Strictly diagnostic: it never repairs anything, and the orphan counts
1744
+ * themselves never affect the exit code — a database full of orphans still
1745
+ * exits 0. It exits non-zero only on a configuration or runtime error (no
1746
+ * database configured, or the report itself could not run), the same
1747
+ * contract every other `db:*` command uses. The existing "no foreign-key
1748
+ * orphan repair, by design" boundary stays exactly where it is. Use `--json`
1749
+ * for scripting.
1750
+ */
1751
+ /** Render one count row as a compact, script-friendly line. */
1752
+ function formatOrphanCountLine(count) {
1753
+ const marker = count.nullable ? "" : " [NOT NULL]";
1754
+ return `${count.childTable}.${count.childColumn} -> ${count.parentTable}.${count.parentColumn}: ${count.orphanCount} orphan(s)${marker}`;
1755
+ }
1756
+ /** Render the full report as console lines. */
1757
+ function formatOrphanReport(report, options = {}) {
1758
+ const lines = [];
1759
+ const affected = report.counts.filter((count) => count.orphanCount > 0);
1760
+ const failedProbeCount = report.skipped.filter((skip) => skip.kind === "probe_failed").length;
1761
+ lines.push(` Engine: ${report.engine} · foreign keys checked: ${report.counts.length}` + (report.skipped.length > 0 ? ` · skipped: ${report.skipped.length}` : ""));
1762
+ if (affected.length === 0 && failedProbeCount === 0) lines.push(" ✅ No orphan rows found across any manifest foreign key.");
1763
+ else if (affected.length === 0) lines.push(` ⚠️ No orphans found among the successfully probed foreign keys, but ${failedProbeCount} probe(s) failed — see Skipped below. The scan is incomplete, not clean.`);
1764
+ else for (const count of affected) {
1765
+ const icon = count.nullable ? "⚠️" : "❌";
1766
+ lines.push(` ${icon} ${formatOrphanCountLine(count)}`);
1767
+ }
1768
+ if (options.verbose) {
1769
+ const clean = report.counts.filter((count) => count.orphanCount === 0);
1770
+ if (clean.length > 0) {
1771
+ lines.push(` ✅ Zero orphans (${clean.length}):`);
1772
+ for (const count of clean) lines.push(` • ${formatOrphanCountLine(count)}`);
1773
+ }
1774
+ }
1775
+ if (report.skipped.length > 0) {
1776
+ lines.push(" Skipped:");
1777
+ for (const skip of report.skipped) {
1778
+ const marker = skip.kind === "probe_failed" ? " [PROBE FAILED]" : "";
1779
+ lines.push(` • ${skip.childTable}.${skip.childColumn} -> ${skip.parentTable}.${skip.parentColumn}: ${skip.reason}${marker}`);
1780
+ }
1781
+ }
1782
+ return lines;
1783
+ }
1784
+ /** Foreign keys with at least one orphan row, for `db:status`'s summary. */
1785
+ function affectedOrphanCounts(report) {
1786
+ return report.counts.filter((count) => count.orphanCount > 0);
1787
+ }
1788
+ var dbOrphansCommand = {
1789
+ name: "db:orphans",
1790
+ description: "Read-only per-foreign-key orphan count report: for every manifest foreign key, prints child/parent table and column, the live orphan row count, and whether the child column is NOT NULL. Never repairs. Orphans found never affect the exit code; exits non-zero only on a configuration or runtime error.",
1791
+ aliases: ["orphans", "db-orphans"],
1792
+ args: [],
1793
+ options: {
1794
+ json: {
1795
+ type: "boolean",
1796
+ description: "Output as JSON (for scripting)",
1797
+ default: false,
1798
+ short: "j"
1799
+ },
1800
+ verbose: {
1801
+ type: "boolean",
1802
+ description: "Also list foreign keys with zero orphans",
1803
+ default: false,
1804
+ short: "v"
1805
+ }
1806
+ },
1807
+ handler: async (_args, options) => {
1808
+ let db;
1809
+ try {
1810
+ const { getPackageConfig } = await import("@happyvertical/smrt-config");
1811
+ const { DEFAULT_CLI_CONFIG } = await import("./config-DfBo4hHU.js").then((n) => n.n);
1812
+ const config = getPackageConfig("cli", DEFAULT_CLI_CONFIG);
1813
+ if (!config.database?.url || config.database.url === ":memory:") {
1814
+ if (options.json) console.log(JSON.stringify({ error: "Database not configured" }));
1815
+ else {
1816
+ console.error("\n❌ Database configuration required");
1817
+ console.error("\nPlease configure database in smrt.config.js\n");
1818
+ }
1819
+ process.exitCode = 1;
1820
+ return;
1821
+ }
1822
+ const dbType = config.database.type || "sqlite";
1823
+ const dbUrl = config.database.url;
1824
+ if (!options.json) {
1825
+ console.log("\n🔎 Foreign-key orphan report\n");
1826
+ console.log(`Database: ${formatDatabaseDisplayUrl(dbType, dbUrl)}\n`);
1827
+ }
1828
+ const { getDatabase } = await import("@happyvertical/sql");
1829
+ db = await getDatabase({
1830
+ type: dbType,
1831
+ url: dbUrl
1832
+ });
1833
+ await autoDiscoverAndLoad();
1834
+ const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
1835
+ const report = await collectForeignKeyOrphanCounts(db, manifestSchemas, { engineHint: dbType });
1836
+ if (options.json) {
1837
+ console.log(JSON.stringify(report, null, 2));
1838
+ return;
1839
+ }
1840
+ for (const line of formatOrphanReport(report, { verbose: options.verbose })) console.log(line);
1841
+ console.log();
1842
+ } catch (error) {
1843
+ if (options.json) console.log(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1844
+ else {
1845
+ console.error("\n❌ Failed to collect the orphan report:");
1846
+ if (error instanceof Error) console.error(` ${error.message}`);
1847
+ }
1848
+ process.exitCode = 1;
1849
+ } finally {
1850
+ await closeDatabaseConnection(db);
1851
+ }
1852
+ }
1853
+ };
1854
+ //#endregion
1461
1855
  //#region src/commands/db-permissions.ts
1462
1856
  /** The only mutating caller is the explicit permissions command. */
1463
1857
  async function runPostgresPermissions(options = {}, optional = false) {
@@ -2085,15 +2479,19 @@ var dbRollbackCommand = {
2085
2479
  /**
2086
2480
  * Collect every declared upsert conflict target, keyed by table name.
2087
2481
  *
2088
- * The conflict target is a registry property, not a manifest one: a class may
2089
- * declare `conflictColumns` explicitly, inherit the CTI `(slug, context)`
2090
- * default, or get the STI `(slug, context, _meta_type)` triple. Whatever it
2091
- * resolves to, the live database needs a matching UNIQUE index or every upsert
2092
- * against that table either errors (PostgreSQL) or duplicates rows.
2482
+ * The conflict target is a registry property of a persisted object, not a
2483
+ * manifest one: an object may declare `conflictColumns` explicitly, inherit
2484
+ * the CTI `(slug, context)` default, or get the STI
2485
+ * `(slug, context, _meta_type)` triple. Collections share their item table,
2486
+ * but never persist through their own conflict target, so they must not add a
2487
+ * synthetic default target to the parity check. Every collected target needs a
2488
+ * matching UNIQUE index or its object upsert either errors (PostgreSQL) or
2489
+ * duplicates rows.
2093
2490
  */
2094
2491
  function collectRegistryConflictTargets() {
2095
2492
  const targets = {};
2096
2493
  for (const className of ObjectRegistry.getQualifiedClassNames()) {
2494
+ if (isCollectionRegistration(className)) continue;
2097
2495
  const tableName = ObjectRegistry.getTableName(className);
2098
2496
  if (!tableName) continue;
2099
2497
  const columns = ObjectRegistry.getConflictColumns(className);
@@ -2108,6 +2506,19 @@ function collectRegistryConflictTargets() {
2108
2506
  return targets;
2109
2507
  }
2110
2508
  /**
2509
+ * Matches core's collection classification, including a registered collection
2510
+ * subclass whose immediate parent is another collection class. Unknown
2511
+ * registrations stay included so a malformed registry cannot hide a real
2512
+ * persistence target from a fail-closed parity check.
2513
+ */
2514
+ function isCollectionRegistration(className) {
2515
+ for (const name of [className, ...ObjectRegistry.getInheritanceChain(className)]) {
2516
+ const registered = ObjectRegistry.getClass(name);
2517
+ if (registered && isSmrtCollectionExtendsName(registered.extends)) return true;
2518
+ }
2519
+ return false;
2520
+ }
2521
+ /**
2111
2522
  * Run the parity check against the configured database.
2112
2523
  *
2113
2524
  * Never throws for an operational problem: connection and introspection
@@ -2441,7 +2852,7 @@ function assertSchemaContract(report) {
2441
2852
  * - Pending migrations
2442
2853
  * - Drift detection warnings
2443
2854
  */
2444
- var CANONICAL_UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
2855
+ var CANONICAL_UUID_RE$1 = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
2445
2856
  function getSchemaColumn(schema, columnName) {
2446
2857
  const columns = schema?.columns;
2447
2858
  if (!columns) return null;
@@ -2465,7 +2876,7 @@ async function countNonUuidTenantIds(db) {
2465
2876
  const row = (await db.query(`SELECT count(*)::text AS count
2466
2877
  FROM tenants
2467
2878
  WHERE nullif(btrim(id), '') IS NOT NULL
2468
- AND btrim(id) !~* '${CANONICAL_UUID_RE}'`))?.rows?.[0] ?? {};
2879
+ AND btrim(id) !~* '${CANONICAL_UUID_RE$1}'`))?.rows?.[0] ?? {};
2469
2880
  return Number(row.count ?? row.n ?? 0);
2470
2881
  }
2471
2882
  async function checkTenantIdUuidPreconditions(input) {
@@ -2603,6 +3014,14 @@ function summarizeSchemaDiff(diff) {
2603
3014
  recommendation: "Run `smrt db:migrate --drop-columns` to drop this orphan column (destructive)."
2604
3015
  });
2605
3016
  break;
3017
+ case "rename_data_pending":
3018
+ if (change.advisory?.severity !== "warning") break;
3019
+ drift.push({
3020
+ name: `${change.table}.${change.name ?? "(unknown)"}`,
3021
+ type: "rename_data_pending",
3022
+ recommendation: change.advisory?.message ?? "A declared column is empty while an undeclared column of a compatible type holds data — data appears to still live in the old column after a field rename. Run `smrt db:diff` for the suggested backfill SQL."
3023
+ });
3024
+ break;
2606
3025
  }
2607
3026
  return drift;
2608
3027
  }
@@ -2733,7 +3152,10 @@ var dbStatusCommand = {
2733
3152
  failedMigrations: summarizeFailedMigrations(failed, null),
2734
3153
  schemaContract,
2735
3154
  parity: null,
2736
- parityError: null
3155
+ parityError: null,
3156
+ orphanedForeignKeys: [],
3157
+ orphanProbeFailures: [],
3158
+ orphansError: null
2737
3159
  };
2738
3160
  let diff = {
2739
3161
  added_tables: [],
@@ -2741,8 +3163,8 @@ var dbStatusCommand = {
2741
3163
  changes: [],
2742
3164
  has_changes: false
2743
3165
  };
3166
+ const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
2744
3167
  if (typeof db.getTableSchema === "function") {
2745
- const manifestSchemas = ObjectRegistry.getAllSchemasAsDefinitions();
2746
3168
  diff = await new SchemaComparer(db).compare(manifestSchemas);
2747
3169
  status.drift = summarizeSchemaDiff(diff);
2748
3170
  status.notes = summarizeSchemaNotes(diff);
@@ -2765,6 +3187,13 @@ var dbStatusCommand = {
2765
3187
  status.parityError = error instanceof Error ? error.message : String(error);
2766
3188
  }
2767
3189
  } else if (options.parity) status.parityError = "The configured database adapter cannot describe tables, so live-schema parity cannot be verified.";
3190
+ try {
3191
+ const orphanReport = await collectForeignKeyOrphanCounts(db, manifestSchemas, { engineHint: dbType });
3192
+ status.orphanedForeignKeys = affectedOrphanCounts(orphanReport);
3193
+ status.orphanProbeFailures = orphanReport.skipped.filter((skip) => skip.kind === "probe_failed");
3194
+ } catch (error) {
3195
+ status.orphansError = error instanceof Error ? error.message : String(error);
3196
+ }
2768
3197
  const failedAssessments = assessFailedMigrations(failed, typeof db.getTableSchema === "function" ? diff : null);
2769
3198
  status.migrations.failed = {
2770
3199
  total: failedAssessments.length,
@@ -2828,6 +3257,23 @@ var dbStatusCommand = {
2828
3257
  }
2829
3258
  console.log();
2830
3259
  }
3260
+ if (status.orphansError) {
3261
+ console.log(`⚠️ Foreign-key orphan check unavailable: ${status.orphansError}`);
3262
+ console.log();
3263
+ } else {
3264
+ if (status.orphanedForeignKeys.length > 0) {
3265
+ console.log(`⚠️ Foreign-key orphans found (${status.orphanedForeignKeys.length}):`);
3266
+ for (const count of status.orphanedForeignKeys) console.log(` • ${formatOrphanCountLine(count)}`);
3267
+ console.log(" Run `smrt db:orphans` for the full report.");
3268
+ console.log();
3269
+ }
3270
+ if (status.orphanProbeFailures.length > 0) {
3271
+ console.log(`⚠️ ${status.orphanProbeFailures.length} foreign-key orphan probe(s) failed (not counted as clean):`);
3272
+ for (const failure of status.orphanProbeFailures) console.log(` • ${failure.childTable}.${failure.childColumn} -> ${failure.parentTable}.${failure.parentColumn}: ${failure.reason}`);
3273
+ console.log(" Run `smrt db:orphans` for details.");
3274
+ console.log();
3275
+ }
3276
+ }
2831
3277
  if (options.parity) {
2832
3278
  console.log("🗄️ Live Schema Parity:");
2833
3279
  if (status.parityError) {
@@ -6525,7 +6971,7 @@ var dbMigrateInt8Command = {
6525
6971
  * drop the old column.
6526
6972
  *
6527
6973
  * 2. **TEXT → native uuid conversion**. Most existing `id`/FK columns already
6528
- * hold canonical UUID strings in TEXT columns and can be promoted to native
6974
+ * hold UUID-shaped strings in TEXT columns and can be promoted to native
6529
6975
  * `uuid`. Conversion is gated on TWO independent checks, BOTH of which must
6530
6976
  * pass for a column to be converted:
6531
6977
  *
@@ -6540,7 +6986,7 @@ var dbMigrateInt8Command = {
6540
6986
  * Tables not present in the manifest (non-SMRT tables in the `public`
6541
6987
  * schema) drop out automatically.
6542
6988
  *
6543
- * (b) **Data-shape** — every non-empty value is already a canonical UUID.
6989
+ * (b) **Data-shape** — every non-empty value is already UUID-shaped.
6544
6990
  * A declared-UUID column that still holds genuine non-uuid values
6545
6991
  * (legacy unhyphenated ids, partially-migrated data, …) is SKIPPED and
6546
6992
  * reported so the operator can clean it before re-running.
@@ -6573,7 +7019,8 @@ var dbMigrateInt8Command = {
6573
7019
  * smrt db:migrate-uuid --rename "parent_id:source_asset_id" --table assets
6574
7020
  * smrt db:migrate-uuid --rename "parent_slug:parent_id" --table tags --skip-convert
6575
7021
  */
6576
- var UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
7022
+ var UUID_RE = "^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{32})$";
7023
+ var CANONICAL_UUID_RE = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
6577
7024
  /**
6578
7025
  * Build the set of schema-declared UUID columns from a manifest's
6579
7026
  * `getAllSchemasAsDefinitions()` output.
@@ -6597,11 +7044,84 @@ function renamedSourceColumns(renameSpecs) {
6597
7044
  return new Set(renameSpecs.map((spec) => declaredUuidKey(spec.table, spec.from)));
6598
7045
  }
6599
7046
  /**
7047
+ * Propagate skips across foreign-key edges to a fixpoint.
7048
+ *
7049
+ * `db:migrate-uuid` converts a column only when BOTH its declared-UUID/clean
7050
+ * data gates pass (see `planUuidConversions`) AND every foreign-key partner
7051
+ * of it will also convert — otherwise `ALTER TABLE … ADD CONSTRAINT` at
7052
+ * recreation time would fail with a text/uuid type mismatch. Rather than
7053
+ * aborting the whole run, block the convertible partner too: a column
7054
+ * skipped for dirty data or because the schema intentionally keeps it TEXT
7055
+ * blocks every foreign-key partner of it, and that block propagates
7056
+ * transitively (a two-hop chain blocks both hops).
7057
+ *
7058
+ * Pure and DB-independent: `edges` is the full set of single-column foreign
7059
+ * keys touching the candidate columns, discovered separately. Multi-column
7060
+ * foreign keys are out of scope here and stay a hard refusal downstream.
7061
+ */
7062
+ /** Human-readable reason for one `skipDirtyData` entry — shared by dry-run/apply logging and FK-block-propagation reasons. */
7063
+ function dirtyDataReason(item) {
7064
+ const parts = [];
7065
+ if (item.nonUuid > 0) parts.push(`${item.nonUuid} non-uuid value(s)`);
7066
+ if (item.duplicateNormalized) parts.push(`${item.duplicateNormalized} duplicate value(s) after normalization`);
7067
+ return parts.join(", ");
7068
+ }
7069
+ function propagateBlockedForeignKeyPartners(plan, edges) {
7070
+ const convertByKey = new Map(plan.convert.map((candidate) => [declaredUuidKey(candidate.table, candidate.column), candidate]));
7071
+ const blockedReason = /* @__PURE__ */ new Map();
7072
+ for (const item of plan.skipDirtyData) blockedReason.set(declaredUuidKey(item.table, item.column), dirtyDataReason(item));
7073
+ for (const item of plan.skipNotDeclared) blockedReason.set(declaredUuidKey(item.table, item.column), "not schema-declared UUID");
7074
+ const skipBlockedPartner = [];
7075
+ let changed = true;
7076
+ while (changed) {
7077
+ changed = false;
7078
+ for (const edge of edges) {
7079
+ const childKey = declaredUuidKey(edge.childTable, edge.childColumn);
7080
+ const parentKey = declaredUuidKey(edge.parentTable, edge.parentColumn);
7081
+ const childBlockedReason = blockedReason.get(childKey);
7082
+ const parentBlockedReason = blockedReason.get(parentKey);
7083
+ if (childBlockedReason !== void 0 && parentBlockedReason === void 0 && convertByKey.has(parentKey)) {
7084
+ const candidate = convertByKey.get(parentKey);
7085
+ if (!candidate) continue;
7086
+ const reason = `blocked by foreign key ${edge.name} to ${edge.childTable}.${edge.childColumn} (${childBlockedReason})`;
7087
+ blockedReason.set(parentKey, reason);
7088
+ convertByKey.delete(parentKey);
7089
+ skipBlockedPartner.push({
7090
+ table: candidate.table,
7091
+ column: candidate.column,
7092
+ reason
7093
+ });
7094
+ changed = true;
7095
+ continue;
7096
+ }
7097
+ if (parentBlockedReason !== void 0 && childBlockedReason === void 0 && convertByKey.has(childKey)) {
7098
+ const candidate = convertByKey.get(childKey);
7099
+ if (!candidate) continue;
7100
+ const reason = `blocked by foreign key ${edge.name} to ${edge.parentTable}.${edge.parentColumn} (${parentBlockedReason})`;
7101
+ blockedReason.set(childKey, reason);
7102
+ convertByKey.delete(childKey);
7103
+ skipBlockedPartner.push({
7104
+ table: candidate.table,
7105
+ column: candidate.column,
7106
+ reason
7107
+ });
7108
+ changed = true;
7109
+ }
7110
+ }
7111
+ }
7112
+ return {
7113
+ convert: [...convertByKey.values()],
7114
+ skipDirtyData: plan.skipDirtyData,
7115
+ skipNotDeclared: plan.skipNotDeclared,
7116
+ skipBlockedPartner
7117
+ };
7118
+ }
7119
+ /**
6600
7120
  * Decide which live TEXT columns to convert to native `uuid`.
6601
7121
  *
6602
7122
  * A column is converted ONLY if BOTH gates pass:
6603
7123
  * 1. it is in the schema-declared-UUID set, AND
6604
- * 2. all of its non-empty values are already canonical UUIDs.
7124
+ * 2. all of its non-empty values are already UUID-shaped.
6605
7125
  *
6606
7126
  * Declared-UUID columns with dirty data are reported in `skipDirtyData`;
6607
7127
  * undeclared columns (schema-intentional TEXT, or non-SMRT tables) are reported
@@ -6619,11 +7139,13 @@ function planUuidConversions(liveColumns, declaredUuid) {
6619
7139
  });
6620
7140
  continue;
6621
7141
  }
6622
- if (col.nonUuid > 0) {
7142
+ const duplicateNormalized = col.duplicateNormalized ?? 0;
7143
+ if (col.nonUuid > 0 || duplicateNormalized > 0) {
6623
7144
  skipDirtyData.push({
6624
7145
  table: col.table,
6625
7146
  column: col.column,
6626
- nonUuid: col.nonUuid
7147
+ nonUuid: col.nonUuid,
7148
+ ...duplicateNormalized > 0 ? { duplicateNormalized } : {}
6627
7149
  });
6628
7150
  continue;
6629
7151
  }
@@ -6809,17 +7331,48 @@ async function convertPostgresUuidColumns(db, declaredUuid, dryRun, renderDryRun
6809
7331
  const column = String(row.column_name);
6810
7332
  if (excludedColumns.has(declaredUuidKey(table, column))) continue;
6811
7333
  let nonUuid = 0;
7334
+ let duplicateNormalized = 0;
6812
7335
  if (declaredUuid.has(declaredUuidKey(table, column))) {
6813
7336
  const { rows } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(table)}
6814
7337
  WHERE nullif(btrim(${quoteIdentifier(column)}), '') IS NOT NULL
6815
7338
  AND btrim(${quoteIdentifier(column)}) !~* '${UUID_RE}'`);
6816
7339
  nonUuid = Number(rows[0]?.n ?? 0);
7340
+ const uniqueIndexes = await findUniqueIndexKeyColumns(db, String(row.relation_oid), Number(row.attribute_number));
7341
+ for (const { keyColumns, nullsNotDistinct } of uniqueIndexes) {
7342
+ const otherColumns = keyColumns.filter((c) => c.name !== column);
7343
+ const otherColumnExpr = (other) => {
7344
+ const quoted = quoteIdentifier(other.name);
7345
+ if (other.typeName === "uuid") return quoted;
7346
+ if (declaredUuid.has(declaredUuidKey(table, other.name))) return `CASE WHEN btrim(${quoted}) ~* '${UUID_RE}' THEN NULLIF(btrim(${quoted}), '')::uuid::text ELSE ${quoted} END`;
7347
+ return quoted;
7348
+ };
7349
+ const nullGuards = nullsNotDistinct ? [] : otherColumns.map((other) => `${quoteIdentifier(other.name)} IS NOT NULL`);
7350
+ const groupBy = [...otherColumns.map(otherColumnExpr), `NULLIF(btrim(${quoteIdentifier(column)}), '')::uuid`].join(", ");
7351
+ const whereClause = [
7352
+ `nullif(btrim(${quoteIdentifier(column)}), '') IS NOT NULL`,
7353
+ `btrim(${quoteIdentifier(column)}) ~* '${UUID_RE}'`,
7354
+ ...nullGuards
7355
+ ].join(" AND ");
7356
+ const { rows: dupRows } = await db.query(`SELECT count(*)::text AS n FROM (
7357
+ SELECT 1
7358
+ FROM ${pgTable(table)}
7359
+ WHERE ${whereClause}
7360
+ GROUP BY ${groupBy}
7361
+ HAVING count(DISTINCT ${quoteIdentifier(column)}) > 1
7362
+ ) collisions`);
7363
+ const found = Number(dupRows[0]?.n ?? 0);
7364
+ if (found > 0) {
7365
+ duplicateNormalized += found;
7366
+ break;
7367
+ }
7368
+ }
6817
7369
  }
6818
7370
  liveColumns.push({
6819
7371
  table,
6820
7372
  column,
6821
7373
  hasDefault: row.column_default != null,
6822
- nonUuid
7374
+ nonUuid,
7375
+ duplicateNormalized
6823
7376
  });
6824
7377
  defaults.set(declaredUuidKey(table, column), row.column_default == null ? null : String(row.column_default));
6825
7378
  }
@@ -6837,10 +7390,14 @@ async function convertPostgresUuidColumns(db, declaredUuid, dryRun, renderDryRun
6837
7390
  AND btrim(${quoteIdentifier(spec.from)}) !~* '${UUID_RE}'`);
6838
7391
  destination.nonUuid += Number(rows[0]?.n ?? 0);
6839
7392
  }
6840
- const { convert, skipDirtyData, skipNotDeclared } = planUuidConversions(liveColumns, declaredUuid);
6841
- console.log(`Found ${candidateRows.length} TEXT id/FK column(s): ${convert.length} convertible, ${skipDirtyData.length + skipNotDeclared.length} skipped.`);
6842
- for (const item of skipDirtyData) console.log(` SKIP ${item.table}.${item.column}: ${item.nonUuid} non-uuid value(s)`);
7393
+ const initialPlan = planUuidConversions(liveColumns, declaredUuid);
7394
+ const { skipDirtyData, skipNotDeclared } = initialPlan;
7395
+ await assertNoCrossSchemaForeignKeyPartners(db, new Set([...initialPlan.convert, ...skipDirtyData].map((candidate) => declaredUuidKey(candidate.table, candidate.column))));
7396
+ const { convert, skipBlockedPartner = [] } = propagateBlockedForeignKeyPartners(initialPlan, await fetchSingleColumnForeignKeyEdges(db));
7397
+ console.log(`Found ${candidateRows.length} TEXT id/FK column(s): ${convert.length} convertible, ${skipDirtyData.length + skipNotDeclared.length + skipBlockedPartner.length} skipped.`);
7398
+ for (const item of skipDirtyData) console.log(` SKIP ${item.table}.${item.column}: ${dirtyDataReason(item)}`);
6843
7399
  for (const item of skipNotDeclared) console.log(` SKIP ${item.table}.${item.column}: not schema-declared UUID`);
7400
+ for (const item of skipBlockedPartner) console.log(` SKIP ${item.table}.${item.column}: ${item.reason}`);
6844
7401
  if (convert.length === 0) {
6845
7402
  console.log("\nNothing to convert. Done.\n");
6846
7403
  return;
@@ -7017,7 +7574,7 @@ async function snapshotGeneratedBridges(db, columns) {
7017
7574
  ].includes(expression)) throw new Error(`Unsupported generated dependency ${bridgeTable}.${bridgeColumn}; only a plain stored TEXT ${sourceColumn}::text bridge without ACLs, comments, inheritance, partitioning, or extra attributes is supported.`);
7018
7575
  const { rows: nonCanonical } = await db.query(`SELECT count(*)::text AS n FROM ${pgTable(table)}
7019
7576
  WHERE ${quoteIdentifier(sourceColumn)} IS NOT NULL
7020
- AND ${quoteIdentifier(sourceColumn)} !~ '${UUID_RE}'`);
7577
+ AND ${quoteIdentifier(sourceColumn)} !~ '${CANONICAL_UUID_RE}'`);
7021
7578
  if (Number(nonCanonical[0]?.n ?? 0) > 0) throw new Error(`Refusing ${table}.${sourceColumn}: its generated TEXT bridge requires canonical lower-case UUID text; normalize or remove non-canonical values before migration.`);
7022
7579
  bridges.push({
7023
7580
  tableOid: String(row.table_oid),
@@ -7078,9 +7635,139 @@ async function snapshotBridgeIndexes(db, table, column) {
7078
7635
  };
7079
7636
  });
7080
7637
  }
7638
+ /**
7639
+ * Every unique or primary-key index that covers `attnum` as a key column
7640
+ * (single-key or composite; INCLUDE-only columns are excluded via
7641
+ * `indnkeyatts`), returned as one entry per covering index — the probed
7642
+ * column included in `keyColumns`, so callers can filter it out to get the
7643
+ * index's "other" key columns for a composite collision check.
7644
+ *
7645
+ * A key column that is itself an expression (not a plain column reference)
7646
+ * has no `pg_attribute` row and is silently dropped from `keyColumns` by the
7647
+ * inner join below — the resulting collision check under-specifies that
7648
+ * index's true key, which only widens (never narrows) what it flags, so it
7649
+ * cannot hide a real collision; documented as a known imprecision rather
7650
+ * than fully modeled here.
7651
+ */
7652
+ async function findUniqueIndexKeyColumns(db, relationOid, attnum) {
7653
+ const { rows } = await db.query(`SELECT to_json(array_agg(json_build_object(
7654
+ 'name', key_attr.attname,
7655
+ 'type', format_type(key_attr.atttypid, key_attr.atttypmod)
7656
+ ) ORDER BY key_order.ord)) AS key_columns,
7657
+ to_json(coalesce(
7658
+ bool_or((to_jsonb(idx) ->> 'indnullsnotdistinct')::boolean),
7659
+ false
7660
+ )) AS nulls_not_distinct
7661
+ FROM pg_index idx
7662
+ CROSS JOIN LATERAL unnest((idx.indkey::int2[])[0:idx.indnkeyatts - 1]) WITH ORDINALITY AS key_order(attnum, ord)
7663
+ JOIN pg_attribute key_attr
7664
+ ON key_attr.attrelid = idx.indrelid AND key_attr.attnum = key_order.attnum
7665
+ WHERE idx.indrelid = ${quoteLiteral(relationOid)}::oid AND idx.indisunique
7666
+ AND ${attnum} = ANY((idx.indkey::int2[])[0:idx.indnkeyatts - 1])
7667
+ GROUP BY idx.indexrelid`);
7668
+ return rows.map((row) => ({
7669
+ keyColumns: row.key_columns.map((col) => ({
7670
+ name: String(col.name),
7671
+ typeName: String(col.type)
7672
+ })),
7673
+ nullsNotDistinct: Boolean(row.nulls_not_distinct)
7674
+ }));
7675
+ }
7676
+ /**
7677
+ * Discover every single-column foreign key with BOTH endpoints in the public
7678
+ * schema — the only schema `db:migrate-uuid` ever converts or recreates DDL
7679
+ * against (see `pgTable`). Both `child_ns` and `parent_ns` are filtered to
7680
+ * `public` so a same-named table or column living in another schema can
7681
+ * never be silently treated as a candidate's FK partner by `declaredUuidKey`,
7682
+ * which keys purely on `table|column` with no schema qualifier. A
7683
+ * cross-schema partner that actually matters to a converting column is
7684
+ * caught separately, and fails closed, by
7685
+ * `assertNoCrossSchemaForeignKeyPartners`.
7686
+ */
7687
+ async function fetchSingleColumnForeignKeyEdges(db) {
7688
+ const { rows } = await db.query(`SELECT con.conname AS name, child.relname AS child_table,
7689
+ child_attr.attname AS child_column,
7690
+ parent.relname AS parent_table, parent_attr.attname AS parent_column
7691
+ FROM pg_constraint con
7692
+ JOIN pg_class child ON child.oid = con.conrelid
7693
+ JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
7694
+ JOIN pg_class parent ON parent.oid = con.confrelid
7695
+ JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
7696
+ JOIN pg_attribute child_attr ON child_attr.attrelid = child.oid AND child_attr.attnum = con.conkey[1]
7697
+ JOIN pg_attribute parent_attr ON parent_attr.attrelid = parent.oid AND parent_attr.attnum = con.confkey[1]
7698
+ WHERE con.contype = 'f' AND child_ns.nspname = 'public' AND parent_ns.nspname = 'public'
7699
+ AND array_length(con.conkey, 1) = 1`);
7700
+ return rows.map((row) => ({
7701
+ name: String(row.name),
7702
+ childTable: String(row.child_table),
7703
+ childColumn: String(row.child_column),
7704
+ parentTable: String(row.parent_table),
7705
+ parentColumn: String(row.parent_column)
7706
+ }));
7707
+ }
7708
+ /**
7709
+ * Fail closed when a (single- OR multi-column) foreign key has exactly one
7710
+ * endpoint in the public schema and at least one of the public-side key
7711
+ * columns is in `relevant` (a schema-declared-UUID column this run treats
7712
+ * as a real conversion candidate/participant).
7713
+ *
7714
+ * `db:migrate-uuid` never converts or recreates DDL outside `public` (see
7715
+ * `pgTable`), and `declaredUuidKey` keys purely on `table|column` with no
7716
+ * schema — so a same-named table/column pair living in another schema could
7717
+ * otherwise be silently mistaken for that candidate's real FK partner
7718
+ * (making `propagateBlockedForeignKeyPartners` or `snapshotForeignKeys`
7719
+ * reason about the wrong table), or a genuine cross-schema partner could go
7720
+ * entirely undiscovered because the edge queries only ever look at
7721
+ * public-to-public constraints. Both are correctness bugs, not merely
7722
+ * documentation gaps, so this check runs independently of `declaredUuidKey`
7723
+ * lookups and fails the whole run with a specific, actionable message
7724
+ * instead of silently mis-propagating or mis-recreating a constraint.
7725
+ *
7726
+ * Deliberately not restricted to single-column FKs: `snapshotForeignKeys`
7727
+ * only refuses an unsupported multi-column FK once it has already matched
7728
+ * an in-`public` participant via `converted`/`bridgeColumns`, which this
7729
+ * cross-schema check runs ahead of and independently from. A composite FK
7730
+ * whose public-side key touches a converting column, but whose other
7731
+ * endpoint lives outside `public`, must fail closed here at preflight —
7732
+ * before `--dry-run` reports a plan `apply` cannot actually execute — the
7733
+ * same as the single-column case.
7734
+ */
7735
+ async function assertNoCrossSchemaForeignKeyPartners(db, relevant) {
7736
+ const { rows } = await db.query(`SELECT con.conname AS name,
7737
+ child_ns.nspname AS child_schema, child.relname AS child_table,
7738
+ to_json(array_agg(child_attr.attname ORDER BY child_key.ord)) AS child_columns,
7739
+ parent_ns.nspname AS parent_schema, parent.relname AS parent_table,
7740
+ to_json(array_agg(parent_attr.attname ORDER BY child_key.ord)) AS parent_columns
7741
+ FROM pg_constraint con
7742
+ JOIN pg_class child ON child.oid = con.conrelid
7743
+ JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
7744
+ JOIN pg_class parent ON parent.oid = con.confrelid
7745
+ JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
7746
+ JOIN unnest(con.conkey) WITH ORDINALITY child_key(attnum, ord) ON true
7747
+ JOIN unnest(con.confkey) WITH ORDINALITY parent_key(attnum, ord) ON parent_key.ord = child_key.ord
7748
+ JOIN pg_attribute child_attr ON child_attr.attrelid = child.oid AND child_attr.attnum = child_key.attnum
7749
+ JOIN pg_attribute parent_attr ON parent_attr.attrelid = parent.oid AND parent_attr.attnum = parent_key.attnum
7750
+ WHERE con.contype = 'f'
7751
+ AND (child_ns.nspname = 'public') <> (parent_ns.nspname = 'public')
7752
+ GROUP BY con.oid, con.conname, child_ns.nspname, child.relname,
7753
+ parent_ns.nspname, parent.relname`);
7754
+ for (const row of rows) {
7755
+ const childSchema = String(row.child_schema);
7756
+ const parentSchema = String(row.parent_schema);
7757
+ const childTable = String(row.child_table);
7758
+ const parentTable = String(row.parent_table);
7759
+ const childColumns = row.child_columns;
7760
+ const parentColumns = row.parent_columns;
7761
+ const childOnPublic = childSchema === "public";
7762
+ const publicTable = childOnPublic ? childTable : parentTable;
7763
+ if (!(childOnPublic ? childColumns : parentColumns).some((column) => relevant.has(declaredUuidKey(publicTable, column)))) continue;
7764
+ throw new Error(`Foreign key ${String(row.name)} crosses the public schema boundary (${childSchema}.${childTable}(${childColumns.join(", ")}) → ${parentSchema}.${parentTable}(${parentColumns.join(", ")})); db:migrate-uuid only ever converts or recreates constraints in the public schema.`);
7765
+ }
7766
+ }
7081
7767
  async function snapshotForeignKeys(db, columns, bridges) {
7082
7768
  const converted = new Set(columns.map((column) => declaredUuidKey(column.table, column.column)));
7083
7769
  const bridgeColumns = new Set(bridges.map((bridge) => declaredUuidKey(bridge.table, bridge.column)));
7770
+ await assertNoCrossSchemaForeignKeyPartners(db, /* @__PURE__ */ new Set([...converted, ...bridgeColumns]));
7084
7771
  const { rows } = await db.query(`SELECT con.oid, child.relname AS child_table, con.conname AS name,
7085
7772
  parent.relname AS parent_table, con.convalidated AS validated,
7086
7773
  pg_get_constraintdef(con.oid) AS definition,
@@ -7097,11 +7784,12 @@ async function snapshotForeignKeys(db, columns, bridges) {
7097
7784
  JOIN pg_class child ON child.oid = con.conrelid
7098
7785
  JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
7099
7786
  JOIN pg_class parent ON parent.oid = con.confrelid
7787
+ JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
7100
7788
  JOIN unnest(con.conkey) WITH ORDINALITY child_key(attnum, ord) ON true
7101
7789
  JOIN unnest(con.confkey) WITH ORDINALITY parent_key(attnum, ord) ON parent_key.ord = child_key.ord
7102
7790
  JOIN pg_attribute child_attr ON child_attr.attrelid = child.oid AND child_attr.attnum = child_key.attnum
7103
7791
  JOIN pg_attribute parent_attr ON parent_attr.attrelid = parent.oid AND parent_attr.attnum = parent_key.attnum
7104
- WHERE con.contype = 'f' AND child_ns.nspname = 'public'`);
7792
+ WHERE con.contype = 'f' AND child_ns.nspname = 'public' AND parent_ns.nspname = 'public'`);
7105
7793
  const participating = [];
7106
7794
  for (const row of rows) {
7107
7795
  const child = declaredUuidKey(String(row.child_table), String(row.child_column));
@@ -7489,6 +8177,95 @@ function renderContextMarkdown(result) {
7489
8177
  return lines.join("\n");
7490
8178
  }
7491
8179
  //#endregion
8180
+ //#region src/commands/dev-runtime.ts
8181
+ /**
8182
+ * dev:runtime — client for the runtime dev-plane (#2782).
8183
+ *
8184
+ * With a running dev server that mounts the in-app plane (`--url` or
8185
+ * `SMRT_DEV_PLANE_URL`), the command calls the JSON route and prints the
8186
+ * envelope. Without one it falls back to the standalone Level 2 boot in this
8187
+ * process, so the same answer is available from a terminal either way.
8188
+ */
8189
+ /** Parse repeatable `--arg key=value` flags; numbers/booleans are coerced. */
8190
+ function parseToolArgs(raw) {
8191
+ const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
8192
+ const args = {};
8193
+ for (const entry of values) {
8194
+ const eq = entry.indexOf("=");
8195
+ if (eq <= 0) continue;
8196
+ const key = entry.slice(0, eq).trim();
8197
+ const value = entry.slice(eq + 1).trim();
8198
+ if (value === "true") args[key] = true;
8199
+ else if (value === "false") args[key] = false;
8200
+ else if (/^-?\d+(\.\d+)?$/.test(value)) args[key] = Number(value);
8201
+ else if (value.includes(",")) args[key] = value.split(",").map((v) => v.trim());
8202
+ else args[key] = value;
8203
+ }
8204
+ return args;
8205
+ }
8206
+ /** Call the in-app plane when a URL is configured; otherwise run locally. */
8207
+ async function runRuntimeTool(tool, args, options = {}) {
8208
+ const url = options.url?.trim() || process.env.SMRT_DEV_PLANE_URL?.trim();
8209
+ if (url) {
8210
+ const token = options.token?.trim() || process.env.SMRT_DEV_MCP_TOKEN?.trim();
8211
+ if (!token) throw new Error("A dev-plane URL is set but no token: pass --token or set SMRT_DEV_MCP_TOKEN");
8212
+ const response = await (options.fetchImpl ?? fetch)(`${url.replace(/\/+$/, "")}/${tool}`, {
8213
+ method: "POST",
8214
+ headers: {
8215
+ authorization: `Bearer ${token}`,
8216
+ "content-type": "application/json"
8217
+ },
8218
+ body: JSON.stringify(args)
8219
+ });
8220
+ if (!response.ok) throw new Error(`dev-plane responded ${response.status} for ${tool}`);
8221
+ return {
8222
+ source: "dev-plane",
8223
+ result: await response.json()
8224
+ };
8225
+ }
8226
+ const local = RUNTIME_TOOLS[tool];
8227
+ if (!local) throw new Error(`Unknown runtime tool "${tool}"; known: ${Object.keys(RUNTIME_TOOLS).join(", ")}`);
8228
+ return {
8229
+ source: "local",
8230
+ result: await local(args)
8231
+ };
8232
+ }
8233
+ var devRuntimeCommands = { "dev:runtime": {
8234
+ name: "dev:runtime",
8235
+ description: "Call a runtime dev-plane tool (runtime-registry, runtime-object, runtime-schema-diff, migration-status, job-health, schedule-health, dispatch-health, recent-changes, registry-drift) against a running dev server or, without one, the local Level 2 boot",
8236
+ args: ["tool"],
8237
+ options: {
8238
+ url: {
8239
+ type: "string",
8240
+ description: "Dev-plane base URL (e.g. http://127.0.0.1:5173/api/_dev); default SMRT_DEV_PLANE_URL"
8241
+ },
8242
+ token: {
8243
+ type: "string",
8244
+ description: "Bearer token; default SMRT_DEV_MCP_TOKEN"
8245
+ },
8246
+ arg: {
8247
+ type: "string",
8248
+ multiple: true,
8249
+ description: "Tool argument as key=value (repeatable; a,b,c becomes an array)"
8250
+ },
8251
+ json: {
8252
+ type: "boolean",
8253
+ description: "Print the raw envelope as JSON (default)",
8254
+ default: true
8255
+ }
8256
+ },
8257
+ handler: async (args, options) => {
8258
+ const tool = args[0];
8259
+ if (!tool) throw new Error("dev:runtime requires a tool name");
8260
+ const { source, result } = await runRuntimeTool(tool, parseToolArgs(options.arg), {
8261
+ url: options.url,
8262
+ token: options.token
8263
+ });
8264
+ console.error(`[smrt] dev:runtime ${tool} via ${source}`);
8265
+ console.log(JSON.stringify(result, null, 2));
8266
+ }
8267
+ } };
8268
+ //#endregion
7492
8269
  //#region src/runtime-check.ts
7493
8270
  var SYSTEM_FIELDS = /* @__PURE__ */ new Set([
7494
8271
  "context",
@@ -8070,6 +8847,40 @@ function resolveDDLPreviewEngine(dbType) {
8070
8847
  }
8071
8848
  }
8072
8849
  /**
8850
+ * Run an orphan-count `SELECT COUNT(*)` (from `orphanCountSql()`) and read
8851
+ * back the count. Adapters disagree on the query-result envelope (some
8852
+ * return `{ rows }`, some a bare array), so normalize both the same way the
8853
+ * rest of the migration path does.
8854
+ *
8855
+ * Deliberately NOT best-effort: `--null-orphans` fails closed (#2748 review)
8856
+ * — if the probe itself cannot run (bad SQL shape, a permissions error, an
8857
+ * adapter that doesn't support the query), that is exactly the "unexpected
8858
+ * shape" case the flag must report and withhold on, not silently treat as
8859
+ * "zero orphans" and proceed to mutate data anyway. Callers that want the
8860
+ * disposition to abort on a probe failure should let this throw and catch
8861
+ * it themselves; there is no built-in fallback to 0.
8862
+ */
8863
+ async function countOrphanRows(db, countSql) {
8864
+ const result = await db.query(countSql);
8865
+ const raw = (Array.isArray(result) ? result : result?.rows ?? [])[0]?.orphan_count;
8866
+ const count = Number(raw);
8867
+ if (!Number.isFinite(count)) throw new Error(`orphan-count probe returned a non-numeric count: ${JSON.stringify(raw)}`);
8868
+ return count;
8869
+ }
8870
+ /**
8871
+ * One-line identity for a migration action withheld under
8872
+ * `--apply-unblocked`, for the withheld-changes report (#2748).
8873
+ */
8874
+ function describeWithheldMigrationAction(action) {
8875
+ switch (action.type) {
8876
+ case "add_index": return `${action.tableName}: index ${action.index?.name ?? "(unnamed)"}`;
8877
+ case "add_foreign_key": return `${action.tableName}: foreign key on ${action.foreignKey?.column ?? "(unknown column)"}`;
8878
+ case "alter_column": return `${action.tableName}.${action.columnName ?? action.column?.name ?? "(unknown column)"}: ${action.alteration ?? "alter_column"}`;
8879
+ case "drop_column": return `${action.tableName}.${action.columnName ?? "(unknown column)"}: drop column`;
8880
+ default: return `${action.tableName}: ${action.type}`;
8881
+ }
8882
+ }
8883
+ /**
8073
8884
  * Run the framework's deferred system-table compatibility pass after
8074
8885
  * `db:migrate` has created the tables it reshapes (issue #2376).
8075
8886
  *
@@ -8932,6 +9743,16 @@ export default testManifest;
8932
9743
  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.",
8933
9744
  default: false
8934
9745
  },
9746
+ "apply-unblocked": {
9747
+ type: "boolean",
9748
+ description: "Withhold only the executable changes that depend on a blocked item (an index or foreign key on a column whose type upgrade is blocked, a foreign key whose orphan rows block it), instead of the default of applying every executable change regardless of dependency. A withheld change is reported with the blocked column it depends on. Off by default: every executable change in the batch still applies even when other items need manual intervention -- this flag makes that dependency-unsafe subset stop applying, it does not add anything that was previously withheld.",
9749
+ default: false
9750
+ },
9751
+ "null-orphans": {
9752
+ type: "boolean",
9753
+ description: "For a foreign key blocked only by orphan child rows: null the orphaned references when the child column is nullable, then add the foreign key. Refuses with the existing manual-repair message when the child column is NOT NULL. Never deletes rows. Off by default.",
9754
+ default: false
9755
+ },
8935
9756
  verbose: {
8936
9757
  type: "boolean",
8937
9758
  description: "Show detailed output",
@@ -9093,6 +9914,57 @@ export default testManifest;
9093
9914
  migrations.push(...partitionedChanges.migrations);
9094
9915
  manualInterventions.push(...partitionedChanges.manualInterventions);
9095
9916
  const advisories = partitionedChanges.advisories;
9917
+ const pendingOrphanDispositions = [];
9918
+ if (options["null-orphans"]) {
9919
+ const { nullable: orphanDispositions } = planOrphanDispositions(manualInterventions);
9920
+ if (orphanDispositions.length > 0) {
9921
+ console.log(`🧹 Orphan-FK disposition (--null-orphans): ${orphanDispositions.length} relationship(s)\n`);
9922
+ for (const item of orphanDispositions) {
9923
+ const countSql = orphanCountSql(item.detectorSql);
9924
+ let beforeCount;
9925
+ try {
9926
+ beforeCount = await countOrphanRows(db, countSql);
9927
+ } catch (error) {
9928
+ console.log(` ✗ ${item.tableName}.${item.column}: orphan-count probe failed, withholding this disposition: ${error instanceof Error ? error.message : String(error)}`);
9929
+ continue;
9930
+ }
9931
+ if (!item.action.foreignKey) continue;
9932
+ if (isDryRun) {
9933
+ console.log(` ${item.tableName}.${item.column}: ${beforeCount} orphan reference(s)`);
9934
+ console.log(` ${item.repairSql};`);
9935
+ console.log(` → would null ${beforeCount} reference(s), then add the foreign key\n`);
9936
+ }
9937
+ const index = manualInterventions.indexOf(item.action);
9938
+ if (index >= 0) manualInterventions.splice(index, 1);
9939
+ const combinedAction = {
9940
+ type: "add_foreign_key",
9941
+ tableName: item.action.tableName,
9942
+ className: item.action.className,
9943
+ foreignKey: item.action.foreignKey,
9944
+ sqlStatements: [item.repairSql, ...renderForeignKeyAddStatements(item.action.tableName, item.action.foreignKey)]
9945
+ };
9946
+ migrations.push(combinedAction);
9947
+ if (!isDryRun) pendingOrphanDispositions.push({
9948
+ tableName: item.tableName,
9949
+ column: item.column,
9950
+ countSql,
9951
+ beforeCount,
9952
+ action: combinedAction
9953
+ });
9954
+ }
9955
+ console.log();
9956
+ }
9957
+ }
9958
+ let withheldForDependency = [];
9959
+ if (options["apply-unblocked"]) {
9960
+ const partition = partitionUnblockedMigrations(migrations, computeBlockedColumns(manualInterventions, advisories), computeOrphanOnlyBlockedColumns(manualInterventions));
9961
+ withheldForDependency = partition.withheld;
9962
+ migrations.length = 0;
9963
+ migrations.push(...partition.applied);
9964
+ const stillPending = filterUnresolvedOrphanDispositions(pendingOrphanDispositions, withheldForDependency);
9965
+ pendingOrphanDispositions.length = 0;
9966
+ pendingOrphanDispositions.push(...stillPending);
9967
+ }
9096
9968
  const preForeignKeyMigrations = migrations.filter((migration) => migration.phase === "pre_foreign_key");
9097
9969
  const remainingMigrations = migrations.filter((migration) => migration.phase !== "pre_foreign_key");
9098
9970
  assertForceMigrationTargetsExist(forceSelection.forceMigrations, [
@@ -9122,12 +9994,17 @@ export default testManifest;
9122
9994
  console.log();
9123
9995
  console.log(" Manual migration required (backup, recreate, restore as needed).\n");
9124
9996
  }
9997
+ if (withheldForDependency.length > 0) {
9998
+ console.log(`🔒 Withheld — depends on a blocked change (--apply-unblocked, ${withheldForDependency.length}):\n`);
9999
+ for (const { action, dependsOn, reason } of withheldForDependency) console.log(` ${describeWithheldMigrationAction(action)}: depends on ${dependsOn} (${reason})`);
10000
+ console.log();
10001
+ }
9125
10002
  printSchemaAdvisories(advisories, {
9126
10003
  orphanTables: diff.orphan_tables ?? [],
9127
10004
  verbose: Boolean(options.verbose)
9128
10005
  });
9129
10006
  const tablesCreated = diff.added_tables.length > 0;
9130
- const schemaUpToDate = migrations.length === 0 && manualInterventions.length === 0 && !tablesCreated && systemTimestampPreview.length === 0;
10007
+ const schemaUpToDate = migrations.length === 0 && manualInterventions.length === 0 && withheldForDependency.length === 0 && !tablesCreated && systemTimestampPreview.length === 0;
9131
10008
  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";
9132
10009
  if (isDryRun) {
9133
10010
  if (schemaUpToDate && !repairData) {
@@ -9219,6 +10096,7 @@ export default testManifest;
9219
10096
  let skippedCount = 0;
9220
10097
  let errorCount = 0;
9221
10098
  let stiErrorCount = 0;
10099
+ let migrationResults = [];
9222
10100
  const schemaChangeCount = diff.added_tables.length + migrations.length + deferredForeignKeyMigrations.length;
9223
10101
  if (applySchemaMigrations && schemaChangeCount > 0) {
9224
10102
  const migrationDefs = [];
@@ -9328,6 +10206,7 @@ export default testManifest;
9328
10206
  console.log(` ✓ ${logInfo?.successMessage ?? result.name} (${checksum})`);
9329
10207
  }
9330
10208
  });
10209
+ migrationResults = results;
9331
10210
  const failed = migrationResultFailure(results);
9332
10211
  if (failed) throw migrationResultError(failed);
9333
10212
  successCount += results.filter((result) => result.success && result.applied !== false && result.execution_time_ms !== 0).length;
@@ -9341,6 +10220,21 @@ export default testManifest;
9341
10220
  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.");
9342
10221
  }
9343
10222
  }
10223
+ const succeededMigrationNames = new Set(migrationResults.filter((result) => result.success).map((result) => result.name));
10224
+ const resolvedDispositions = pendingOrphanDispositions.filter((pending) => {
10225
+ const migrationName = getSyntheticMigrationNameForAction(pending.action);
10226
+ return migrationName ? succeededMigrationNames.has(migrationName) : false;
10227
+ });
10228
+ if (resolvedDispositions.length > 0) {
10229
+ if (errorCount > 0) console.log(" (some changes in this batch failed; only fully-applied --null-orphans dispositions are listed below)");
10230
+ for (const pending of resolvedDispositions) try {
10231
+ const afterCount = await countOrphanRows(db, pending.countSql);
10232
+ console.log(` ✓ ${pending.tableName}.${pending.column}: nulled ${Math.max(pending.beforeCount - afterCount, 0)} orphan reference(s) (${afterCount} remaining)`);
10233
+ } catch (error) {
10234
+ console.log(` ✓ ${pending.tableName}.${pending.column}: orphan references nulled and foreign key added (post-apply count unavailable: ${error instanceof Error ? error.message : String(error)})`);
10235
+ }
10236
+ console.log();
10237
+ }
9344
10238
  if (schemaUpToDate && !repairData) console.log(upToDateMessage);
9345
10239
  if (repairData) {
9346
10240
  console.log("\n🔄 Repairing STI discriminators to qualified names...\n");
@@ -9431,7 +10325,7 @@ export default testManifest;
9431
10325
  } else console.log("ℹ️ No schema migrations were applied (none matched expected types)\n");
9432
10326
  }
9433
10327
  if (shouldFailDbMigrate({
9434
- manualInterventionCount: manualInterventions.length,
10328
+ manualInterventionCount: manualInterventions.length + withheldForDependency.length,
9435
10329
  tableErrorCount,
9436
10330
  migrationErrorCount: errorCount,
9437
10331
  stiErrorCount,
@@ -9721,6 +10615,7 @@ export default testManifest;
9721
10615
  },
9722
10616
  "runtime:check": runtimeCheckCommand,
9723
10617
  ...devKnowledgeCommands,
10618
+ ...devRuntimeCommands,
9724
10619
  "db:status": dbStatusCommand,
9725
10620
  "db:history": dbHistoryCommand,
9726
10621
  "db:diff": dbDiffCommand,
@@ -9730,6 +10625,7 @@ export default testManifest;
9730
10625
  "db:migrate-int8": dbMigrateInt8Command,
9731
10626
  "db:migrate-agent-schedule-slugs": dbMigrateAgentScheduleSlugsCommand,
9732
10627
  "db:drop-framework-base-tables": dbDropFrameworkBaseTablesCommand,
10628
+ "db:orphans": dbOrphansCommand,
9733
10629
  "db:prune": dbPruneCommand,
9734
10630
  "db:permissions": dbPermissionsCommand,
9735
10631
  "config:export": configExportCommand,