turbine-orm 0.28.3 → 0.30.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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/cli/index.js +5 -0
  3. package/dist/cjs/cli/mcp.js +22 -92
  4. package/dist/cjs/client.js +69 -5
  5. package/dist/cjs/generate.js +71 -25
  6. package/dist/cjs/index.js +4 -1
  7. package/dist/cjs/introspect.js +350 -120
  8. package/dist/cjs/mssql.js +18 -133
  9. package/dist/cjs/mysql.js +16 -129
  10. package/dist/cjs/optional-peer-import.cjs +122 -0
  11. package/dist/cjs/powdb.js +440 -81
  12. package/dist/cjs/powql.js +49 -25
  13. package/dist/cjs/query/builder.js +290 -23
  14. package/dist/cjs/query/filters.js +32 -1
  15. package/dist/cjs/schema-metadata.js +316 -0
  16. package/dist/cjs/sqlite.js +8 -89
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +5 -0
  19. package/dist/cli/mcp.d.ts +18 -0
  20. package/dist/cli/mcp.js +22 -93
  21. package/dist/client.d.ts +44 -6
  22. package/dist/client.js +69 -5
  23. package/dist/generate.d.ts +16 -4
  24. package/dist/generate.js +71 -25
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +2 -0
  27. package/dist/introspect.d.ts +94 -1
  28. package/dist/introspect.js +345 -120
  29. package/dist/mssql.js +16 -101
  30. package/dist/mysql.js +14 -97
  31. package/dist/optional-peer-import.cjs +89 -0
  32. package/dist/optional-peer-import.d.cts +53 -0
  33. package/dist/powdb.d.ts +94 -26
  34. package/dist/powdb.js +435 -80
  35. package/dist/powql.d.ts +6 -0
  36. package/dist/powql.js +51 -27
  37. package/dist/query/builder.d.ts +60 -3
  38. package/dist/query/builder.js +291 -24
  39. package/dist/query/deferred.d.ts +7 -2
  40. package/dist/query/filters.d.ts +18 -0
  41. package/dist/query/filters.js +30 -0
  42. package/dist/query/types.d.ts +19 -0
  43. package/dist/schema-metadata.d.ts +77 -0
  44. package/dist/schema-metadata.js +313 -0
  45. package/dist/schema.d.ts +10 -0
  46. package/dist/sqlite.js +9 -90
  47. package/package.json +3 -3
@@ -16,6 +16,11 @@ exports.pgConfActionToReferential = pgConfActionToReferential;
16
16
  exports.introspect = introspect;
17
17
  exports.introspectPostgresCatalog = introspectPostgresCatalog;
18
18
  exports.stripCheckWrapper = stripCheckWrapper;
19
+ exports.relationNameFromColumn = relationNameFromColumn;
20
+ exports.isUnknownTsType = isUnknownTsType;
21
+ exports.buildRelationsFromForeignKeys = buildRelationsFromForeignKeys;
22
+ exports.addAutoManyToManyRelations = addAutoManyToManyRelations;
23
+ exports.deriveEngineRelations = deriveEngineRelations;
19
24
  const pg_1 = __importDefault(require("pg"));
20
25
  const dialect_js_1 = require("./dialect.js");
21
26
  const schema_js_1 = require("./schema.js");
@@ -53,6 +58,7 @@ const SQL_COLUMNS = `
53
58
  table_name,
54
59
  column_name,
55
60
  udt_name,
61
+ udt_schema,
56
62
  data_type,
57
63
  is_nullable,
58
64
  column_default,
@@ -155,6 +161,7 @@ const SQL_MATVIEW_COLUMNS = `
155
161
  c.relname AS table_name,
156
162
  a.attname AS column_name,
157
163
  t.typname AS udt_name,
164
+ tn.nspname AS udt_schema,
158
165
  CASE WHEN t.typcategory = 'A' THEN 'ARRAY' ELSE 'base' END AS data_type,
159
166
  CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
160
167
  NULL AS column_default,
@@ -167,6 +174,7 @@ const SQL_MATVIEW_COLUMNS = `
167
174
  JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
168
175
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
169
176
  JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
177
+ JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace
170
178
  WHERE n.nspname = $1
171
179
  AND c.relkind = 'm'
172
180
  AND a.attnum > 0
@@ -297,6 +305,14 @@ async function introspectPostgresCatalog(options) {
297
305
  arrayType,
298
306
  pgArrayType: arrayType,
299
307
  maxLength: row.character_maximum_length ?? undefined,
308
+ // Record the type's schema ONLY when it lives outside the introspected
309
+ // schema (and isn't a pg_catalog builtin). A same-named enum in another
310
+ // schema must NOT get this schema's `::"enum"` cast — search_path would
311
+ // resolve the cast to the wrong type (see enumTypeForColumn). Omitting
312
+ // it for the common case keeps generated metadata byte-identical.
313
+ ...(typeof row.udt_schema === 'string' && row.udt_schema !== schema && row.udt_schema !== 'pg_catalog'
314
+ ? { pgTypeSchema: row.udt_schema }
315
+ : {}),
300
316
  };
301
317
  if (!columnsByTable.has(tableName))
302
318
  columnsByTable.set(tableName, []);
@@ -368,6 +384,9 @@ async function introspectPostgresCatalog(options) {
368
384
  enums[row.typname] = [];
369
385
  enums[row.typname].push(row.enumlabel);
370
386
  }
387
+ // ----- Build foreign key map -----
388
+ // Group FK rows by constraint_name to correctly handle multi-column composite FKs.
389
+ // Each constraint becomes one FKEntry with arrays of columns.
371
390
  const fkGroups = new Map();
372
391
  for (const row of fkResult.rows) {
373
392
  if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
@@ -388,65 +407,21 @@ async function introspectPostgresCatalog(options) {
388
407
  }
389
408
  const foreignKeys = Array.from(fkGroups.values());
390
409
  // ----- Build relations from foreign keys -----
391
- // Count FKs per (source, target) pair for disambiguation
392
- const fkCounts = new Map();
393
- for (const fk of foreignKeys) {
394
- const key = `${fk.sourceTable}→${fk.targetTable}`;
395
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
396
- }
397
- const relationsByTable = new Map();
398
- for (const fk of foreignKeys) {
399
- const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
400
- const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
401
- // For single-column FKs, keep string form for backwards compatibility.
402
- // For multi-column (composite) FKs, use array form.
403
- const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
404
- const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
405
- // --- belongsTo on the source (child) table ---
406
- // e.g. posts.user_id → users.id creates posts.user (belongsTo)
407
- // For composite FKs with disambiguation, use the constraint name
408
- const belongsToName = needsDisambiguation
409
- ? fk.sourceColumns.length === 1
410
- ? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
411
- : (0, schema_js_1.snakeToCamel)(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
412
- : (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
413
- // Referential actions (omit the 'no action' default to keep metadata lean).
414
- const actions = fkActions.get(fk.constraintName);
415
- const actionFields = {};
416
- if (actions?.onDelete && actions.onDelete !== 'no action')
417
- actionFields.onDelete = actions.onDelete;
418
- if (actions?.onUpdate && actions.onUpdate !== 'no action')
419
- actionFields.onUpdate = actions.onUpdate;
420
- if (!relationsByTable.has(fk.sourceTable))
421
- relationsByTable.set(fk.sourceTable, {});
422
- relationsByTable.get(fk.sourceTable)[belongsToName] = {
423
- type: 'belongsTo',
424
- name: belongsToName,
425
- from: fk.sourceTable,
426
- to: fk.targetTable,
427
- foreignKey,
428
- referenceKey,
429
- ...actionFields,
430
- };
431
- // --- hasMany on the target (parent) table ---
432
- // e.g. posts.user_id → users.id creates users.posts (hasMany)
433
- const hasManyName = needsDisambiguation
434
- ? fk.sourceColumns.length === 1
435
- ? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
436
- : (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
437
- : (0, schema_js_1.snakeToCamel)(fk.sourceTable);
438
- if (!relationsByTable.has(fk.targetTable))
439
- relationsByTable.set(fk.targetTable, {});
440
- relationsByTable.get(fk.targetTable)[hasManyName] = {
441
- type: 'hasMany',
442
- name: hasManyName,
443
- from: fk.targetTable,
444
- to: fk.sourceTable,
445
- foreignKey,
446
- referenceKey,
447
- ...actionFields,
448
- };
410
+ // Delegated to the pure, unit-testable builder. Relation names are derived
411
+ // per-FK-column when several FKs point at the same target, and every name
412
+ // is collision-checked against the table's scalar column fields so a
413
+ // relation can never shadow a column (which generated unsound types and
414
+ // made both surfaces unusable — dogfood T-4).
415
+ const columnFieldsByTable = new Map();
416
+ const unknownTypedFieldsByTable = new Map();
417
+ for (const [tbl, cols] of columnsByTable) {
418
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
419
+ // Enum-typed columns also report tsType 'unknown' here, but generate.ts
420
+ // gives them a concrete union type a shadow of one was type-broken on
421
+ // main, so only genuine json/jsonb columns qualify as historical shadows.
422
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
449
423
  }
424
+ const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable);
450
425
  // ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
451
426
  //
452
427
  // Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
@@ -466,68 +441,9 @@ async function introspectPostgresCatalog(options) {
466
441
  // For such a J linking A and B we ADD a `manyToMany` relation on A → B and
467
442
  // symmetrically on B → A, both routed `through` J. The existing belongsTo /
468
443
  // hasMany relations derived from J's FKs are left untouched — this block
469
- // never removes or renames anything. If the chosen relation name already
470
- // exists on the source table (e.g. another relation grabbed it), we SKIP to
471
- // stay additive.
472
- for (const tableName of tableNames) {
473
- const pk = pkByTable.get(tableName) ?? [];
474
- if (pk.length !== 2)
475
- continue;
476
- // FKs whose source is this table.
477
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
478
- if (tableFks.length !== 2)
479
- continue;
480
- // Both FKs must be single-column.
481
- if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
482
- continue;
483
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
484
- const pkSet = new Set(pk);
485
- // Both FK columns must be the PK columns (and vice-versa).
486
- if (!fkCols.every((c) => pkSet.has(c)))
487
- continue;
488
- if (new Set(fkCols).size !== 2)
489
- continue;
490
- // Two DISTINCT target tables.
491
- const [fkA, fkB] = tableFks;
492
- if (fkA.targetTable === fkB.targetTable)
493
- continue;
494
- // No payload columns: J's columns are exactly the two FK/PK columns.
495
- const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
496
- if (jCols.length !== 2)
497
- continue;
498
- // For each direction, the m2m `referenceKey` is the *targeted* table's
499
- // referenced column(s); the junction's sourceKey is the FK column pointing
500
- // to that table; the targetKey is the FK column pointing to the OTHER table.
501
- const addM2M = (self, other) => {
502
- const sourceTbl = self.targetTable; // A
503
- const targetTbl = other.targetTable; // B
504
- const relName = (0, schema_js_1.snakeToCamel)(targetTbl); // plural table name → e.g. "tags"
505
- if (!relationsByTable.has(sourceTbl))
506
- relationsByTable.set(sourceTbl, {});
507
- const existing = relationsByTable.get(sourceTbl);
508
- // Additive-only: never clobber an existing relation name.
509
- if (existing[relName])
510
- return;
511
- existing[relName] = {
512
- type: 'manyToMany',
513
- name: relName,
514
- from: sourceTbl,
515
- to: targetTbl,
516
- // referenceKey = A's referenced column(s) that J's sourceKey points at.
517
- referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
518
- // foreignKey is unused for m2m correlation but kept for shape parity
519
- // (mirrors the source-side reference for back-compat consumers).
520
- foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
521
- through: {
522
- table: tableName,
523
- sourceKey: self.sourceColumns[0], // J col → A
524
- targetKey: other.sourceColumns[0], // J col → B
525
- },
526
- };
527
- };
528
- addM2M(fkA, fkB); // A → B
529
- addM2M(fkB, fkA); // B → A
530
- }
444
+ // never removes or renames anything. Naming/collision handling lives in the
445
+ // shared addAutoManyToManyRelations helper.
446
+ addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
531
447
  // ----- Assemble TableMetadata for each table -----
532
448
  const tables = {};
533
449
  for (const tableName of tableNames) {
@@ -603,3 +519,317 @@ function stripCheckWrapper(def) {
603
519
  }
604
520
  return s;
605
521
  }
522
+ /**
523
+ * Derive a belongsTo relation name from its FK column. Strips a trailing
524
+ * `_id` (snake_case) or `Id` (camelCase column names — common in Prisma-ported
525
+ * schemas where columns are quoted camelCase identifiers), then camelCases:
526
+ * `current_version_id` and `currentVersionId` both yield `currentVersion`.
527
+ * Stripping is what keeps the scalar FK field (`currentVersionId`) targetable
528
+ * alongside the relation. A column literally named `id` (nothing left after
529
+ * stripping) keeps its own name.
530
+ */
531
+ function relationNameFromColumn(column) {
532
+ let base = column;
533
+ if (/_id$/i.test(base))
534
+ base = base.slice(0, -3);
535
+ else if (/[a-z0-9]Id$/.test(base))
536
+ base = base.slice(0, -2);
537
+ if (base.length === 0)
538
+ base = column;
539
+ return (0, schema_js_1.snakeToCamel)(base);
540
+ }
541
+ /** Uppercase the first character (camelCase → PascalCase join helper). */
542
+ function upperFirst(s) {
543
+ return s.charAt(0).toUpperCase() + s.slice(1);
544
+ }
545
+ /**
546
+ * True for the tsType forms a json/jsonb column maps to (`unknown`, nullable
547
+ * `unknown | null`). A relation shadowing such a column is a HISTORICAL shadow
548
+ * that worked at runtime and compiled (`unknown` absorbs the relation
549
+ * payload), so the legacy-first naming keeps it instead of renaming.
550
+ */
551
+ function isUnknownTsType(tsType) {
552
+ return tsType === 'unknown' || tsType === 'unknown | null';
553
+ }
554
+ /**
555
+ * Resolve a derived relation name against the names already taken on the
556
+ * table (scalar column fields + previously assigned relations). On collision,
557
+ * applies a deterministic `Rel` / `Rel2` / `Rel3`… suffix and warns — a
558
+ * colliding name would otherwise shadow a column field and generate types
559
+ * that fail `tsc --strict` (TS2430/TS2322).
560
+ */
561
+ function resolveRelationNameCollision(candidate, taken, table, source) {
562
+ if (!taken.has(candidate))
563
+ return candidate;
564
+ let name = `${candidate}Rel`;
565
+ for (let i = 2; taken.has(name); i++)
566
+ name = `${candidate}Rel${i}`;
567
+ console.warn(`[turbine] Relation name "${candidate}" on table "${table}" (from ${source}) collides with an existing column or relation — using "${name}" instead.`);
568
+ return name;
569
+ }
570
+ /**
571
+ * Build the belongsTo/hasMany relation maps for every table from its foreign
572
+ * keys. Naming rules (LEGACY-FIRST — a relation name that previously worked at
573
+ * runtime must never change out from under a regenerating app):
574
+ *
575
+ * 1. First compute the historical derivation exactly as it shipped before
576
+ * the collision guard existed: belongsTo strips a case-SENSITIVE `_id`
577
+ * suffix (`snakeToCamel(col.replace(/_id$/, ''))` when several FKs point
578
+ * at the same target, else the singularized target table), and hasMany is
579
+ * `snakeToCamel(`${source}_by_${strippedColumn}`)` (else the source
580
+ * table). If that legacy name is free, KEEP IT — even when it looks odd
581
+ * (`blogPostsByAuthorId`, `postsBy_Author`): those names were collision-
582
+ * free and worked, so regenerating must not rename them.
583
+ * 2. If the legacy name collides ONLY with a scalar column whose tsType is
584
+ * `unknown` (json/jsonb), keep it anyway with a warning: the shadow is
585
+ * historical, ran fine at runtime, and compiled (`unknown` absorbs the
586
+ * relation payload; generate.ts's typeSafeRelations omits the relation
587
+ * from the type layer).
588
+ * 3. On a genuine collision (concrete-typed column shadow, or a previously
589
+ * assigned relation), fall back to the modern derivation — the `_id`/`Id`
590
+ * case-insensitive strip of {@link relationNameFromColumn} plus the
591
+ * `By`-composed reverse name — which fixes the camelCase-FK shadowing
592
+ * shapes that were actually BROKEN before (relation name === scalar FK
593
+ * field → unusable types).
594
+ * 4. Last resort: deterministic `Rel`/`Rel2` suffix + warning.
595
+ *
596
+ * @param columnFieldsByTable camelCase column *fields* per table — used to
597
+ * guarantee relations never shadow concrete-typed scalar columns.
598
+ * @param unknownTypedFieldsByTable subset of the column fields whose tsType is
599
+ * `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
600
+ */
601
+ function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable) {
602
+ // Count FKs per (source, target) pair for disambiguation.
603
+ const fkCounts = new Map();
604
+ for (const fk of foreignKeys) {
605
+ const key = `${fk.sourceTable}→${fk.targetTable}`;
606
+ fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
607
+ }
608
+ const relationsByTable = new Map();
609
+ // Names already taken per table: seeded with the scalar column fields so a
610
+ // relation can never shadow a column; relation names are added as assigned.
611
+ const takenByTable = new Map();
612
+ const takenFor = (table) => {
613
+ let taken = takenByTable.get(table);
614
+ if (!taken) {
615
+ taken = new Set(columnFieldsByTable.get(table) ?? []);
616
+ takenByTable.set(table, taken);
617
+ }
618
+ return taken;
619
+ };
620
+ // Relation names actually assigned so far (as opposed to column fields) —
621
+ // needed to tell "collides only with a column" apart from "collides with an
622
+ // already-assigned relation" for the legacy-shadow-preserving rule.
623
+ const assignedByTable = new Map();
624
+ const assignedFor = (table) => {
625
+ let assigned = assignedByTable.get(table);
626
+ if (!assigned) {
627
+ assigned = new Set();
628
+ assignedByTable.set(table, assigned);
629
+ }
630
+ return assigned;
631
+ };
632
+ /** Legacy-first name resolution — see the naming rules in the JSDoc above. */
633
+ const resolveName = (legacy, modern, table, source) => {
634
+ const taken = takenFor(table);
635
+ if (!taken.has(legacy))
636
+ return legacy;
637
+ // Historical json/jsonb shadow: previously worked at runtime AND compiled
638
+ // (tsType `unknown` absorbs the relation payload). Keep the name, warn —
639
+ // typeSafeRelations() keeps the generated type layer sound.
640
+ if (!assignedFor(table).has(legacy) && unknownTypedFieldsByTable?.get(table)?.has(legacy)) {
641
+ console.warn(`[turbine] Relation "${legacy}" on table "${table}" (from ${source}) shadows the json/jsonb column ` +
642
+ `"${legacy}" — keeping the historical name for runtime compatibility; the relation is omitted from ` +
643
+ `the generated types. Rename the column to expose it.`);
644
+ return legacy;
645
+ }
646
+ if (modern !== null && modern !== legacy && !taken.has(modern))
647
+ return modern;
648
+ return resolveRelationNameCollision(modern ?? legacy, taken, table, source);
649
+ };
650
+ for (const fk of foreignKeys) {
651
+ const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
652
+ const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
653
+ const singleColumn = fk.sourceColumns.length === 1;
654
+ // For single-column FKs, keep string form for backwards compatibility.
655
+ // For multi-column (composite) FKs, use array form.
656
+ const foreignKey = singleColumn ? fk.sourceColumns[0] : fk.sourceColumns;
657
+ const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
658
+ // Composite FKs have no single column to derive from — fall back to the
659
+ // constraint name (with the usual fk_/-_fkey affixes stripped).
660
+ const constraintBase = fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '');
661
+ // --- belongsTo on the source (child) table ---
662
+ // e.g. posts.user_id → users.id creates posts.user (belongsTo)
663
+ const legacyBelongsTo = needsDisambiguation
664
+ ? singleColumn
665
+ ? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
666
+ : (0, schema_js_1.snakeToCamel)(constraintBase)
667
+ : (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
668
+ const modernBelongsTo = needsDisambiguation && singleColumn ? relationNameFromColumn(fk.sourceColumns[0]) : null;
669
+ const belongsToName = resolveName(legacyBelongsTo, modernBelongsTo, fk.sourceTable, `FK ${fk.constraintName}`);
670
+ takenFor(fk.sourceTable).add(belongsToName);
671
+ assignedFor(fk.sourceTable).add(belongsToName);
672
+ // Referential actions (omit the 'no action' default to keep metadata lean).
673
+ const actions = fkActions?.get(fk.constraintName);
674
+ const actionFields = {};
675
+ if (actions?.onDelete && actions.onDelete !== 'no action')
676
+ actionFields.onDelete = actions.onDelete;
677
+ if (actions?.onUpdate && actions.onUpdate !== 'no action')
678
+ actionFields.onUpdate = actions.onUpdate;
679
+ if (!relationsByTable.has(fk.sourceTable))
680
+ relationsByTable.set(fk.sourceTable, {});
681
+ relationsByTable.get(fk.sourceTable)[belongsToName] = {
682
+ type: 'belongsTo',
683
+ name: belongsToName,
684
+ from: fk.sourceTable,
685
+ to: fk.targetTable,
686
+ foreignKey,
687
+ referenceKey,
688
+ ...actionFields,
689
+ };
690
+ // --- hasMany on the target (parent) table ---
691
+ // e.g. posts.user_id → users.id creates users.posts (hasMany)
692
+ const legacyHasMany = needsDisambiguation
693
+ ? singleColumn
694
+ ? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
695
+ : (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${constraintBase}`)
696
+ : (0, schema_js_1.snakeToCamel)(fk.sourceTable);
697
+ const modernHasMany = needsDisambiguation
698
+ ? singleColumn
699
+ ? `${(0, schema_js_1.snakeToCamel)(fk.sourceTable)}By${upperFirst(relationNameFromColumn(fk.sourceColumns[0]))}`
700
+ : `${(0, schema_js_1.snakeToCamel)(fk.sourceTable)}By${upperFirst((0, schema_js_1.snakeToCamel)(constraintBase))}`
701
+ : null;
702
+ const hasManyName = resolveName(legacyHasMany, modernHasMany, fk.targetTable, `FK ${fk.constraintName}`);
703
+ takenFor(fk.targetTable).add(hasManyName);
704
+ assignedFor(fk.targetTable).add(hasManyName);
705
+ if (!relationsByTable.has(fk.targetTable))
706
+ relationsByTable.set(fk.targetTable, {});
707
+ relationsByTable.get(fk.targetTable)[hasManyName] = {
708
+ type: 'hasMany',
709
+ name: hasManyName,
710
+ from: fk.targetTable,
711
+ to: fk.sourceTable,
712
+ foreignKey,
713
+ referenceKey,
714
+ ...actionFields,
715
+ };
716
+ }
717
+ return relationsByTable;
718
+ }
719
+ /**
720
+ * Conservative auto-`manyToMany` detection over pure junction tables, shared
721
+ * by the Postgres introspector, the engine introspectors (SQLite / MySQL /
722
+ * MSSQL), the MCP server, and `schemaDefToMetadata()` so all surfaces derive
723
+ * IDENTICAL relation names for the same logical schema.
724
+ *
725
+ * A table J is a PURE junction only when ALL of these hold:
726
+ * 1. J's primary key is exactly two columns.
727
+ * 2. J has exactly two FKs, each single-column.
728
+ * 3. Each FK's source column is one of J's two PK columns.
729
+ * 4. The two FKs target two DISTINCT tables (A and B).
730
+ * 5. J has no payload columns beyond the two FK/PK columns.
731
+ *
732
+ * For such a J linking A and B this ADDS a `manyToMany` on A → B and B → A
733
+ * routed `through` J. It never removes or renames an existing relation:
734
+ * - an already-assigned relation with the same name → SKIP (additive-only,
735
+ * unchanged historical behavior);
736
+ * - a shadowed json/jsonb (`unknown`-typed) column → keep the historical
737
+ * name + warn (it worked at runtime and compiled);
738
+ * - a shadowed concrete-typed column → deterministic `Rel` suffix + warn
739
+ * instead of silently dropping the relation.
740
+ */
741
+ function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, columnNamesByTable, relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable) {
742
+ for (const tableName of tableNames) {
743
+ const pk = pkByTable.get(tableName) ?? [];
744
+ if (pk.length !== 2)
745
+ continue;
746
+ // FKs whose source is this table — both must be single-column.
747
+ const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
748
+ if (tableFks.length !== 2)
749
+ continue;
750
+ if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
751
+ continue;
752
+ const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
753
+ const pkSet = new Set(pk);
754
+ // Both FK columns must be the PK columns (and vice-versa).
755
+ if (!fkCols.every((c) => pkSet.has(c)))
756
+ continue;
757
+ if (new Set(fkCols).size !== 2)
758
+ continue;
759
+ // Two DISTINCT target tables.
760
+ const [fkA, fkB] = tableFks;
761
+ if (fkA.targetTable === fkB.targetTable)
762
+ continue;
763
+ // No payload columns: J's columns are exactly the two FK/PK columns.
764
+ const jCols = columnNamesByTable.get(tableName) ?? [];
765
+ if (jCols.length !== 2)
766
+ continue;
767
+ // For each direction, the m2m `referenceKey` is the *targeted* table's
768
+ // referenced column(s); the junction's sourceKey is the FK column pointing
769
+ // to that table; the targetKey is the FK column pointing to the OTHER table.
770
+ const addM2M = (self, other) => {
771
+ const sourceTbl = self.targetTable; // A
772
+ const targetTbl = other.targetTable; // B
773
+ let relName = (0, schema_js_1.snakeToCamel)(targetTbl); // plural table name → e.g. "tags"
774
+ if (!relationsByTable.has(sourceTbl))
775
+ relationsByTable.set(sourceTbl, {});
776
+ const existing = relationsByTable.get(sourceTbl);
777
+ // Additive-only: never clobber an existing relation name.
778
+ if (existing[relName])
779
+ return;
780
+ const columnFields = columnFieldsByTable?.get(sourceTbl);
781
+ if (columnFields?.has(relName)) {
782
+ if (unknownTypedFieldsByTable?.get(sourceTbl)?.has(relName)) {
783
+ // Historical json/jsonb shadow — worked at runtime, compiled fine.
784
+ console.warn(`[turbine] Relation "${relName}" on table "${sourceTbl}" (junction ${tableName}) shadows the ` +
785
+ `json/jsonb column "${relName}" — keeping the historical name for runtime compatibility; ` +
786
+ `the relation is omitted from the generated types.`);
787
+ }
788
+ else {
789
+ const taken = new Set([...columnFields, ...Object.keys(existing)]);
790
+ relName = resolveRelationNameCollision(relName, taken, sourceTbl, `junction ${tableName}`);
791
+ }
792
+ }
793
+ existing[relName] = {
794
+ type: 'manyToMany',
795
+ name: relName,
796
+ from: sourceTbl,
797
+ to: targetTbl,
798
+ // referenceKey = A's referenced column(s) that J's sourceKey points at.
799
+ referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
800
+ // foreignKey is unused for m2m correlation but kept for shape parity
801
+ // (mirrors the source-side reference for back-compat consumers).
802
+ foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
803
+ through: {
804
+ table: tableName,
805
+ sourceKey: self.sourceColumns[0], // J col → A
806
+ targetKey: other.sourceColumns[0], // J col → B
807
+ },
808
+ };
809
+ };
810
+ addM2M(fkA, fkB); // A → B
811
+ addM2M(fkB, fkA); // B → A
812
+ }
813
+ }
814
+ /**
815
+ * One-stop relation derivation for the engine introspectors (SQLite / MySQL /
816
+ * MSSQL): filters the FK list to the introspected table set, seeds the
817
+ * taken-name / json-shadow maps from the engine's column metadata, and runs
818
+ * the SAME `buildRelationsFromForeignKeys` + `addAutoManyToManyRelations`
819
+ * pipeline as the Postgres introspector — so every engine derives identical
820
+ * relation names for the same logical schema (the engines previously carried
821
+ * stale copies of a retired naming scheme).
822
+ */
823
+ function deriveEngineRelations(tableNames, foreignKeys, pkByTable, columnsByTable) {
824
+ const tableSet = new Set(tableNames);
825
+ const fks = foreignKeys.filter((fk) => tableSet.has(fk.sourceTable) && tableSet.has(fk.targetTable));
826
+ const columnFieldsByTable = new Map();
827
+ const unknownTypedFieldsByTable = new Map();
828
+ for (const [tbl, cols] of columnsByTable) {
829
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
830
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType)).map((c) => c.field)));
831
+ }
832
+ const relationsByTable = buildRelationsFromForeignKeys(fks, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
833
+ addAutoManyToManyRelations(tableNames, fks, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
834
+ return relationsByTable;
835
+ }