@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.
Files changed (79) hide show
  1. package/AGENTS.md +8 -0
  2. package/agents/schema-paths.md +79 -0
  3. package/dist/cascade.d.ts.map +1 -1
  4. package/dist/cascade.js +2 -1
  5. package/dist/cascade.js.map +1 -1
  6. package/dist/collection.d.ts +10 -1
  7. package/dist/collection.d.ts.map +1 -1
  8. package/dist/collection.js +14 -4
  9. package/dist/collection.js.map +1 -1
  10. package/dist/errors.d.ts +18 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +21 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/manifest/static-manifest.d.ts.map +1 -1
  18. package/dist/manifest/static-manifest.js +9 -1
  19. package/dist/manifest/static-manifest.js.map +1 -1
  20. package/dist/manifest/store.js +1 -1
  21. package/dist/manifest/store.js.map +1 -1
  22. package/dist/manifest.json +9 -1
  23. package/dist/migrations/differ.d.ts +56 -0
  24. package/dist/migrations/differ.d.ts.map +1 -1
  25. package/dist/migrations/differ.js +152 -27
  26. package/dist/migrations/differ.js.map +1 -1
  27. package/dist/migrations/null-equal-indexes.js +1 -1
  28. package/dist/migrations/orchestrate.d.ts +7 -0
  29. package/dist/migrations/orchestrate.d.ts.map +1 -1
  30. package/dist/migrations/orchestrate.js +4 -2
  31. package/dist/migrations/orchestrate.js.map +1 -1
  32. package/dist/migrations/tracker.d.ts +29 -1
  33. package/dist/migrations/tracker.d.ts.map +1 -1
  34. package/dist/migrations/tracker.js +92 -7
  35. package/dist/migrations/tracker.js.map +1 -1
  36. package/dist/object.d.ts +128 -0
  37. package/dist/object.d.ts.map +1 -1
  38. package/dist/object.js +333 -44
  39. package/dist/object.js.map +1 -1
  40. package/dist/registry/schema-builder.d.ts.map +1 -1
  41. package/dist/registry/schema-builder.js +7 -5
  42. package/dist/registry/schema-builder.js.map +1 -1
  43. package/dist/registry.d.ts +68 -4
  44. package/dist/registry.d.ts.map +1 -1
  45. package/dist/registry.js +119 -8
  46. package/dist/registry.js.map +1 -1
  47. package/dist/scanner/index.d.ts +1 -1
  48. package/dist/scanner/index.d.ts.map +1 -1
  49. package/dist/scanner/manifest-generator.d.ts +3 -2
  50. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  51. package/dist/scanner/manifest-generator.js +12 -8
  52. package/dist/scanner/manifest-generator.js.map +1 -1
  53. package/dist/scanner/types.d.ts +21 -0
  54. package/dist/scanner/types.d.ts.map +1 -1
  55. package/dist/scanner/types.js.map +1 -1
  56. package/dist/schema/conflict-target.d.ts +68 -11
  57. package/dist/schema/conflict-target.d.ts.map +1 -1
  58. package/dist/schema/conflict-target.js +68 -12
  59. package/dist/schema/conflict-target.js.map +1 -1
  60. package/dist/schema/foreign-key-ddl.d.ts +17 -1
  61. package/dist/schema/foreign-key-ddl.d.ts.map +1 -1
  62. package/dist/schema/foreign-key-ddl.js +29 -3
  63. package/dist/schema/foreign-key-ddl.js.map +1 -1
  64. package/dist/schema/foreign-key-policy.d.ts +8 -0
  65. package/dist/schema/foreign-key-policy.d.ts.map +1 -1
  66. package/dist/schema/foreign-key-policy.js +2 -1
  67. package/dist/schema/foreign-key-policy.js.map +1 -1
  68. package/dist/schema/generator.d.ts +7 -0
  69. package/dist/schema/generator.d.ts.map +1 -1
  70. package/dist/schema/generator.js +18 -10
  71. package/dist/schema/generator.js.map +1 -1
  72. package/dist/schema/text-cast-probe.d.ts +50 -1
  73. package/dist/schema/text-cast-probe.d.ts.map +1 -1
  74. package/dist/schema/text-cast-probe.js +46 -11
  75. package/dist/schema/text-cast-probe.js.map +1 -1
  76. package/dist/schema/types.d.ts +6 -0
  77. package/dist/schema/types.d.ts.map +1 -1
  78. package/dist/smrt-knowledge.json +12 -7
  79. 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
- if (liveForeignKeys.some((live) => live.column === foreignKey.column)) {
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 && jsonProbe?.status === "clean" && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
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, conversionOptions)
1138
+ suggestedSql: renderJsonbColumnConversion(tableName, colName, jsonConversionOptions)
1049
1139
  }
1050
1140
  });
1051
- else if (jsonUpgradeCandidate && jsonProbe?.status === "clean") {
1052
- const statements = renderJsonbColumnConversion(tableName, colName, conversionOptions);
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 && timestamptzProbe?.status === "clean" && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
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, conversionOptions)
1188
+ suggestedSql: renderTimestamptzColumnConversion(tableName, colName, timestamptzConversionOptions)
1098
1189
  }
1099
1190
  });
1100
- else if (timestamptzUpgradeCandidate && timestamptzProbe?.status === "clean") {
1101
- const statements = renderTimestamptzColumnConversion(tableName, colName, conversionOptions);
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 \`smrt db:migrate --postgres-timestamp-legacy-timezone=UTC\`, then rerun.`,
1126
- suggestedSql: renderTimestamptzColumnConversion(tableName, colName, conversionOptions)
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 generatedSQL = this.generateTypeUpgradeSQL(tableName, colName, colDef, dbCol.type);
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
  }