turbine-orm 0.65.0 → 0.66.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/README.md +34 -32
- package/dist/adapters/cockroachdb.js +21 -3
- package/dist/adapters/index.d.ts +15 -0
- package/dist/adapters/yugabytedb.js +20 -3
- package/dist/cjs/adapters/cockroachdb.js +21 -3
- package/dist/cjs/adapters/index.d.ts +15 -0
- package/dist/cjs/adapters/yugabytedb.js +20 -3
- package/dist/cjs/cli/destructive.d.ts +18 -4
- package/dist/cjs/cli/destructive.js +230 -122
- package/dist/cjs/cli/index.d.ts +21 -4
- package/dist/cjs/cli/index.js +119 -22
- package/dist/cjs/cli/mcp.d.ts +28 -8
- package/dist/cjs/cli/mcp.js +170 -127
- package/dist/cjs/cli/migrate.d.ts +134 -13
- package/dist/cjs/cli/migrate.js +349 -241
- package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cjs/cli/pii-predicate-guard.js +390 -0
- package/dist/cjs/cli/prisma-resolve.js +75 -4
- package/dist/cjs/cli/prisma-schema.d.ts +17 -1
- package/dist/cjs/cli/prisma-schema.js +83 -17
- package/dist/cjs/cli/sql-statements.d.ts +125 -0
- package/dist/cjs/cli/sql-statements.js +378 -0
- package/dist/cjs/cli/studio.js +49 -118
- package/dist/cjs/cli/ui.d.ts +1 -1
- package/dist/cjs/client.d.ts +43 -0
- package/dist/cjs/client.js +125 -6
- package/dist/cjs/dialect.d.ts +123 -0
- package/dist/cjs/dialect.js +33 -0
- package/dist/cjs/errors.d.ts +74 -1
- package/dist/cjs/errors.js +239 -25
- package/dist/cjs/index-advisor.d.ts +33 -1
- package/dist/cjs/index-advisor.js +32 -1
- package/dist/cjs/introspect.d.ts +48 -0
- package/dist/cjs/introspect.js +222 -91
- package/dist/cjs/mssql.js +43 -1
- package/dist/cjs/mysql.d.ts +5 -2
- package/dist/cjs/mysql.js +202 -17
- package/dist/cjs/nested-write.js +6 -1
- package/dist/cjs/pipeline-submittable.js +17 -3
- package/dist/cjs/pipeline.js +75 -9
- package/dist/cjs/powdb.d.ts +23 -0
- package/dist/cjs/powdb.js +33 -1
- package/dist/cjs/powql.d.ts +61 -9
- package/dist/cjs/powql.js +186 -49
- package/dist/cjs/prisma-compat.js +160 -41
- package/dist/cjs/query/aggregates.d.ts +1 -1
- package/dist/cjs/query/aggregates.js +80 -18
- package/dist/cjs/query/batched-loader.d.ts +10 -0
- package/dist/cjs/query/batched-loader.js +268 -7
- package/dist/cjs/query/builder.d.ts +73 -0
- package/dist/cjs/query/builder.js +225 -28
- package/dist/cjs/query/filters.d.ts +162 -0
- package/dist/cjs/query/filters.js +250 -1
- package/dist/cjs/query/relations.d.ts +10 -10
- package/dist/cjs/query/relations.js +93 -12
- package/dist/cjs/query/types.d.ts +14 -1
- package/dist/cjs/query/utils.d.ts +146 -2
- package/dist/cjs/query/utils.js +210 -4
- package/dist/cjs/query/warn-registry.d.ts +10 -0
- package/dist/cjs/query/warn-registry.js +10 -0
- package/dist/cjs/query/where-compile.d.ts +30 -0
- package/dist/cjs/query/where-compile.js +41 -0
- package/dist/cjs/query/where.d.ts +128 -13
- package/dist/cjs/query/where.js +215 -77
- package/dist/cjs/query/writes.d.ts +1 -1
- package/dist/cjs/query/writes.js +39 -15
- package/dist/cjs/schema-builder.d.ts +2 -1
- package/dist/cjs/schema-sql.d.ts +94 -4
- package/dist/cjs/schema-sql.js +506 -30
- package/dist/cjs/schema.d.ts +3 -1
- package/dist/cjs/sqlite.d.ts +6 -0
- package/dist/cjs/sqlite.js +151 -10
- package/dist/cjs/typed-sql.d.ts +29 -1
- package/dist/cjs/typed-sql.js +30 -12
- package/dist/cli/destructive.d.ts +18 -4
- package/dist/cli/destructive.js +229 -121
- package/dist/cli/index.d.ts +21 -4
- package/dist/cli/index.js +120 -24
- package/dist/cli/mcp.d.ts +28 -8
- package/dist/cli/mcp.js +172 -129
- package/dist/cli/migrate.d.ts +134 -13
- package/dist/cli/migrate.js +347 -238
- package/dist/cli/pii-predicate-guard.d.ts +112 -0
- package/dist/cli/pii-predicate-guard.js +386 -0
- package/dist/cli/prisma-resolve.js +75 -4
- package/dist/cli/prisma-schema.d.ts +17 -1
- package/dist/cli/prisma-schema.js +83 -17
- package/dist/cli/sql-statements.d.ts +125 -0
- package/dist/cli/sql-statements.js +373 -0
- package/dist/cli/studio.js +49 -118
- package/dist/cli/ui.d.ts +1 -1
- package/dist/client.d.ts +43 -0
- package/dist/client.js +126 -7
- package/dist/dialect.d.ts +123 -0
- package/dist/dialect.js +33 -0
- package/dist/errors.d.ts +74 -1
- package/dist/errors.js +228 -19
- package/dist/index-advisor.d.ts +33 -1
- package/dist/index-advisor.js +31 -1
- package/dist/introspect.d.ts +48 -0
- package/dist/introspect.js +221 -91
- package/dist/mssql.js +44 -2
- package/dist/mysql.d.ts +5 -2
- package/dist/mysql.js +203 -18
- package/dist/nested-write.js +7 -2
- package/dist/pipeline-submittable.js +18 -4
- package/dist/pipeline.js +76 -10
- package/dist/powdb.d.ts +23 -0
- package/dist/powdb.js +33 -2
- package/dist/powql.d.ts +61 -9
- package/dist/powql.js +187 -50
- package/dist/prisma-compat.js +160 -41
- package/dist/query/aggregates.d.ts +1 -1
- package/dist/query/aggregates.js +82 -20
- package/dist/query/batched-loader.d.ts +10 -0
- package/dist/query/batched-loader.js +270 -9
- package/dist/query/builder.d.ts +73 -0
- package/dist/query/builder.js +226 -30
- package/dist/query/filters.d.ts +162 -0
- package/dist/query/filters.js +246 -1
- package/dist/query/relations.d.ts +10 -10
- package/dist/query/relations.js +94 -14
- package/dist/query/types.d.ts +14 -1
- package/dist/query/utils.d.ts +146 -2
- package/dist/query/utils.js +204 -3
- package/dist/query/warn-registry.d.ts +10 -0
- package/dist/query/warn-registry.js +10 -0
- package/dist/query/where-compile.d.ts +30 -0
- package/dist/query/where-compile.js +40 -1
- package/dist/query/where.d.ts +128 -13
- package/dist/query/where.js +216 -80
- package/dist/query/writes.d.ts +1 -1
- package/dist/query/writes.js +40 -16
- package/dist/schema-builder.d.ts +2 -1
- package/dist/schema-sql.d.ts +94 -4
- package/dist/schema-sql.js +505 -30
- package/dist/schema.d.ts +3 -1
- package/dist/sqlite.d.ts +6 -0
- package/dist/sqlite.js +151 -10
- package/dist/typed-sql.d.ts +29 -1
- package/dist/typed-sql.js +30 -12
- package/package.json +6 -4
package/dist/cjs/schema-sql.js
CHANGED
|
@@ -19,6 +19,7 @@ exports.diffReferentialAction = diffReferentialAction;
|
|
|
19
19
|
exports.diffEnumValues = diffEnumValues;
|
|
20
20
|
exports.diffCheckConstraints = diffCheckConstraints;
|
|
21
21
|
exports.schemaDiff = schemaDiff;
|
|
22
|
+
exports.planTypeChange = planTypeChange;
|
|
22
23
|
exports.findDestructivePushStatements = findDestructivePushStatements;
|
|
23
24
|
exports.schemaPush = schemaPush;
|
|
24
25
|
exports.schemaToSQLString = schemaToSQLString;
|
|
@@ -588,22 +589,49 @@ function diffCheckConstraints(table, schemaChecks, dbChecks, dialect = dialect_j
|
|
|
588
589
|
/**
|
|
589
590
|
* Compare a SchemaDef against a live Postgres database and return the diff.
|
|
590
591
|
*
|
|
591
|
-
* Connects to the database, inspects
|
|
592
|
-
* DDL is needed to make the database match the schema definition.
|
|
592
|
+
* Connects to the database, inspects `options.schema` (default `public`), and
|
|
593
|
+
* computes what DDL is needed to make the database match the schema definition.
|
|
593
594
|
*/
|
|
594
|
-
async function schemaDiff(schema, connectionString) {
|
|
595
|
+
async function schemaDiff(schema, connectionString, options = {}) {
|
|
595
596
|
const dialect = dialect_js_1.postgresDialect;
|
|
597
|
+
// The Postgres namespace every catalog read below is scoped to. Named
|
|
598
|
+
// `pgSchema` because `schema` is already the SchemaDef in this function.
|
|
599
|
+
const pgSchema = options.schema ?? 'public';
|
|
596
600
|
const client = new pg_1.default.Client({ connectionString });
|
|
597
601
|
await client.connect();
|
|
598
602
|
try {
|
|
599
|
-
// Get existing tables in the
|
|
600
|
-
const tableResult = await client.query(`SELECT tablename FROM pg_tables WHERE schemaname =
|
|
603
|
+
// Get existing tables in the target schema
|
|
604
|
+
const tableResult = await client.query(`SELECT tablename FROM pg_tables WHERE schemaname = $1`, [pgSchema]);
|
|
601
605
|
const existingTables = new Set(tableResult.rows.map((r) => r.tablename));
|
|
602
|
-
// Get existing columns for all tables
|
|
603
|
-
|
|
606
|
+
// Get existing columns for all tables. `numeric_precision` / `numeric_scale`
|
|
607
|
+
// ride along with `character_maximum_length` so a length- or precision-only
|
|
608
|
+
// change is visible (see typeDifference); comparing `udt_name` alone
|
|
609
|
+
// reported "already in sync" for VARCHAR(255) against a declared
|
|
610
|
+
// varchar(10), and the widening direction of that produced runtime
|
|
611
|
+
// "value too long" errors the diff had said were impossible.
|
|
612
|
+
const columnResult = await client.query(`SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default,
|
|
613
|
+
character_maximum_length, numeric_precision, numeric_scale
|
|
604
614
|
FROM information_schema.columns
|
|
605
|
-
WHERE table_schema =
|
|
606
|
-
ORDER BY table_name, ordinal_position
|
|
615
|
+
WHERE table_schema = $1
|
|
616
|
+
ORDER BY table_name, ordinal_position`, [pgSchema]);
|
|
617
|
+
// The type EXACTLY as Postgres writes it, for the modifiers
|
|
618
|
+
// information_schema structurally cannot report: an array's element length
|
|
619
|
+
// (`character varying(255)[]`, where character_maximum_length is NULL) and a
|
|
620
|
+
// pgvector dimension count (`vector(3)`, which has no column at all). Both
|
|
621
|
+
// were invisible to the diff, so both reported "already in sync" for a
|
|
622
|
+
// schema that differed. It doubles as the reverse-ALTER spelling of any type
|
|
623
|
+
// this generator has no keyword for. Restricted to ordinary and partitioned
|
|
624
|
+
// tables to match the pg_tables read above.
|
|
625
|
+
const formattedResult = await client.query(`SELECT cl.relname AS table_name, a.attname AS column_name,
|
|
626
|
+
format_type(a.atttypid, a.atttypmod) AS formatted_type
|
|
627
|
+
FROM pg_attribute a
|
|
628
|
+
JOIN pg_class cl ON cl.oid = a.attrelid
|
|
629
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
630
|
+
WHERE n.nspname = $1 AND cl.relkind IN ('r', 'p') AND a.attnum > 0 AND NOT a.attisdropped`, [pgSchema]);
|
|
631
|
+
const formattedTypes = new Map();
|
|
632
|
+
for (const row of formattedResult.rows) {
|
|
633
|
+
formattedTypes.set(`${row.table_name}.${row.column_name}`, row.formatted_type);
|
|
634
|
+
}
|
|
607
635
|
const dbColumns = {};
|
|
608
636
|
for (const row of columnResult.rows) {
|
|
609
637
|
if (!dbColumns[row.table_name]) {
|
|
@@ -615,6 +643,9 @@ async function schemaDiff(schema, connectionString) {
|
|
|
615
643
|
isNullable: row.is_nullable === 'YES',
|
|
616
644
|
columnDefault: row.column_default,
|
|
617
645
|
maxLength: row.character_maximum_length,
|
|
646
|
+
numericPrecision: row.numeric_precision,
|
|
647
|
+
numericScale: row.numeric_scale,
|
|
648
|
+
formattedType: formattedTypes.get(`${row.table_name}.${row.column_name}`) ?? null,
|
|
618
649
|
};
|
|
619
650
|
}
|
|
620
651
|
// Get single-column UNIQUE constraints (excluding PKs)
|
|
@@ -623,15 +654,15 @@ async function schemaDiff(schema, connectionString) {
|
|
|
623
654
|
JOIN information_schema.key_column_usage kcu
|
|
624
655
|
ON tc.constraint_name = kcu.constraint_name
|
|
625
656
|
AND tc.table_schema = kcu.table_schema
|
|
626
|
-
WHERE tc.table_schema =
|
|
657
|
+
WHERE tc.table_schema = $1
|
|
627
658
|
AND tc.constraint_type = 'UNIQUE'
|
|
628
659
|
AND tc.constraint_name IN (
|
|
629
660
|
SELECT constraint_name
|
|
630
661
|
FROM information_schema.key_column_usage
|
|
631
|
-
WHERE table_schema =
|
|
662
|
+
WHERE table_schema = $1
|
|
632
663
|
GROUP BY constraint_name
|
|
633
664
|
HAVING COUNT(*) = 1
|
|
634
|
-
)
|
|
665
|
+
)`, [pgSchema]);
|
|
635
666
|
// Map: table → column → constraint_name for single-col uniques
|
|
636
667
|
const dbUniques = {};
|
|
637
668
|
for (const row of uniqueResult.rows) {
|
|
@@ -644,8 +675,8 @@ async function schemaDiff(schema, connectionString) {
|
|
|
644
675
|
FROM pg_type t
|
|
645
676
|
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
646
677
|
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
647
|
-
WHERE n.nspname =
|
|
648
|
-
ORDER BY t.typname, e.enumsortorder
|
|
678
|
+
WHERE n.nspname = $1
|
|
679
|
+
ORDER BY t.typname, e.enumsortorder`, [pgSchema]);
|
|
649
680
|
const dbEnums = {};
|
|
650
681
|
for (const row of enumResult.rows) {
|
|
651
682
|
if (!dbEnums[row.typname])
|
|
@@ -662,8 +693,8 @@ async function schemaDiff(schema, connectionString) {
|
|
|
662
693
|
JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
|
|
663
694
|
JOIN pg_catalog.pg_attribute att ON att.attrelid = con.conrelid AND att.attnum = con.conkey[1]
|
|
664
695
|
JOIN pg_catalog.pg_attribute tatt ON tatt.attrelid = con.confrelid AND tatt.attnum = con.confkey[1]
|
|
665
|
-
WHERE con.contype = 'f' AND n.nspname =
|
|
666
|
-
AND array_length(con.conkey, 1) = 1
|
|
696
|
+
WHERE con.contype = 'f' AND n.nspname = $1
|
|
697
|
+
AND array_length(con.conkey, 1) = 1`, [pgSchema]);
|
|
667
698
|
const dbForeignKeys = {};
|
|
668
699
|
for (const row of fkResult.rows) {
|
|
669
700
|
if (!dbForeignKeys[row.table_name])
|
|
@@ -682,7 +713,7 @@ async function schemaDiff(schema, connectionString) {
|
|
|
682
713
|
FROM pg_constraint con
|
|
683
714
|
JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
|
|
684
715
|
JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
|
|
685
|
-
WHERE con.contype = 'c' AND n.nspname =
|
|
716
|
+
WHERE con.contype = 'c' AND n.nspname = $1`, [pgSchema]);
|
|
686
717
|
const dbChecks = {};
|
|
687
718
|
for (const row of checkResult.rows) {
|
|
688
719
|
if (!dbChecks[row.table_name])
|
|
@@ -692,7 +723,7 @@ async function schemaDiff(schema, connectionString) {
|
|
|
692
723
|
// Existing index NAMES per table (for the declared-index diff). Covers PK,
|
|
693
724
|
// unique-constraint, FK, and user indexes alike; the diff only ADDs declared
|
|
694
725
|
// indexes whose name is missing and never auto-drops any (see below).
|
|
695
|
-
const indexResult = await client.query(`SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname =
|
|
726
|
+
const indexResult = await client.query(`SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1`, [pgSchema]);
|
|
696
727
|
const dbIndexes = {};
|
|
697
728
|
for (const row of indexResult.rows) {
|
|
698
729
|
if (!dbIndexes[row.tablename])
|
|
@@ -771,17 +802,48 @@ async function schemaDiff(schema, connectionString) {
|
|
|
771
802
|
// (a downcast on a PK loses data / breaks the sequence). This also
|
|
772
803
|
// preserves back-compat for DBs whose `serial` columns were created as
|
|
773
804
|
// BIGSERIAL (int8) before 0.24.0, `push` won't try to shrink them.
|
|
774
|
-
|
|
775
|
-
|
|
805
|
+
// planTypeChange owns the whole decision: whether the types differ at
|
|
806
|
+
// all (including length / precision modifiers), whether the conversion
|
|
807
|
+
// needs an explicit USING, and what the conversion costs.
|
|
808
|
+
const typePlan = planTypeChange(config, dbCol, snakeName);
|
|
809
|
+
if (typePlan.kind === 'warn') {
|
|
810
|
+
result.warnings.push(`column "${tableName}"."${snakeName}": ${typePlan.reason}`);
|
|
811
|
+
}
|
|
812
|
+
else if (typePlan.kind === 'alter') {
|
|
776
813
|
// resolveDdlType handles enum names, vector(n), arrays, and VARCHAR(n) -
|
|
777
814
|
// config.type alone would emit the internal ENUM/VECTOR sentinels here.
|
|
778
815
|
const sqlType = resolveDdlType(config, dialect, snakeName);
|
|
779
|
-
const oldSqlType = udtToSqlType(dbCol
|
|
780
|
-
const
|
|
781
|
-
|
|
816
|
+
const oldSqlType = udtToSqlType(dbCol);
|
|
817
|
+
const col = dialect.quoteIdentifier(snakeName);
|
|
818
|
+
// `USING` only where the conversion needs one. Within a type family
|
|
819
|
+
// Postgres has an assignment cast, and letting IT decide is the whole
|
|
820
|
+
// point: an explicit `::VARCHAR(10)` truncates a 27-character value,
|
|
821
|
+
// while the plain ALTER raises "value too long" and the migration
|
|
822
|
+
// stops. See planTypeChange.
|
|
823
|
+
//
|
|
824
|
+
// Where a USING IS needed (the conversion crosses type families and
|
|
825
|
+
// has no assignment cast), the cast targets the UNBOUNDED base type
|
|
826
|
+
// rather than the declared one, because an explicit cast amputates
|
|
827
|
+
// wherever it lands. Dropping the USING was only half the fix: every
|
|
828
|
+
// cross-family conversion INTO a bounded string still carried
|
|
829
|
+
// `USING col::VARCHAR(n)` and still truncated, silently and on every
|
|
830
|
+
// engine-side value shape (jsonb, integer, uuid, an enum label, bytea,
|
|
831
|
+
// a timestamp). Casting to the unbounded base keeps the conversion
|
|
832
|
+
// Postgres cannot do implicitly while leaving the LENGTH check to the
|
|
833
|
+
// assignment into the column, which raises "value too long" and stops.
|
|
834
|
+
const using = typePlan.needsUsing ? ` USING ${col}::${unboundedCastType(sqlType)}` : '';
|
|
835
|
+
const reverseUsing = typePlan.needsUsing ? ` USING ${col}::${unboundedCastType(oldSqlType)}` : '';
|
|
836
|
+
const sql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${col} TYPE ${sqlType}${using};`;
|
|
837
|
+
const reverseSql = `ALTER TABLE ${dialect.quoteIdentifier(tableName)} ALTER COLUMN ${col} TYPE ${oldSqlType}${reverseUsing};`;
|
|
782
838
|
alterDef.columns.push({ column: snakeName, action: 'alter_type', sql, reverseSql });
|
|
783
839
|
result.statements.push(sql);
|
|
784
840
|
result.reverseStatements.unshift(reverseSql);
|
|
841
|
+
// The destructive gate sees the statement and refuses by default; its
|
|
842
|
+
// label is necessarily generic ("cast may truncate or fail"). This
|
|
843
|
+
// warning names what THIS conversion does to THESE rows.
|
|
844
|
+
if (typePlan.loss) {
|
|
845
|
+
result.warnings.push(`column "${tableName}"."${snakeName}" changes type from ${oldSqlType} to ${sqlType}: ${typePlan.loss}`);
|
|
846
|
+
}
|
|
785
847
|
}
|
|
786
848
|
// Check NOT NULL mismatch
|
|
787
849
|
const shouldBeNotNull = config.isNotNull || config.isPrimaryKey || isSerialType(config.type);
|
|
@@ -995,6 +1057,294 @@ async function schemaDiff(schema, connectionString) {
|
|
|
995
1057
|
await client.end();
|
|
996
1058
|
}
|
|
997
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Rough type families. Within a family Postgres has an ASSIGNMENT cast between
|
|
1062
|
+
* every pair the schema builder can produce, which is what lets the diff emit a
|
|
1063
|
+
* plain `ALTER ... TYPE` with no `USING` (see {@link planTypeChange}). That
|
|
1064
|
+
* sentence is the family's whole contract, and two things used to break it:
|
|
1065
|
+
*
|
|
1066
|
+
* - ARRAYS returned null, so every array-to-array change was treated as a
|
|
1067
|
+
* cross-family conversion and got an explicit `USING col::VARCHAR(5)[]`,
|
|
1068
|
+
* which truncates each element exactly as the scalar form does. Postgres
|
|
1069
|
+
* has the same assignment casts between array types as between their
|
|
1070
|
+
* elements (verified pair by pair for every type the schema builder can
|
|
1071
|
+
* produce), so an array's family is its ELEMENT's family with `[]`
|
|
1072
|
+
* appended. The suffix matters: `text` and `varchar(5)[]` must NOT come out
|
|
1073
|
+
* the same family, because there is no automatic cast between a scalar and
|
|
1074
|
+
* an array and the plain ALTER would be unrunnable.
|
|
1075
|
+
* - `time` / `timetz` were listed as `temporal` alongside date and the
|
|
1076
|
+
* timestamps, and the contract simply is not true there: pg_cast has NO
|
|
1077
|
+
* entry between `time` and `date`, `timestamp` or `timestamptz` in EITHER
|
|
1078
|
+
* direction, so `ALTER COLUMN c TYPE DATE` on a `time` column fails with
|
|
1079
|
+
* "cannot be cast automatically". They are their own family, which makes a
|
|
1080
|
+
* time-to-date change cross-family: still not something Turbine can apply,
|
|
1081
|
+
* but now it says so in the loss warning instead of asserting a cast that
|
|
1082
|
+
* does not exist. (`{date, timestamp, timestamptz}` and `{time, timetz}`
|
|
1083
|
+
* are each internally complete.)
|
|
1084
|
+
*/
|
|
1085
|
+
function udtFamily(udt) {
|
|
1086
|
+
if (udt.startsWith('_')) {
|
|
1087
|
+
const element = udtFamily(udt.slice(1));
|
|
1088
|
+
return element === null ? null : `${element}[]`;
|
|
1089
|
+
}
|
|
1090
|
+
switch (udt) {
|
|
1091
|
+
case 'text':
|
|
1092
|
+
case 'varchar':
|
|
1093
|
+
case 'bpchar':
|
|
1094
|
+
case 'char':
|
|
1095
|
+
return 'string';
|
|
1096
|
+
case 'int2':
|
|
1097
|
+
case 'int4':
|
|
1098
|
+
case 'int8':
|
|
1099
|
+
case 'numeric':
|
|
1100
|
+
case 'float4':
|
|
1101
|
+
case 'float8':
|
|
1102
|
+
return 'numeric';
|
|
1103
|
+
case 'date':
|
|
1104
|
+
case 'timestamp':
|
|
1105
|
+
case 'timestamptz':
|
|
1106
|
+
return 'temporal';
|
|
1107
|
+
case 'time':
|
|
1108
|
+
case 'timetz':
|
|
1109
|
+
return 'time-of-day';
|
|
1110
|
+
case 'json':
|
|
1111
|
+
case 'jsonb':
|
|
1112
|
+
return 'json';
|
|
1113
|
+
case 'bool':
|
|
1114
|
+
return 'boolean';
|
|
1115
|
+
default:
|
|
1116
|
+
return null; // uuid, bytea, enums, vector: no intra-family conversions
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
/** Drop the array marker from a family name, so the per-family rules apply to both. */
|
|
1120
|
+
function elementFamily(family) {
|
|
1121
|
+
if (family === null)
|
|
1122
|
+
return null;
|
|
1123
|
+
return family.endsWith('[]') ? family.slice(0, -2) : family;
|
|
1124
|
+
}
|
|
1125
|
+
/** Drop Postgres's array prefix from a udt name (`_int4` → `int4`). */
|
|
1126
|
+
function elementUdt(udt) {
|
|
1127
|
+
return udt.startsWith('_') ? udt.slice(1) : udt;
|
|
1128
|
+
}
|
|
1129
|
+
/** Render a udt name the way a reader writes it (`_varchar` → `varchar[]`). */
|
|
1130
|
+
function describeUdt(udt) {
|
|
1131
|
+
return udt.startsWith('_') ? `${udt.slice(1)}[]` : udt;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* The type a `USING` cast should name so the cast itself cannot lose data.
|
|
1135
|
+
*
|
|
1136
|
+
* Only `VARCHAR(n)` (and its array form) carries a bound that an explicit cast
|
|
1137
|
+
* silently enforces by TRUNCATING; `TEXT` is the same type family with no bound,
|
|
1138
|
+
* so `col::TEXT` converts exactly as `col::VARCHAR(n)` would and then the
|
|
1139
|
+
* assignment into the declared column applies the length check properly. Every
|
|
1140
|
+
* other type this generator emits either has no length modifier or refuses the
|
|
1141
|
+
* conversion outright (pgvector's dimension count), so it is returned unchanged.
|
|
1142
|
+
*/
|
|
1143
|
+
function unboundedCastType(sqlType) {
|
|
1144
|
+
const bounded = /^VARCHAR\(\d+\)(\[\])?$/i.exec(sqlType);
|
|
1145
|
+
return bounded ? `TEXT${bounded[1] ?? ''}` : sqlType;
|
|
1146
|
+
}
|
|
1147
|
+
/** Rank within the integer/real widening order, for the narrowing checks. */
|
|
1148
|
+
const NUMERIC_RANK = { int2: 1, int4: 2, int8: 3, float4: 4, float8: 5, numeric: 6 };
|
|
1149
|
+
/**
|
|
1150
|
+
* Rank within the temporal detail order (a lower rank carries less
|
|
1151
|
+
* information). Covers the `temporal` family only: `time` / `timetz` are a
|
|
1152
|
+
* separate family with no cast to or from these, so ranking them against a
|
|
1153
|
+
* date would compare two values that never meet.
|
|
1154
|
+
*/
|
|
1155
|
+
const TEMPORAL_RANK = { date: 1, timestamp: 3, timestamptz: 4 };
|
|
1156
|
+
/**
|
|
1157
|
+
* The single numeric modifier in a `format_type` rendering, or null when the
|
|
1158
|
+
* type carries none. `character varying(255)[]` → 255, `vector(3)` → 3,
|
|
1159
|
+
* `numeric(10,2)` → 10, `text` → null.
|
|
1160
|
+
*
|
|
1161
|
+
* This exists because information_schema cannot express these: it reports
|
|
1162
|
+
* `character_maximum_length` NULL for an ARRAY of varchar (the length belongs to
|
|
1163
|
+
* the element type) and has no column at all for a pgvector dimension count. A
|
|
1164
|
+
* diff reading it alone therefore saw `varchar(255)[]` and a declared
|
|
1165
|
+
* `varchar(5)[]` as identical, and `vector(3)` and a declared 1536 dimensions as
|
|
1166
|
+
* identical, and reported "already in sync" for both.
|
|
1167
|
+
*/
|
|
1168
|
+
function typeModifier(formattedType) {
|
|
1169
|
+
if (!formattedType)
|
|
1170
|
+
return null;
|
|
1171
|
+
const match = /\((\d+)(?:\s*,\s*\d+)?\)/.exec(formattedType);
|
|
1172
|
+
return match ? Number(match[1]) : null;
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* The length bound on a string column, from whichever source carries it:
|
|
1176
|
+
* information_schema for a scalar varchar, the `format_type` modifier for an
|
|
1177
|
+
* array of varchar. Null means unbounded (`text`, a bare `VARCHAR`), which for
|
|
1178
|
+
* the loss checks reads as "could be longer than any target bound".
|
|
1179
|
+
*/
|
|
1180
|
+
function stringLengthOf(col) {
|
|
1181
|
+
return col.maxLength ?? typeModifier(col.formattedType);
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* A sentence naming what this specific conversion does to existing rows, or
|
|
1185
|
+
* undefined when nothing is lost. Deliberately says whether Postgres REFUSES
|
|
1186
|
+
* the row or silently changes it: those are very different operational events,
|
|
1187
|
+
* and the generic "cast may truncate or fail" told the reader neither.
|
|
1188
|
+
*/
|
|
1189
|
+
function describeTypeLoss(from, toUdt, toMaxLength, column) {
|
|
1190
|
+
const q = `"${column}"`;
|
|
1191
|
+
const fromFamily = udtFamily(from.udtName);
|
|
1192
|
+
const toFamily = udtFamily(toUdt);
|
|
1193
|
+
// Within one family the rules are the same for a scalar and for an array of
|
|
1194
|
+
// it; only the noun changes, since an array's bound applies per element.
|
|
1195
|
+
const sameFamily = fromFamily !== null && fromFamily === toFamily;
|
|
1196
|
+
const family = elementFamily(toFamily);
|
|
1197
|
+
const fromUdt = elementUdt(from.udtName);
|
|
1198
|
+
const toElementUdt = elementUdt(toUdt);
|
|
1199
|
+
const isArray = toFamily?.endsWith('[]') ?? false;
|
|
1200
|
+
const subject = isArray ? `array elements in ${q}` : `values in ${q}`;
|
|
1201
|
+
// A bounded string TARGET enforces its bound on every value, whatever the
|
|
1202
|
+
// source type was, because the conversion renders the value as text first.
|
|
1203
|
+
// Only the same-family case can prove the bound is already satisfied.
|
|
1204
|
+
if (family === 'string' && toMaxLength != null) {
|
|
1205
|
+
const fromLen = sameFamily ? stringLengthOf(from) : null;
|
|
1206
|
+
if (fromLen === null || fromLen > toMaxLength) {
|
|
1207
|
+
const rendered = sameFamily ? '' : 'whose text form is ';
|
|
1208
|
+
return `${subject} ${rendered}longer than ${toMaxLength} characters make this statement FAIL (Postgres refuses the conversion; it does not truncate). Shorten or migrate those rows first.`;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (family === 'numeric' && sameFamily) {
|
|
1212
|
+
const fromRank = NUMERIC_RANK[fromUdt] ?? 0;
|
|
1213
|
+
const toRank = NUMERIC_RANK[toElementUdt] ?? 0;
|
|
1214
|
+
const toIsInteger = toElementUdt === 'int2' || toElementUdt === 'int4' || toElementUdt === 'int8';
|
|
1215
|
+
const fromIsInteger = fromUdt === 'int2' || fromUdt === 'int4' || fromUdt === 'int8';
|
|
1216
|
+
if (toIsInteger && !fromIsInteger) {
|
|
1217
|
+
return `fractional ${subject} are ROUNDED to whole numbers (silently, row by row).`;
|
|
1218
|
+
}
|
|
1219
|
+
if (toIsInteger && fromIsInteger && toRank < fromRank) {
|
|
1220
|
+
return `${subject} outside the ${toElementUdt} range make this statement FAIL ("integer out of range").`;
|
|
1221
|
+
}
|
|
1222
|
+
if (toRank < fromRank) {
|
|
1223
|
+
return `${subject} lose precision (silently rounded to what ${toElementUdt} can represent).`;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (family === 'temporal' && sameFamily) {
|
|
1227
|
+
const fromRank = TEMPORAL_RANK[fromUdt] ?? 0;
|
|
1228
|
+
const toRank = TEMPORAL_RANK[toElementUdt] ?? 0;
|
|
1229
|
+
if (toElementUdt === 'date' && fromRank > 1)
|
|
1230
|
+
return `the time-of-day component of ${q} is DROPPED (silently).`;
|
|
1231
|
+
if (toRank < fromRank)
|
|
1232
|
+
return `${q} loses its time-zone offset (converted to the session time zone).`;
|
|
1233
|
+
}
|
|
1234
|
+
if (elementFamily(fromFamily) === 'time-of-day' && family === 'temporal') {
|
|
1235
|
+
// Not "fails for some rows": pg_cast has no entry between `time`/`timetz`
|
|
1236
|
+
// and `date`/`timestamp`/`timestamptz` in either direction, implicit,
|
|
1237
|
+
// assignment or explicit, so the statement cannot run at all, on an empty
|
|
1238
|
+
// table included. Saying "must be a valid date literal" would send the
|
|
1239
|
+
// reader off to clean data that was never the problem.
|
|
1240
|
+
return `Postgres has NO cast from ${describeUdt(from.udtName)} to ${describeUdt(toUdt)}, in either direction, so this statement ALWAYS FAILS (a time of day carries no date to convert). Add the new column and backfill it in a manual migration instead.`;
|
|
1241
|
+
}
|
|
1242
|
+
if (!sameFamily) {
|
|
1243
|
+
return `every existing value in ${q} must be a valid ${describeUdt(toUdt)} literal or this statement FAILS.`;
|
|
1244
|
+
}
|
|
1245
|
+
return '';
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Decide what the diff should emit for one column's type.
|
|
1249
|
+
*
|
|
1250
|
+
* Two things this deliberately does that the previous `udtName !== expectedUdt`
|
|
1251
|
+
* check did not:
|
|
1252
|
+
*
|
|
1253
|
+
* 1. It compares LENGTH and NUMERIC PRECISION, not just the UDT name. A
|
|
1254
|
+
* declared `varchar(10)` against a database `VARCHAR(255)` produced no
|
|
1255
|
+
* statement and no warning, so `push` printed "Database is already in
|
|
1256
|
+
* sync" for a schema that genuinely differed. The widening direction is
|
|
1257
|
+
* the operationally painful one: the schema says 255, the column is 10,
|
|
1258
|
+
* push says in sync, and writes fail at runtime with "value too long".
|
|
1259
|
+
* 2. It reports `needsUsing` FALSE for a same-family conversion. The old
|
|
1260
|
+
* statement always appended `USING col::type`, and an EXPLICIT cast to
|
|
1261
|
+
* `varchar(n)` TRUNCATES where the plain assignment cast raises "value too
|
|
1262
|
+
* long". Postgres refuses that migration on its own; the generated `USING`
|
|
1263
|
+
* converted the refusal into silent data amputation. Same shape for
|
|
1264
|
+
* `numeric -> integer` and `timestamptz -> date`. `USING` is emitted only
|
|
1265
|
+
* where the conversion genuinely needs it (text to uuid, text to an enum,
|
|
1266
|
+
* anything crossing a type family), where no assignment cast exists.
|
|
1267
|
+
*
|
|
1268
|
+
* Numeric PRECISION drift gets a warning rather than a statement: `defineSchema`
|
|
1269
|
+
* has no way to declare `numeric(10, 2)`, so every such column would otherwise
|
|
1270
|
+
* get a table-rewriting `ALTER ... TYPE NUMERIC` on every push, discarding a
|
|
1271
|
+
* constraint the schema never had the vocabulary to ask for.
|
|
1272
|
+
*/
|
|
1273
|
+
function planTypeChange(config, dbCol, column) {
|
|
1274
|
+
const expectedUdt = schemaTypeToUdt(config);
|
|
1275
|
+
if (expectedUdt === null) {
|
|
1276
|
+
return {
|
|
1277
|
+
kind: 'warn',
|
|
1278
|
+
reason: `schema type "${config.type}" has no known Postgres type mapping, so its type is not diffed.`,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
if (isSerialType(config.type))
|
|
1282
|
+
return { kind: 'none' };
|
|
1283
|
+
if (dbCol.udtName !== expectedUdt) {
|
|
1284
|
+
const loss = describeTypeLoss(dbCol, expectedUdt, config.maxLength, column);
|
|
1285
|
+
const sameFamily = udtFamily(dbCol.udtName) !== null && udtFamily(dbCol.udtName) === udtFamily(expectedUdt);
|
|
1286
|
+
return { kind: 'alter', needsUsing: !sameFamily, ...(loss ? { loss } : {}) };
|
|
1287
|
+
}
|
|
1288
|
+
// Same UDT: the remaining differences are the type MODIFIERS. Some are in
|
|
1289
|
+
// information_schema, some only in the catalog's format_type rendering.
|
|
1290
|
+
if (expectedUdt === 'varchar') {
|
|
1291
|
+
// `maxLength: null` on a varchar column is a real declaration (unbounded
|
|
1292
|
+
// VARCHAR), not an omission: schemaToSQL emits a bare `VARCHAR` for it.
|
|
1293
|
+
const want = config.maxLength ?? null;
|
|
1294
|
+
if (want !== dbCol.maxLength) {
|
|
1295
|
+
const loss = describeTypeLoss(dbCol, expectedUdt, want, column);
|
|
1296
|
+
return { kind: 'alter', needsUsing: false, ...(loss ? { loss } : {}) };
|
|
1297
|
+
}
|
|
1298
|
+
return { kind: 'none' };
|
|
1299
|
+
}
|
|
1300
|
+
if (expectedUdt === '_varchar') {
|
|
1301
|
+
// The same length comparison for `varchar(n)[]`. It needs a different
|
|
1302
|
+
// source because information_schema reports character_maximum_length NULL
|
|
1303
|
+
// for an array (the modifier belongs to the element type), so comparing it
|
|
1304
|
+
// saw `varchar(255)[]` and a declared `varchar(5)[]` as identical and
|
|
1305
|
+
// reported "already in sync" for a schema that genuinely differed, with no
|
|
1306
|
+
// statement and no warning. The plain ALTER carries the change on this path
|
|
1307
|
+
// too: Postgres has the same assignment cast between the array types as
|
|
1308
|
+
// between their elements, and enforces the bound per element.
|
|
1309
|
+
const want = config.maxLength ?? null;
|
|
1310
|
+
if (want !== typeModifier(dbCol.formattedType)) {
|
|
1311
|
+
const loss = describeTypeLoss(dbCol, expectedUdt, want, column);
|
|
1312
|
+
return { kind: 'alter', needsUsing: false, ...(loss ? { loss } : {}) };
|
|
1313
|
+
}
|
|
1314
|
+
return { kind: 'none' };
|
|
1315
|
+
}
|
|
1316
|
+
if (expectedUdt === 'vector') {
|
|
1317
|
+
// pgvector's dimension count is a type modifier, invisible to
|
|
1318
|
+
// information_schema, so a `vector(3)` column against a declared 1536
|
|
1319
|
+
// dimensions produced no statement and no warning; the app then failed on
|
|
1320
|
+
// its first write with "expected 3 dimensions, not 1536". Same shape as the
|
|
1321
|
+
// varchar(255)-against-varchar(10) bug above. A dimension change cannot be
|
|
1322
|
+
// applied to populated rows at all (pgvector rejects it with or without an
|
|
1323
|
+
// explicit cast), which is what the loss sentence has to say.
|
|
1324
|
+
const want = config.vectorDimensions ?? null;
|
|
1325
|
+
const have = typeModifier(dbCol.formattedType);
|
|
1326
|
+
if (want !== have) {
|
|
1327
|
+
const loss = want == null
|
|
1328
|
+
? `existing vectors in "${column}" keep their dimension count; an unbounded vector column cannot be produced from a bounded one while rows exist, so this statement FAILS.`
|
|
1329
|
+
: `every existing vector in "${column}" must already have exactly ${want} dimensions or this statement FAILS (pgvector refuses the conversion; it does not pad or truncate).`;
|
|
1330
|
+
return { kind: 'alter', needsUsing: false, loss };
|
|
1331
|
+
}
|
|
1332
|
+
return { kind: 'none' };
|
|
1333
|
+
}
|
|
1334
|
+
if (expectedUdt === 'numeric' && dbCol.numericPrecision !== null) {
|
|
1335
|
+
// Guarded on `numeric` alone: information_schema reports a precision for
|
|
1336
|
+
// every integer and float type too (int4 -> 32), which is not a modifier
|
|
1337
|
+
// anyone declared.
|
|
1338
|
+
const stored = dbCol.numericScale === null
|
|
1339
|
+
? `numeric(${dbCol.numericPrecision})`
|
|
1340
|
+
: `numeric(${dbCol.numericPrecision}, ${dbCol.numericScale})`;
|
|
1341
|
+
return {
|
|
1342
|
+
kind: 'warn',
|
|
1343
|
+
reason: `the database column is ${stored} but the schema declares a bare NUMERIC. defineSchema cannot express numeric precision/scale, so Turbine leaves the column alone rather than rewriting away a constraint it cannot represent. Change it in a manual migration if that is intended.`,
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
return { kind: 'none' };
|
|
1347
|
+
}
|
|
998
1348
|
/**
|
|
999
1349
|
* Map a schema column type to its expected PostgreSQL UDT name.
|
|
1000
1350
|
*/
|
|
@@ -1029,15 +1379,35 @@ function schemaTypeToUdt(config) {
|
|
|
1029
1379
|
return base;
|
|
1030
1380
|
}
|
|
1031
1381
|
/**
|
|
1032
|
-
* Reverse map:
|
|
1382
|
+
* Reverse map: a database column's type → the SQL type token that restores it
|
|
1383
|
+
* (used for the reverse ALTER TYPE, and to name the old type in a warning).
|
|
1384
|
+
*
|
|
1385
|
+
* Two rules, and the second one is why this takes the whole column:
|
|
1386
|
+
*
|
|
1387
|
+
* 1. A type this generator itself emits gets its own uppercase spelling back,
|
|
1388
|
+
* so a reverse statement reads like the forward one. `numeric` now carries
|
|
1389
|
+
* its precision and scale: a `numeric(10, 2)` column changed to INTEGER
|
|
1390
|
+
* reversed to a bare `NUMERIC`, quietly discarding the constraint the DOWN
|
|
1391
|
+
* was supposed to restore.
|
|
1392
|
+
* 2. ANYTHING ELSE is not a keyword and must not be treated as one. The old
|
|
1393
|
+
* fallback was `udtName.toUpperCase()`, which produces an unquoted
|
|
1394
|
+
* identifier: correct only by accident for an all-lowercase name, and
|
|
1395
|
+
* wrong for every enum, domain or extension type with a capital in it
|
|
1396
|
+
* (`"Probe"` became `PROBE`, which Postgres folds back to `probe` and then
|
|
1397
|
+
* fails with `type "probe" does not exist`). The catalog's own
|
|
1398
|
+
* `format_type` rendering is the answer: already quoted where quoting is
|
|
1399
|
+
* needed, already schema-qualified when the type is not in the search_path,
|
|
1400
|
+
* and already carrying its modifiers (`vector(3)`, `citext`,
|
|
1401
|
+
* `character varying(255)[]`). Quoting the bare udt name is the fallback
|
|
1402
|
+
* for a hand-built column with no catalog rendering.
|
|
1033
1403
|
*/
|
|
1034
|
-
function udtToSqlType(
|
|
1404
|
+
function udtToSqlType(dbCol) {
|
|
1035
1405
|
const map = {
|
|
1036
1406
|
int8: 'BIGINT',
|
|
1037
1407
|
int4: 'INTEGER',
|
|
1038
1408
|
int2: 'SMALLINT',
|
|
1039
1409
|
text: 'TEXT',
|
|
1040
|
-
varchar: maxLength ? `VARCHAR(${maxLength})` : 'VARCHAR',
|
|
1410
|
+
varchar: dbCol.maxLength ? `VARCHAR(${dbCol.maxLength})` : 'VARCHAR',
|
|
1041
1411
|
bool: 'BOOLEAN',
|
|
1042
1412
|
timestamptz: 'TIMESTAMPTZ',
|
|
1043
1413
|
date: 'DATE',
|
|
@@ -1045,10 +1415,21 @@ function udtToSqlType(udtName, maxLength) {
|
|
|
1045
1415
|
uuid: 'UUID',
|
|
1046
1416
|
float4: 'REAL',
|
|
1047
1417
|
float8: 'DOUBLE PRECISION',
|
|
1048
|
-
numeric:
|
|
1418
|
+
numeric: numericSqlType(dbCol),
|
|
1049
1419
|
bytea: 'BYTEA',
|
|
1050
1420
|
};
|
|
1051
|
-
|
|
1421
|
+
const mapped = map[dbCol.udtName];
|
|
1422
|
+
if (mapped)
|
|
1423
|
+
return mapped;
|
|
1424
|
+
return dbCol.formattedType ?? dialect_js_1.postgresDialect.quoteIdentifier(dbCol.udtName);
|
|
1425
|
+
}
|
|
1426
|
+
/** `NUMERIC`, `NUMERIC(10)` or `NUMERIC(10, 2)`, whichever the column actually is. */
|
|
1427
|
+
function numericSqlType(dbCol) {
|
|
1428
|
+
if (dbCol.numericPrecision == null)
|
|
1429
|
+
return 'NUMERIC';
|
|
1430
|
+
if (dbCol.numericScale == null)
|
|
1431
|
+
return `NUMERIC(${dbCol.numericPrecision})`;
|
|
1432
|
+
return `NUMERIC(${dbCol.numericPrecision}, ${dbCol.numericScale})`;
|
|
1052
1433
|
}
|
|
1053
1434
|
/**
|
|
1054
1435
|
* Normalize a database default value for comparison.
|
|
@@ -1093,6 +1474,73 @@ function findDestructivePushStatements(statements) {
|
|
|
1093
1474
|
hits.push(...(0, destructive_js_1.scanDestructiveSql)(stmt));
|
|
1094
1475
|
return hits;
|
|
1095
1476
|
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Quote a schema name for use INSIDE a `search_path` GUC value. The value is
|
|
1479
|
+
* bound as a parameter to set_config, but Postgres parses its contents as an
|
|
1480
|
+
* identifier list, so a name needing quotes (mixed case, a dot, a space) has to
|
|
1481
|
+
* carry them itself. Doubling `"` follows the same rule as quoteIdent.
|
|
1482
|
+
*/
|
|
1483
|
+
function quoteSearchPathIdent(name) {
|
|
1484
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Pin the transaction's `search_path` so the unqualified identifiers in the
|
|
1488
|
+
* generated DDL resolve in the SAME namespace the diff read, and refuse a
|
|
1489
|
+
* target schema that does not exist.
|
|
1490
|
+
*
|
|
1491
|
+
* Three separate failures live here, which is why it is one function:
|
|
1492
|
+
*
|
|
1493
|
+
* 1. The pin used to be applied only for a non-public target, on the reasoning
|
|
1494
|
+
* that the default path is already public. It is not: `search_path` is a
|
|
1495
|
+
* role/database/connection-string setting, so a role with
|
|
1496
|
+
* `search_path = app, public` and NO configured schema had the diff reading
|
|
1497
|
+
* `public` (unconditional, it binds the name as a parameter) while the DDL
|
|
1498
|
+
* landed in `app`. The push reports success, and the second push fails with
|
|
1499
|
+
* `relation "..." already exists` forever after, because the diff still
|
|
1500
|
+
* cannot see the table it created. Skipping the pin for `public` protected
|
|
1501
|
+
* the byte-identity of the SQL TEXT, which is not the thing that has to
|
|
1502
|
+
* stay identical; the EFFECT is, and the effect of pinning `"public"` on a
|
|
1503
|
+
* connection that already resolved to public is nil.
|
|
1504
|
+
* 2. Postgres does not validate schema names in `search_path`: setting it to a
|
|
1505
|
+
* namespace that does not exist succeeds, and name resolution simply skips
|
|
1506
|
+
* the missing entry. So a typo in `schema` silently redirected every
|
|
1507
|
+
* CREATE into whatever came next in the path (public), reported success,
|
|
1508
|
+
* and wedged the second push exactly as (1) does. `to_regnamespace`
|
|
1509
|
+
* returns NULL for a missing namespace, which is the check.
|
|
1510
|
+
* 3. Pinning `"<target>", public` REPLACED the caller's path rather than
|
|
1511
|
+
* extending it, so an extension installed in its own schema stopped
|
|
1512
|
+
* resolving. On the common managed-Postgres layout (`"$user", public,
|
|
1513
|
+
* extensions`) a `vector` / `citext` / `hstore` / `postgis` column, an
|
|
1514
|
+
* extension opclass in a CREATE INDEX, or a CHECK calling an extension
|
|
1515
|
+
* function all failed with `type "..." does not exist` under a pin that the
|
|
1516
|
+
* same DDL did not need without it. The caller's entries are appended
|
|
1517
|
+
* verbatim (they are already valid GUC syntax, and `SHOW` renders them with
|
|
1518
|
+
* whatever quoting they need), so the only change to resolution is that the
|
|
1519
|
+
* target schema is consulted FIRST, which is the whole point: an
|
|
1520
|
+
* unqualified CREATE uses the first entry.
|
|
1521
|
+
*
|
|
1522
|
+
* `set_config(..., true)` is transaction-local, so none of this can leak onto a
|
|
1523
|
+
* pooled backend, and the value is BOUND rather than interpolated.
|
|
1524
|
+
*/
|
|
1525
|
+
async function pinSearchPath(client, pgSchema) {
|
|
1526
|
+
const present = await client.query(`SELECT to_regnamespace($1) IS NOT NULL AS present`, [
|
|
1527
|
+
pgSchema,
|
|
1528
|
+
]);
|
|
1529
|
+
if (!present.rows[0]?.present) {
|
|
1530
|
+
throw new errors_js_1.ValidationError(`[turbine] Schema "${pgSchema}" does not exist in this database. ` +
|
|
1531
|
+
`Postgres accepts a missing namespace in search_path without complaint, so pushing anyway would ` +
|
|
1532
|
+
`create every table in whichever schema resolves next (usually public) and report success. ` +
|
|
1533
|
+
`Create the schema first (CREATE SCHEMA "${pgSchema}") or correct the configured schema name.`);
|
|
1534
|
+
}
|
|
1535
|
+
// Read the path the connection would otherwise use, so the pin EXTENDS it
|
|
1536
|
+
// instead of discarding it. An empty value is possible (`SHOW` renders it as
|
|
1537
|
+
// an empty identifier) and would leave a trailing comma, so it is dropped.
|
|
1538
|
+
const shown = await client.query('SHOW search_path');
|
|
1539
|
+
const inherited = (shown.rows[0]?.search_path ?? '').trim();
|
|
1540
|
+
const target = quoteSearchPathIdent(pgSchema);
|
|
1541
|
+
const value = inherited ? `${target}, ${inherited}` : target;
|
|
1542
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [value]);
|
|
1543
|
+
}
|
|
1096
1544
|
/** Format the destructive-push refusal message (mirrors the migrate gate copy). */
|
|
1097
1545
|
function formatDestructivePushError(hits) {
|
|
1098
1546
|
const lines = ['[turbine] Refusing to apply schema changes containing DESTRUCTIVE statements:', ''];
|
|
@@ -1134,13 +1582,24 @@ exports.DestructivePushRefusal = DestructivePushRefusal;
|
|
|
1134
1582
|
* `allowDestructive: true` is passed. The CLI (`turbine push`) catches this and
|
|
1135
1583
|
* prompts for the same typed confirmation as `migrate up`; programmatic callers
|
|
1136
1584
|
* must opt in explicitly.
|
|
1585
|
+
*
|
|
1586
|
+
* `options.schema` (default `public`) scopes BOTH halves: the diff reads that
|
|
1587
|
+
* namespace, and the generated DDL (which names tables unqualified) is executed
|
|
1588
|
+
* with a transaction-local `search_path` pinned so the target schema resolves
|
|
1589
|
+
* FIRST. Without the second half a diff that correctly found nothing in `app`
|
|
1590
|
+
* would still emit `CREATE TABLE "users"` into whatever the connection's
|
|
1591
|
+
* search_path happens to resolve to. The pin is unconditional (`public` is a
|
|
1592
|
+
* target like any other, since a role's own search_path may not lead there),
|
|
1593
|
+
* refuses a schema that does not exist, and APPENDS the connection's existing
|
|
1594
|
+
* path so types living elsewhere still resolve. See {@link pinSearchPath}.
|
|
1137
1595
|
*/
|
|
1138
1596
|
async function schemaPush(schema, connectionString, options = {}) {
|
|
1597
|
+
const pgSchema = options.schema ?? 'public';
|
|
1139
1598
|
// Accept a precomputed diff so a caller (the CLI) can diff ONCE, show the
|
|
1140
1599
|
// plan, confirm, and apply the EXACT statements it displayed. Without this,
|
|
1141
1600
|
// schemaPush would re-diff on the post-confirmation retry, so a concurrent
|
|
1142
1601
|
// schema change between confirm and apply could alter the applied set (TOCTOU).
|
|
1143
|
-
const diff = options.precomputedDiff ?? (await schemaDiff(schema, connectionString));
|
|
1602
|
+
const diff = options.precomputedDiff ?? (await schemaDiff(schema, connectionString, { schema: pgSchema }));
|
|
1144
1603
|
const result = {
|
|
1145
1604
|
statementsExecuted: 0,
|
|
1146
1605
|
statements: diff.statements,
|
|
@@ -1162,6 +1621,10 @@ async function schemaPush(schema, connectionString, options = {}) {
|
|
|
1162
1621
|
await client.connect();
|
|
1163
1622
|
try {
|
|
1164
1623
|
await client.query('BEGIN');
|
|
1624
|
+
// Unconditional: the diff's read side is unconditional too, and the two
|
|
1625
|
+
// halves disagreeing about which namespace they mean is what wedges a
|
|
1626
|
+
// project permanently. See pinSearchPath for all three failure modes.
|
|
1627
|
+
await pinSearchPath(client, pgSchema);
|
|
1165
1628
|
for (const sql of diff.statements) {
|
|
1166
1629
|
await client.query(sql);
|
|
1167
1630
|
result.statementsExecuted++;
|
|
@@ -1169,7 +1632,20 @@ async function schemaPush(schema, connectionString, options = {}) {
|
|
|
1169
1632
|
await client.query('COMMIT');
|
|
1170
1633
|
}
|
|
1171
1634
|
catch (err) {
|
|
1172
|
-
|
|
1635
|
+
// The ROLLBACK is best-effort, and its own failure must never REPLACE the
|
|
1636
|
+
// error being unwound. The common way to get here is a connection that has
|
|
1637
|
+
// already died: the DDL fails, the ROLLBACK then fails too, and an
|
|
1638
|
+
// unguarded `await` throws the connection error out of this catch block, so
|
|
1639
|
+
// the caller is shown a dead socket instead of the constraint violation or
|
|
1640
|
+
// syntax error that actually caused the push to fail. There is also nothing
|
|
1641
|
+
// to do about a failed ROLLBACK: a broken connection is discarded below and
|
|
1642
|
+
// the server rolls the transaction back on its own.
|
|
1643
|
+
try {
|
|
1644
|
+
await client.query('ROLLBACK');
|
|
1645
|
+
}
|
|
1646
|
+
catch {
|
|
1647
|
+
// swallowed on purpose, see above
|
|
1648
|
+
}
|
|
1173
1649
|
throw err;
|
|
1174
1650
|
}
|
|
1175
1651
|
finally {
|