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.
Files changed (142) hide show
  1. package/README.md +34 -32
  2. package/dist/adapters/cockroachdb.js +21 -3
  3. package/dist/adapters/index.d.ts +15 -0
  4. package/dist/adapters/yugabytedb.js +20 -3
  5. package/dist/cjs/adapters/cockroachdb.js +21 -3
  6. package/dist/cjs/adapters/index.d.ts +15 -0
  7. package/dist/cjs/adapters/yugabytedb.js +20 -3
  8. package/dist/cjs/cli/destructive.d.ts +18 -4
  9. package/dist/cjs/cli/destructive.js +230 -122
  10. package/dist/cjs/cli/index.d.ts +21 -4
  11. package/dist/cjs/cli/index.js +119 -22
  12. package/dist/cjs/cli/mcp.d.ts +28 -8
  13. package/dist/cjs/cli/mcp.js +170 -127
  14. package/dist/cjs/cli/migrate.d.ts +134 -13
  15. package/dist/cjs/cli/migrate.js +349 -241
  16. package/dist/cjs/cli/pii-predicate-guard.d.ts +112 -0
  17. package/dist/cjs/cli/pii-predicate-guard.js +390 -0
  18. package/dist/cjs/cli/prisma-resolve.js +75 -4
  19. package/dist/cjs/cli/prisma-schema.d.ts +17 -1
  20. package/dist/cjs/cli/prisma-schema.js +83 -17
  21. package/dist/cjs/cli/sql-statements.d.ts +125 -0
  22. package/dist/cjs/cli/sql-statements.js +378 -0
  23. package/dist/cjs/cli/studio.js +49 -118
  24. package/dist/cjs/cli/ui.d.ts +1 -1
  25. package/dist/cjs/client.d.ts +43 -0
  26. package/dist/cjs/client.js +125 -6
  27. package/dist/cjs/dialect.d.ts +123 -0
  28. package/dist/cjs/dialect.js +33 -0
  29. package/dist/cjs/errors.d.ts +74 -1
  30. package/dist/cjs/errors.js +239 -25
  31. package/dist/cjs/index-advisor.d.ts +33 -1
  32. package/dist/cjs/index-advisor.js +32 -1
  33. package/dist/cjs/introspect.d.ts +48 -0
  34. package/dist/cjs/introspect.js +222 -91
  35. package/dist/cjs/mssql.js +43 -1
  36. package/dist/cjs/mysql.d.ts +5 -2
  37. package/dist/cjs/mysql.js +202 -17
  38. package/dist/cjs/nested-write.js +6 -1
  39. package/dist/cjs/pipeline-submittable.js +17 -3
  40. package/dist/cjs/pipeline.js +75 -9
  41. package/dist/cjs/powdb.d.ts +23 -0
  42. package/dist/cjs/powdb.js +33 -1
  43. package/dist/cjs/powql.d.ts +61 -9
  44. package/dist/cjs/powql.js +186 -49
  45. package/dist/cjs/prisma-compat.js +160 -41
  46. package/dist/cjs/query/aggregates.d.ts +1 -1
  47. package/dist/cjs/query/aggregates.js +80 -18
  48. package/dist/cjs/query/batched-loader.d.ts +10 -0
  49. package/dist/cjs/query/batched-loader.js +268 -7
  50. package/dist/cjs/query/builder.d.ts +73 -0
  51. package/dist/cjs/query/builder.js +225 -28
  52. package/dist/cjs/query/filters.d.ts +162 -0
  53. package/dist/cjs/query/filters.js +250 -1
  54. package/dist/cjs/query/relations.d.ts +10 -10
  55. package/dist/cjs/query/relations.js +93 -12
  56. package/dist/cjs/query/types.d.ts +14 -1
  57. package/dist/cjs/query/utils.d.ts +146 -2
  58. package/dist/cjs/query/utils.js +210 -4
  59. package/dist/cjs/query/warn-registry.d.ts +10 -0
  60. package/dist/cjs/query/warn-registry.js +10 -0
  61. package/dist/cjs/query/where-compile.d.ts +30 -0
  62. package/dist/cjs/query/where-compile.js +41 -0
  63. package/dist/cjs/query/where.d.ts +128 -13
  64. package/dist/cjs/query/where.js +215 -77
  65. package/dist/cjs/query/writes.d.ts +1 -1
  66. package/dist/cjs/query/writes.js +39 -15
  67. package/dist/cjs/schema-builder.d.ts +2 -1
  68. package/dist/cjs/schema-sql.d.ts +94 -4
  69. package/dist/cjs/schema-sql.js +506 -30
  70. package/dist/cjs/schema.d.ts +3 -1
  71. package/dist/cjs/sqlite.d.ts +6 -0
  72. package/dist/cjs/sqlite.js +151 -10
  73. package/dist/cjs/typed-sql.d.ts +29 -1
  74. package/dist/cjs/typed-sql.js +30 -12
  75. package/dist/cli/destructive.d.ts +18 -4
  76. package/dist/cli/destructive.js +229 -121
  77. package/dist/cli/index.d.ts +21 -4
  78. package/dist/cli/index.js +120 -24
  79. package/dist/cli/mcp.d.ts +28 -8
  80. package/dist/cli/mcp.js +172 -129
  81. package/dist/cli/migrate.d.ts +134 -13
  82. package/dist/cli/migrate.js +347 -238
  83. package/dist/cli/pii-predicate-guard.d.ts +112 -0
  84. package/dist/cli/pii-predicate-guard.js +386 -0
  85. package/dist/cli/prisma-resolve.js +75 -4
  86. package/dist/cli/prisma-schema.d.ts +17 -1
  87. package/dist/cli/prisma-schema.js +83 -17
  88. package/dist/cli/sql-statements.d.ts +125 -0
  89. package/dist/cli/sql-statements.js +373 -0
  90. package/dist/cli/studio.js +49 -118
  91. package/dist/cli/ui.d.ts +1 -1
  92. package/dist/client.d.ts +43 -0
  93. package/dist/client.js +126 -7
  94. package/dist/dialect.d.ts +123 -0
  95. package/dist/dialect.js +33 -0
  96. package/dist/errors.d.ts +74 -1
  97. package/dist/errors.js +228 -19
  98. package/dist/index-advisor.d.ts +33 -1
  99. package/dist/index-advisor.js +31 -1
  100. package/dist/introspect.d.ts +48 -0
  101. package/dist/introspect.js +221 -91
  102. package/dist/mssql.js +44 -2
  103. package/dist/mysql.d.ts +5 -2
  104. package/dist/mysql.js +203 -18
  105. package/dist/nested-write.js +7 -2
  106. package/dist/pipeline-submittable.js +18 -4
  107. package/dist/pipeline.js +76 -10
  108. package/dist/powdb.d.ts +23 -0
  109. package/dist/powdb.js +33 -2
  110. package/dist/powql.d.ts +61 -9
  111. package/dist/powql.js +187 -50
  112. package/dist/prisma-compat.js +160 -41
  113. package/dist/query/aggregates.d.ts +1 -1
  114. package/dist/query/aggregates.js +82 -20
  115. package/dist/query/batched-loader.d.ts +10 -0
  116. package/dist/query/batched-loader.js +270 -9
  117. package/dist/query/builder.d.ts +73 -0
  118. package/dist/query/builder.js +226 -30
  119. package/dist/query/filters.d.ts +162 -0
  120. package/dist/query/filters.js +246 -1
  121. package/dist/query/relations.d.ts +10 -10
  122. package/dist/query/relations.js +94 -14
  123. package/dist/query/types.d.ts +14 -1
  124. package/dist/query/utils.d.ts +146 -2
  125. package/dist/query/utils.js +204 -3
  126. package/dist/query/warn-registry.d.ts +10 -0
  127. package/dist/query/warn-registry.js +10 -0
  128. package/dist/query/where-compile.d.ts +30 -0
  129. package/dist/query/where-compile.js +40 -1
  130. package/dist/query/where.d.ts +128 -13
  131. package/dist/query/where.js +216 -80
  132. package/dist/query/writes.d.ts +1 -1
  133. package/dist/query/writes.js +40 -16
  134. package/dist/schema-builder.d.ts +2 -1
  135. package/dist/schema-sql.d.ts +94 -4
  136. package/dist/schema-sql.js +505 -30
  137. package/dist/schema.d.ts +3 -1
  138. package/dist/sqlite.d.ts +6 -0
  139. package/dist/sqlite.js +151 -10
  140. package/dist/typed-sql.d.ts +29 -1
  141. package/dist/typed-sql.js +30 -12
  142. package/package.json +6 -4
@@ -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
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
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
- * (`assertNoPiiPredicates`, and its `filters` param refuses even `isNull`,
643
- * because null-ness is an oracle too).
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
- const assertWithinDepth = (depth) => {
660
- if (depth <= PII_GUARD_MAX_DEPTH)
661
- return;
662
- throw jsonRpcError(-32602, `Query is nested more than ${PII_GUARD_MAX_DEPTH} levels deep, past the point where the PII guard can ` +
663
- `prove it does not filter or sort on a tagged column, so it is refused. Flatten the query.`);
664
- };
665
- const refuse = (owner, column, why) => {
666
- throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
667
- `EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
668
- `reveals that value one character at a time. Filter on a visible column instead.`);
669
- };
670
- /**
671
- * Why this column may not appear in a predicate, or null when it may.
672
- *
673
- * The two reasons are the two `sample_rows` already refuses to FETCH
674
- * (`classifyHiddenColumns`), and they are deliberately the same set: a column
675
- * whose bytes are too sensitive to sample is too sensitive to binary-search
676
- * out of the planner. A column absent from the table is not judged here, the
677
- * builder rejects it by name a moment later.
678
- */
679
- const hiddenReason = (owner, column) => {
680
- if (owner.columns.some((col) => col.name === column && col.pii === true))
681
- return 'is PII-tagged';
682
- if (SECRET_NAME_PATTERN.test(column))
683
- return 'has a secret-looking name';
684
- return null;
685
- };
686
- const visitClause = (node, owner, depth) => {
687
- assertWithinDepth(depth);
688
- if (!owner || node === null || typeof node !== 'object')
689
- return;
690
- // `orderBy` accepts an array of single-key objects, and so does a `NOT`
691
- // list. Element order carries no nesting, so the depth is unchanged.
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(`SELECT tc.table_name, kcu.column_name
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 AND tc.table_schema = kcu.table_schema
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
- client.query(`SELECT tc.table_name AS source_table, kcu.column_name AS source_column,
880
- ccu.table_name AS target_table, ccu.column_name AS target_column, tc.constraint_name
881
- FROM information_schema.table_constraints tc
882
- JOIN information_schema.key_column_usage kcu
883
- ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
884
- JOIN information_schema.constraint_column_usage ccu
885
- ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
886
- WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1`, [options.schema]),
887
- client.query(`SELECT tc.table_name, tc.constraint_name, kcu.column_name
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 AND tc.table_schema = kcu.table_schema
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(`SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1`, [options.schema]),
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
- * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
1044
- * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
1045
- * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
1046
- * generate` derived DIFFERENT relation names from the same database.
1047
- * Exported for the parity unit test.
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
- const group = groups.get(row.constraint_name) ?? {
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.constraint_name, group);
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
- const relations = (0, introspect_js_1.buildRelationsFromForeignKeys)(foreignKeys, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
1076
- (0, introspect_js_1.addAutoManyToManyRelations)(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relations, columnFieldsByTable, unknownTypedFieldsByTable);
1077
- return relations;
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[tableName];
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
- * Split a SQL script into individual statements on top-level semicolons.
120
- *
121
- * A correct tokenizer, not a `split(';')`: a semicolon inside a single-quoted
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
- * Comment-only fragments are dropped; every returned statement is trimmed and
130
- * carries no trailing semicolon.
168
+ * @internal exported for tests.
131
169
  */
132
- export declare function splitSqlStatements(sql: string): string[];
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
  *