@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.
- package/AGENTS.md +16 -13
- package/agents/db-migrate-partial-apply.md +189 -0
- package/agents/db-orphans.md +46 -0
- package/agents/type-drift.md +53 -0
- package/dist/{commands-BiXRxejk.js → commands-DEHkYYWF.js} +921 -25
- package/dist/index.js +9 -9
- package/package.json +10 -9
|
@@ -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
|
|
2089
|
-
* declare `conflictColumns` explicitly, inherit
|
|
2090
|
-
*
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
6841
|
-
|
|
6842
|
-
|
|
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)} !~ '${
|
|
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,
|