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
@@ -28,6 +28,7 @@ exports.parsePlainUniqueIndexColumns = parsePlainUniqueIndexColumns;
28
28
  exports.detectUniqueForeignKeySets = detectUniqueForeignKeySets;
29
29
  exports.buildRelationsFromForeignKeys = buildRelationsFromForeignKeys;
30
30
  exports.addAutoManyToManyRelations = addAutoManyToManyRelations;
31
+ exports.deriveCatalogRelations = deriveCatalogRelations;
31
32
  exports.deriveEngineRelations = deriveEngineRelations;
32
33
  const pg_1 = __importDefault(require("pg"));
33
34
  const dialect_js_1 = require("./dialect.js");
@@ -93,22 +94,88 @@ const SQL_PRIMARY_KEYS = `
93
94
  AND tc.table_schema = $1
94
95
  ORDER BY tc.table_name, kcu.ordinal_position
95
96
  `;
97
+ // Foreign keys, read from pg_catalog rather than information_schema.
98
+ //
99
+ // This CANNOT be expressed against information_schema. The obvious formulation
100
+ // joins key_column_usage (the constrained columns) to constraint_column_usage
101
+ // (the referenced columns) on the constraint NAME, and that is wrong twice:
102
+ //
103
+ // 1. The two column lists have no positional link there, so the join is an
104
+ // N-by-N cross product. A composite FK cities(country, region_code) ->
105
+ // regions(country, code) came back as four rows, and grouping them gave
106
+ // foreignKey ['country','country','region_code','region_code'] against
107
+ // referenceKey ['country','code','country','code'] - four AND-ed
108
+ // correlations, two of them pairing the wrong columns. Every read through
109
+ // the relation silently returned nothing, with no error.
110
+ // 2. Postgres only requires a constraint name to be unique per TABLE
111
+ // (conrelid, conname), so two tables in one schema may both have a
112
+ // `shared_fk`. Joining on the name alone crosses them: each table's FK
113
+ // picks up the other's referenced column, so one relation is lost and the
114
+ // other points at a column that does not exist on its target (42703 at
115
+ // query time). Which one won depended on catalog row order.
116
+ //
117
+ // conkey and confkey are parallel arrays, so unnesting BOTH `WITH ORDINALITY`
118
+ // and joining on the ordinal is the pairing, exactly. The constraint OID is the
119
+ // grouping key: it is unique catalog-wide, unlike the name.
120
+ //
121
+ // `target_schema` is selected so a cross-schema reference can be recognized
122
+ // rather than mistaken for a same-named local table (see the FK grouping loop).
123
+ // Referential actions come from the same row, which also removes the separate
124
+ // name-keyed actions query that shared bug 2.
125
+ //
126
+ // `conparentid = 0` keeps only DECLARED constraints. Declaring one foreign key
127
+ // against a PARTITIONED table makes Postgres materialize an extra constraint per
128
+ // partition, each pointing at that partition rather than at the parent, and each
129
+ // with `conparentid` set to the declared constraint's OID. Without this filter a
130
+ // single `items(bucket_id) REFERENCES buckets(id)` against a two-partition
131
+ // `buckets` introspected as THREE belongsTo relations (`bucket`, `bucketsLo`,
132
+ // `bucketsHi`), measured on PG 16. The two extras are fully generated, typed and
133
+ // autocompleting, and resolve to `null` for every row whose parent lives in the
134
+ // other partition, so they read as an intermittently-empty relation rather than
135
+ // as an error. The same clone exists when the REFERENCING side is partitioned
136
+ // (verified: `conparentid` is set there too), where the partition inherits the
137
+ // parent table's declared FK and needs no relation of its own.
138
+ //
139
+ // ORDER BY is (source table, constraint name), NOT `con.oid`. The OID is
140
+ // ALLOCATION order, so it encodes the order the DDL happened to run in, and the
141
+ // FK walk order decides which relation wins a contested NAME: relation naming in
142
+ // buildRelationsFromForeignKeys accumulates `taken` names as it walks, and the
143
+ // loser gets a `Rel` suffix. On a `users` table with both a `profiles` child
144
+ // (UNIQUE FK, so hasOne) and a `profile` child (plain FK, so hasMany), both
145
+ // derive the name `profile`, and the two creation orders produced
146
+ // `users.profile = hasOne -> profiles` versus `users.profile = hasMany ->
147
+ // profile`. Same logical schema, different cardinality and a different TABLE
148
+ // behind the same relation name, so a database restored from a dump disagreed
149
+ // with one built by running the migrations. Sorting by name makes the walk a
150
+ // function of the schema instead of its history. (conrelid, conname) is unique
151
+ // in Postgres and relname is unique per namespace, so the pair is a total order
152
+ // here, and sk.ord still pairs conkey to confkey within a constraint.
96
153
  const SQL_FOREIGN_KEYS = `
97
154
  SELECT
98
- tc.table_name AS source_table,
99
- kcu.column_name AS source_column,
100
- ccu.table_name AS target_table,
101
- ccu.column_name AS target_column,
102
- tc.constraint_name
103
- FROM information_schema.table_constraints tc
104
- JOIN information_schema.key_column_usage kcu
105
- ON tc.constraint_name = kcu.constraint_name
106
- AND tc.table_schema = kcu.table_schema
107
- JOIN information_schema.constraint_column_usage ccu
108
- ON tc.constraint_name = ccu.constraint_name
109
- AND tc.table_schema = ccu.table_schema
110
- WHERE tc.constraint_type = 'FOREIGN KEY'
111
- AND tc.table_schema = $1
155
+ con.oid::text AS constraint_oid,
156
+ con.conname AS constraint_name,
157
+ src.relname AS source_table,
158
+ src_att.attname AS source_column,
159
+ tgt_ns.nspname AS target_schema,
160
+ tgt.relname AS target_table,
161
+ tgt_att.attname AS target_column,
162
+ con.confdeltype,
163
+ con.confupdtype
164
+ FROM pg_catalog.pg_constraint con
165
+ JOIN pg_catalog.pg_class src ON src.oid = con.conrelid
166
+ JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace
167
+ JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid
168
+ JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
169
+ JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS sk(attnum, ord) ON TRUE
170
+ JOIN LATERAL unnest(con.confkey) WITH ORDINALITY AS tk(attnum, ord) ON tk.ord = sk.ord
171
+ JOIN pg_catalog.pg_attribute src_att
172
+ ON src_att.attrelid = con.conrelid AND src_att.attnum = sk.attnum
173
+ JOIN pg_catalog.pg_attribute tgt_att
174
+ ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = tk.attnum
175
+ WHERE con.contype = 'f'
176
+ AND con.conparentid = 0
177
+ AND src_ns.nspname = $1
178
+ ORDER BY src.relname, con.conname, sk.ord
112
179
  `;
113
180
  const SQL_UNIQUE_CONSTRAINTS = `
114
181
  SELECT
@@ -124,19 +191,19 @@ const SQL_UNIQUE_CONSTRAINTS = `
124
191
  AND tc.table_schema = $1
125
192
  ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position
126
193
  `;
194
+ // Both of the next two queries are ordered for the same reason the FK query is:
195
+ // their rows land in metadata.ts as ARRAYS (`indexes`, `checks`), so an unordered
196
+ // read makes the generated file a function of physical catalog order rather than
197
+ // of the schema. A `DROP INDEX` + `CREATE INDEX` of an unchanged index, or a
198
+ // VACUUM FULL, is enough to permute them, which shows up as generated-file diff
199
+ // noise between one developer's machine and CI and defeats the byte-identical
200
+ // claim that makes a regenerate safe to commit. Index and check constraint names
201
+ // are both unique per schema, so each sort is total.
127
202
  const SQL_INDEXES = `
128
203
  SELECT tablename, indexname, indexdef
129
204
  FROM pg_indexes
130
205
  WHERE schemaname = $1
131
- `;
132
- // Foreign-key referential actions (ON DELETE / ON UPDATE) live in pg_catalog,
133
- // not information_schema. Keyed by constraint name for join with SQL_FOREIGN_KEYS.
134
- const SQL_FK_ACTIONS = `
135
- SELECT con.conname, con.confdeltype, con.confupdtype
136
- FROM pg_constraint con
137
- JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
138
- WHERE con.contype = 'f'
139
- AND n.nspname = $1
206
+ ORDER BY tablename, indexname
140
207
  `;
141
208
  // CHECK constraints (contype = 'c'). NOT NULL is stored as attnotnull, not a
142
209
  // check constraint, so it never appears here.
@@ -147,6 +214,7 @@ const SQL_CHECKS = `
147
214
  JOIN pg_catalog.pg_namespace n ON n.oid = con.connamespace
148
215
  WHERE con.contype = 'c'
149
216
  AND n.nspname = $1
217
+ ORDER BY rel.relname, con.conname
150
218
  `;
151
219
  // Views (relkind 'v'), column metadata comes free from information_schema.columns.
152
220
  const SQL_VIEWS = `
@@ -334,12 +402,11 @@ async function introspectPostgresCatalog(options) {
334
402
  });
335
403
  try {
336
404
  // Run all information_schema queries in parallel
337
- const [tablesResult, columnsResult, pkResult, fkResult, fkActionsResult, uniqueResult, indexResult, checkResult, enumResult,] = await Promise.all([
405
+ const [tablesResult, columnsResult, pkResult, fkResult, uniqueResult, indexResult, checkResult, enumResult] = await Promise.all([
338
406
  pool.query(SQL_TABLES, [schema]),
339
407
  pool.query(SQL_COLUMNS, [schema]),
340
408
  pool.query(SQL_PRIMARY_KEYS, [schema]),
341
409
  pool.query(SQL_FOREIGN_KEYS, [schema]),
342
- pool.query(SQL_FK_ACTIONS, [schema]),
343
410
  pool.query(SQL_UNIQUE_CONSTRAINTS, [schema]),
344
411
  pool.query(SQL_INDEXES, [schema]),
345
412
  pool.query(SQL_CHECKS, [schema]),
@@ -362,14 +429,6 @@ async function introspectPostgresCatalog(options) {
362
429
  viewNameSet.add(r.table_name);
363
430
  matviewColumnRows.push(...matviewColsResult.rows);
364
431
  }
365
- // constraint_name → { onDelete, onUpdate } referential actions.
366
- const fkActions = new Map();
367
- for (const row of fkActionsResult.rows) {
368
- fkActions.set(row.conname, {
369
- onDelete: pgConfActionToReferential(row.confdeltype),
370
- onUpdate: pgConfActionToReferential(row.confupdtype),
371
- });
372
- }
373
432
  // Filter tables by include/exclude + default bookkeeping-table exclusions
374
433
  // (F12). Views/matviews join the base tables as candidates so the filters
375
434
  // apply uniformly.
@@ -500,20 +559,39 @@ async function introspectPostgresCatalog(options) {
500
559
  enums[row.typname].push(row.enumlabel);
501
560
  }
502
561
  // ----- Build foreign key map -----
503
- // Group FK rows by constraint_name to correctly handle multi-column composite FKs.
504
- // Each constraint becomes one FKEntry with arrays of columns.
562
+ // Group FK rows by constraint OID, NOT by constraint name: Postgres only
563
+ // requires a name to be unique per table, so two tables in one schema may
564
+ // both own a `shared_fk` and grouping by name merges them into one entry
565
+ // (see SQL_FOREIGN_KEYS). Rows arrive ordered by (oid, ordinal), so pushing
566
+ // in arrival order preserves the column pairing the query established.
567
+ //
568
+ // A reference to a table in ANOTHER schema is skipped DELIBERATELY, and by
569
+ // its schema rather than by tableSet membership. Generated metadata keys
570
+ // tables by bare name and emits unqualified SQL, so a relation to
571
+ // `other.things` has no table to point at; worse, checking only
572
+ // `tableSet.has(target_table)` would bind it to a same-named table in THIS
573
+ // schema and generate a relation that reads the wrong table entirely.
574
+ // Skipped references are reported once, so they are visible rather than
575
+ // silently absent.
505
576
  const fkGroups = new Map();
577
+ const crossSchemaRefs = [];
506
578
  for (const row of fkResult.rows) {
507
- if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
579
+ if (!tableSet.has(row.source_table))
508
580
  continue;
509
- const key = row.constraint_name;
581
+ if (row.target_schema !== schema) {
582
+ crossSchemaRefs.push(`${row.source_table}.${row.source_column} -> ${row.target_schema}.${row.target_table}.${row.target_column}`);
583
+ continue;
584
+ }
585
+ if (!tableSet.has(row.target_table))
586
+ continue;
587
+ const key = row.constraint_oid;
510
588
  if (!fkGroups.has(key)) {
511
589
  fkGroups.set(key, {
512
590
  sourceTable: row.source_table,
513
591
  sourceColumns: [],
514
592
  targetTable: row.target_table,
515
593
  targetColumns: [],
516
- constraintName: key,
594
+ constraintName: row.constraint_name,
517
595
  });
518
596
  }
519
597
  const entry = fkGroups.get(key);
@@ -521,60 +599,43 @@ async function introspectPostgresCatalog(options) {
521
599
  entry.targetColumns.push(row.target_column);
522
600
  }
523
601
  const foreignKeys = Array.from(fkGroups.values());
524
- // ----- Build relations from foreign keys -----
525
- // Delegated to the pure, unit-testable builder. Relation names are derived
526
- // per-FK-column when several FKs point at the same target, and every name
527
- // is collision-checked against the table's scalar column fields so a
528
- // relation can never shadow a column (which generated unsound types and
529
- // made both surfaces unusable).
530
- const columnFieldsByTable = new Map();
531
- const unknownTypedFieldsByTable = new Map();
532
- for (const [tbl, cols] of columnsByTable) {
533
- columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
534
- // Enum-typed columns also report tsType 'unknown' here, but generate.ts
535
- // gives them a concrete union type, a shadow of one was type-broken on
536
- // main, so only genuine json/jsonb columns qualify as historical shadows.
537
- unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
602
+ if (crossSchemaRefs.length > 0) {
603
+ console.warn(`[turbine] Skipped ${crossSchemaRefs.length} foreign key(s) referencing a table outside schema "${schema}": ` +
604
+ `${crossSchemaRefs.join(', ')}. Generated clients address tables by bare name within one schema, so no ` +
605
+ `relation is emitted for these. Introspect the other schema separately, or add the target table to this one.`);
538
606
  }
539
- // F2: unless the caller opts out, detect child FK column sets that a unique
540
- // constraint / plain unique index exactly covers, so the reverse relation is
541
- // emitted as a one-to-one (`hasOne`) instead of `hasMany`.
542
- const uniqueSetsByTable = options.legacyToManyUniques
543
- ? undefined
544
- : detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable);
545
- const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable, uniqueSetsByTable);
546
- // ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
547
- //
548
- // Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
549
- // junction, but a `enrollments(student_id, course_id, grade, enrolled_at)`
550
- // table is a first-class entity, not a join table. Prisma and Drizzle both
551
- // require explicit m2m declaration for exactly this reason.
552
- //
553
- // We only treat a table J as a PURE junction when ALL of these hold:
554
- // 1. J's primary key is exactly two columns.
555
- // 2. J has exactly two FKs, each single-column.
556
- // 3. Each FK's source column is one of J's two PK columns (the PK *is* the
557
- // two FK columns, no surrogate PK, no extra identity).
558
- // 4. The two FKs target two DISTINCT tables (A and B).
559
- // 5. J has no columns beyond those two FK/PK columns (no payload columns
560
- // like `grade` or `created_at`).
561
- //
562
- // For such a J linking A and B we ADD a `manyToMany` relation on A → B and
563
- // symmetrically on B → A, both routed `through` J. The existing belongsTo /
564
- // hasMany relations derived from J's FKs are left untouched, this block
565
- // never removes or renames anything. Naming/collision handling lives in the
566
- // shared addAutoManyToManyRelations helper.
567
- //
568
- // Prisma's implicit m2m junctions have no primary key (just a two-column
569
- // UNIQUE index over the FK columns), so pass the introspected two-column
570
- // unique indexes as the fallback junction-key source.
571
- const uniqueIndexColsByTable = new Map();
572
- for (const [tbl, idxs] of indexesByTable) {
573
- const twoColUniques = idxs.filter((idx) => idx.unique && idx.columns.length === 2).map((idx) => idx.columns);
574
- if (twoColUniques.length > 0)
575
- uniqueIndexColsByTable.set(tbl, twoColUniques);
607
+ // Referential actions (ON DELETE / ON UPDATE) per constraint. Keyed
608
+ // "<table>::<constraint>" because the NAME alone is not unique (the same
609
+ // collision SQL_FOREIGN_KEYS documents); buildRelationsFromForeignKeys
610
+ // prefers that key and falls back to the bare name for callers that build
611
+ // the map from a code-first schema, where names are synthesized per table.
612
+ const fkActions = new Map();
613
+ for (const row of fkResult.rows) {
614
+ fkActions.set(`${row.source_table}::${row.constraint_name}`, {
615
+ onDelete: pgConfActionToReferential(row.confdeltype),
616
+ onUpdate: pgConfActionToReferential(row.confupdtype),
617
+ });
576
618
  }
577
- addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable, uniqueIndexColsByTable);
619
+ // ----- Build relations from foreign keys -----
620
+ // Delegated to the shared catalog derivation, which `turbine mcp` also
621
+ // calls. Relation names are derived per-FK-column when several FKs point at
622
+ // the same target, every name is collision-checked against the table's
623
+ // scalar column fields so a relation can never shadow a column (which
624
+ // generated unsound types and made both surfaces unusable), a UNIQUE FK
625
+ // flips the reverse side to `hasOne`, and pure junction tables additionally
626
+ // get a `manyToMany` on each side. See deriveCatalogRelations for why the
627
+ // whole pipeline is one function rather than a call site per surface.
628
+ const relationsByTable = deriveCatalogRelations({
629
+ tableNames,
630
+ foreignKeys,
631
+ pkByTable,
632
+ columnsByTable,
633
+ uniqueByTable,
634
+ indexesByTable,
635
+ enums,
636
+ fkActions,
637
+ legacyToManyUniques: options.legacyToManyUniques,
638
+ });
578
639
  // ----- Assemble TableMetadata for each table -----
579
640
  const tables = {};
580
641
  for (const tableName of tableNames) {
@@ -939,7 +1000,24 @@ function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActio
939
1000
  takenFor(fk.sourceTable).add(belongsToName);
940
1001
  assignedFor(fk.sourceTable).add(belongsToName);
941
1002
  // Referential actions (omit the 'no action' default to keep metadata lean).
942
- const actions = fkActions?.get(fk.constraintName);
1003
+ // A constraint NAME is only unique per table in Postgres, so the catalog
1004
+ // introspector keys this map "<table>::<constraint>". Callers that
1005
+ // synthesize names from a code-first schema key it by bare name, so both
1006
+ // spellings resolve.
1007
+ //
1008
+ // The bare-name fallback is LIVE, not defensive: `schemaDefToMetadata` keys
1009
+ // its map by the synthesized `<table>_<column>_fkey` alone, so the qualified
1010
+ // lookup always misses there and this second lookup is the ONLY thing that
1011
+ // carries a `defineSchema` relation's onDelete/onUpdate into the metadata.
1012
+ // Deleting it would silently drop referential actions from every code-first
1013
+ // schema (verified: a `references: { onDelete: 'cascade' }` resolves through
1014
+ // this branch and through no other). It is safe for that producer precisely
1015
+ // because the name it synthesizes already embeds the source table, so a bare
1016
+ // hit cannot belong to a different table's constraint. A future producer
1017
+ // emitting TABLE-AGNOSTIC constraint names would break that property and
1018
+ // could mis-attribute one table's ON DELETE to another's relation; such a
1019
+ // producer must key the map "<table>::<constraint>" like the catalog reader.
1020
+ const actions = fkActions?.get(`${fk.sourceTable}::${fk.constraintName}`) ?? fkActions?.get(fk.constraintName) ?? undefined;
943
1021
  const actionFields = {};
944
1022
  if (actions?.onDelete && actions.onDelete !== 'no action')
945
1023
  actionFields.onDelete = actions.onDelete;
@@ -1111,6 +1189,59 @@ function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, columnNa
1111
1189
  addM2M(fkB, fkA); // B → A
1112
1190
  }
1113
1191
  }
1192
+ /**
1193
+ * One-stop relation derivation for every surface that reads a live PostgreSQL
1194
+ * CATALOG: `turbine generate` (via {@link introspectPostgresCatalog}) and the
1195
+ * MCP server, which introspects for itself because it cannot assume generated
1196
+ * metadata exists.
1197
+ *
1198
+ * THE REASON THIS IS ONE FUNCTION and not two call sites: the pipeline it drives
1199
+ * is `buildRelationsFromForeignKeys` (five parameters, two optional) plus
1200
+ * `addAutoManyToManyRelations` (eight parameters, three optional), and every
1201
+ * optional one CHANGES THE ANSWER while omitting it stays silently type-correct.
1202
+ * Hand-mirroring them drifted exactly that way: MCP passed four arguments and so
1203
+ * never received `uniqueSetsByTable`, which meant a UNIQUE foreign key produced
1204
+ * `users.profile` (hasOne) under `turbine generate` and `users.profiles`
1205
+ * (hasMany) under `turbine mcp`, against the same database. An MCP client
1206
+ * following its own schema tool then queried `with: { profiles: true }` and got
1207
+ * `TURBINE_E005 Unknown relation`. MCP also omitted `uniqueIndexColsByTable`,
1208
+ * losing every auto-m2m relation through a Prisma-style PK-less junction. Adding
1209
+ * an argument here now reaches both surfaces or neither.
1210
+ *
1211
+ * The engine introspectors (SQLite / MySQL / MSSQL) keep their own
1212
+ * {@link deriveEngineRelations} because they deliberately do NOT do the
1213
+ * unique-FK → `hasOne` flip.
1214
+ */
1215
+ function deriveCatalogRelations(inputs) {
1216
+ const { tableNames, foreignKeys, pkByTable, columnsByTable, uniqueByTable, indexesByTable, enums } = inputs;
1217
+ const columnFieldsByTable = new Map();
1218
+ const unknownTypedFieldsByTable = new Map();
1219
+ for (const [tbl, cols] of columnsByTable) {
1220
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
1221
+ // Enum-typed columns also report tsType 'unknown' here, but generate.ts
1222
+ // gives them a concrete union type, so a shadow of one is type-broken and
1223
+ // must NOT be preserved as a historical json/jsonb shadow.
1224
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
1225
+ }
1226
+ // F2: unless the caller opts out, detect child FK column sets that a unique
1227
+ // constraint / plain unique index exactly covers, so the reverse relation is
1228
+ // emitted as a one-to-one (`hasOne`) instead of `hasMany`.
1229
+ const uniqueSetsByTable = inputs.legacyToManyUniques
1230
+ ? undefined
1231
+ : detectUniqueForeignKeySets(pkByTable, uniqueByTable, indexesByTable);
1232
+ const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, inputs.fkActions, unknownTypedFieldsByTable, uniqueSetsByTable);
1233
+ // Prisma's implicit m2m junctions have no primary key (just a two-column
1234
+ // UNIQUE index over the FK columns), so pass the introspected two-column
1235
+ // unique indexes as the fallback junction-key source.
1236
+ const uniqueIndexColsByTable = new Map();
1237
+ for (const [tbl, idxs] of indexesByTable) {
1238
+ const twoColUniques = idxs.filter((idx) => idx.unique && idx.columns.length === 2).map((idx) => idx.columns);
1239
+ if (twoColUniques.length > 0)
1240
+ uniqueIndexColsByTable.set(tbl, twoColUniques);
1241
+ }
1242
+ addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable, uniqueIndexColsByTable);
1243
+ return relationsByTable;
1244
+ }
1114
1245
  /**
1115
1246
  * One-stop relation derivation for the engine introspectors (SQLite / MySQL /
1116
1247
  * MSSQL): filters the FK list to the introspected table set, seeds the
package/dist/cjs/mssql.js CHANGED
@@ -218,6 +218,16 @@ function augmentMssqlError(err) {
218
218
  const m = /(?:constraint|index)\s+'([^']+)'/i.exec(msg) ?? /'([^']+)'/.exec(msg);
219
219
  if (m?.[1])
220
220
  target.constraint = m[1];
221
+ // The message ends `The duplicate key value is (alice@example.com).`, i.e.
222
+ // SQL Server reports the conflicting ROW VALUE in the message text and has
223
+ // no `detail` field for 'safe' mode to redact, so this error used to reach
224
+ // `.cause` verbatim. Flag it so the message is withheld in 'safe' mode
225
+ // (see markValueBearingMessage); the constraint NAME is already captured
226
+ // above and survives in both modes.
227
+ //
228
+ // Deliberately only 2627/2601: 547 (FK/CHECK conflict) and 515 (NULL into
229
+ // a non-nullable column) name the database/table/column and never a value.
230
+ (0, errors_js_1.markValueBearingMessage)(err);
221
231
  return err;
222
232
  }
223
233
  // 547 = FOREIGN KEY / CHECK constraint conflict (message distinguishes them).
@@ -535,6 +545,26 @@ exports.mssqlDialect = {
535
545
  quoteIdentifier(name) {
536
546
  return `[${name.replace(/]/g, ']]')}]`;
537
547
  },
548
+ /**
549
+ * T-SQL's `LIKE` has a FOURTH metacharacter the SQL-standard set does not:
550
+ * `[` opens a character class (`[abc]`, `[a-z]`, `[^x]`). So the shared
551
+ * `escapeLike` left `contains: '[draft]'` meaning "contains any one of d, r,
552
+ * a, f or t" here, while PostgreSQL, MySQL and SQLite all read the value as a
553
+ * literal (verified: none of the other three treat `[` specially). Not
554
+ * injection, the operand is still a bound parameter; a wrong ANSWER, and one
555
+ * that gets broader the longer the bracketed text is.
556
+ *
557
+ * Only `[` needs escaping. A `]` outside a class is a literal to T-SQL, and
558
+ * escaping the opener is enough to stop a class from ever being opened, so
559
+ * `]` is deliberately left alone (escaping it would be harmless but is not
560
+ * required, and the smaller pattern is the one that stays obviously correct).
561
+ * The prefix is the same backslash the emitted `ESCAPE '\'` clause names, and
562
+ * the backslash pass runs FIRST so an escape character in the value is itself
563
+ * escaped before any is added.
564
+ */
565
+ escapeLikePattern(value) {
566
+ return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_').replace(/\[/g, '\\[');
567
+ },
538
568
  // SQL Server aggregate casts: COUNT → INT, AVG/float → FLOAT.
539
569
  jsonWireRule(columnType) {
540
570
  const t = columnType.toLowerCase();
@@ -564,6 +594,17 @@ exports.mssqlDialect = {
564
594
  },
565
595
  };
566
596
  }
597
+ // NO json rule here, and that is a checked answer rather than an omission.
598
+ // SQL Server on the supported floor (2016 through 2022) has no JSON column
599
+ // type: `mssqlColumnType` maps JSON/JSONB to NVARCHAR(MAX), and
600
+ // introspection reports the carrier as `nvarchar`. FOR JSON PATH escapes an
601
+ // nvarchar cell as a JSON *string* (only `buildRelationSubquery`'s nested
602
+ // relations are JSON_QUERY-wrapped, never a scalar column), so parsing the
603
+ // row hands back exactly the string the driver returns for a top-level
604
+ // read. Both routes already agree, and casting would only add noise. This
605
+ // is the divergence MySQL DOES have, where JSON_OBJECT embeds a real JSON
606
+ // column as a nested value while the driver hands back text.
607
+ //
567
608
  // Binary columns come out of FOR JSON PATH as BASE64 text ("AQL/") rather
568
609
  // than bytes. Style 2 converts to bare hex, which rebuilds exactly.
569
610
  if (t === 'binary' || t === 'varbinary' || t === 'image') {
@@ -1239,7 +1280,8 @@ function parseMssqlConfig(connectionString) {
1239
1280
  return config;
1240
1281
  }
1241
1282
  catch {
1242
- throw new errors_js_1.ConnectionError(`[turbine] Invalid MSSQL connection string: "${connectionString}"`);
1283
+ // Never echo the value, see malformedConnectionStringMessage.
1284
+ throw new errors_js_1.ConnectionError((0, errors_js_1.malformedConnectionStringMessage)('SQL Server', 'mssql://user:password@localhost:1433/app'));
1243
1285
  }
1244
1286
  }
1245
1287
  /**
@@ -164,8 +164,11 @@ export interface TurbineMysqlOptions extends EngineClientConfig {
164
164
  * `disconnect()` is a no-op, advanced config like SSL lives here).
165
165
  *
166
166
  * When Turbine builds the pool (string/config), it pins the correct mysql2 flags
167
- * (named placeholders, bignum, UTC dates, JSON-as-string), probes `SELECT VERSION()`
168
- * to reject MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created.
167
+ * (named placeholders, bignum, UTC dates), probes `SELECT VERSION()` to reject
168
+ * MySQL < 8.0 / MariaDB, and `disconnect()` closes the pool it created. None of
169
+ * those flags changes a column's TYPE, so an injected pool reads every value
170
+ * the same way (see {@link MYSQL_DRIVER_FLAGS}); `namedPlaceholders: true` is
171
+ * the one an injected pool must set for itself.
169
172
  *
170
173
  * @example
171
174
  * ```ts