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
package/dist/cli/mcp.js CHANGED
@@ -3,11 +3,12 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import pg from 'pg';
5
5
  import { findMissingRelationIndexes } from '../index-advisor.js';
6
- import { addAutoManyToManyRelations, buildRelationsFromForeignKeys, isUnknownTsType, } from '../introspect.js';
6
+ import { deriveCatalogRelations } from '../introspect.js';
7
7
  import { QueryInterface, quoteIdent } from '../query/index.js';
8
- import { registerUtcTemporalParsers } from '../query/utils.js';
8
+ import { ownLookup, registerUtcTemporalParsers } from '../query/utils.js';
9
9
  import { isDateType, pgArrayType, pgTypeToTs, snakeToCamel, } from '../schema.js';
10
10
  import { listMigrationFiles } from './migrate.js';
11
+ import { assertNoPiiPredicates as assertNoPiiPredicatesShared } from './pii-predicate-guard.js';
11
12
  import { applyPiiTags, loadPiiTags } from './pii-tags.js';
12
13
  import { redactUrl } from './ui.js';
13
14
  /**
@@ -588,7 +589,11 @@ async function explainQuery(ctx, args) {
588
589
  throw jsonRpcError(-32602, err instanceof Error ? err.message : String(err));
589
590
  }
590
591
  // QueryInterface emits unqualified identifiers; pin search_path like Studio.
591
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
592
+ // The bound value's CONTENTS are parsed as an identifier list, so the name
593
+ // has to carry its own quotes: a raw `My.Schema` pins search_path to nothing
594
+ // and the statement below fails with `relation "..." does not exist`, while
595
+ // a raw mixed-case name silently case-folds onto a different schema.
596
+ await client.query(`SELECT set_config('search_path', $1, true)`, [quoteIdent(ctx.options.schema)]);
592
597
  const result = await client.query(`EXPLAIN (FORMAT JSON) ${deferred.sql}`, deferred.params);
593
598
  return {
594
599
  table: table.name,
@@ -598,17 +603,6 @@ async function explainQuery(ctx, args) {
598
603
  };
599
604
  });
600
605
  }
601
- /** Relation-filter wrappers whose body is a clause against the relation's target. */
602
- const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
603
- /**
604
- * Recursion bound for the PII guard walk. Reaching it REFUSES the request, it
605
- * is not a quiet stop: returning at the cap would mean a payload padded with
606
- * enough nested `NOT` wrappers walks the guard off the end of its own recursion
607
- * and then hands the untouched predicate to the builder. Sits far above the
608
- * builder's own depth-10 relation cap, so nothing buildable is refused for
609
- * depth alone.
610
- */
611
- const PII_GUARD_MAX_DEPTH = 32;
612
606
  /**
613
607
  * Refuse an `explain_query` that filters or sorts on a hidden column: one that
614
608
  * is PII-tagged, or one whose NAME matches {@link SECRET_NAME_PATTERN}.
@@ -630,9 +624,12 @@ const PII_GUARD_MAX_DEPTH = 32;
630
624
  * refuse the predicate. It matches the rule the rest of the codebase already
631
625
  * states: predicates on PII are allowed IN THE ORM because they return no
632
626
  * value, and that reasoning stops holding the moment the query's SELECTIVITY is
633
- * itself the reply. Studio drew the same line for the same reason
634
- * (`assertNoPiiPredicates`, and its `filters` param refuses even `isNull`,
635
- * because null-ness is an oracle too).
627
+ * itself the reply. Studio drew the same line for the same reason, which is why
628
+ * the WALK is now one shared module (`cli/pii-predicate-guard.ts`) rather than a
629
+ * second copy of it here: the two copies had the same job, the same name, and
630
+ * the same hole, and only a shared walk makes closing it once close it in both.
631
+ * What stays here is the part that is genuinely the MCP server's: the two
632
+ * reasons a column is hidden, and a JSON-RPC refusal.
636
633
  *
637
634
  * `select` is NOT refused: explain returns no rows, and naming a column reveals
638
635
  * nothing about its contents.
@@ -648,82 +645,39 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
648
645
  `query does not filter or sort on a PII column, and row estimates on such a column are an extraction ` +
649
646
  `oracle. Re-run \`turbine generate\`, or call explain_query without where/orderBy.`);
650
647
  }
651
- const assertWithinDepth = (depth) => {
652
- if (depth <= PII_GUARD_MAX_DEPTH)
653
- return;
654
- throw jsonRpcError(-32602, `Query is nested more than ${PII_GUARD_MAX_DEPTH} levels deep, past the point where the PII guard can ` +
655
- `prove it does not filter or sort on a tagged column, so it is refused. Flatten the query.`);
656
- };
657
- const refuse = (owner, column, why) => {
658
- throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
659
- `EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
660
- `reveals that value one character at a time. Filter on a visible column instead.`);
661
- };
662
- /**
663
- * Why this column may not appear in a predicate, or null when it may.
664
- *
665
- * The two reasons are the two `sample_rows` already refuses to FETCH
666
- * (`classifyHiddenColumns`), and they are deliberately the same set: a column
667
- * whose bytes are too sensitive to sample is too sensitive to binary-search
668
- * out of the planner. A column absent from the table is not judged here, the
669
- * builder rejects it by name a moment later.
670
- */
671
- const hiddenReason = (owner, column) => {
672
- if (owner.columns.some((col) => col.name === column && col.pii === true))
673
- return 'is PII-tagged';
674
- if (SECRET_NAME_PATTERN.test(column))
675
- return 'has a secret-looking name';
676
- return null;
677
- };
678
- const visitClause = (node, owner, depth) => {
679
- assertWithinDepth(depth);
680
- if (!owner || node === null || typeof node !== 'object')
681
- return;
682
- // `orderBy` accepts an array of single-key objects, and so does a `NOT`
683
- // list. Element order carries no nesting, so the depth is unchanged.
684
- if (Array.isArray(node)) {
685
- for (const item of node)
686
- visitClause(item, owner, depth);
687
- return;
688
- }
689
- for (const [key, value] of Object.entries(node)) {
690
- if (key === 'AND' || key === 'OR' || key === 'NOT') {
691
- visitClause(value, owner, depth + 1);
692
- continue;
693
- }
694
- const relation = Object.hasOwn(owner.relations, key) ? owner.relations[key] : undefined;
695
- if (relation) {
696
- visitRelationValue(value, metadata.tables[relation.to], depth + 1);
697
- continue;
698
- }
699
- // A predicate may name a column by its camelCase field OR by its real
700
- // column name; both compile to the same SQL, so both have to be checked.
701
- const column = Object.hasOwn(owner.columnMap, key) ? owner.columnMap[key] : key;
702
- const why = hiddenReason(owner, column);
703
- if (why)
704
- refuse(owner, column, why);
705
- }
706
- };
707
- /**
708
- * A relation predicate arrives either bare (`{ user: { email: {...} } }`) or
709
- * wrapped in a cardinality operator (`{ user: { is: { email: {...} } } }`),
710
- * and BOTH resolve against the relation's target. Walking the wrapper as if
711
- * `is` were a column of the target would skip the inner clause entirely, so
712
- * descend into every wrapper member AND into the node itself.
713
- */
714
- const visitRelationValue = (value, target, depth) => {
715
- assertWithinDepth(depth);
716
- if (!target || value === null || typeof value !== 'object')
717
- return;
718
- const node = value;
719
- for (const wrapper of RELATION_FILTER_WRAPPERS) {
720
- if (Object.hasOwn(node, wrapper))
721
- visitClause(node[wrapper], target, depth + 1);
722
- }
723
- visitClause(node, target, depth);
724
- };
725
- visitClause(args.where, table, 0);
726
- visitClause(args.orderBy, table, 0);
648
+ assertNoPiiPredicatesShared(args, table, {
649
+ metadata,
650
+ /**
651
+ * Why this column may not appear in a predicate, or null when it may.
652
+ *
653
+ * The two reasons are the two `sample_rows` already refuses to FETCH
654
+ * (`classifyHiddenColumns`), and they are deliberately the same set: a
655
+ * column whose bytes are too sensitive to sample is too sensitive to
656
+ * binary-search out of the planner. A column absent from the table is not
657
+ * judged here, the builder rejects it by name a moment later.
658
+ */
659
+ hiddenReason: (owner, column) => {
660
+ if (owner.columns.some((col) => col.name === column && col.pii === true))
661
+ return 'is PII-tagged';
662
+ if (SECRET_NAME_PATTERN.test(column))
663
+ return 'has a secret-looking name';
664
+ return null;
665
+ },
666
+ refuseColumn: (owner, column, why) => {
667
+ throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
668
+ `EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
669
+ `reveals that value one character at a time. Filter on a visible column instead.`);
670
+ },
671
+ refuseDepth: (maxDepth) => {
672
+ throw jsonRpcError(-32602, `Query is nested more than ${maxDepth} levels deep, past the point where the PII guard can ` +
673
+ `prove it does not filter or sort on a tagged column, so it is refused. Flatten the query.`);
674
+ },
675
+ refuseShape: (owner, key) => {
676
+ throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the ` +
677
+ `query does not filter or sort on a hidden column, and refuses it rather than guessing. ` +
678
+ `Remove it and explain the query without it.`);
679
+ },
680
+ });
727
681
  }
728
682
  /**
729
683
  * Extract the allowed findMany subset for explain_query (no `with` / raw SQL).
@@ -862,27 +816,103 @@ async function loadSchemaMetadata(client, options) {
862
816
  FROM information_schema.columns
863
817
  WHERE table_schema = $1
864
818
  ORDER BY table_name, ordinal_position`, [options.schema]),
865
- client.query(`SELECT tc.table_name, kcu.column_name
819
+ client.query(
820
+ // Joined on the table as well as the constraint name. Not because two
821
+ // primary keys can share a name (they cannot: a PK is backed by an INDEX,
822
+ // and index names ARE unique per schema, so Postgres itself refuses the
823
+ // second `CREATE TABLE ... CONSTRAINT pk_shared PRIMARY KEY` with
824
+ // `relation "pk_shared" already exists`, verified on PG 16) but because
825
+ // the join reads as if the name were the identity, which is what put the
826
+ // FOREIGN KEY query below one refactor away from a silent cross product.
827
+ // A foreign key has no backing index and so genuinely can collide.
828
+ `SELECT tc.table_name, kcu.column_name
866
829
  FROM information_schema.table_constraints tc
867
830
  JOIN information_schema.key_column_usage kcu
868
- ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
831
+ ON tc.constraint_name = kcu.constraint_name
832
+ AND tc.table_schema = kcu.table_schema
833
+ AND tc.table_name = kcu.table_name
869
834
  WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = $1
870
835
  ORDER BY tc.table_name, kcu.ordinal_position`, [options.schema]),
871
- client.query(`SELECT tc.table_name AS source_table, kcu.column_name AS source_column,
872
- ccu.table_name AS target_table, ccu.column_name AS target_column, tc.constraint_name
873
- FROM information_schema.table_constraints tc
874
- JOIN information_schema.key_column_usage kcu
875
- ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
876
- JOIN information_schema.constraint_column_usage ccu
877
- ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
878
- WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = $1`, [options.schema]),
879
- client.query(`SELECT tc.table_name, tc.constraint_name, kcu.column_name
836
+ // Foreign keys come from pg_catalog, not information_schema, and the reason
837
+ // is the same one written up over `SQL_FOREIGN_KEYS` in ../introspect.ts
838
+ // (KEEP THE TWO IN LOCKSTEP: this is a second copy of that query because
839
+ // introspect.ts does not export it, and mcp reads through its own pooled
840
+ // client inside a read-only transaction rather than opening the pool
841
+ // `introspect()` owns). The information_schema formulation this replaces
842
+ // joined key_column_usage (constrained columns) to constraint_column_usage
843
+ // (referenced columns) on the constraint NAME, which is wrong twice:
844
+ //
845
+ // 1. Those two column lists have no positional link, so the join is an
846
+ // N-by-N cross product: a two-column FK came back as four rows and
847
+ // grouped into four AND-ed correlations, two of them pairing the wrong
848
+ // columns. Every read through the relation silently returned nothing.
849
+ // 2. A constraint name is unique per TABLE (conrelid, conname), not per
850
+ // schema, so two tables may both have a `shared_fk`. This is specific
851
+ // to foreign keys: a PRIMARY KEY or UNIQUE constraint is backed by an
852
+ // index and index names ARE schema-unique, so Postgres refuses that
853
+ // collision outright, while an FK has no backing index and the
854
+ // collision is legal. On the name alone the two cross: measured on PG
855
+ // 16, two tables with a `shared_fk` produced EIGHT rows instead of two,
856
+ // which grouped by name into one entry, so one table lost its relation
857
+ // entirely and the other pointed at a column its target does not have
858
+ // (42703 at query time). Which one won depended on catalog row order.
859
+ //
860
+ // conkey and confkey are parallel arrays, so unnesting BOTH `WITH
861
+ // ORDINALITY` and joining on the ordinal IS the pairing, exactly; the OID is
862
+ // the grouping key because it is unique catalog-wide.
863
+ //
864
+ // The target is constrained to the SAME schema, which is what the old query
865
+ // did implicitly (it joined on ccu.table_schema). Keeping it explicit
866
+ // matters: `buildRelations` resolves targets by bare name against the
867
+ // introspected table set, so a cross-schema reference to a same-named table
868
+ // would silently bind to the local one.
869
+ //
870
+ // `conparentid = 0` (declared constraints only) and the by-NAME ordering are
871
+ // both part of the lockstep: one FK against a partitioned table otherwise
872
+ // yields an extra phantom relation per partition, and ordering by `con.oid`
873
+ // makes relation naming a function of DDL execution order rather than of the
874
+ // schema. Both are written up at length over SQL_FOREIGN_KEYS.
875
+ client.query(`SELECT
876
+ con.oid::text AS constraint_oid,
877
+ con.conname AS constraint_name,
878
+ src.relname AS source_table,
879
+ src_att.attname AS source_column,
880
+ tgt.relname AS target_table,
881
+ tgt_att.attname AS target_column
882
+ FROM pg_catalog.pg_constraint con
883
+ JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
884
+ JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
885
+ JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
886
+ JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
887
+ JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS sk(attnum, ord) ON TRUE
888
+ JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS tk(attnum, ord) ON tk.ord = sk.ord
889
+ JOIN pg_catalog.pg_attribute src_att
890
+ ON src_att.attrelid = con.conrelid AND src_att.attnum = sk.attnum
891
+ JOIN pg_catalog.pg_attribute tgt_att
892
+ ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = tk.attnum
893
+ WHERE con.contype = 'f'
894
+ AND con.conparentid = 0
895
+ AND src_ns.nspname = $1
896
+ AND tgt_ns.nspname = src_ns.nspname
897
+ ORDER BY src.relname, con.conname, sk.ord`, [options.schema]),
898
+ client.query(
899
+ // Joined on the table too, same reasoning as the primary-key query above:
900
+ // a UNIQUE constraint is index-backed and therefore cannot collide, and
901
+ // the join says so.
902
+ `SELECT tc.table_name, tc.constraint_name, kcu.column_name
880
903
  FROM information_schema.table_constraints tc
881
904
  JOIN information_schema.key_column_usage kcu
882
- ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
905
+ ON tc.constraint_name = kcu.constraint_name
906
+ AND tc.table_schema = kcu.table_schema
907
+ AND tc.table_name = kcu.table_name
883
908
  WHERE tc.constraint_type = 'UNIQUE' AND tc.table_schema = $1
884
909
  ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position`, [options.schema]),
885
- client.query(`SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1`, [options.schema]),
910
+ client.query(
911
+ // Ordered for the same reason SQL_INDEXES is: these rows feed the
912
+ // unique-set detection that decides hasOne-versus-hasMany, and they are
913
+ // reported verbatim by the schema tools, so physical catalog order must
914
+ // not leak into either answer. Index names are unique per schema.
915
+ `SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`, [options.schema]),
886
916
  client.query(`SELECT t.typname, e.enumlabel
887
917
  FROM pg_type t
888
918
  JOIN pg_enum e ON t.oid = e.enumtypid
@@ -969,7 +999,7 @@ async function loadSchemaMetadata(client, options) {
969
999
  labels.push(row.enumlabel);
970
1000
  enums[row.typname] = labels;
971
1001
  }
972
- const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows, enums);
1002
+ const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows, uniqueByTable, indexesByTable, enums);
973
1003
  const tables = {};
974
1004
  for (const tableName of tableNames) {
975
1005
  const columns = columnsByTable.get(tableName) ?? [];
@@ -1031,20 +1061,36 @@ async function loadSchemaMetadata(client, options) {
1031
1061
  return { metadata, piiTags };
1032
1062
  }
1033
1063
  /**
1034
- * Group raw FK rows into constraint-level entries and delegate relation
1035
- * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
1036
- * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
1037
- * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
1038
- * generate` derived DIFFERENT relation names from the same database.
1039
- * Exported for the parity unit test.
1064
+ * Group raw FK rows into constraint-level entries and delegate relation naming
1065
+ * to `deriveCatalogRelations` in ../introspect.ts, the SAME entry point
1066
+ * `turbine generate` goes through. MCP introspects for itself (it cannot assume
1067
+ * generated metadata exists), so any divergence here is a divergence a live MCP
1068
+ * client trips over: it reads a relation name out of the MCP schema tool, passes
1069
+ * it back in a query, and the core builder rejects it.
1070
+ *
1071
+ * MCP used to assemble the pipeline by hand and drifted twice over, both times
1072
+ * by OMITTING an optional argument, which is silently type-correct and changes
1073
+ * the answer: without `uniqueSetsByTable` a UNIQUE foreign key came back as
1074
+ * `users.profiles` (hasMany) where generate said `users.profile` (hasOne), and
1075
+ * without `uniqueIndexColsByTable` every Prisma-style PK-less junction lost its
1076
+ * auto-m2m relations. Passing the whole input set to one shared function is what
1077
+ * makes those two failures impossible rather than merely fixed.
1078
+ *
1079
+ * `uniqueByTable` and `indexesByTable` are therefore REQUIRED parameters, not
1080
+ * optional ones: an optional catalog input is exactly how the hasOne flip went
1081
+ * missing here in the first place. Exported for the parity unit test.
1040
1082
  */
1041
- export function buildRelations(tableNames, columnsByTable, pkByTable, rows, enums = {}) {
1083
+ export function buildRelations(tableNames, columnsByTable, pkByTable, rows, uniqueByTable, indexesByTable, enums = {}) {
1042
1084
  const tableSet = new Set(tableNames);
1043
1085
  const groups = new Map();
1044
1086
  for (const row of rows) {
1045
1087
  if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
1046
1088
  continue;
1047
- const group = groups.get(row.constraint_name) ?? {
1089
+ // Keyed on the constraint OID, never the name: see ForeignKeyRow. The query
1090
+ // orders by (source table, constraint name, source ordinal), so the two
1091
+ // column lists stay paired and the walk order does not depend on the order
1092
+ // the constraints happened to be created in.
1093
+ const group = groups.get(row.constraint_oid) ?? {
1048
1094
  sourceTable: row.source_table,
1049
1095
  sourceColumns: [],
1050
1096
  targetTable: row.target_table,
@@ -1053,20 +1099,17 @@ export function buildRelations(tableNames, columnsByTable, pkByTable, rows, enum
1053
1099
  };
1054
1100
  group.sourceColumns.push(row.source_column);
1055
1101
  group.targetColumns.push(row.target_column);
1056
- groups.set(row.constraint_name, group);
1057
- }
1058
- const foreignKeys = [...groups.values()];
1059
- const columnFieldsByTable = new Map();
1060
- const unknownTypedFieldsByTable = new Map();
1061
- for (const [tbl, cols] of columnsByTable) {
1062
- columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
1063
- // Enum-typed columns also report tsType 'unknown', but the generated type
1064
- // layer gives them a concrete union, only json/jsonb qualify as shadows.
1065
- unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
1102
+ groups.set(row.constraint_oid, group);
1066
1103
  }
1067
- const relations = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
1068
- addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relations, columnFieldsByTable, unknownTypedFieldsByTable);
1069
- return relations;
1104
+ return deriveCatalogRelations({
1105
+ tableNames,
1106
+ foreignKeys: [...groups.values()],
1107
+ pkByTable,
1108
+ columnsByTable,
1109
+ uniqueByTable,
1110
+ indexesByTable,
1111
+ enums,
1112
+ });
1070
1113
  }
1071
1114
  async function estimateRows(client, schema) {
1072
1115
  const result = await client.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
@@ -1079,7 +1122,7 @@ async function estimateRows(client, schema) {
1079
1122
  return counts;
1080
1123
  }
1081
1124
  function requireTable(metadata, tableName) {
1082
- const table = metadata.tables[tableName];
1125
+ const table = ownLookup(metadata.tables, tableName);
1083
1126
  if (!table) {
1084
1127
  const available = Object.keys(metadata.tables).join(', ') || '(none)';
1085
1128
  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
  *