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/cli/mcp.js
CHANGED
|
@@ -16,6 +16,7 @@ const index_js_1 = require("../query/index.js");
|
|
|
16
16
|
const utils_js_1 = require("../query/utils.js");
|
|
17
17
|
const schema_js_1 = require("../schema.js");
|
|
18
18
|
const migrate_js_1 = require("./migrate.js");
|
|
19
|
+
const pii_predicate_guard_js_1 = require("./pii-predicate-guard.js");
|
|
19
20
|
const pii_tags_js_1 = require("./pii-tags.js");
|
|
20
21
|
const ui_js_1 = require("./ui.js");
|
|
21
22
|
/**
|
|
@@ -596,7 +597,11 @@ async function explainQuery(ctx, args) {
|
|
|
596
597
|
throw jsonRpcError(-32602, err instanceof Error ? err.message : String(err));
|
|
597
598
|
}
|
|
598
599
|
// QueryInterface emits unqualified identifiers; pin search_path like Studio.
|
|
599
|
-
|
|
600
|
+
// The bound value's CONTENTS are parsed as an identifier list, so the name
|
|
601
|
+
// has to carry its own quotes: a raw `My.Schema` pins search_path to nothing
|
|
602
|
+
// and the statement below fails with `relation "..." does not exist`, while
|
|
603
|
+
// a raw mixed-case name silently case-folds onto a different schema.
|
|
604
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [(0, index_js_1.quoteIdent)(ctx.options.schema)]);
|
|
600
605
|
const result = await client.query(`EXPLAIN (FORMAT JSON) ${deferred.sql}`, deferred.params);
|
|
601
606
|
return {
|
|
602
607
|
table: table.name,
|
|
@@ -606,17 +611,6 @@ async function explainQuery(ctx, args) {
|
|
|
606
611
|
};
|
|
607
612
|
});
|
|
608
613
|
}
|
|
609
|
-
/** Relation-filter wrappers whose body is a clause against the relation's target. */
|
|
610
|
-
const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
|
|
611
|
-
/**
|
|
612
|
-
* Recursion bound for the PII guard walk. Reaching it REFUSES the request, it
|
|
613
|
-
* is not a quiet stop: returning at the cap would mean a payload padded with
|
|
614
|
-
* enough nested `NOT` wrappers walks the guard off the end of its own recursion
|
|
615
|
-
* and then hands the untouched predicate to the builder. Sits far above the
|
|
616
|
-
* builder's own depth-10 relation cap, so nothing buildable is refused for
|
|
617
|
-
* depth alone.
|
|
618
|
-
*/
|
|
619
|
-
const PII_GUARD_MAX_DEPTH = 32;
|
|
620
614
|
/**
|
|
621
615
|
* Refuse an `explain_query` that filters or sorts on a hidden column: one that
|
|
622
616
|
* is PII-tagged, or one whose NAME matches {@link SECRET_NAME_PATTERN}.
|
|
@@ -638,9 +632,12 @@ const PII_GUARD_MAX_DEPTH = 32;
|
|
|
638
632
|
* refuse the predicate. It matches the rule the rest of the codebase already
|
|
639
633
|
* states: predicates on PII are allowed IN THE ORM because they return no
|
|
640
634
|
* value, and that reasoning stops holding the moment the query's SELECTIVITY is
|
|
641
|
-
* itself the reply. Studio drew the same line for the same reason
|
|
642
|
-
*
|
|
643
|
-
*
|
|
635
|
+
* itself the reply. Studio drew the same line for the same reason, which is why
|
|
636
|
+
* the WALK is now one shared module (`cli/pii-predicate-guard.ts`) rather than a
|
|
637
|
+
* second copy of it here: the two copies had the same job, the same name, and
|
|
638
|
+
* the same hole, and only a shared walk makes closing it once close it in both.
|
|
639
|
+
* What stays here is the part that is genuinely the MCP server's: the two
|
|
640
|
+
* reasons a column is hidden, and a JSON-RPC refusal.
|
|
644
641
|
*
|
|
645
642
|
* `select` is NOT refused: explain returns no rows, and naming a column reveals
|
|
646
643
|
* nothing about its contents.
|
|
@@ -656,82 +653,39 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
|
|
|
656
653
|
`query does not filter or sort on a PII column, and row estimates on such a column are an extraction ` +
|
|
657
654
|
`oracle. Re-run \`turbine generate\`, or call explain_query without where/orderBy.`);
|
|
658
655
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if (Array.isArray(node)) {
|
|
693
|
-
for (const item of node)
|
|
694
|
-
visitClause(item, owner, depth);
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
for (const [key, value] of Object.entries(node)) {
|
|
698
|
-
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
699
|
-
visitClause(value, owner, depth + 1);
|
|
700
|
-
continue;
|
|
701
|
-
}
|
|
702
|
-
const relation = Object.hasOwn(owner.relations, key) ? owner.relations[key] : undefined;
|
|
703
|
-
if (relation) {
|
|
704
|
-
visitRelationValue(value, metadata.tables[relation.to], depth + 1);
|
|
705
|
-
continue;
|
|
706
|
-
}
|
|
707
|
-
// A predicate may name a column by its camelCase field OR by its real
|
|
708
|
-
// column name; both compile to the same SQL, so both have to be checked.
|
|
709
|
-
const column = Object.hasOwn(owner.columnMap, key) ? owner.columnMap[key] : key;
|
|
710
|
-
const why = hiddenReason(owner, column);
|
|
711
|
-
if (why)
|
|
712
|
-
refuse(owner, column, why);
|
|
713
|
-
}
|
|
714
|
-
};
|
|
715
|
-
/**
|
|
716
|
-
* A relation predicate arrives either bare (`{ user: { email: {...} } }`) or
|
|
717
|
-
* wrapped in a cardinality operator (`{ user: { is: { email: {...} } } }`),
|
|
718
|
-
* and BOTH resolve against the relation's target. Walking the wrapper as if
|
|
719
|
-
* `is` were a column of the target would skip the inner clause entirely, so
|
|
720
|
-
* descend into every wrapper member AND into the node itself.
|
|
721
|
-
*/
|
|
722
|
-
const visitRelationValue = (value, target, depth) => {
|
|
723
|
-
assertWithinDepth(depth);
|
|
724
|
-
if (!target || value === null || typeof value !== 'object')
|
|
725
|
-
return;
|
|
726
|
-
const node = value;
|
|
727
|
-
for (const wrapper of RELATION_FILTER_WRAPPERS) {
|
|
728
|
-
if (Object.hasOwn(node, wrapper))
|
|
729
|
-
visitClause(node[wrapper], target, depth + 1);
|
|
730
|
-
}
|
|
731
|
-
visitClause(node, target, depth);
|
|
732
|
-
};
|
|
733
|
-
visitClause(args.where, table, 0);
|
|
734
|
-
visitClause(args.orderBy, table, 0);
|
|
656
|
+
(0, pii_predicate_guard_js_1.assertNoPiiPredicates)(args, table, {
|
|
657
|
+
metadata,
|
|
658
|
+
/**
|
|
659
|
+
* Why this column may not appear in a predicate, or null when it may.
|
|
660
|
+
*
|
|
661
|
+
* The two reasons are the two `sample_rows` already refuses to FETCH
|
|
662
|
+
* (`classifyHiddenColumns`), and they are deliberately the same set: a
|
|
663
|
+
* column whose bytes are too sensitive to sample is too sensitive to
|
|
664
|
+
* binary-search out of the planner. A column absent from the table is not
|
|
665
|
+
* judged here, the builder rejects it by name a moment later.
|
|
666
|
+
*/
|
|
667
|
+
hiddenReason: (owner, column) => {
|
|
668
|
+
if (owner.columns.some((col) => col.name === column && col.pii === true))
|
|
669
|
+
return 'is PII-tagged';
|
|
670
|
+
if (SECRET_NAME_PATTERN.test(column))
|
|
671
|
+
return 'has a secret-looking name';
|
|
672
|
+
return null;
|
|
673
|
+
},
|
|
674
|
+
refuseColumn: (owner, column, why) => {
|
|
675
|
+
throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
|
|
676
|
+
`EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
|
|
677
|
+
`reveals that value one character at a time. Filter on a visible column instead.`);
|
|
678
|
+
},
|
|
679
|
+
refuseDepth: (maxDepth) => {
|
|
680
|
+
throw jsonRpcError(-32602, `Query is nested more than ${maxDepth} levels deep, past the point where the PII guard can ` +
|
|
681
|
+
`prove it does not filter or sort on a tagged column, so it is refused. Flatten the query.`);
|
|
682
|
+
},
|
|
683
|
+
refuseShape: (owner, key) => {
|
|
684
|
+
throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the ` +
|
|
685
|
+
`query does not filter or sort on a hidden column, and refuses it rather than guessing. ` +
|
|
686
|
+
`Remove it and explain the query without it.`);
|
|
687
|
+
},
|
|
688
|
+
});
|
|
735
689
|
}
|
|
736
690
|
/**
|
|
737
691
|
* Extract the allowed findMany subset for explain_query (no `with` / raw SQL).
|
|
@@ -870,27 +824,103 @@ async function loadSchemaMetadata(client, options) {
|
|
|
870
824
|
FROM information_schema.columns
|
|
871
825
|
WHERE table_schema = $1
|
|
872
826
|
ORDER BY table_name, ordinal_position`, [options.schema]),
|
|
873
|
-
client.query(
|
|
827
|
+
client.query(
|
|
828
|
+
// Joined on the table as well as the constraint name. Not because two
|
|
829
|
+
// primary keys can share a name (they cannot: a PK is backed by an INDEX,
|
|
830
|
+
// and index names ARE unique per schema, so Postgres itself refuses the
|
|
831
|
+
// second `CREATE TABLE ... CONSTRAINT pk_shared PRIMARY KEY` with
|
|
832
|
+
// `relation "pk_shared" already exists`, verified on PG 16) but because
|
|
833
|
+
// the join reads as if the name were the identity, which is what put the
|
|
834
|
+
// FOREIGN KEY query below one refactor away from a silent cross product.
|
|
835
|
+
// A foreign key has no backing index and so genuinely can collide.
|
|
836
|
+
`SELECT tc.table_name, kcu.column_name
|
|
874
837
|
FROM information_schema.table_constraints tc
|
|
875
838
|
JOIN information_schema.key_column_usage kcu
|
|
876
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
839
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
840
|
+
AND tc.table_schema = kcu.table_schema
|
|
841
|
+
AND tc.table_name = kcu.table_name
|
|
877
842
|
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
|
|
878
843
|
ORDER BY tc.table_name, kcu.ordinal_position`, [options.schema]),
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
844
|
+
// Foreign keys come from pg_catalog, not information_schema, and the reason
|
|
845
|
+
// is the same one written up over `SQL_FOREIGN_KEYS` in ../introspect.ts
|
|
846
|
+
// (KEEP THE TWO IN LOCKSTEP: this is a second copy of that query because
|
|
847
|
+
// introspect.ts does not export it, and mcp reads through its own pooled
|
|
848
|
+
// client inside a read-only transaction rather than opening the pool
|
|
849
|
+
// `introspect()` owns). The information_schema formulation this replaces
|
|
850
|
+
// joined key_column_usage (constrained columns) to constraint_column_usage
|
|
851
|
+
// (referenced columns) on the constraint NAME, which is wrong twice:
|
|
852
|
+
//
|
|
853
|
+
// 1. Those two column lists have no positional link, so the join is an
|
|
854
|
+
// N-by-N cross product: a two-column FK came back as four rows and
|
|
855
|
+
// grouped into four AND-ed correlations, two of them pairing the wrong
|
|
856
|
+
// columns. Every read through the relation silently returned nothing.
|
|
857
|
+
// 2. A constraint name is unique per TABLE (conrelid, conname), not per
|
|
858
|
+
// schema, so two tables may both have a `shared_fk`. This is specific
|
|
859
|
+
// to foreign keys: a PRIMARY KEY or UNIQUE constraint is backed by an
|
|
860
|
+
// index and index names ARE schema-unique, so Postgres refuses that
|
|
861
|
+
// collision outright, while an FK has no backing index and the
|
|
862
|
+
// collision is legal. On the name alone the two cross: measured on PG
|
|
863
|
+
// 16, two tables with a `shared_fk` produced EIGHT rows instead of two,
|
|
864
|
+
// which grouped by name into one entry, so one table lost its relation
|
|
865
|
+
// entirely and the other pointed at a column its target does not have
|
|
866
|
+
// (42703 at query time). Which one won depended on catalog row order.
|
|
867
|
+
//
|
|
868
|
+
// conkey and confkey are parallel arrays, so unnesting BOTH `WITH
|
|
869
|
+
// ORDINALITY` and joining on the ordinal IS the pairing, exactly; the OID is
|
|
870
|
+
// the grouping key because it is unique catalog-wide.
|
|
871
|
+
//
|
|
872
|
+
// The target is constrained to the SAME schema, which is what the old query
|
|
873
|
+
// did implicitly (it joined on ccu.table_schema). Keeping it explicit
|
|
874
|
+
// matters: `buildRelations` resolves targets by bare name against the
|
|
875
|
+
// introspected table set, so a cross-schema reference to a same-named table
|
|
876
|
+
// would silently bind to the local one.
|
|
877
|
+
//
|
|
878
|
+
// `conparentid = 0` (declared constraints only) and the by-NAME ordering are
|
|
879
|
+
// both part of the lockstep: one FK against a partitioned table otherwise
|
|
880
|
+
// yields an extra phantom relation per partition, and ordering by `con.oid`
|
|
881
|
+
// makes relation naming a function of DDL execution order rather than of the
|
|
882
|
+
// schema. Both are written up at length over SQL_FOREIGN_KEYS.
|
|
883
|
+
client.query(`SELECT
|
|
884
|
+
con.oid::text AS constraint_oid,
|
|
885
|
+
con.conname AS constraint_name,
|
|
886
|
+
src.relname AS source_table,
|
|
887
|
+
src_att.attname AS source_column,
|
|
888
|
+
tgt.relname AS target_table,
|
|
889
|
+
tgt_att.attname AS target_column
|
|
890
|
+
FROM pg_catalog.pg_constraint con
|
|
891
|
+
JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
|
|
892
|
+
JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
|
|
893
|
+
JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
|
|
894
|
+
JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
|
|
895
|
+
JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS sk(attnum, ord) ON TRUE
|
|
896
|
+
JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS tk(attnum, ord) ON tk.ord = sk.ord
|
|
897
|
+
JOIN pg_catalog.pg_attribute src_att
|
|
898
|
+
ON src_att.attrelid = con.conrelid AND src_att.attnum = sk.attnum
|
|
899
|
+
JOIN pg_catalog.pg_attribute tgt_att
|
|
900
|
+
ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = tk.attnum
|
|
901
|
+
WHERE con.contype = 'f'
|
|
902
|
+
AND con.conparentid = 0
|
|
903
|
+
AND src_ns.nspname = $1
|
|
904
|
+
AND tgt_ns.nspname = src_ns.nspname
|
|
905
|
+
ORDER BY src.relname, con.conname, sk.ord`, [options.schema]),
|
|
906
|
+
client.query(
|
|
907
|
+
// Joined on the table too, same reasoning as the primary-key query above:
|
|
908
|
+
// a UNIQUE constraint is index-backed and therefore cannot collide, and
|
|
909
|
+
// the join says so.
|
|
910
|
+
`SELECT tc.table_name, tc.constraint_name, kcu.column_name
|
|
888
911
|
FROM information_schema.table_constraints tc
|
|
889
912
|
JOIN information_schema.key_column_usage kcu
|
|
890
|
-
ON tc.constraint_name = kcu.constraint_name
|
|
913
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
914
|
+
AND tc.table_schema = kcu.table_schema
|
|
915
|
+
AND tc.table_name = kcu.table_name
|
|
891
916
|
WHERE tc.constraint_type = 'UNIQUE' AND tc.table_schema = $1
|
|
892
917
|
ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`, [options.schema]),
|
|
893
|
-
client.query(
|
|
918
|
+
client.query(
|
|
919
|
+
// Ordered for the same reason SQL_INDEXES is: these rows feed the
|
|
920
|
+
// unique-set detection that decides hasOne-versus-hasMany, and they are
|
|
921
|
+
// reported verbatim by the schema tools, so physical catalog order must
|
|
922
|
+
// not leak into either answer. Index names are unique per schema.
|
|
923
|
+
`SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`, [options.schema]),
|
|
894
924
|
client.query(`SELECT t.typname, e.enumlabel
|
|
895
925
|
FROM pg_type t
|
|
896
926
|
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
@@ -977,7 +1007,7 @@ async function loadSchemaMetadata(client, options) {
|
|
|
977
1007
|
labels.push(row.enumlabel);
|
|
978
1008
|
enums[row.typname] = labels;
|
|
979
1009
|
}
|
|
980
|
-
const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows, enums);
|
|
1010
|
+
const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows, uniqueByTable, indexesByTable, enums);
|
|
981
1011
|
const tables = {};
|
|
982
1012
|
for (const tableName of tableNames) {
|
|
983
1013
|
const columns = columnsByTable.get(tableName) ?? [];
|
|
@@ -1039,20 +1069,36 @@ async function loadSchemaMetadata(client, options) {
|
|
|
1039
1069
|
return { metadata, piiTags };
|
|
1040
1070
|
}
|
|
1041
1071
|
/**
|
|
1042
|
-
* Group raw FK rows into constraint-level entries and delegate relation
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1072
|
+
* Group raw FK rows into constraint-level entries and delegate relation naming
|
|
1073
|
+
* to `deriveCatalogRelations` in ../introspect.ts, the SAME entry point
|
|
1074
|
+
* `turbine generate` goes through. MCP introspects for itself (it cannot assume
|
|
1075
|
+
* generated metadata exists), so any divergence here is a divergence a live MCP
|
|
1076
|
+
* client trips over: it reads a relation name out of the MCP schema tool, passes
|
|
1077
|
+
* it back in a query, and the core builder rejects it.
|
|
1078
|
+
*
|
|
1079
|
+
* MCP used to assemble the pipeline by hand and drifted twice over, both times
|
|
1080
|
+
* by OMITTING an optional argument, which is silently type-correct and changes
|
|
1081
|
+
* the answer: without `uniqueSetsByTable` a UNIQUE foreign key came back as
|
|
1082
|
+
* `users.profiles` (hasMany) where generate said `users.profile` (hasOne), and
|
|
1083
|
+
* without `uniqueIndexColsByTable` every Prisma-style PK-less junction lost its
|
|
1084
|
+
* auto-m2m relations. Passing the whole input set to one shared function is what
|
|
1085
|
+
* makes those two failures impossible rather than merely fixed.
|
|
1086
|
+
*
|
|
1087
|
+
* `uniqueByTable` and `indexesByTable` are therefore REQUIRED parameters, not
|
|
1088
|
+
* optional ones: an optional catalog input is exactly how the hasOne flip went
|
|
1089
|
+
* missing here in the first place. Exported for the parity unit test.
|
|
1048
1090
|
*/
|
|
1049
|
-
function buildRelations(tableNames, columnsByTable, pkByTable, rows, enums = {}) {
|
|
1091
|
+
function buildRelations(tableNames, columnsByTable, pkByTable, rows, uniqueByTable, indexesByTable, enums = {}) {
|
|
1050
1092
|
const tableSet = new Set(tableNames);
|
|
1051
1093
|
const groups = new Map();
|
|
1052
1094
|
for (const row of rows) {
|
|
1053
1095
|
if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
|
|
1054
1096
|
continue;
|
|
1055
|
-
|
|
1097
|
+
// Keyed on the constraint OID, never the name: see ForeignKeyRow. The query
|
|
1098
|
+
// orders by (source table, constraint name, source ordinal), so the two
|
|
1099
|
+
// column lists stay paired and the walk order does not depend on the order
|
|
1100
|
+
// the constraints happened to be created in.
|
|
1101
|
+
const group = groups.get(row.constraint_oid) ?? {
|
|
1056
1102
|
sourceTable: row.source_table,
|
|
1057
1103
|
sourceColumns: [],
|
|
1058
1104
|
targetTable: row.target_table,
|
|
@@ -1061,20 +1107,17 @@ function buildRelations(tableNames, columnsByTable, pkByTable, rows, enums = {})
|
|
|
1061
1107
|
};
|
|
1062
1108
|
group.sourceColumns.push(row.source_column);
|
|
1063
1109
|
group.targetColumns.push(row.target_column);
|
|
1064
|
-
groups.set(row.
|
|
1065
|
-
}
|
|
1066
|
-
const foreignKeys = [...groups.values()];
|
|
1067
|
-
const columnFieldsByTable = new Map();
|
|
1068
|
-
const unknownTypedFieldsByTable = new Map();
|
|
1069
|
-
for (const [tbl, cols] of columnsByTable) {
|
|
1070
|
-
columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
|
|
1071
|
-
// Enum-typed columns also report tsType 'unknown', but the generated type
|
|
1072
|
-
// layer gives them a concrete union, only json/jsonb qualify as shadows.
|
|
1073
|
-
unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => (0, introspect_js_1.isUnknownTsType)(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
|
|
1110
|
+
groups.set(row.constraint_oid, group);
|
|
1074
1111
|
}
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1112
|
+
return (0, introspect_js_1.deriveCatalogRelations)({
|
|
1113
|
+
tableNames,
|
|
1114
|
+
foreignKeys: [...groups.values()],
|
|
1115
|
+
pkByTable,
|
|
1116
|
+
columnsByTable,
|
|
1117
|
+
uniqueByTable,
|
|
1118
|
+
indexesByTable,
|
|
1119
|
+
enums,
|
|
1120
|
+
});
|
|
1078
1121
|
}
|
|
1079
1122
|
async function estimateRows(client, schema) {
|
|
1080
1123
|
const result = await client.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
|
|
@@ -1087,7 +1130,7 @@ async function estimateRows(client, schema) {
|
|
|
1087
1130
|
return counts;
|
|
1088
1131
|
}
|
|
1089
1132
|
function requireTable(metadata, tableName) {
|
|
1090
|
-
const table = metadata.tables
|
|
1133
|
+
const table = (0, utils_js_1.ownLookup)(metadata.tables, tableName);
|
|
1091
1134
|
if (!table) {
|
|
1092
1135
|
const available = Object.keys(metadata.tables).join(', ') || '(none)';
|
|
1093
1136
|
throw jsonRpcError(-32602, `Unknown table "${tableName}". Available: ${available}`);
|
|
@@ -15,6 +15,14 @@ import pg from 'pg';
|
|
|
15
15
|
import type { DatabaseAdapter } from '../adapters/index.js';
|
|
16
16
|
import { type Dialect } from '../dialect.js';
|
|
17
17
|
import { type DestructiveStatement } from './destructive.js';
|
|
18
|
+
import { splitSqlStatements } from './sql-statements.js';
|
|
19
|
+
/**
|
|
20
|
+
* Re-exported from `./sql-statements.js`, which owns the one tokenizer this
|
|
21
|
+
* module and `destructive.js` both speak. It used to live here, and the guard
|
|
22
|
+
* carried a second, subtly different copy: see that module's header for what
|
|
23
|
+
* they disagreed about and what it cost.
|
|
24
|
+
*/
|
|
25
|
+
export { splitSqlStatements };
|
|
18
26
|
export interface MigrationFile {
|
|
19
27
|
/** Full filename (e.g. "20260325120000_create_users.sql") */
|
|
20
28
|
filename: string;
|
|
@@ -79,6 +87,17 @@ export interface MigrationRunResult {
|
|
|
79
87
|
}
|
|
80
88
|
/** Extract the YYYYMMDDHHMMSS timestamp prefix from a migration name, or null. */
|
|
81
89
|
export declare function migrationTimestamp(name: string): string | null;
|
|
90
|
+
/**
|
|
91
|
+
* Refuse any migration in the batch that manages its own transactions.
|
|
92
|
+
*
|
|
93
|
+
* `-- turbine:no-transaction` files are exempt: they were never wrapped, so
|
|
94
|
+
* theirs is a real (and supported) transaction to manage. Pre-flight over the
|
|
95
|
+
* WHOLE batch, before anything runs, so a bad file at position 3 does not leave
|
|
96
|
+
* migrations 1 and 2 applied.
|
|
97
|
+
*
|
|
98
|
+
* @internal exported for tests.
|
|
99
|
+
*/
|
|
100
|
+
export declare function assertNoEmbeddedTransactions(files: MigrationFile[], section: 'up' | 'down'): void;
|
|
82
101
|
/** Scan a set of migration files' UP sections for data-destroying statements. */
|
|
83
102
|
export declare function collectUpDestructive(files: MigrationFile[]): DestructiveOffender[];
|
|
84
103
|
/**
|
|
@@ -86,6 +105,25 @@ export declare function collectUpDestructive(files: MigrationFile[]): Destructiv
|
|
|
86
105
|
* Expected format: YYYYMMDDHHMMSS_description.sql
|
|
87
106
|
*/
|
|
88
107
|
export declare function parseMigrationFilename(filename: string): MigrationFile | null;
|
|
108
|
+
/**
|
|
109
|
+
* A migration name as it can safely appear in a file's `-- Migration:` header
|
|
110
|
+
* comment.
|
|
111
|
+
*
|
|
112
|
+
* The header sits ABOVE the `-- UP` marker, and the raw CLI argument used to be
|
|
113
|
+
* interpolated into it verbatim. A `--` comment ends at the first newline, so a
|
|
114
|
+
* name carrying one closes the comment and everything after it becomes file
|
|
115
|
+
* content: a name of `x\n-- turbine:no-transaction\n-- UP\nDROP TABLE users;`
|
|
116
|
+
* wrote both an execution directive and executable SQL into a migration the
|
|
117
|
+
* user never authored. Only the FILENAME was sanitized, which is the one place
|
|
118
|
+
* the injection could not reach.
|
|
119
|
+
*
|
|
120
|
+
* Collapsing every run of whitespace to a single space is the whole fix: the
|
|
121
|
+
* argument then cannot leave the one comment line it was written on, and `\s`
|
|
122
|
+
* covers `\r` and the Unicode line separators too, all of which Postgres also
|
|
123
|
+
* treats as ending a `--` comment. The readable spelling is preserved, unlike
|
|
124
|
+
* {@link sanitizeName}, because this is documentation for a human.
|
|
125
|
+
*/
|
|
126
|
+
export declare function headerSafeName(name: string): string;
|
|
89
127
|
/**
|
|
90
128
|
* Sanitize a migration name: lowercase, replace non-alnum with _, collapse duplicates, trim.
|
|
91
129
|
*/
|
|
@@ -112,24 +150,24 @@ export interface ParsedMigration {
|
|
|
112
150
|
}
|
|
113
151
|
/**
|
|
114
152
|
* Parse migration content string into UP and DOWN sections plus directives.
|
|
153
|
+
*
|
|
154
|
+
* Throws `MigrationError` when the file carries no `-- UP` marker at all.
|
|
155
|
+
* Returning `{ up: '', down: '' }` there meant the entire file was treated as
|
|
156
|
+
* preamble and the migration recorded as applied having executed nothing, which
|
|
157
|
+
* is worse than any error: the database is missing the change and the history
|
|
158
|
+
* says it is present. `source` (a path) is only used to name the file.
|
|
159
|
+
*
|
|
115
160
|
* Exported for unit testing.
|
|
116
161
|
*/
|
|
117
|
-
export declare function parseMigrationContent(content: string): ParsedMigration;
|
|
162
|
+
export declare function parseMigrationContent(content: string, source?: string): ParsedMigration;
|
|
118
163
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* string (including a backslash-escaping `E'...'` string), a double-quoted
|
|
123
|
-
* identifier, a dollar-quoted body, a line comment
|
|
124
|
-
* (`--`), or a block comment (`/* *\/`, which Postgres allows to nest) must NOT
|
|
125
|
-
* split. This is the one production-destroying failure mode of no-transaction
|
|
126
|
-
* migrations (a partial statement executed against production), so the behavior
|
|
127
|
-
* is pinned by exhaustive unit tests.
|
|
164
|
+
* Top-level transaction-control statements in a migration body, as displayable
|
|
165
|
+
* text. Empty for a clean file. Comment- and literal-aware via the shared
|
|
166
|
+
* tokenizer, so a `COMMIT` inside a comment or a string is not flagged.
|
|
128
167
|
*
|
|
129
|
-
*
|
|
130
|
-
* carries no trailing semicolon.
|
|
168
|
+
* @internal exported for tests.
|
|
131
169
|
*/
|
|
132
|
-
export declare function
|
|
170
|
+
export declare function findTransactionControlStatements(body: string): string[];
|
|
133
171
|
/**
|
|
134
172
|
* Parse a migration file into UP and DOWN sections.
|
|
135
173
|
*/
|
|
@@ -240,6 +278,62 @@ export declare function createMigration(migrationsDir: string, name: string, aut
|
|
|
240
278
|
* without contending on a single hardcoded lock ID.
|
|
241
279
|
*/
|
|
242
280
|
export declare function deriveLockId(databaseName: string): number;
|
|
281
|
+
/**
|
|
282
|
+
* The connection surface the migration lock needs. `pg.Client` satisfies it;
|
|
283
|
+
* tests substitute a fake so the dedicated-connection wiring can be exercised
|
|
284
|
+
* without a database.
|
|
285
|
+
*
|
|
286
|
+
* @internal
|
|
287
|
+
*/
|
|
288
|
+
export interface MigrationLockClient {
|
|
289
|
+
query(sql: string, params?: unknown[]): Promise<unknown>;
|
|
290
|
+
end(): Promise<void>;
|
|
291
|
+
}
|
|
292
|
+
/** A held (or refused) migration lock, and whatever must be released with it. */
|
|
293
|
+
export interface MigrationLock {
|
|
294
|
+
/** False when another migration already holds the lock. */
|
|
295
|
+
acquired: boolean;
|
|
296
|
+
lockId: number;
|
|
297
|
+
adapter: DatabaseAdapter;
|
|
298
|
+
/**
|
|
299
|
+
* The dedicated connection holding the lock, present only for adapters whose
|
|
300
|
+
* lock lives in an open transaction. Closed by {@link releaseMigrationLock}.
|
|
301
|
+
*/
|
|
302
|
+
lockClient?: MigrationLockClient;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Take the migration lock, on a DEDICATED connection when the adapter needs one.
|
|
306
|
+
*
|
|
307
|
+
* The CockroachDB and YugabyteDB adapters lock a row in `_turbine_lock` with
|
|
308
|
+
* `SELECT ... FOR UPDATE NOWAIT` and deliberately leave that transaction OPEN,
|
|
309
|
+
* because a row lock only exists for as long as its transaction does. The runner
|
|
310
|
+
* then applies every migration on the SAME connection, and
|
|
311
|
+
* `runMigrationInTransaction` issues BEGIN ... COMMIT per file. That COMMIT ends
|
|
312
|
+
* the LOCK's transaction: from migration 2 onward the run was unprotected, a
|
|
313
|
+
* concurrent `turbine migrate` could take the lock and replay those files, and
|
|
314
|
+
* `releaseLock`'s later COMMIT was a no-op that warned rather than failed, so
|
|
315
|
+
* nothing surfaced. The same collision had a second face: a
|
|
316
|
+
* `-- turbine:no-transaction` migration running FIRST executed inside the still
|
|
317
|
+
* open lock transaction, so `CREATE INDEX CONCURRENTLY` failed with "cannot run
|
|
318
|
+
* inside a transaction block" while the identical file placed second succeeded.
|
|
319
|
+
*
|
|
320
|
+
* A second connection separates the two transaction scopes, which is the only
|
|
321
|
+
* thing that makes the lock outlive a migration. The advisory-lock path (plain
|
|
322
|
+
* Postgres, AlloyDB, Timescale) is session-scoped rather than
|
|
323
|
+
* transaction-scoped, opens NO second connection, and is byte-identical to what
|
|
324
|
+
* it has always done.
|
|
325
|
+
*
|
|
326
|
+
* @internal exported for tests.
|
|
327
|
+
*/
|
|
328
|
+
export declare function acquireMigrationLock(runner: MigrationLockClient, lockId: number, adapter: DatabaseAdapter | undefined, openLockConnection: () => Promise<MigrationLockClient>): Promise<MigrationLock>;
|
|
329
|
+
/**
|
|
330
|
+
* Release a lock taken by {@link acquireMigrationLock}, and close the dedicated
|
|
331
|
+
* connection when there is one. A refused lock owns nothing, so releasing it is
|
|
332
|
+
* a no-op rather than an unlock of somebody else's lock.
|
|
333
|
+
*
|
|
334
|
+
* @internal exported for tests.
|
|
335
|
+
*/
|
|
336
|
+
export declare function releaseMigrationLock(lock: MigrationLock, runner: MigrationLockClient): Promise<void>;
|
|
243
337
|
/**
|
|
244
338
|
* The minimal query surface a transactional migration body needs.
|
|
245
339
|
* `pg.Client` satisfies it; tests supply a fake.
|
|
@@ -327,6 +421,33 @@ export declare function migrateDeploy(connectionString: string, migrationsDir: s
|
|
|
327
421
|
adapter?: DatabaseAdapter;
|
|
328
422
|
allowDrift?: boolean;
|
|
329
423
|
}): Promise<MigrationRunResult>;
|
|
424
|
+
/**
|
|
425
|
+
* Roll back a prepared LIFO batch, newest first, stopping at the first
|
|
426
|
+
* migration that cannot be rolled back.
|
|
427
|
+
*
|
|
428
|
+
* A rollback batch is strictly LIFO and must have NO GAPS. The
|
|
429
|
+
* "file not found" and "no DOWN section" branches used to `continue`, so a
|
|
430
|
+
* `--step 3` whose middle migration had no DOWN section rolled back 3 and then
|
|
431
|
+
* 1: the oldest migration's schema was torn down while the data migration 2 had
|
|
432
|
+
* seeded into it was still expected to exist, and the tracking table was left
|
|
433
|
+
* claiming migration 2 alone was applied. The next `migrate up` then re-ran 1
|
|
434
|
+
* and 3 and never re-ran 2, so that data was gone permanently. Both branches
|
|
435
|
+
* now stop, which is what the SQL-failure branches have always done.
|
|
436
|
+
*
|
|
437
|
+
* Split out of {@link migrateDown} so the ordering contract is testable against
|
|
438
|
+
* a fake client, without a database.
|
|
439
|
+
*
|
|
440
|
+
* @internal exported for tests; not part of the CLI's public surface.
|
|
441
|
+
*/
|
|
442
|
+
export declare function rollbackMigrations(client: MigrationTxClient, toRollback: Array<{
|
|
443
|
+
name: string;
|
|
444
|
+
}>, fileMap: Map<string, MigrationFile>, deleteApplied: string): Promise<{
|
|
445
|
+
rolledBack: MigrationFile[];
|
|
446
|
+
errors: Array<{
|
|
447
|
+
file: MigrationFile;
|
|
448
|
+
error: string;
|
|
449
|
+
}>;
|
|
450
|
+
}>;
|
|
330
451
|
/**
|
|
331
452
|
* Rollback the last N migrations (DOWN).
|
|
332
453
|
*
|