@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.
- package/AGENTS.md +8 -0
- package/agents/schema-paths.md +90 -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 +58 -0
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +187 -32
- 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.
|
|
@@ -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
|
|
729
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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,
|
|
1138
|
+
suggestedSql: renderJsonbColumnConversion(tableName, colName, jsonConversionOptions)
|
|
1019
1139
|
}
|
|
1020
1140
|
});
|
|
1021
|
-
else if (jsonUpgradeCandidate &&
|
|
1022
|
-
const statements = renderJsonbColumnConversion(tableName, colName,
|
|
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 &&
|
|
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,
|
|
1188
|
+
suggestedSql: renderTimestamptzColumnConversion(tableName, colName, timestamptzConversionOptions)
|
|
1068
1189
|
}
|
|
1069
1190
|
});
|
|
1070
|
-
else if (timestamptzUpgradeCandidate &&
|
|
1071
|
-
const statements = renderTimestamptzColumnConversion(tableName, colName,
|
|
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
|
|
1096
|
-
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)
|
|
1097
1222
|
}
|
|
1098
1223
|
});
|
|
1099
1224
|
else if (this.isCompatibleTypeUpgrade(colDef.type, dbCol.type)) {
|
|
1100
|
-
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 });
|
|
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
|
}
|