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
@@ -44,6 +44,7 @@ const SQL_COLUMNS = `
44
44
  table_name,
45
45
  column_name,
46
46
  udt_name,
47
+ udt_schema,
47
48
  data_type,
48
49
  is_nullable,
49
50
  column_default,
@@ -146,6 +147,7 @@ const SQL_MATVIEW_COLUMNS = `
146
147
  c.relname AS table_name,
147
148
  a.attname AS column_name,
148
149
  t.typname AS udt_name,
150
+ tn.nspname AS udt_schema,
149
151
  CASE WHEN t.typcategory = 'A' THEN 'ARRAY' ELSE 'base' END AS data_type,
150
152
  CASE WHEN a.attnotnull THEN 'NO' ELSE 'YES' END AS is_nullable,
151
153
  NULL AS column_default,
@@ -158,6 +160,7 @@ const SQL_MATVIEW_COLUMNS = `
158
160
  JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
159
161
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
160
162
  JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
163
+ JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace
161
164
  WHERE n.nspname = $1
162
165
  AND c.relkind = 'm'
163
166
  AND a.attnum > 0
@@ -288,6 +291,14 @@ export async function introspectPostgresCatalog(options) {
288
291
  arrayType,
289
292
  pgArrayType: arrayType,
290
293
  maxLength: row.character_maximum_length ?? undefined,
294
+ // Record the type's schema ONLY when it lives outside the introspected
295
+ // schema (and isn't a pg_catalog builtin). A same-named enum in another
296
+ // schema must NOT get this schema's `::"enum"` cast — search_path would
297
+ // resolve the cast to the wrong type (see enumTypeForColumn). Omitting
298
+ // it for the common case keeps generated metadata byte-identical.
299
+ ...(typeof row.udt_schema === 'string' && row.udt_schema !== schema && row.udt_schema !== 'pg_catalog'
300
+ ? { pgTypeSchema: row.udt_schema }
301
+ : {}),
291
302
  };
292
303
  if (!columnsByTable.has(tableName))
293
304
  columnsByTable.set(tableName, []);
@@ -359,6 +370,9 @@ export async function introspectPostgresCatalog(options) {
359
370
  enums[row.typname] = [];
360
371
  enums[row.typname].push(row.enumlabel);
361
372
  }
373
+ // ----- Build foreign key map -----
374
+ // Group FK rows by constraint_name to correctly handle multi-column composite FKs.
375
+ // Each constraint becomes one FKEntry with arrays of columns.
362
376
  const fkGroups = new Map();
363
377
  for (const row of fkResult.rows) {
364
378
  if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
@@ -379,65 +393,21 @@ export async function introspectPostgresCatalog(options) {
379
393
  }
380
394
  const foreignKeys = Array.from(fkGroups.values());
381
395
  // ----- Build relations from foreign keys -----
382
- // Count FKs per (source, target) pair for disambiguation
383
- const fkCounts = new Map();
384
- for (const fk of foreignKeys) {
385
- const key = `${fk.sourceTable}→${fk.targetTable}`;
386
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
387
- }
388
- const relationsByTable = new Map();
389
- for (const fk of foreignKeys) {
390
- const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
391
- const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
392
- // For single-column FKs, keep string form for backwards compatibility.
393
- // For multi-column (composite) FKs, use array form.
394
- const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
395
- const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
396
- // --- belongsTo on the source (child) table ---
397
- // e.g. posts.user_id → users.id creates posts.user (belongsTo)
398
- // For composite FKs with disambiguation, use the constraint name
399
- const belongsToName = needsDisambiguation
400
- ? fk.sourceColumns.length === 1
401
- ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
402
- : snakeToCamel(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
403
- : singularize(snakeToCamel(fk.targetTable));
404
- // Referential actions (omit the 'no action' default to keep metadata lean).
405
- const actions = fkActions.get(fk.constraintName);
406
- const actionFields = {};
407
- if (actions?.onDelete && actions.onDelete !== 'no action')
408
- actionFields.onDelete = actions.onDelete;
409
- if (actions?.onUpdate && actions.onUpdate !== 'no action')
410
- actionFields.onUpdate = actions.onUpdate;
411
- if (!relationsByTable.has(fk.sourceTable))
412
- relationsByTable.set(fk.sourceTable, {});
413
- relationsByTable.get(fk.sourceTable)[belongsToName] = {
414
- type: 'belongsTo',
415
- name: belongsToName,
416
- from: fk.sourceTable,
417
- to: fk.targetTable,
418
- foreignKey,
419
- referenceKey,
420
- ...actionFields,
421
- };
422
- // --- hasMany on the target (parent) table ---
423
- // e.g. posts.user_id → users.id creates users.posts (hasMany)
424
- const hasManyName = needsDisambiguation
425
- ? fk.sourceColumns.length === 1
426
- ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
427
- : snakeToCamel(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
428
- : snakeToCamel(fk.sourceTable);
429
- if (!relationsByTable.has(fk.targetTable))
430
- relationsByTable.set(fk.targetTable, {});
431
- relationsByTable.get(fk.targetTable)[hasManyName] = {
432
- type: 'hasMany',
433
- name: hasManyName,
434
- from: fk.targetTable,
435
- to: fk.sourceTable,
436
- foreignKey,
437
- referenceKey,
438
- ...actionFields,
439
- };
396
+ // Delegated to the pure, unit-testable builder. Relation names are derived
397
+ // per-FK-column when several FKs point at the same target, and every name
398
+ // is collision-checked against the table's scalar column fields so a
399
+ // relation can never shadow a column (which generated unsound types and
400
+ // made both surfaces unusable — dogfood T-4).
401
+ const columnFieldsByTable = new Map();
402
+ const unknownTypedFieldsByTable = new Map();
403
+ for (const [tbl, cols] of columnsByTable) {
404
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
405
+ // Enum-typed columns also report tsType 'unknown' here, but generate.ts
406
+ // gives them a concrete union type a shadow of one was type-broken on
407
+ // main, so only genuine json/jsonb columns qualify as historical shadows.
408
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
440
409
  }
410
+ const relationsByTable = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable);
441
411
  // ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
442
412
  //
443
413
  // Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
@@ -457,68 +427,9 @@ export async function introspectPostgresCatalog(options) {
457
427
  // For such a J linking A and B we ADD a `manyToMany` relation on A → B and
458
428
  // symmetrically on B → A, both routed `through` J. The existing belongsTo /
459
429
  // hasMany relations derived from J's FKs are left untouched — this block
460
- // never removes or renames anything. If the chosen relation name already
461
- // exists on the source table (e.g. another relation grabbed it), we SKIP to
462
- // stay additive.
463
- for (const tableName of tableNames) {
464
- const pk = pkByTable.get(tableName) ?? [];
465
- if (pk.length !== 2)
466
- continue;
467
- // FKs whose source is this table.
468
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
469
- if (tableFks.length !== 2)
470
- continue;
471
- // Both FKs must be single-column.
472
- if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
473
- continue;
474
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
475
- const pkSet = new Set(pk);
476
- // Both FK columns must be the PK columns (and vice-versa).
477
- if (!fkCols.every((c) => pkSet.has(c)))
478
- continue;
479
- if (new Set(fkCols).size !== 2)
480
- continue;
481
- // Two DISTINCT target tables.
482
- const [fkA, fkB] = tableFks;
483
- if (fkA.targetTable === fkB.targetTable)
484
- continue;
485
- // No payload columns: J's columns are exactly the two FK/PK columns.
486
- const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
487
- if (jCols.length !== 2)
488
- continue;
489
- // For each direction, the m2m `referenceKey` is the *targeted* table's
490
- // referenced column(s); the junction's sourceKey is the FK column pointing
491
- // to that table; the targetKey is the FK column pointing to the OTHER table.
492
- const addM2M = (self, other) => {
493
- const sourceTbl = self.targetTable; // A
494
- const targetTbl = other.targetTable; // B
495
- const relName = snakeToCamel(targetTbl); // plural table name → e.g. "tags"
496
- if (!relationsByTable.has(sourceTbl))
497
- relationsByTable.set(sourceTbl, {});
498
- const existing = relationsByTable.get(sourceTbl);
499
- // Additive-only: never clobber an existing relation name.
500
- if (existing[relName])
501
- return;
502
- existing[relName] = {
503
- type: 'manyToMany',
504
- name: relName,
505
- from: sourceTbl,
506
- to: targetTbl,
507
- // referenceKey = A's referenced column(s) that J's sourceKey points at.
508
- referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
509
- // foreignKey is unused for m2m correlation but kept for shape parity
510
- // (mirrors the source-side reference for back-compat consumers).
511
- foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
512
- through: {
513
- table: tableName,
514
- sourceKey: self.sourceColumns[0], // J col → A
515
- targetKey: other.sourceColumns[0], // J col → B
516
- },
517
- };
518
- };
519
- addM2M(fkA, fkB); // A → B
520
- addM2M(fkB, fkA); // B → A
521
- }
430
+ // never removes or renames anything. Naming/collision handling lives in the
431
+ // shared addAutoManyToManyRelations helper.
432
+ addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
522
433
  // ----- Assemble TableMetadata for each table -----
523
434
  const tables = {};
524
435
  for (const tableName of tableNames) {
@@ -594,3 +505,317 @@ export function stripCheckWrapper(def) {
594
505
  }
595
506
  return s;
596
507
  }
508
+ /**
509
+ * Derive a belongsTo relation name from its FK column. Strips a trailing
510
+ * `_id` (snake_case) or `Id` (camelCase column names — common in Prisma-ported
511
+ * schemas where columns are quoted camelCase identifiers), then camelCases:
512
+ * `current_version_id` and `currentVersionId` both yield `currentVersion`.
513
+ * Stripping is what keeps the scalar FK field (`currentVersionId`) targetable
514
+ * alongside the relation. A column literally named `id` (nothing left after
515
+ * stripping) keeps its own name.
516
+ */
517
+ export function relationNameFromColumn(column) {
518
+ let base = column;
519
+ if (/_id$/i.test(base))
520
+ base = base.slice(0, -3);
521
+ else if (/[a-z0-9]Id$/.test(base))
522
+ base = base.slice(0, -2);
523
+ if (base.length === 0)
524
+ base = column;
525
+ return snakeToCamel(base);
526
+ }
527
+ /** Uppercase the first character (camelCase → PascalCase join helper). */
528
+ function upperFirst(s) {
529
+ return s.charAt(0).toUpperCase() + s.slice(1);
530
+ }
531
+ /**
532
+ * True for the tsType forms a json/jsonb column maps to (`unknown`, nullable
533
+ * `unknown | null`). A relation shadowing such a column is a HISTORICAL shadow
534
+ * that worked at runtime and compiled (`unknown` absorbs the relation
535
+ * payload), so the legacy-first naming keeps it instead of renaming.
536
+ */
537
+ export function isUnknownTsType(tsType) {
538
+ return tsType === 'unknown' || tsType === 'unknown | null';
539
+ }
540
+ /**
541
+ * Resolve a derived relation name against the names already taken on the
542
+ * table (scalar column fields + previously assigned relations). On collision,
543
+ * applies a deterministic `Rel` / `Rel2` / `Rel3`… suffix and warns — a
544
+ * colliding name would otherwise shadow a column field and generate types
545
+ * that fail `tsc --strict` (TS2430/TS2322).
546
+ */
547
+ function resolveRelationNameCollision(candidate, taken, table, source) {
548
+ if (!taken.has(candidate))
549
+ return candidate;
550
+ let name = `${candidate}Rel`;
551
+ for (let i = 2; taken.has(name); i++)
552
+ name = `${candidate}Rel${i}`;
553
+ console.warn(`[turbine] Relation name "${candidate}" on table "${table}" (from ${source}) collides with an existing column or relation — using "${name}" instead.`);
554
+ return name;
555
+ }
556
+ /**
557
+ * Build the belongsTo/hasMany relation maps for every table from its foreign
558
+ * keys. Naming rules (LEGACY-FIRST — a relation name that previously worked at
559
+ * runtime must never change out from under a regenerating app):
560
+ *
561
+ * 1. First compute the historical derivation exactly as it shipped before
562
+ * the collision guard existed: belongsTo strips a case-SENSITIVE `_id`
563
+ * suffix (`snakeToCamel(col.replace(/_id$/, ''))` when several FKs point
564
+ * at the same target, else the singularized target table), and hasMany is
565
+ * `snakeToCamel(`${source}_by_${strippedColumn}`)` (else the source
566
+ * table). If that legacy name is free, KEEP IT — even when it looks odd
567
+ * (`blogPostsByAuthorId`, `postsBy_Author`): those names were collision-
568
+ * free and worked, so regenerating must not rename them.
569
+ * 2. If the legacy name collides ONLY with a scalar column whose tsType is
570
+ * `unknown` (json/jsonb), keep it anyway with a warning: the shadow is
571
+ * historical, ran fine at runtime, and compiled (`unknown` absorbs the
572
+ * relation payload; generate.ts's typeSafeRelations omits the relation
573
+ * from the type layer).
574
+ * 3. On a genuine collision (concrete-typed column shadow, or a previously
575
+ * assigned relation), fall back to the modern derivation — the `_id`/`Id`
576
+ * case-insensitive strip of {@link relationNameFromColumn} plus the
577
+ * `By`-composed reverse name — which fixes the camelCase-FK shadowing
578
+ * shapes that were actually BROKEN before (relation name === scalar FK
579
+ * field → unusable types).
580
+ * 4. Last resort: deterministic `Rel`/`Rel2` suffix + warning.
581
+ *
582
+ * @param columnFieldsByTable camelCase column *fields* per table — used to
583
+ * guarantee relations never shadow concrete-typed scalar columns.
584
+ * @param unknownTypedFieldsByTable subset of the column fields whose tsType is
585
+ * `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
586
+ */
587
+ export function buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, fkActions, unknownTypedFieldsByTable) {
588
+ // Count FKs per (source, target) pair for disambiguation.
589
+ const fkCounts = new Map();
590
+ for (const fk of foreignKeys) {
591
+ const key = `${fk.sourceTable}→${fk.targetTable}`;
592
+ fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
593
+ }
594
+ const relationsByTable = new Map();
595
+ // Names already taken per table: seeded with the scalar column fields so a
596
+ // relation can never shadow a column; relation names are added as assigned.
597
+ const takenByTable = new Map();
598
+ const takenFor = (table) => {
599
+ let taken = takenByTable.get(table);
600
+ if (!taken) {
601
+ taken = new Set(columnFieldsByTable.get(table) ?? []);
602
+ takenByTable.set(table, taken);
603
+ }
604
+ return taken;
605
+ };
606
+ // Relation names actually assigned so far (as opposed to column fields) —
607
+ // needed to tell "collides only with a column" apart from "collides with an
608
+ // already-assigned relation" for the legacy-shadow-preserving rule.
609
+ const assignedByTable = new Map();
610
+ const assignedFor = (table) => {
611
+ let assigned = assignedByTable.get(table);
612
+ if (!assigned) {
613
+ assigned = new Set();
614
+ assignedByTable.set(table, assigned);
615
+ }
616
+ return assigned;
617
+ };
618
+ /** Legacy-first name resolution — see the naming rules in the JSDoc above. */
619
+ const resolveName = (legacy, modern, table, source) => {
620
+ const taken = takenFor(table);
621
+ if (!taken.has(legacy))
622
+ return legacy;
623
+ // Historical json/jsonb shadow: previously worked at runtime AND compiled
624
+ // (tsType `unknown` absorbs the relation payload). Keep the name, warn —
625
+ // typeSafeRelations() keeps the generated type layer sound.
626
+ if (!assignedFor(table).has(legacy) && unknownTypedFieldsByTable?.get(table)?.has(legacy)) {
627
+ console.warn(`[turbine] Relation "${legacy}" on table "${table}" (from ${source}) shadows the json/jsonb column ` +
628
+ `"${legacy}" — keeping the historical name for runtime compatibility; the relation is omitted from ` +
629
+ `the generated types. Rename the column to expose it.`);
630
+ return legacy;
631
+ }
632
+ if (modern !== null && modern !== legacy && !taken.has(modern))
633
+ return modern;
634
+ return resolveRelationNameCollision(modern ?? legacy, taken, table, source);
635
+ };
636
+ for (const fk of foreignKeys) {
637
+ const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
638
+ const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
639
+ const singleColumn = fk.sourceColumns.length === 1;
640
+ // For single-column FKs, keep string form for backwards compatibility.
641
+ // For multi-column (composite) FKs, use array form.
642
+ const foreignKey = singleColumn ? fk.sourceColumns[0] : fk.sourceColumns;
643
+ const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
644
+ // Composite FKs have no single column to derive from — fall back to the
645
+ // constraint name (with the usual fk_/-_fkey affixes stripped).
646
+ const constraintBase = fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '');
647
+ // --- belongsTo on the source (child) table ---
648
+ // e.g. posts.user_id → users.id creates posts.user (belongsTo)
649
+ const legacyBelongsTo = needsDisambiguation
650
+ ? singleColumn
651
+ ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
652
+ : snakeToCamel(constraintBase)
653
+ : singularize(snakeToCamel(fk.targetTable));
654
+ const modernBelongsTo = needsDisambiguation && singleColumn ? relationNameFromColumn(fk.sourceColumns[0]) : null;
655
+ const belongsToName = resolveName(legacyBelongsTo, modernBelongsTo, fk.sourceTable, `FK ${fk.constraintName}`);
656
+ takenFor(fk.sourceTable).add(belongsToName);
657
+ assignedFor(fk.sourceTable).add(belongsToName);
658
+ // Referential actions (omit the 'no action' default to keep metadata lean).
659
+ const actions = fkActions?.get(fk.constraintName);
660
+ const actionFields = {};
661
+ if (actions?.onDelete && actions.onDelete !== 'no action')
662
+ actionFields.onDelete = actions.onDelete;
663
+ if (actions?.onUpdate && actions.onUpdate !== 'no action')
664
+ actionFields.onUpdate = actions.onUpdate;
665
+ if (!relationsByTable.has(fk.sourceTable))
666
+ relationsByTable.set(fk.sourceTable, {});
667
+ relationsByTable.get(fk.sourceTable)[belongsToName] = {
668
+ type: 'belongsTo',
669
+ name: belongsToName,
670
+ from: fk.sourceTable,
671
+ to: fk.targetTable,
672
+ foreignKey,
673
+ referenceKey,
674
+ ...actionFields,
675
+ };
676
+ // --- hasMany on the target (parent) table ---
677
+ // e.g. posts.user_id → users.id creates users.posts (hasMany)
678
+ const legacyHasMany = needsDisambiguation
679
+ ? singleColumn
680
+ ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
681
+ : snakeToCamel(`${fk.sourceTable}_by_${constraintBase}`)
682
+ : snakeToCamel(fk.sourceTable);
683
+ const modernHasMany = needsDisambiguation
684
+ ? singleColumn
685
+ ? `${snakeToCamel(fk.sourceTable)}By${upperFirst(relationNameFromColumn(fk.sourceColumns[0]))}`
686
+ : `${snakeToCamel(fk.sourceTable)}By${upperFirst(snakeToCamel(constraintBase))}`
687
+ : null;
688
+ const hasManyName = resolveName(legacyHasMany, modernHasMany, fk.targetTable, `FK ${fk.constraintName}`);
689
+ takenFor(fk.targetTable).add(hasManyName);
690
+ assignedFor(fk.targetTable).add(hasManyName);
691
+ if (!relationsByTable.has(fk.targetTable))
692
+ relationsByTable.set(fk.targetTable, {});
693
+ relationsByTable.get(fk.targetTable)[hasManyName] = {
694
+ type: 'hasMany',
695
+ name: hasManyName,
696
+ from: fk.targetTable,
697
+ to: fk.sourceTable,
698
+ foreignKey,
699
+ referenceKey,
700
+ ...actionFields,
701
+ };
702
+ }
703
+ return relationsByTable;
704
+ }
705
+ /**
706
+ * Conservative auto-`manyToMany` detection over pure junction tables, shared
707
+ * by the Postgres introspector, the engine introspectors (SQLite / MySQL /
708
+ * MSSQL), the MCP server, and `schemaDefToMetadata()` so all surfaces derive
709
+ * IDENTICAL relation names for the same logical schema.
710
+ *
711
+ * A table J is a PURE junction only when ALL of these hold:
712
+ * 1. J's primary key is exactly two columns.
713
+ * 2. J has exactly two FKs, each single-column.
714
+ * 3. Each FK's source column is one of J's two PK columns.
715
+ * 4. The two FKs target two DISTINCT tables (A and B).
716
+ * 5. J has no payload columns beyond the two FK/PK columns.
717
+ *
718
+ * For such a J linking A and B this ADDS a `manyToMany` on A → B and B → A
719
+ * routed `through` J. It never removes or renames an existing relation:
720
+ * - an already-assigned relation with the same name → SKIP (additive-only,
721
+ * unchanged historical behavior);
722
+ * - a shadowed json/jsonb (`unknown`-typed) column → keep the historical
723
+ * name + warn (it worked at runtime and compiled);
724
+ * - a shadowed concrete-typed column → deterministic `Rel` suffix + warn
725
+ * instead of silently dropping the relation.
726
+ */
727
+ export function addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, columnNamesByTable, relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable) {
728
+ for (const tableName of tableNames) {
729
+ const pk = pkByTable.get(tableName) ?? [];
730
+ if (pk.length !== 2)
731
+ continue;
732
+ // FKs whose source is this table — both must be single-column.
733
+ const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
734
+ if (tableFks.length !== 2)
735
+ continue;
736
+ if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
737
+ continue;
738
+ const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
739
+ const pkSet = new Set(pk);
740
+ // Both FK columns must be the PK columns (and vice-versa).
741
+ if (!fkCols.every((c) => pkSet.has(c)))
742
+ continue;
743
+ if (new Set(fkCols).size !== 2)
744
+ continue;
745
+ // Two DISTINCT target tables.
746
+ const [fkA, fkB] = tableFks;
747
+ if (fkA.targetTable === fkB.targetTable)
748
+ continue;
749
+ // No payload columns: J's columns are exactly the two FK/PK columns.
750
+ const jCols = columnNamesByTable.get(tableName) ?? [];
751
+ if (jCols.length !== 2)
752
+ continue;
753
+ // For each direction, the m2m `referenceKey` is the *targeted* table's
754
+ // referenced column(s); the junction's sourceKey is the FK column pointing
755
+ // to that table; the targetKey is the FK column pointing to the OTHER table.
756
+ const addM2M = (self, other) => {
757
+ const sourceTbl = self.targetTable; // A
758
+ const targetTbl = other.targetTable; // B
759
+ let relName = snakeToCamel(targetTbl); // plural table name → e.g. "tags"
760
+ if (!relationsByTable.has(sourceTbl))
761
+ relationsByTable.set(sourceTbl, {});
762
+ const existing = relationsByTable.get(sourceTbl);
763
+ // Additive-only: never clobber an existing relation name.
764
+ if (existing[relName])
765
+ return;
766
+ const columnFields = columnFieldsByTable?.get(sourceTbl);
767
+ if (columnFields?.has(relName)) {
768
+ if (unknownTypedFieldsByTable?.get(sourceTbl)?.has(relName)) {
769
+ // Historical json/jsonb shadow — worked at runtime, compiled fine.
770
+ console.warn(`[turbine] Relation "${relName}" on table "${sourceTbl}" (junction ${tableName}) shadows the ` +
771
+ `json/jsonb column "${relName}" — keeping the historical name for runtime compatibility; ` +
772
+ `the relation is omitted from the generated types.`);
773
+ }
774
+ else {
775
+ const taken = new Set([...columnFields, ...Object.keys(existing)]);
776
+ relName = resolveRelationNameCollision(relName, taken, sourceTbl, `junction ${tableName}`);
777
+ }
778
+ }
779
+ existing[relName] = {
780
+ type: 'manyToMany',
781
+ name: relName,
782
+ from: sourceTbl,
783
+ to: targetTbl,
784
+ // referenceKey = A's referenced column(s) that J's sourceKey points at.
785
+ referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
786
+ // foreignKey is unused for m2m correlation but kept for shape parity
787
+ // (mirrors the source-side reference for back-compat consumers).
788
+ foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
789
+ through: {
790
+ table: tableName,
791
+ sourceKey: self.sourceColumns[0], // J col → A
792
+ targetKey: other.sourceColumns[0], // J col → B
793
+ },
794
+ };
795
+ };
796
+ addM2M(fkA, fkB); // A → B
797
+ addM2M(fkB, fkA); // B → A
798
+ }
799
+ }
800
+ /**
801
+ * One-stop relation derivation for the engine introspectors (SQLite / MySQL /
802
+ * MSSQL): filters the FK list to the introspected table set, seeds the
803
+ * taken-name / json-shadow maps from the engine's column metadata, and runs
804
+ * the SAME `buildRelationsFromForeignKeys` + `addAutoManyToManyRelations`
805
+ * pipeline as the Postgres introspector — so every engine derives identical
806
+ * relation names for the same logical schema (the engines previously carried
807
+ * stale copies of a retired naming scheme).
808
+ */
809
+ export function deriveEngineRelations(tableNames, foreignKeys, pkByTable, columnsByTable) {
810
+ const tableSet = new Set(tableNames);
811
+ const fks = foreignKeys.filter((fk) => tableSet.has(fk.sourceTable) && tableSet.has(fk.targetTable));
812
+ const columnFieldsByTable = new Map();
813
+ const unknownTypedFieldsByTable = new Map();
814
+ for (const [tbl, cols] of columnsByTable) {
815
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
816
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType)).map((c) => c.field)));
817
+ }
818
+ const relationsByTable = buildRelationsFromForeignKeys(fks, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
819
+ addAutoManyToManyRelations(tableNames, fks, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relationsByTable, columnFieldsByTable, unknownTypedFieldsByTable);
820
+ return relationsByTable;
821
+ }
package/dist/mssql.js CHANGED
@@ -92,7 +92,9 @@
92
92
  import { TurbineClient } from './client.js';
93
93
  import { postgresDialect, } from './dialect.js';
94
94
  import { ConnectionError, RelationError, UnsupportedFeatureError, ValidationError } from './errors.js';
95
- import { camelToSnake, isDateType, normalizeKeyColumns, singularize, snakeToCamel, } from './schema.js';
95
+ import { deriveEngineRelations } from './introspect.js';
96
+ import importOptionalPeer from './optional-peer-import.cjs';
97
+ import { camelToSnake, isDateType, normalizeKeyColumns, snakeToCamel, } from './schema.js';
96
98
  // ---------------------------------------------------------------------------
97
99
  // SQL Server / connection limits
98
100
  // ---------------------------------------------------------------------------
@@ -853,103 +855,15 @@ function buildForJsonManyToMany(dialect, ctx, h) {
853
855
  }
854
856
  const num = (v) => (typeof v === 'string' ? Number(v) : (v ?? 0));
855
857
  /**
856
- * Derive belongsTo + hasMany relations (and conservatively-detected manyToMany
857
- * junctions) from a flat foreign-key list. Mirrors the PostgreSQL / MySQL / SQLite
858
- * introspectors so the produced {@link SchemaMetadata} has an identical relation
859
- * shape across engines.
858
+ * Derive relations from the FK list via the SHARED introspection pipeline
859
+ * (`deriveEngineRelations` `buildRelationsFromForeignKeys` +
860
+ * `addAutoManyToManyRelations` in introspect.ts), so this engine derives
861
+ * IDENTICAL relation names to `turbine generate` against Postgres for the
862
+ * same logical schema — legacy-first naming, per-column disambiguation, and
863
+ * collision resolution against scalar column fields included.
860
864
  */
861
865
  function buildRelationsFromForeignKeys(tableNames, foreignKeys, pkByTable, columnsByTable) {
862
- const tableSet = new Set(tableNames);
863
- const fkCounts = new Map();
864
- for (const fk of foreignKeys) {
865
- const key = `${fk.sourceTable}->${fk.targetTable}`;
866
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
867
- }
868
- const relationsByTable = new Map();
869
- for (const fk of foreignKeys) {
870
- if (!tableSet.has(fk.targetTable))
871
- continue;
872
- const needsDisambiguation = (fkCounts.get(`${fk.sourceTable}->${fk.targetTable}`) ?? 0) > 1;
873
- const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
874
- const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
875
- const belongsToName = needsDisambiguation && fk.sourceColumns.length === 1
876
- ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
877
- : singularize(snakeToCamel(fk.targetTable));
878
- if (!relationsByTable.has(fk.sourceTable))
879
- relationsByTable.set(fk.sourceTable, {});
880
- relationsByTable.get(fk.sourceTable)[belongsToName] = {
881
- type: 'belongsTo',
882
- name: belongsToName,
883
- from: fk.sourceTable,
884
- to: fk.targetTable,
885
- foreignKey,
886
- referenceKey,
887
- };
888
- const hasManyName = needsDisambiguation && fk.sourceColumns.length === 1
889
- ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
890
- : snakeToCamel(fk.sourceTable);
891
- if (!relationsByTable.has(fk.targetTable))
892
- relationsByTable.set(fk.targetTable, {});
893
- relationsByTable.get(fk.targetTable)[hasManyName] = {
894
- type: 'hasMany',
895
- name: hasManyName,
896
- from: fk.targetTable,
897
- to: fk.sourceTable,
898
- foreignKey,
899
- referenceKey,
900
- };
901
- }
902
- // Conservative many-to-many auto-detection (additive): a table J is a pure
903
- // junction iff PK is exactly two columns, exactly two single-column FKs whose
904
- // source columns ARE the PK, two distinct target tables, and no payload columns.
905
- for (const tableName of tableNames) {
906
- const pk = pkByTable.get(tableName) ?? [];
907
- if (pk.length !== 2)
908
- continue;
909
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
910
- if (tableFks.length !== 2)
911
- continue;
912
- if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
913
- continue;
914
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
915
- const pkSet = new Set(pk);
916
- if (!fkCols.every((c) => pkSet.has(c)))
917
- continue;
918
- if (new Set(fkCols).size !== 2)
919
- continue;
920
- const [fkA, fkB] = tableFks;
921
- if (fkA.targetTable === fkB.targetTable)
922
- continue;
923
- const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
924
- if (jCols.length !== 2)
925
- continue;
926
- const addM2M = (self, other) => {
927
- const sourceTbl = self.targetTable;
928
- const targetTbl = other.targetTable;
929
- const relName = snakeToCamel(targetTbl);
930
- if (!relationsByTable.has(sourceTbl))
931
- relationsByTable.set(sourceTbl, {});
932
- const existing = relationsByTable.get(sourceTbl);
933
- if (existing[relName])
934
- return;
935
- existing[relName] = {
936
- type: 'manyToMany',
937
- name: relName,
938
- from: sourceTbl,
939
- to: targetTbl,
940
- referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
941
- foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
942
- through: {
943
- table: tableName,
944
- sourceKey: self.sourceColumns[0],
945
- targetKey: other.sourceColumns[0],
946
- },
947
- };
948
- };
949
- addM2M(fkA, fkB);
950
- addM2M(fkB, fkA);
951
- }
952
- return relationsByTable;
866
+ return deriveEngineRelations(tableNames, foreignKeys, pkByTable, columnsByTable);
953
867
  }
954
868
  /**
955
869
  * Introspect a SQL Server database into the same {@link SchemaMetadata} shape the
@@ -1196,11 +1110,12 @@ async function loadMssql() {
1196
1110
  let mod;
1197
1111
  try {
1198
1112
  // `mssql` ships no bundled type declarations (it needs @types/mssql, which
1199
- // Turbine deliberately does not depend on) — the structural MssqlModule above
1200
- // is our typed surface. Import through a widened specifier so tsc treats the
1201
- // typeless module as `any` instead of erroring (TS7016).
1202
- const specifier = 'mssql';
1203
- mod = (await import(specifier));
1113
+ // Turbine deliberately does not depend on) — the structural MssqlModule
1114
+ // above is our typed surface; the helper returns `unknown` so no TS7016.
1115
+ // Via the .cts helper so the CJS build keeps a path to a REAL dynamic
1116
+ // import() even if a future mssql major goes ESM-only (the CommonJS pass
1117
+ // transpiles a plain `import()` here into `require()`).
1118
+ mod = (await importOptionalPeer('mssql'));
1204
1119
  }
1205
1120
  catch (err) {
1206
1121
  throw new ConnectionError("[turbine] turbine-orm/mssql requires the optional peer dependency 'mssql'. Install it: npm i mssql. " +