@happyvertical/smrt-core 0.51.39 → 0.52.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 +8 -0
- package/agents/schema-paths.md +79 -0
- package/dist/cascade.d.ts.map +1 -1
- package/dist/cascade.js +2 -1
- package/dist/cascade.js.map +1 -1
- package/dist/collection.d.ts +10 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +14 -4
- package/dist/collection.js.map +1 -1
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +9 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +9 -1
- package/dist/migrations/differ.d.ts +56 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +152 -27
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/null-equal-indexes.js +1 -1
- package/dist/migrations/orchestrate.d.ts +7 -0
- package/dist/migrations/orchestrate.d.ts.map +1 -1
- package/dist/migrations/orchestrate.js +4 -2
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/tracker.d.ts +29 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +92 -7
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/object.d.ts +128 -0
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +333 -44
- package/dist/object.js.map +1 -1
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +7 -5
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry.d.ts +68 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +119 -8
- package/dist/registry.js.map +1 -1
- package/dist/scanner/index.d.ts +1 -1
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.d.ts +3 -2
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +12 -8
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +21 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/conflict-target.d.ts +68 -11
- package/dist/schema/conflict-target.d.ts.map +1 -1
- package/dist/schema/conflict-target.js +68 -12
- package/dist/schema/conflict-target.js.map +1 -1
- package/dist/schema/foreign-key-ddl.d.ts +17 -1
- package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
- package/dist/schema/foreign-key-ddl.js +29 -3
- package/dist/schema/foreign-key-ddl.js.map +1 -1
- package/dist/schema/foreign-key-policy.d.ts +8 -0
- package/dist/schema/foreign-key-policy.d.ts.map +1 -1
- package/dist/schema/foreign-key-policy.js +2 -1
- package/dist/schema/foreign-key-policy.js.map +1 -1
- package/dist/schema/generator.d.ts +7 -0
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +18 -10
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/text-cast-probe.d.ts +50 -1
- package/dist/schema/text-cast-probe.d.ts.map +1 -1
- package/dist/schema/text-cast-probe.js +46 -11
- package/dist/schema/text-cast-probe.js.map +1 -1
- package/dist/schema/types.d.ts +6 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +12 -7
- package/package.json +10 -10
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { conflictIndexName, isLegacyNaturalKeyIndex } from "../schema/conflict-target.js";
|
|
1
2
|
import { normalizeForeignKeyAction, requireForeignKeyAction } from "../schema/foreign-key-policy.js";
|
|
2
3
|
import { isJsonPathIndex, isStiSubtypeUniqueIndex, renderIndexTarget, shortenIdentifier } from "../schema/index-utils.js";
|
|
3
|
-
import { foreignKeyConstraintName, foreignKeyRelationshipKey, renderForeignKeyAddStatements, renderForeignKeyOrphanDetector, renderForeignKeyOrphanRepair, schemaForeignKeys, schemaForeignKeysForEngine } from "../schema/foreign-key-ddl.js";
|
|
4
|
+
import { foreignKeyConstraintName, foreignKeyRelationshipKey, renderForeignKeyAddStatements, renderForeignKeyOrphanDetector, renderForeignKeyOrphanRepair, renderForeignKeyReplaceStatements, schemaForeignKeys, schemaForeignKeysForEngine } from "../schema/foreign-key-ddl.js";
|
|
4
5
|
import { renderNullEqualConflictIndex } from "../schema/ddl/null-equal-index.js";
|
|
5
6
|
import { detectEngine, getDDLStrategy } from "../schema/ddl/index.js";
|
|
6
|
-
import { conflictIndexName } from "../schema/conflict-target.js";
|
|
7
7
|
import { mapWithConcurrency } from "../schema/bounded-concurrency.js";
|
|
8
8
|
import { columnsAllValuesUuidShapedBatch, columnsHaveNonEmptyValueBatch, nonEmptyValuePredicate, resolveRenameDataPendingCandidates, uuidInvalidShapePredicate } from "../schema/column-data-probes.js";
|
|
9
|
-
import { maskSampleValue, probeCastSafety, renderJsonbColumnConversion, renderTimestamptzColumnConversion } from "../schema/text-cast-probe.js";
|
|
9
|
+
import { isEmptyTextOnlyProbe, maskSampleValue, probeCastSafety, renderEmptyTextAsNullExpression, renderEmptyTextPredicate, renderJsonbColumnConversion, renderTimestamptzColumnConversion } from "../schema/text-cast-probe.js";
|
|
10
10
|
import { describeForeignKeyTypeConflict, planUuidConvergence, renderUuidConvergenceRepairHint, renderUuidShapeProbe } from "../schema/uuid-convergence.js";
|
|
11
11
|
import "../schema/utils.js";
|
|
12
12
|
import { planSqliteTableRebuilds, sqliteBackfillPlaceholderSql, sqliteRebuildPlaceholderSql } from "./sqlite-rebuild.js";
|
|
@@ -19,6 +19,12 @@ import { createLogger } from "@happyvertical/logger";
|
|
|
19
19
|
* a SchemaDiff with the differences.
|
|
20
20
|
*/
|
|
21
21
|
var logger = createLogger({ level: "info" });
|
|
22
|
+
/** Actions that change child rows when the parent is deleted or updated. */
|
|
23
|
+
var ROW_CHANGING_FOREIGN_KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
24
|
+
"CASCADE",
|
|
25
|
+
"SET NULL",
|
|
26
|
+
"SET DEFAULT"
|
|
27
|
+
]);
|
|
22
28
|
/**
|
|
23
29
|
* `mismatch.actual` of the manual change reported when a required column
|
|
24
30
|
* could not be added to a populated table (#3008): the column is absent.
|
|
@@ -675,7 +681,21 @@ var SchemaComparer = class SchemaComparer {
|
|
|
675
681
|
* affirmatively confirmed safe or affirmatively confirmed unsafe.
|
|
676
682
|
*/
|
|
677
683
|
async probeTextCastShape(tableName, columnName, kind) {
|
|
678
|
-
return probeCastSafety(this.db, tableName, columnName, kind);
|
|
684
|
+
return probeCastSafety(this.db, tableName, columnName, kind, { emptyTextAsNull: this.options.emptyTextAsNull === true });
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Explain a probe's empty-text finding (#3226) for a blocking advisory:
|
|
688
|
+
* how many of the offending values are empty text and what the opt-in
|
|
689
|
+
* would do with them (or why it cannot).
|
|
690
|
+
*/
|
|
691
|
+
describeEmptyTextFinding(probe, nullable) {
|
|
692
|
+
const emptyCount = probe.emptyCount ?? 0;
|
|
693
|
+
if (emptyCount === 0) return "";
|
|
694
|
+
const emptyOnly = emptyCount === probe.count;
|
|
695
|
+
const subject = emptyOnly ? `All ${emptyCount} are empty text ('').` : `${emptyCount} of them are empty text ('').`;
|
|
696
|
+
if (!nullable) return ` ${subject} The column is NOT NULL, so empty text cannot become NULL; store real values instead.`;
|
|
697
|
+
if (!emptyOnly) return ` ${subject} \`--empty-text-as-null\` would store those as NULL, but the other value(s) must be repaired first.`;
|
|
698
|
+
return ` ${subject} Rerun with \`smrt db:migrate --empty-text-as-null\` to store them as NULL (the column is nullable).`;
|
|
679
699
|
}
|
|
680
700
|
/**
|
|
681
701
|
* Turn the convergence plan into diff entries: executable `type_upgrade`
|
|
@@ -791,7 +811,13 @@ var SchemaComparer = class SchemaComparer {
|
|
|
791
811
|
uuidCastSide: orphanOptions.uuidCastSide,
|
|
792
812
|
nullable: orphanOptions.nullable
|
|
793
813
|
});
|
|
794
|
-
|
|
814
|
+
const sameColumn = liveForeignKeys.some((live) => live.column === foreignKey.column);
|
|
815
|
+
let replacement;
|
|
816
|
+
if (sameColumn && this.engine === "postgres") replacement = await this.findReplaceableForeignKey(tableName, liveForeignKeys, foreignKey, {
|
|
817
|
+
onDelete: expectedDelete,
|
|
818
|
+
onUpdate: expectedUpdate
|
|
819
|
+
});
|
|
820
|
+
if (sameColumn && !replacement) {
|
|
795
821
|
changes.push({
|
|
796
822
|
type: "add_foreign_key",
|
|
797
823
|
table: tableName,
|
|
@@ -799,7 +825,7 @@ var SchemaComparer = class SchemaComparer {
|
|
|
799
825
|
foreignKey,
|
|
800
826
|
advisory: {
|
|
801
827
|
severity: "warning",
|
|
802
|
-
message: `Foreign key ${tableName}.${foreignKey.column} exists with a different target or action. Drop the old constraint deliberately, repair any orphan rows, then rerun the migration
|
|
828
|
+
message: `Foreign key ${tableName}.${foreignKey.column} exists with a different target or action. ` + (this.engine === "postgres" ? "Drop the old constraint deliberately, repair any orphan rows, then rerun the migration." : this.engine === "sqlite" ? "SQLite cannot drop or alter a foreign key in place: rebuild the table with the generated constraint (repair any orphan rows first)." : "DuckDB cannot alter a foreign key in place: rebuild the table with the generated constraint (repair any orphan rows first)."),
|
|
803
829
|
suggestedSql: [detectorSql, repairSql]
|
|
804
830
|
}
|
|
805
831
|
});
|
|
@@ -891,11 +917,60 @@ var SchemaComparer = class SchemaComparer {
|
|
|
891
917
|
table: tableName,
|
|
892
918
|
name: constraintName,
|
|
893
919
|
foreignKey,
|
|
894
|
-
sqlStatements: renderForeignKeyAddStatements(tableName, foreignKey)
|
|
920
|
+
sqlStatements: replacement ? renderForeignKeyReplaceStatements(tableName, foreignKey, replacement.constraintName) : renderForeignKeyAddStatements(tableName, foreignKey),
|
|
921
|
+
...replacement ? { note: replacement.note } : {},
|
|
922
|
+
...replacement?.destructive ? { advisory: {
|
|
923
|
+
severity: "warning",
|
|
924
|
+
message: replacement.destructive
|
|
925
|
+
} } : {}
|
|
895
926
|
});
|
|
896
927
|
}
|
|
897
928
|
return changes;
|
|
898
929
|
}
|
|
930
|
+
/**
|
|
931
|
+
* Decide whether a live PostgreSQL foreign key on the same column and
|
|
932
|
+
* target can have its referential actions converged automatically
|
|
933
|
+
* (#3023). The constraint must be one SMRT rendered: the deterministic
|
|
934
|
+
* canonical name, one column to one column, MATCH SIMPLE, not deferrable,
|
|
935
|
+
* already validated, and with default trigger enforcement. Only then is it
|
|
936
|
+
* replaced build-then-swap (`renderForeignKeyReplaceStatements()`: a staged
|
|
937
|
+
* constraint added NOT VALID and validated before the old one is dropped
|
|
938
|
+
* and the staged one renamed; `--postgres-safe` validates and swaps outside
|
|
939
|
+
* the batch transaction). A change that turns on a row-changing action
|
|
940
|
+
* carries a `warning` advisory. Everything else keeps the manual advisory, because a
|
|
941
|
+
* differently named or shaped constraint may be one an operator created on
|
|
942
|
+
* purpose.
|
|
943
|
+
*/
|
|
944
|
+
async findReplaceableForeignKey(tableName, liveForeignKeys, foreignKey, expected) {
|
|
945
|
+
const onColumn = (liveForeignKeys || []).filter((live) => live.column === foreignKey.column);
|
|
946
|
+
if (onColumn.length !== 1) return void 0;
|
|
947
|
+
const [live] = onColumn;
|
|
948
|
+
if (live.referencesTable !== foreignKey.referencesTable || live.referencesColumn !== foreignKey.referencesColumn) return;
|
|
949
|
+
const canonicalName = foreignKeyConstraintName(tableName, foreignKey);
|
|
950
|
+
let rows;
|
|
951
|
+
try {
|
|
952
|
+
const result = await this.db.query(`SELECT con.conname AS constraint_name, con.convalidated AS validated, con.condeferrable AS deferrable, con.confmatchtype AS match_type, cardinality(con.conkey) AS child_keys, cardinality(con.confkey) AS parent_keys, child_attr.attname AS child_column, parent.relname AS parent_table, parent_attr.attname AS parent_column, EXISTS (SELECT 1 FROM pg_trigger AS trg WHERE trg.tgconstraint = con.oid AND trg.tgenabled <> 'O') AS nondefault_trigger_mode FROM pg_constraint AS con JOIN pg_class AS child ON child.oid = con.conrelid JOIN pg_namespace AS child_ns ON child_ns.oid = child.relnamespace JOIN pg_class AS parent ON parent.oid = con.confrelid JOIN pg_attribute AS child_attr ON child_attr.attrelid = con.conrelid AND child_attr.attnum = con.conkey[1] JOIN pg_attribute AS parent_attr ON parent_attr.attrelid = con.confrelid AND parent_attr.attnum = con.confkey[1] WHERE con.contype = 'f' AND child_ns.nspname = 'public' AND child.relname = ${this.quoteLiteral(tableName)} AND child_attr.attname = ${this.quoteLiteral(foreignKey.column)}`);
|
|
953
|
+
rows = Array.isArray(result) ? result : result.rows || [];
|
|
954
|
+
} catch {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (rows.length !== 1) return void 0;
|
|
958
|
+
const [row] = rows;
|
|
959
|
+
if (row.constraint_name !== canonicalName || row.parent_table !== foreignKey.referencesTable || row.parent_column !== foreignKey.referencesColumn || Number(row.child_keys) !== 1 || Number(row.parent_keys) !== 1 || row.validated !== true || row.deferrable === true || row.match_type !== "s" || row.nondefault_trigger_mode === true) return;
|
|
960
|
+
const liveDelete = normalizeForeignKeyAction(live.onDelete) || "NO ACTION";
|
|
961
|
+
const liveUpdate = normalizeForeignKeyAction(live.onUpdate) || "NO ACTION";
|
|
962
|
+
const parts = [];
|
|
963
|
+
if (liveDelete !== expected.onDelete) parts.push(`ON DELETE ${liveDelete} → ${expected.onDelete}`);
|
|
964
|
+
if (liveUpdate !== expected.onUpdate) parts.push(`ON UPDATE ${liveUpdate} → ${expected.onUpdate}`);
|
|
965
|
+
const destructive = [];
|
|
966
|
+
if (liveDelete !== expected.onDelete && ROW_CHANGING_FOREIGN_KEY_ACTIONS.has(expected.onDelete)) destructive.push(`deleting a ${foreignKey.referencesTable} row will now ${expected.onDelete === "CASCADE" ? "DELETE" : `${expected.onDelete} on`} its ${tableName} rows (was ${liveDelete})`);
|
|
967
|
+
if (liveUpdate !== expected.onUpdate && ROW_CHANGING_FOREIGN_KEY_ACTIONS.has(expected.onUpdate)) destructive.push(`changing a ${foreignKey.referencesTable}.${foreignKey.referencesColumn} value will now ${expected.onUpdate} ${tableName}.${foreignKey.column} (was ${liveUpdate})`);
|
|
968
|
+
return {
|
|
969
|
+
constraintName: canonicalName,
|
|
970
|
+
note: `replaces ${canonicalName}: ${parts.join(", ")}`,
|
|
971
|
+
...destructive.length > 0 ? { destructive: `DESTRUCTIVE foreign-key change on ${tableName}.${foreignKey.column}: ${destructive.join("; ")}. This applies to every delete, including raw SQL, admin tooling and tenant purges. Review it before migrating; declare a different onDelete/onUpdate to keep the old behaviour.` } : {}
|
|
972
|
+
};
|
|
973
|
+
}
|
|
899
974
|
async foreignKeyHasOrphans(tableName, foreignKey, options) {
|
|
900
975
|
try {
|
|
901
976
|
const result = await this.db.query(renderForeignKeyOrphanDetector(tableName, foreignKey, {
|
|
@@ -1024,6 +1099,12 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1024
1099
|
let timestamptzProbe;
|
|
1025
1100
|
if (timestamptzUpgradeCandidate) timestamptzProbe = await this.probeTextCastShape(tableName, colName, "timestamptz");
|
|
1026
1101
|
const nativeJsonDrift = nativeJsonToJsonb && (jsonProbe?.status === "clean" || jsonProbe?.status === "dirty");
|
|
1102
|
+
const emptyTextNullable = colDef.notNull !== true && dbCol.notNull !== true;
|
|
1103
|
+
const convertEmptyText = this.options.emptyTextAsNull === true && emptyTextNullable;
|
|
1104
|
+
const jsonEmptyTextToNull = convertEmptyText && isEmptyTextOnlyProbe(jsonProbe);
|
|
1105
|
+
const timestamptzEmptyTextToNull = convertEmptyText && isEmptyTextOnlyProbe(timestamptzProbe);
|
|
1106
|
+
const jsonConvertible = jsonProbe?.status === "clean" || jsonEmptyTextToNull;
|
|
1107
|
+
const timestamptzConvertible = timestamptzProbe?.status === "clean" || timestamptzEmptyTextToNull;
|
|
1027
1108
|
if ((normalizedExpected !== normalizedActual || nativeJsonDrift) && !isUuidTextEquivalent && !isJsonTextEquivalent) {
|
|
1028
1109
|
typeDrifted = !(nativeJsonToJsonb && jsonProbe?.status === "dirty");
|
|
1029
1110
|
const hasLiveDefault = dbCol.defaultValue !== null && dbCol.defaultValue !== void 0;
|
|
@@ -1031,12 +1112,21 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1031
1112
|
hasLiveDefault,
|
|
1032
1113
|
manifestDefaultValue: colDef.defaultValue
|
|
1033
1114
|
};
|
|
1115
|
+
const jsonConversionOptions = {
|
|
1116
|
+
...conversionOptions,
|
|
1117
|
+
...jsonEmptyTextToNull ? { emptyTextAsNull: true } : {}
|
|
1118
|
+
};
|
|
1119
|
+
const timestamptzConversionOptions = {
|
|
1120
|
+
...conversionOptions,
|
|
1121
|
+
...timestamptzEmptyTextToNull ? { emptyTextAsNull: true } : {}
|
|
1122
|
+
};
|
|
1123
|
+
const emptyTextNote = (probe) => isEmptyTextOnlyProbe(probe) ? { note: `${probe.emptyCount} empty-text value(s) become NULL (--empty-text-as-null)` } : {};
|
|
1034
1124
|
const liveOnlyDefaultNeedsRelaxOptIn = hasLiveDefault && colDef.defaultValue === void 0 && !this.options.relaxColumns;
|
|
1035
1125
|
const jsonMismatch = {
|
|
1036
1126
|
expected: nativeJsonToJsonb ? expectedEngineType : colDef.type,
|
|
1037
1127
|
actual: dbCol.type
|
|
1038
1128
|
};
|
|
1039
|
-
if (jsonUpgradeCandidate &&
|
|
1129
|
+
if (jsonUpgradeCandidate && jsonConvertible && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
|
|
1040
1130
|
type: "type_upgrade",
|
|
1041
1131
|
table: tableName,
|
|
1042
1132
|
name: colName,
|
|
@@ -1045,11 +1135,11 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1045
1135
|
advisory: {
|
|
1046
1136
|
severity: "warning",
|
|
1047
1137
|
message: `blocked: ${tableName}.${colName} has a live default (${String(dbCol.defaultValue)}) the manifest no longer declares; converging to jsonb requires dropping it first. ${this.relaxHint("drop it as part of this conversion")}`,
|
|
1048
|
-
suggestedSql: renderJsonbColumnConversion(tableName, colName,
|
|
1138
|
+
suggestedSql: renderJsonbColumnConversion(tableName, colName, jsonConversionOptions)
|
|
1049
1139
|
}
|
|
1050
1140
|
});
|
|
1051
|
-
else if (jsonUpgradeCandidate &&
|
|
1052
|
-
const statements = renderJsonbColumnConversion(tableName, colName,
|
|
1141
|
+
else if (jsonUpgradeCandidate && jsonConvertible) {
|
|
1142
|
+
const statements = renderJsonbColumnConversion(tableName, colName, jsonConversionOptions);
|
|
1053
1143
|
changes.push({
|
|
1054
1144
|
type: "type_upgrade",
|
|
1055
1145
|
table: tableName,
|
|
@@ -1057,7 +1147,8 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1057
1147
|
column: colDef,
|
|
1058
1148
|
mismatch: jsonMismatch,
|
|
1059
1149
|
sql: statements[statements.length - 1],
|
|
1060
|
-
sqlStatements: statements
|
|
1150
|
+
sqlStatements: statements,
|
|
1151
|
+
...jsonEmptyTextToNull ? emptyTextNote(jsonProbe) : {}
|
|
1061
1152
|
});
|
|
1062
1153
|
} else if (jsonUpgradeCandidate && jsonProbe?.status === "dirty" && jsonProbe.reason === "preservation_unverified") changes.push({
|
|
1063
1154
|
type: "type_upgrade",
|
|
@@ -1078,11 +1169,11 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1078
1169
|
mismatch: jsonMismatch,
|
|
1079
1170
|
advisory: {
|
|
1080
1171
|
severity: "warning",
|
|
1081
|
-
message: `blocked: ${tableName}.${colName} is declared JSON but ${jsonProbe.count} ${jsonProbe.reason === "duplicate_keys" ? "JSON object(s) carry duplicate keys that jsonb would silently collapse" : `live value(s) ${nativeJsonToJsonb ? "cannot be stored as jsonb (e.g. a \\u0000 escape)" : "are not valid JSON"}`} (sample: ${jsonProbe.sample ? maskSampleValue(jsonProbe.sample) : "unavailable"}). ${jsonProbe.reason === "duplicate_keys" ? "Converting keeps only the last value of each duplicated key; deduplicate the keys (deciding which value to keep), then rerun " : "Repair or clear the offending value(s), then rerun "}\`smrt db:migrate\`.`,
|
|
1172
|
+
message: `blocked: ${tableName}.${colName} is declared JSON but ${jsonProbe.count} ${jsonProbe.reason === "duplicate_keys" ? "JSON object(s) carry duplicate keys that jsonb would silently collapse" : `live value(s) ${nativeJsonToJsonb ? "cannot be stored as jsonb (e.g. a \\u0000 escape)" : "are not valid JSON"}`} (sample: ${jsonProbe.sample !== void 0 ? maskSampleValue(jsonProbe.sample) : "unavailable"}).${jsonProbe.reason === void 0 ? this.describeEmptyTextFinding(jsonProbe, emptyTextNullable) : ""} ${jsonProbe.reason === "duplicate_keys" ? "Converting keeps only the last value of each duplicated key; deduplicate the keys (deciding which value to keep), then rerun " : "Repair or clear the offending value(s), then rerun "}\`smrt db:migrate\`.`,
|
|
1082
1173
|
...jsonProbe.reason === "duplicate_keys" ? {} : { suggestedSql: renderJsonbColumnConversion(tableName, colName, conversionOptions) }
|
|
1083
1174
|
}
|
|
1084
1175
|
});
|
|
1085
|
-
else if (timestamptzUpgradeCandidate &&
|
|
1176
|
+
else if (timestamptzUpgradeCandidate && timestamptzConvertible && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
|
|
1086
1177
|
type: "type_upgrade",
|
|
1087
1178
|
table: tableName,
|
|
1088
1179
|
name: colName,
|
|
@@ -1094,11 +1185,11 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1094
1185
|
advisory: {
|
|
1095
1186
|
severity: "warning",
|
|
1096
1187
|
message: `blocked: ${tableName}.${colName} has a live default (${String(dbCol.defaultValue)}) the manifest no longer declares; converging to timestamptz requires dropping it first. ${this.relaxHint("drop it as part of this conversion")}`,
|
|
1097
|
-
suggestedSql: renderTimestamptzColumnConversion(tableName, colName,
|
|
1188
|
+
suggestedSql: renderTimestamptzColumnConversion(tableName, colName, timestamptzConversionOptions)
|
|
1098
1189
|
}
|
|
1099
1190
|
});
|
|
1100
|
-
else if (timestamptzUpgradeCandidate &&
|
|
1101
|
-
const statements = renderTimestamptzColumnConversion(tableName, colName,
|
|
1191
|
+
else if (timestamptzUpgradeCandidate && timestamptzConvertible) {
|
|
1192
|
+
const statements = renderTimestamptzColumnConversion(tableName, colName, timestamptzConversionOptions);
|
|
1102
1193
|
changes.push({
|
|
1103
1194
|
type: "type_upgrade",
|
|
1104
1195
|
table: tableName,
|
|
@@ -1109,7 +1200,8 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1109
1200
|
actual: dbCol.type
|
|
1110
1201
|
},
|
|
1111
1202
|
sql: statements[statements.length - 1],
|
|
1112
|
-
sqlStatements: statements
|
|
1203
|
+
sqlStatements: statements,
|
|
1204
|
+
...timestamptzEmptyTextToNull ? emptyTextNote(timestamptzProbe) : {}
|
|
1113
1205
|
});
|
|
1114
1206
|
} else if (timestamptzUpgradeCandidate && timestamptzProbe?.status === "dirty") changes.push({
|
|
1115
1207
|
type: "type_upgrade",
|
|
@@ -1122,12 +1214,16 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1122
1214
|
},
|
|
1123
1215
|
advisory: {
|
|
1124
1216
|
severity: "warning",
|
|
1125
|
-
message: `blocked: ${tableName}.${colName} is declared TIMESTAMP but ${timestamptzProbe.count} live value(s) do not parse as an unambiguous timestamp (sample: ${timestamptzProbe.sample ? maskSampleValue(timestamptzProbe.sample) : "unavailable"}). Repair the offending value(s), or confirm legacy naive wall-clock provenance with
|
|
1126
|
-
suggestedSql: renderTimestamptzColumnConversion(tableName, colName,
|
|
1217
|
+
message: `blocked: ${tableName}.${colName} is declared TIMESTAMP but ${timestamptzProbe.count} live value(s) do not parse as an unambiguous timestamp (sample: ${timestamptzProbe.sample !== void 0 ? maskSampleValue(timestamptzProbe.sample) : "unavailable"}).${this.describeEmptyTextFinding(timestamptzProbe, emptyTextNullable)}${isEmptyTextOnlyProbe(timestamptzProbe) ? "" : " Repair the offending value(s), or confirm legacy naive wall-clock provenance with `smrt db:migrate --postgres-timestamp-legacy-timezone=UTC`, then rerun."}`,
|
|
1218
|
+
suggestedSql: renderTimestamptzColumnConversion(tableName, colName, isEmptyTextOnlyProbe(timestamptzProbe) && emptyTextNullable ? {
|
|
1219
|
+
...conversionOptions,
|
|
1220
|
+
emptyTextAsNull: true
|
|
1221
|
+
} : conversionOptions)
|
|
1127
1222
|
}
|
|
1128
1223
|
});
|
|
1129
1224
|
else if (this.isCompatibleTypeUpgrade(colDef.type, dbCol.type)) {
|
|
1130
|
-
const
|
|
1225
|
+
const integerEmptyTextToNull = convertEmptyText && this.engine === "postgres" && this.normalizeType(colDef.type) === "INTEGER" && normalizedActual === "TEXT";
|
|
1226
|
+
const generatedSQL = this.generateTypeUpgradeSQL(tableName, colName, colDef, dbCol.type, { emptyTextAsNull: integerEmptyTextToNull });
|
|
1131
1227
|
changes.push({
|
|
1132
1228
|
type: "type_upgrade",
|
|
1133
1229
|
table: tableName,
|
|
@@ -1138,7 +1234,8 @@ var SchemaComparer = class SchemaComparer {
|
|
|
1138
1234
|
actual: dbCol.type
|
|
1139
1235
|
},
|
|
1140
1236
|
sql: generatedSQL.sql,
|
|
1141
|
-
...generatedSQL.statements ? { sqlStatements: generatedSQL.statements } : {}
|
|
1237
|
+
...generatedSQL.statements ? { sqlStatements: generatedSQL.statements } : {},
|
|
1238
|
+
...integerEmptyTextToNull ? { note: "empty-text value(s), if any, become NULL (--empty-text-as-null)" } : {}
|
|
1142
1239
|
});
|
|
1143
1240
|
} else if (!this.options.ignoreTypeMismatches) changes.push({
|
|
1144
1241
|
type: "type_mismatch",
|
|
@@ -2019,6 +2116,19 @@ var SchemaComparer = class SchemaComparer {
|
|
|
2019
2116
|
const equivalentIndexNames = dbIndexSignatures.get(manifestSignature);
|
|
2020
2117
|
if (equivalentIndexNames && equivalentIndexNames.size > 0) {
|
|
2021
2118
|
for (const name of equivalentIndexNames) claimedDbIndexes.add(name);
|
|
2119
|
+
const legacyName = this.legacyTenantLedIndexName(tableName, idx);
|
|
2120
|
+
if (legacyName && this.engine === "postgres" && equivalentIndexNames.has(legacyName) && !dbIndexesByName.has(idx.name)) {
|
|
2121
|
+
const renameSql = `ALTER INDEX IF EXISTS ${this.quoteIdentifier(legacyName)} RENAME TO ${this.quoteIdentifier(idx.name)}`;
|
|
2122
|
+
changes.push({
|
|
2123
|
+
type: "add_index",
|
|
2124
|
+
table: tableName,
|
|
2125
|
+
name: idx.name,
|
|
2126
|
+
index: idx,
|
|
2127
|
+
note: `renames ${legacyName} (same columns)`,
|
|
2128
|
+
sql: renameSql,
|
|
2129
|
+
sqlStatements: [renameSql]
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2022
2132
|
continue;
|
|
2023
2133
|
}
|
|
2024
2134
|
changes.push({
|
|
@@ -2055,6 +2165,7 @@ var SchemaComparer = class SchemaComparer {
|
|
|
2055
2165
|
}
|
|
2056
2166
|
const redundantPrimaryKeyIndex = idx.unique !== true && idx.columns.length === 1 && dbPredicateFor(idx.name) === "" && primaryKeyColumns.length === 1 && primaryKeyColumns[0] === idx.columns[0];
|
|
2057
2167
|
const supersededConflictIndex = idx.unique === true && predicateAware && dbPredicateFor(idx.name) === "" && idx.columns.length > 0 && idx.name === shortenIdentifier(conflictIndexName(tableName, idx.columns)) && manifestIndexes.some((declared) => declared.unique === true && !declared.where && declared.columns.length > idx.columns.length && idx.columns.every((column) => declared.columns.includes(column)) && declared.columns.every((column) => column in dbSchema.columns));
|
|
2168
|
+
if (supersededConflictIndex && manifestIndexes.some((declared) => declared.unique === true && !declared.where && isLegacyNaturalKeyIndex(idx.columns, declared.columns)) && !this.options.dropLegacyNaturalKey) continue;
|
|
2058
2169
|
if (!redundantPrimaryKeyIndex && !supersededConflictIndex && !this.options.includeDroppedIndexes) continue;
|
|
2059
2170
|
changes.push({
|
|
2060
2171
|
type: "drop_index",
|
|
@@ -2066,6 +2177,20 @@ var SchemaComparer = class SchemaComparer {
|
|
|
2066
2177
|
return changes;
|
|
2067
2178
|
}
|
|
2068
2179
|
/**
|
|
2180
|
+
* The framework's pre-release name for a manifest's tenant-led default
|
|
2181
|
+
* natural key: `<table>_slug_context_idx` (CTI) or
|
|
2182
|
+
* `<table>_slug_context_meta_type_idx` (STI), when `idx` is a unique,
|
|
2183
|
+
* unqualified default key led by one ownership column and its manifest name
|
|
2184
|
+
* differs (an inferred owner, see `conflictIndexName()`). `undefined`
|
|
2185
|
+
* otherwise.
|
|
2186
|
+
*/
|
|
2187
|
+
legacyTenantLedIndexName(tableName, idx) {
|
|
2188
|
+
const columns = idx.columns ?? [];
|
|
2189
|
+
if (idx.unique !== true || idx.where || isJsonPathIndex(idx) || columns.length < 3 || !isLegacyNaturalKeyIndex(columns.slice(1), columns)) return;
|
|
2190
|
+
const legacyName = shortenIdentifier(conflictIndexName(tableName, columns.slice(1)));
|
|
2191
|
+
return legacyName === idx.name ? void 0 : legacyName;
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2069
2194
|
* Generate a signature for an index based on its columns, uniqueness, and
|
|
2070
2195
|
* (normalized) partial-index predicate. Used for functional equivalence
|
|
2071
2196
|
* checking (Issue #741) and predicate-drift detection (Issue #1692).
|
|
@@ -2226,7 +2351,7 @@ var SchemaComparer = class SchemaComparer {
|
|
|
2226
2351
|
* - DuckDB: ALTER COLUMN TYPE (native type conversion)
|
|
2227
2352
|
* - PostgreSQL: ALTER COLUMN TYPE with USING clause
|
|
2228
2353
|
*/
|
|
2229
|
-
generateTypeUpgradeSQL(tableName, colName, colDef, dbType) {
|
|
2354
|
+
generateTypeUpgradeSQL(tableName, colName, colDef, dbType, options = {}) {
|
|
2230
2355
|
const quotedTable = this.quoteIdentifier(tableName);
|
|
2231
2356
|
const quotedCol = this.quoteIdentifier(colName);
|
|
2232
2357
|
const manifestType = colDef.type;
|
|
@@ -2239,7 +2364,7 @@ var SchemaComparer = class SchemaComparer {
|
|
|
2239
2364
|
case "postgres": {
|
|
2240
2365
|
const manifestNormalized = this.normalizeType(manifestType);
|
|
2241
2366
|
const dbNormalized = this.normalizeType(dbType);
|
|
2242
|
-
const preflightSQL = manifestNormalized === "INTEGER" && (dbNormalized === "TEXT" || dbNormalized === "REAL") ? this.generatePostgresIntegerPreflightSQL(quotedTable, quotedCol, tableName, colName, dbNormalized) : manifestNormalized === "TIMESTAMP" && [
|
|
2367
|
+
const preflightSQL = manifestNormalized === "INTEGER" && (dbNormalized === "TEXT" || dbNormalized === "REAL") ? this.generatePostgresIntegerPreflightSQL(quotedTable, quotedCol, tableName, colName, dbNormalized, options.emptyTextAsNull === true && dbNormalized === "TEXT") : manifestNormalized === "TIMESTAMP" && [
|
|
2243
2368
|
"TIMESTAMP",
|
|
2244
2369
|
"TEXT",
|
|
2245
2370
|
"JSON"
|
|
@@ -2250,7 +2375,7 @@ var SchemaComparer = class SchemaComparer {
|
|
|
2250
2375
|
if (manifestNormalized === "JSON" && dbNormalized === "TEXT") typeClause += ` USING to_jsonb(${quotedCol})`;
|
|
2251
2376
|
else if (manifestNormalized === "TEXT" && dbNormalized === "JSON") typeClause += ` USING ${quotedCol}::text`;
|
|
2252
2377
|
else if (manifestNormalized === "TEXT" && dbNormalized === "UUID") typeClause += ` USING ${quotedCol}::text`;
|
|
2253
|
-
else if (manifestNormalized === "INTEGER" && dbNormalized === "TEXT") typeClause += ` USING trim(${quotedCol}::text)::bigint`;
|
|
2378
|
+
else if (manifestNormalized === "INTEGER" && dbNormalized === "TEXT") typeClause += options.emptyTextAsNull ? ` USING trim((${renderEmptyTextAsNullExpression(quotedCol)}))::bigint` : ` USING trim(${quotedCol}::text)::bigint`;
|
|
2254
2379
|
else if (manifestNormalized === "INTEGER" && dbNormalized === "REAL") typeClause += ` USING ${quotedCol}::bigint`;
|
|
2255
2380
|
else if (manifestNormalized === "TIMESTAMP" && (dbNormalized === "TEXT" || dbNormalized === "JSON")) typeClause += ` USING NULLIF(NULLIF(trim(both '"' from ${quotedCol}::text), ''), 'null')::timestamptz`;
|
|
2256
2381
|
else if (manifestNormalized === "TIMESTAMP" && dbNormalized === "TIMESTAMP" && this.normalizeType(targetType) === "TIMESTAMPTZ") typeClause += ` USING ${quotedCol} AT TIME ZONE 'UTC'`;
|
|
@@ -2281,8 +2406,8 @@ var SchemaComparer = class SchemaComparer {
|
|
|
2281
2406
|
* INTEGER. PostgreSQL's REAL→INTEGER cast rounds fractional values, so the
|
|
2282
2407
|
* generated migration must fail before the ALTER can silently change data.
|
|
2283
2408
|
*/
|
|
2284
|
-
generatePostgresIntegerPreflightSQL(quotedTable, quotedCol, tableName, colName, dbNormalized) {
|
|
2285
|
-
const invalidCondition = dbNormalized === "REAL" ? `${quotedCol} IS NOT NULL AND ${quotedCol} <> trunc(${quotedCol})` : `${quotedCol} IS NOT NULL AND trim(${quotedCol}::text) !~ '^[+-]?[0-9]+$'`;
|
|
2409
|
+
generatePostgresIntegerPreflightSQL(quotedTable, quotedCol, tableName, colName, dbNormalized, emptyTextAsNull = false) {
|
|
2410
|
+
const invalidCondition = dbNormalized === "REAL" ? `${quotedCol} IS NOT NULL AND ${quotedCol} <> trunc(${quotedCol})` : emptyTextAsNull ? `${quotedCol} IS NOT NULL AND NOT ${renderEmptyTextPredicate(quotedCol)} AND trim(${quotedCol}::text) !~ '^[+-]?[0-9]+$'` : `${quotedCol} IS NOT NULL AND trim(${quotedCol}::text) !~ '^[+-]?[0-9]+$'`;
|
|
2286
2411
|
const message = `Cannot convert ${tableName}.${colName} to INTEGER: found non-integer values`;
|
|
2287
2412
|
return `DO $$ BEGIN IF EXISTS (SELECT 1 FROM ${quotedTable} WHERE ${invalidCondition}) THEN RAISE EXCEPTION ${this.quoteLiteral(message)}; END IF; END $$`;
|
|
2288
2413
|
}
|