@happyvertical/smrt-core 0.51.38 → 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 +90 -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 +58 -0
  24. package/dist/migrations/differ.d.ts.map +1 -1
  25. package/dist/migrations/differ.js +187 -32
  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.
@@ -330,6 +336,8 @@ var SchemaComparer = class SchemaComparer {
330
336
  liveSchemas = /* @__PURE__ */ new Map();
331
337
  /** Convergence plan for this run; `null` on non-PostgreSQL engines. */
332
338
  uuidConvergence = null;
339
+ /** Parents created by this compare() batch, never a standalone compareTable(). */
340
+ plannedTables = /* @__PURE__ */ new Map();
333
341
  /**
334
342
  * Rename-pending advisory findings for this `compare()` run, keyed by
335
343
  * table name (#2878). `compareTable()` reads from here when it is being
@@ -401,6 +409,7 @@ var SchemaComparer = class SchemaComparer {
401
409
  this.indexPredicateCache = null;
402
410
  try {
403
411
  const existingTables = await this.getExistingTables();
412
+ this.plannedTables = new Map(Object.entries(manifestSchemas).filter(([name, schema]) => !existingTables.has(name) && schema.tableName === name));
404
413
  await this.prefetchLiveSchemas(Object.keys(manifestSchemas).filter((tableName) => existingTables.has(tableName)));
405
414
  await this.precomputeIndexPredicates();
406
415
  await this.precomputeRenameDataPending(manifestSchemas, existingTables);
@@ -432,6 +441,7 @@ var SchemaComparer = class SchemaComparer {
432
441
  }
433
442
  return diff;
434
443
  } finally {
444
+ this.plannedTables.clear();
435
445
  this.renameDataPendingCache = null;
436
446
  this.indexPredicateCache = null;
437
447
  }
@@ -671,7 +681,21 @@ var SchemaComparer = class SchemaComparer {
671
681
  * affirmatively confirmed safe or affirmatively confirmed unsafe.
672
682
  */
673
683
  async probeTextCastShape(tableName, columnName, kind) {
674
- 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).`;
675
699
  }
676
700
  /**
677
701
  * Turn the convergence plan into diff entries: executable `type_upgrade`
@@ -722,11 +746,12 @@ var SchemaComparer = class SchemaComparer {
722
746
  * `undefined` means the pair is compatible (or unknowable) and the ordinary
723
747
  * add path applies.
724
748
  */
725
- async describeForeignKeyTypeBlock(tableName, dbSchema, foreignKey) {
749
+ async describeForeignKeyTypeBlock(tableName, dbSchema, manifest, foreignKey) {
726
750
  if (this.engine !== "postgres") return void 0;
727
751
  const parentSchema = foreignKey.referencesTable === tableName ? dbSchema : await this.getLiveSchema(foreignKey.referencesTable);
728
- const childType = dbSchema.columns[foreignKey.column]?.type;
729
- const parentType = parentSchema?.columns[foreignKey.referencesColumn]?.type;
752
+ const declaredType = (type) => type === void 0 ? void 0 : this.ddlStrategy.mapType(isValidSQLDataType(type) ? type : "TEXT");
753
+ const childType = dbSchema.columns[foreignKey.column]?.type ?? declaredType(manifest.columns[foreignKey.column]?.type);
754
+ const parentType = parentSchema?.columns[foreignKey.referencesColumn]?.type ?? declaredType(this.plannedTables.get(foreignKey.referencesTable)?.columns[foreignKey.referencesColumn]?.type);
730
755
  if (!childType || !parentType) return void 0;
731
756
  const plan = this.uuidConvergence;
732
757
  if ((plan?.effectiveType(tableName, foreignKey.column) ?? this.normalizeType(childType)) === (plan?.effectiveType(foreignKey.referencesTable, foreignKey.referencesColumn) ?? this.normalizeType(parentType))) return void 0;
@@ -786,7 +811,13 @@ var SchemaComparer = class SchemaComparer {
786
811
  uuidCastSide: orphanOptions.uuidCastSide,
787
812
  nullable: orphanOptions.nullable
788
813
  });
789
- 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) {
790
821
  changes.push({
791
822
  type: "add_foreign_key",
792
823
  table: tableName,
@@ -794,7 +825,7 @@ var SchemaComparer = class SchemaComparer {
794
825
  foreignKey,
795
826
  advisory: {
796
827
  severity: "warning",
797
- 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)."),
798
829
  suggestedSql: [detectorSql, repairSql]
799
830
  }
800
831
  });
@@ -816,7 +847,7 @@ var SchemaComparer = class SchemaComparer {
816
847
  });
817
848
  continue;
818
849
  }
819
- const typeBlock = await this.describeForeignKeyTypeBlock(tableName, dbSchema, foreignKey);
850
+ const typeBlock = await this.describeForeignKeyTypeBlock(tableName, dbSchema, manifest, foreignKey);
820
851
  if (typeBlock) {
821
852
  const textNodes = manifest.columns[foreignKey.column]?.type === "UUID" && manifestSchemas[foreignKey.referencesTable]?.columns[foreignKey.referencesColumn]?.type === "UUID" ? [this.normalizeType(dbSchema.columns[foreignKey.column]?.type ?? "") === "TEXT" ? {
822
853
  table: tableName,
@@ -839,7 +870,32 @@ var SchemaComparer = class SchemaComparer {
839
870
  });
840
871
  continue;
841
872
  }
842
- if (Boolean(dbSchema.columns[foreignKey.column]) && await this.foreignKeyHasOrphans(tableName, foreignKey, orphanOptions)) {
873
+ const plannedParent = this.liveSchemas.get(foreignKey.referencesTable) ? void 0 : this.plannedTables.get(foreignKey.referencesTable);
874
+ if (plannedParent) {
875
+ const parentColumn = plannedParent.columns[foreignKey.referencesColumn];
876
+ let childEmpty = false;
877
+ if (parentColumn) try {
878
+ const result = await this.db.query(`SELECT 1 FROM ${this.quoteIdentifier(tableName)} LIMIT 1`);
879
+ childEmpty = (Array.isArray(result) ? result : result.rows || []).length === 0;
880
+ } catch (error) {
881
+ throw new Error(`[SchemaComparer] Cannot prove ${tableName} empty for planned parent ${foreignKey.referencesTable}; refusing automatic constraint addition: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
882
+ }
883
+ if (!parentColumn || !childEmpty) {
884
+ changes.push({
885
+ type: "add_foreign_key",
886
+ table: tableName,
887
+ name: foreignKeyConstraintName(tableName, foreignKey),
888
+ foreignKey,
889
+ advisory: {
890
+ severity: "warning",
891
+ message: !parentColumn ? `Cannot add foreign key ${tableName}.${foreignKey.column}: planned parent ${foreignKey.referencesTable} has no column ${foreignKey.referencesColumn}. Correct the manifest, then rerun.` : `Cannot add foreign key ${tableName}.${foreignKey.column}: child table is not empty and planned parent ${foreignKey.referencesTable} does not exist yet. Create and populate the parent deliberately, then rerun the orphan preflight.`
892
+ }
893
+ });
894
+ continue;
895
+ }
896
+ }
897
+ const childColumnExists = Boolean(dbSchema.columns[foreignKey.column]);
898
+ if (!plannedParent && childColumnExists && await this.foreignKeyHasOrphans(tableName, foreignKey, orphanOptions)) {
843
899
  changes.push({
844
900
  type: "add_foreign_key",
845
901
  table: tableName,
@@ -861,11 +917,60 @@ var SchemaComparer = class SchemaComparer {
861
917
  table: tableName,
862
918
  name: constraintName,
863
919
  foreignKey,
864
- 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
+ } } : {}
865
926
  });
866
927
  }
867
928
  return changes;
868
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
+ }
869
974
  async foreignKeyHasOrphans(tableName, foreignKey, options) {
870
975
  try {
871
976
  const result = await this.db.query(renderForeignKeyOrphanDetector(tableName, foreignKey, {
@@ -994,6 +1099,12 @@ var SchemaComparer = class SchemaComparer {
994
1099
  let timestamptzProbe;
995
1100
  if (timestamptzUpgradeCandidate) timestamptzProbe = await this.probeTextCastShape(tableName, colName, "timestamptz");
996
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;
997
1108
  if ((normalizedExpected !== normalizedActual || nativeJsonDrift) && !isUuidTextEquivalent && !isJsonTextEquivalent) {
998
1109
  typeDrifted = !(nativeJsonToJsonb && jsonProbe?.status === "dirty");
999
1110
  const hasLiveDefault = dbCol.defaultValue !== null && dbCol.defaultValue !== void 0;
@@ -1001,12 +1112,21 @@ var SchemaComparer = class SchemaComparer {
1001
1112
  hasLiveDefault,
1002
1113
  manifestDefaultValue: colDef.defaultValue
1003
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)` } : {};
1004
1124
  const liveOnlyDefaultNeedsRelaxOptIn = hasLiveDefault && colDef.defaultValue === void 0 && !this.options.relaxColumns;
1005
1125
  const jsonMismatch = {
1006
1126
  expected: nativeJsonToJsonb ? expectedEngineType : colDef.type,
1007
1127
  actual: dbCol.type
1008
1128
  };
1009
- if (jsonUpgradeCandidate && jsonProbe?.status === "clean" && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
1129
+ if (jsonUpgradeCandidate && jsonConvertible && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
1010
1130
  type: "type_upgrade",
1011
1131
  table: tableName,
1012
1132
  name: colName,
@@ -1015,11 +1135,11 @@ var SchemaComparer = class SchemaComparer {
1015
1135
  advisory: {
1016
1136
  severity: "warning",
1017
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")}`,
1018
- suggestedSql: renderJsonbColumnConversion(tableName, colName, conversionOptions)
1138
+ suggestedSql: renderJsonbColumnConversion(tableName, colName, jsonConversionOptions)
1019
1139
  }
1020
1140
  });
1021
- else if (jsonUpgradeCandidate && jsonProbe?.status === "clean") {
1022
- const statements = renderJsonbColumnConversion(tableName, colName, conversionOptions);
1141
+ else if (jsonUpgradeCandidate && jsonConvertible) {
1142
+ const statements = renderJsonbColumnConversion(tableName, colName, jsonConversionOptions);
1023
1143
  changes.push({
1024
1144
  type: "type_upgrade",
1025
1145
  table: tableName,
@@ -1027,7 +1147,8 @@ var SchemaComparer = class SchemaComparer {
1027
1147
  column: colDef,
1028
1148
  mismatch: jsonMismatch,
1029
1149
  sql: statements[statements.length - 1],
1030
- sqlStatements: statements
1150
+ sqlStatements: statements,
1151
+ ...jsonEmptyTextToNull ? emptyTextNote(jsonProbe) : {}
1031
1152
  });
1032
1153
  } else if (jsonUpgradeCandidate && jsonProbe?.status === "dirty" && jsonProbe.reason === "preservation_unverified") changes.push({
1033
1154
  type: "type_upgrade",
@@ -1048,11 +1169,11 @@ var SchemaComparer = class SchemaComparer {
1048
1169
  mismatch: jsonMismatch,
1049
1170
  advisory: {
1050
1171
  severity: "warning",
1051
- 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\`.`,
1052
1173
  ...jsonProbe.reason === "duplicate_keys" ? {} : { suggestedSql: renderJsonbColumnConversion(tableName, colName, conversionOptions) }
1053
1174
  }
1054
1175
  });
1055
- else if (timestamptzUpgradeCandidate && timestamptzProbe?.status === "clean" && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
1176
+ else if (timestamptzUpgradeCandidate && timestamptzConvertible && liveOnlyDefaultNeedsRelaxOptIn) changes.push({
1056
1177
  type: "type_upgrade",
1057
1178
  table: tableName,
1058
1179
  name: colName,
@@ -1064,11 +1185,11 @@ var SchemaComparer = class SchemaComparer {
1064
1185
  advisory: {
1065
1186
  severity: "warning",
1066
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")}`,
1067
- suggestedSql: renderTimestamptzColumnConversion(tableName, colName, conversionOptions)
1188
+ suggestedSql: renderTimestamptzColumnConversion(tableName, colName, timestamptzConversionOptions)
1068
1189
  }
1069
1190
  });
1070
- else if (timestamptzUpgradeCandidate && timestamptzProbe?.status === "clean") {
1071
- const statements = renderTimestamptzColumnConversion(tableName, colName, conversionOptions);
1191
+ else if (timestamptzUpgradeCandidate && timestamptzConvertible) {
1192
+ const statements = renderTimestamptzColumnConversion(tableName, colName, timestamptzConversionOptions);
1072
1193
  changes.push({
1073
1194
  type: "type_upgrade",
1074
1195
  table: tableName,
@@ -1079,7 +1200,8 @@ var SchemaComparer = class SchemaComparer {
1079
1200
  actual: dbCol.type
1080
1201
  },
1081
1202
  sql: statements[statements.length - 1],
1082
- sqlStatements: statements
1203
+ sqlStatements: statements,
1204
+ ...timestamptzEmptyTextToNull ? emptyTextNote(timestamptzProbe) : {}
1083
1205
  });
1084
1206
  } else if (timestamptzUpgradeCandidate && timestamptzProbe?.status === "dirty") changes.push({
1085
1207
  type: "type_upgrade",
@@ -1092,12 +1214,16 @@ var SchemaComparer = class SchemaComparer {
1092
1214
  },
1093
1215
  advisory: {
1094
1216
  severity: "warning",
1095
- 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.`,
1096
- 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)
1097
1222
  }
1098
1223
  });
1099
1224
  else if (this.isCompatibleTypeUpgrade(colDef.type, dbCol.type)) {
1100
- 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 });
1101
1227
  changes.push({
1102
1228
  type: "type_upgrade",
1103
1229
  table: tableName,
@@ -1108,7 +1234,8 @@ var SchemaComparer = class SchemaComparer {
1108
1234
  actual: dbCol.type
1109
1235
  },
1110
1236
  sql: generatedSQL.sql,
1111
- ...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)" } : {}
1112
1239
  });
1113
1240
  } else if (!this.options.ignoreTypeMismatches) changes.push({
1114
1241
  type: "type_mismatch",
@@ -1989,6 +2116,19 @@ var SchemaComparer = class SchemaComparer {
1989
2116
  const equivalentIndexNames = dbIndexSignatures.get(manifestSignature);
1990
2117
  if (equivalentIndexNames && equivalentIndexNames.size > 0) {
1991
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
+ }
1992
2132
  continue;
1993
2133
  }
1994
2134
  changes.push({
@@ -2025,6 +2165,7 @@ var SchemaComparer = class SchemaComparer {
2025
2165
  }
2026
2166
  const redundantPrimaryKeyIndex = idx.unique !== true && idx.columns.length === 1 && dbPredicateFor(idx.name) === "" && primaryKeyColumns.length === 1 && primaryKeyColumns[0] === idx.columns[0];
2027
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;
2028
2169
  if (!redundantPrimaryKeyIndex && !supersededConflictIndex && !this.options.includeDroppedIndexes) continue;
2029
2170
  changes.push({
2030
2171
  type: "drop_index",
@@ -2036,6 +2177,20 @@ var SchemaComparer = class SchemaComparer {
2036
2177
  return changes;
2037
2178
  }
2038
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
+ /**
2039
2194
  * Generate a signature for an index based on its columns, uniqueness, and
2040
2195
  * (normalized) partial-index predicate. Used for functional equivalence
2041
2196
  * checking (Issue #741) and predicate-drift detection (Issue #1692).
@@ -2196,7 +2351,7 @@ var SchemaComparer = class SchemaComparer {
2196
2351
  * - DuckDB: ALTER COLUMN TYPE (native type conversion)
2197
2352
  * - PostgreSQL: ALTER COLUMN TYPE with USING clause
2198
2353
  */
2199
- generateTypeUpgradeSQL(tableName, colName, colDef, dbType) {
2354
+ generateTypeUpgradeSQL(tableName, colName, colDef, dbType, options = {}) {
2200
2355
  const quotedTable = this.quoteIdentifier(tableName);
2201
2356
  const quotedCol = this.quoteIdentifier(colName);
2202
2357
  const manifestType = colDef.type;
@@ -2209,7 +2364,7 @@ var SchemaComparer = class SchemaComparer {
2209
2364
  case "postgres": {
2210
2365
  const manifestNormalized = this.normalizeType(manifestType);
2211
2366
  const dbNormalized = this.normalizeType(dbType);
2212
- 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" && [
2213
2368
  "TIMESTAMP",
2214
2369
  "TEXT",
2215
2370
  "JSON"
@@ -2220,7 +2375,7 @@ var SchemaComparer = class SchemaComparer {
2220
2375
  if (manifestNormalized === "JSON" && dbNormalized === "TEXT") typeClause += ` USING to_jsonb(${quotedCol})`;
2221
2376
  else if (manifestNormalized === "TEXT" && dbNormalized === "JSON") typeClause += ` USING ${quotedCol}::text`;
2222
2377
  else if (manifestNormalized === "TEXT" && dbNormalized === "UUID") typeClause += ` USING ${quotedCol}::text`;
2223
- 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`;
2224
2379
  else if (manifestNormalized === "INTEGER" && dbNormalized === "REAL") typeClause += ` USING ${quotedCol}::bigint`;
2225
2380
  else if (manifestNormalized === "TIMESTAMP" && (dbNormalized === "TEXT" || dbNormalized === "JSON")) typeClause += ` USING NULLIF(NULLIF(trim(both '"' from ${quotedCol}::text), ''), 'null')::timestamptz`;
2226
2381
  else if (manifestNormalized === "TIMESTAMP" && dbNormalized === "TIMESTAMP" && this.normalizeType(targetType) === "TIMESTAMPTZ") typeClause += ` USING ${quotedCol} AT TIME ZONE 'UTC'`;
@@ -2251,8 +2406,8 @@ var SchemaComparer = class SchemaComparer {
2251
2406
  * INTEGER. PostgreSQL's REAL→INTEGER cast rounds fractional values, so the
2252
2407
  * generated migration must fail before the ALTER can silently change data.
2253
2408
  */
2254
- generatePostgresIntegerPreflightSQL(quotedTable, quotedCol, tableName, colName, dbNormalized) {
2255
- 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]+$'`;
2256
2411
  const message = `Cannot convert ${tableName}.${colName} to INTEGER: found non-integer values`;
2257
2412
  return `DO $$ BEGIN IF EXISTS (SELECT 1 FROM ${quotedTable} WHERE ${invalidCondition}) THEN RAISE EXCEPTION ${this.quoteLiteral(message)}; END IF; END $$`;
2258
2413
  }