turbine-orm 0.29.0 → 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 +33 -3
  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 +424 -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 +6 -2
  22. package/dist/client.js +33 -3
  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 +87 -25
  34. package/dist/powdb.js +419 -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
package/README.md CHANGED
@@ -882,7 +882,7 @@ Everything is honest about what ports and what doesn't. Features marked **PG-onl
882
882
 
883
883
  **Engine notes:** SQLite uses `RETURNING` (≥ 3.35) just like Postgres. MySQL has no `RETURNING`, so writes re-`SELECT` the affected row and **`createMany` returns `[]`** (the rows ARE inserted — re-query if you need them). SQL Server returns rows via `OUTPUT`/`MERGE`; `DISTINCT ON` is Postgres-only. Only Postgres streams via a true cursor (constant memory); the other engines' `findManyStream` materializes the result then yields it in batches. Optimistic locking throws `OptimisticLockError` on all engines (on MySQL the conflict is detected from the version-checked UPDATE's affected-row count). The `turbine` CLI (`generate`, `migrate`) is currently PostgreSQL-only — point the engine factories at a hand-written or programmatically introspected `SCHEMA`.
884
884
 
885
- **PowDB** speaks its own non-SQL query language (PowQL), so it sits outside the SQL matrix above. Writes use a trailing **`returning`** keyword (upsert reselects by PK). PKs are server-assigned `auto` ints **or** client UUIDs. Nested relations load client-side (N+1, including many-to-many via the junction — no `json_agg`). Nested writes cover hasMany/hasOne/belongsTo; many-to-many nested writes are not supported. Transactions are single-writer (no nested savepoints). Schema is code-first via `defineSchema` (no wire introspection). Embedded `syncMode: 'normal'` moves fsync off the commit path; the networked transport runs the same data over a socket. Cursor streaming and the Postgres-only trio (pgvector / LISTEN/NOTIFY / RLS session GUCs) throw `UnsupportedFeatureError`. Full details: **[turbineorm.dev/engines#powdb](https://turbineorm.dev/engines#powdb)**.
885
+ **PowDB** speaks its own non-SQL query language (PowQL), so it sits outside the SQL matrix above. Writes use a trailing **`returning`** keyword (upsert reselects by PK). PKs are server-assigned `auto` ints **or** client UUIDs. Nested relations load client-side (N+1, including many-to-many via the junction — no `json_agg`). Nested writes cover hasMany/hasOne/belongsTo; many-to-many nested writes are not supported. Transactions are single-writer: concurrent `$transaction` calls queue FIFO (bounded by `transactionQueueTimeoutMs`); nested/re-entrant transactions throw typed errors (no savepoints). Schema is code-first via `defineSchema` (no wire introspection) — `schemaDefToMetadata()` bridges it to any engine that needs runtime metadata. Embedded `syncMode: 'normal'` moves fsync off the commit path; the networked transport runs the same data over a socket. Cursor streaming and the Postgres-only trio (pgvector / LISTEN/NOTIFY / RLS session GUCs) throw `UnsupportedFeatureError`. Full details: **[turbineorm.dev/engines#powdb](https://turbineorm.dev/engines#powdb)**.
886
886
 
887
887
  Full setup, signatures, and the complete support matrix: **[turbineorm.dev/engines](https://turbineorm.dev/engines)**.
888
888
 
@@ -141,6 +141,9 @@ function parseArgs(argv = process.argv.slice(2)) {
141
141
  case '--include-views':
142
142
  result.includeViews = true;
143
143
  break;
144
+ case '--no-timestamp':
145
+ result.noTimestamp = true;
146
+ break;
144
147
  case '--allow-destructive':
145
148
  result.allowDestructive = true;
146
149
  break;
@@ -557,6 +560,7 @@ async function cmdGenerate(args, config) {
557
560
  outDir: config.out,
558
561
  connectionString: url,
559
562
  zod: args.zod,
563
+ noTimestamp: args.noTimestamp,
560
564
  });
561
565
  genSpinner.succeed(`Generated ${(0, ui_js_1.bold)(String(result.files.length))} files in ${(0, ui_js_1.elapsed)(startTime)}`);
562
566
  // List files
@@ -1503,6 +1507,7 @@ function showGenerateHelp() {
1503
1507
  console.log(` ${(0, ui_js_1.cyan)('--exclude')} ${(0, ui_js_1.dim)('<tables>')} Comma-separated tables to exclude`);
1504
1508
  console.log(` ${(0, ui_js_1.cyan)('--zod')} Also emit ${(0, ui_js_1.cyan)('zod.ts')} validation schemas ${(0, ui_js_1.dim)('(needs the zod dep)')}`);
1505
1509
  console.log(` ${(0, ui_js_1.cyan)('--include-views')} Include views + materialized views as read-only entities`);
1510
+ console.log(` ${(0, ui_js_1.cyan)('--no-timestamp')} Omit the ${(0, ui_js_1.dim)('Generated at:')} header line ${(0, ui_js_1.dim)('(reproducible, diff-stable output)')}`);
1506
1511
  console.log(` ${(0, ui_js_1.cyan)('--allow-empty')} Generate even when introspection matches 0 tables`);
1507
1512
  (0, ui_js_1.newline)();
1508
1513
  }
@@ -4,12 +4,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.startMcpServer = startMcpServer;
7
+ exports.buildRelations = buildRelations;
7
8
  exports.runMcpServer = runMcpServer;
8
9
  const node_crypto_1 = require("node:crypto");
9
10
  const node_fs_1 = require("node:fs");
10
11
  const node_path_1 = require("node:path");
11
12
  const pg_1 = __importDefault(require("pg"));
12
13
  const index_advisor_js_1 = require("../index-advisor.js");
14
+ const introspect_js_1 = require("../introspect.js");
13
15
  const index_js_1 = require("../query/index.js");
14
16
  const schema_js_1 = require("../schema.js");
15
17
  const migrate_js_1 = require("./migrate.js");
@@ -557,7 +559,7 @@ async function loadSchemaMetadata(client, options) {
557
559
  labels.push(row.enumlabel);
558
560
  enums[row.typname] = labels;
559
561
  }
560
- const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows);
562
+ const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows, enums);
561
563
  const tables = {};
562
564
  for (const tableName of tableNames) {
563
565
  const columns = columnsByTable.get(tableName) ?? [];
@@ -596,7 +598,15 @@ async function loadSchemaMetadata(client, options) {
596
598
  }
597
599
  return { tables, enums };
598
600
  }
599
- function buildRelations(tableNames, columnsByTable, pkByTable, rows) {
601
+ /**
602
+ * Group raw FK rows into constraint-level entries and delegate relation
603
+ * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
604
+ * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
605
+ * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
606
+ * generate` derived DIFFERENT relation names from the same database (N-3).
607
+ * Exported for the parity unit test.
608
+ */
609
+ function buildRelations(tableNames, columnsByTable, pkByTable, rows, enums = {}) {
600
610
  const tableSet = new Set(tableNames);
601
611
  const groups = new Map();
602
612
  for (const row of rows) {
@@ -614,95 +624,18 @@ function buildRelations(tableNames, columnsByTable, pkByTable, rows) {
614
624
  groups.set(row.constraint_name, group);
615
625
  }
616
626
  const foreignKeys = [...groups.values()];
617
- const fkCounts = new Map();
618
- for (const fk of foreignKeys) {
619
- const key = `${fk.sourceTable}→${fk.targetTable}`;
620
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
621
- }
622
- const relations = new Map();
623
- for (const fk of foreignKeys) {
624
- const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
625
- const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
626
- const foreignKey = oneOrMany(fk.sourceColumns);
627
- const referenceKey = oneOrMany(fk.targetColumns);
628
- const belongsToName = needsDisambiguation
629
- ? fk.sourceColumns.length === 1
630
- ? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
631
- : (0, schema_js_1.snakeToCamel)(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
632
- : (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
633
- const hasManyName = needsDisambiguation
634
- ? fk.sourceColumns.length === 1
635
- ? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
636
- : (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
637
- : (0, schema_js_1.snakeToCamel)(fk.sourceTable);
638
- const sourceRels = relations.get(fk.sourceTable) ?? {};
639
- sourceRels[belongsToName] = {
640
- type: 'belongsTo',
641
- name: belongsToName,
642
- from: fk.sourceTable,
643
- to: fk.targetTable,
644
- foreignKey,
645
- referenceKey,
646
- };
647
- relations.set(fk.sourceTable, sourceRels);
648
- const targetRels = relations.get(fk.targetTable) ?? {};
649
- targetRels[hasManyName] = {
650
- type: 'hasMany',
651
- name: hasManyName,
652
- from: fk.targetTable,
653
- to: fk.sourceTable,
654
- foreignKey,
655
- referenceKey,
656
- };
657
- relations.set(fk.targetTable, targetRels);
658
- }
659
- addManyToManyRelations(tableNames, columnsByTable, pkByTable, foreignKeys, relations);
627
+ const columnFieldsByTable = new Map();
628
+ const unknownTypedFieldsByTable = new Map();
629
+ for (const [tbl, cols] of columnsByTable) {
630
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
631
+ // Enum-typed columns also report tsType 'unknown', but the generated type
632
+ // layer gives them a concrete union — only json/jsonb qualify as shadows.
633
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => (0, introspect_js_1.isUnknownTsType)(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
634
+ }
635
+ const relations = (0, introspect_js_1.buildRelationsFromForeignKeys)(foreignKeys, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
636
+ (0, introspect_js_1.addAutoManyToManyRelations)(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relations, columnFieldsByTable, unknownTypedFieldsByTable);
660
637
  return relations;
661
638
  }
662
- function addManyToManyRelations(tableNames, columnsByTable, pkByTable, foreignKeys, relations) {
663
- for (const tableName of tableNames) {
664
- const pk = pkByTable.get(tableName) ?? [];
665
- if (pk.length !== 2)
666
- continue;
667
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
668
- if (tableFks.length !== 2 || tableFks.some((fk) => fk.sourceColumns.length !== 1))
669
- continue;
670
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
671
- const pkSet = new Set(pk);
672
- if (!fkCols.every((column) => pkSet.has(column)) || new Set(fkCols).size !== 2)
673
- continue;
674
- const [fkA, fkB] = tableFks;
675
- if (fkA.targetTable === fkB.targetTable)
676
- continue;
677
- const junctionColumns = (columnsByTable.get(tableName) ?? []).map((column) => column.name);
678
- if (junctionColumns.length !== 2)
679
- continue;
680
- addManyToManyDirection(relations, tableName, fkA, fkB);
681
- addManyToManyDirection(relations, tableName, fkB, fkA);
682
- }
683
- }
684
- function addManyToManyDirection(relations, junctionTable, self, other) {
685
- const sourceTable = self.targetTable;
686
- const targetTable = other.targetTable;
687
- const relName = (0, schema_js_1.snakeToCamel)(targetTable);
688
- const tableRelations = relations.get(sourceTable) ?? {};
689
- if (tableRelations[relName])
690
- return;
691
- tableRelations[relName] = {
692
- type: 'manyToMany',
693
- name: relName,
694
- from: sourceTable,
695
- to: targetTable,
696
- referenceKey: oneOrMany(self.targetColumns),
697
- foreignKey: oneOrMany(self.targetColumns),
698
- through: {
699
- table: junctionTable,
700
- sourceKey: self.sourceColumns[0],
701
- targetKey: other.sourceColumns[0],
702
- },
703
- };
704
- relations.set(sourceTable, tableRelations);
705
- }
706
639
  async function estimateRows(client, schema) {
707
640
  const result = await client.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
708
641
  FROM pg_class c
@@ -730,9 +663,6 @@ function extractIndexColumns(indexdef) {
730
663
  .replace(/ (ASC|DESC)$/i, '')
731
664
  .replace(/^"|"$/g, ''));
732
665
  }
733
- function oneOrMany(columns) {
734
- return columns.length === 1 ? columns[0] : columns;
735
- }
736
666
  function optionalLimit(value) {
737
667
  if (value === undefined)
738
668
  return 50;
@@ -771,14 +771,31 @@ class TurbineClient {
771
771
  */
772
772
  async transaction(fn) {
773
773
  const client = await this.pool.connect();
774
+ /**
775
+ * Only true once BEGIN has actually succeeded. If BEGIN itself throws
776
+ * (e.g. a single-writer engine's transaction gate times out or rejects a
777
+ * re-entrant begin), issuing a "best-effort" ROLLBACK would be a stray
778
+ * statement from a context that never opened a transaction — on a driver
779
+ * with one shared engine handle (PowDB embedded) it would roll back a
780
+ * DIFFERENT caller's open transaction.
781
+ */
782
+ let began = false;
774
783
  try {
775
784
  await client.query(this.dialect.beginStatement());
785
+ began = true;
776
786
  const result = await fn(client);
777
787
  await client.query(this.dialect.commitStatement());
778
788
  return result;
779
789
  }
780
790
  catch (err) {
781
- await client.query(this.dialect.rollbackStatement());
791
+ if (began) {
792
+ try {
793
+ await client.query(this.dialect.rollbackStatement());
794
+ }
795
+ catch {
796
+ // Best-effort rollback — the connection may have died mid-query.
797
+ }
798
+ }
782
799
  throw err;
783
800
  }
784
801
  finally {
@@ -812,11 +829,21 @@ class TurbineClient {
812
829
  }
813
830
  };
814
831
  let timedOut = false;
832
+ /**
833
+ * Only true once BEGIN has actually succeeded. If BEGIN itself throws —
834
+ * e.g. a single-writer engine's transaction gate times out in its FIFO
835
+ * queue or rejects a re-entrant begin (PowDB, E002/E017) — this context
836
+ * never opened a transaction, so the catch below must NOT issue its
837
+ * best-effort ROLLBACK: on a driver with one shared engine handle that
838
+ * stray ROLLBACK would tear down a DIFFERENT caller's open transaction.
839
+ */
840
+ let began = false;
815
841
  try {
816
842
  // BEGIN with optional isolation level — the dialect owns the keyword and
817
843
  // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
818
844
  const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
819
845
  await client.query(this.dialect.beginStatement(isolationSql));
846
+ began = true;
820
847
  // Apply transaction-local session context (RLS / multi-tenant GUCs).
821
848
  // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
822
849
  // user fn. Any error here propagates to the catch below and rolls back
@@ -889,8 +916,11 @@ class TurbineClient {
889
916
  // If the timeout fired we already destroyed the connection — issuing a
890
917
  // ROLLBACK on a released client would throw "Client has already been
891
918
  // released". Skip the rollback in that case (the backend rolled back
892
- // when its socket was closed).
893
- if (!timedOut && !released) {
919
+ // when its socket was closed). Likewise skip it when BEGIN never
920
+ // succeeded (`began` false) — there is no transaction to roll back and
921
+ // the stray statement could hit another caller's transaction on a
922
+ // shared-handle engine.
923
+ if (began && !timedOut && !released) {
894
924
  try {
895
925
  await client.query(this.dialect.rollbackStatement());
896
926
  }
@@ -57,21 +57,22 @@ function generate(options) {
57
57
  }
58
58
  (0, node_fs_1.mkdirSync)(outDir, { recursive: true });
59
59
  const files = [];
60
+ const fileOptions = { noTimestamp: options.noTimestamp };
60
61
  // Generate types.ts
61
- const typesContent = generateTypes(options.schema);
62
+ const typesContent = generateTypes(options.schema, fileOptions);
62
63
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'types.ts'), typesContent, 'utf-8');
63
64
  files.push('types.ts');
64
65
  // Generate metadata.ts
65
- const metadataContent = generateMetadata(options.schema);
66
+ const metadataContent = generateMetadata(options.schema, fileOptions);
66
67
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'metadata.ts'), metadataContent, 'utf-8');
67
68
  files.push('metadata.ts');
68
69
  // Generate index.ts (configured client)
69
- const indexContent = generateIndex(options.schema);
70
+ const indexContent = generateIndex(options.schema, fileOptions);
70
71
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'index.ts'), indexContent, 'utf-8');
71
72
  files.push('index.ts');
72
73
  // Generate zod.ts (optional — --zod flag)
73
74
  if (options.zod) {
74
- const zodContent = generateZod(options.schema);
75
+ const zodContent = generateZod(options.schema, fileOptions);
75
76
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'zod.ts'), zodContent, 'utf-8');
76
77
  files.push('zod.ts');
77
78
  }
@@ -80,24 +81,51 @@ function generate(options) {
80
81
  // ---------------------------------------------------------------------------
81
82
  // types.ts generator
82
83
  // ---------------------------------------------------------------------------
83
- function generatedFileHeader() {
84
+ function generatedFileHeader(options) {
85
+ // `noTimestamp` omits the volatile line entirely (T-8b) so regenerating an
86
+ // unchanged schema produces byte-identical files.
84
87
  return [
85
88
  '/**',
86
89
  ' * Auto-generated by turbine-orm — DO NOT EDIT',
87
90
  ' *',
88
- ` * Generated at: ${new Date().toISOString()}`,
91
+ ...(options?.noTimestamp ? [] : [` * Generated at: ${new Date().toISOString()}`]),
89
92
  ' * @see https://turbineorm.dev',
90
93
  ' */',
91
94
  '',
92
95
  ];
93
96
  }
97
+ /**
98
+ * The relations of a table that are safe to surface in the generated TYPE
99
+ * layer. A relation whose name equals a scalar column field would shadow the
100
+ * column: `interface XWithY extends X` becomes TS2430, the `XCreate & { y?: … }`
101
+ * intersection collapses (TS2322), and neither the column nor the relation is
102
+ * targetable. Introspection no longer produces such names (they are
103
+ * disambiguated at the source), but hand-written or legacy metadata may —
104
+ * skip those relations here with a warning instead of emitting broken types.
105
+ * The runtime metadata (metadata.ts) still carries every relation.
106
+ */
107
+ function typeSafeRelations(table, warn = true) {
108
+ const columnFields = new Set(table.columns.map((c) => c.field));
109
+ const usable = [];
110
+ for (const [relName, rel] of Object.entries(table.relations)) {
111
+ if (columnFields.has(relName)) {
112
+ if (warn) {
113
+ console.warn(`[turbine] Relation "${relName}" on table "${table.name}" shadows a column field of the same name — ` +
114
+ `omitting it from the generated types. Rename the relation (or the column) to expose it.`);
115
+ }
116
+ continue;
117
+ }
118
+ usable.push([relName, rel]);
119
+ }
120
+ return usable;
121
+ }
94
122
  /**
95
123
  * Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
96
124
  * and *Relations brand-field interfaces). Exported so tests can pin the
97
125
  * generator output without writing files to disk.
98
126
  */
99
- function generateTypes(schema) {
100
- const lines = [...generatedFileHeader()];
127
+ function generateTypes(schema, options) {
128
+ const lines = [...generatedFileHeader(options)];
101
129
  // We import UpdateOperatorInput so generated *Update types can express
102
130
  // atomic increment / decrement / multiply / divide / set operators on
103
131
  // numeric columns (TASK-3.4).
@@ -113,9 +141,15 @@ function generateTypes(schema) {
113
141
  // `${TargetType}Relations` (for deep inference) or `{}` (the no-relations
114
142
  // default) into each `RelationDescriptor`. Built once up-front because
115
143
  // relations can point at tables we haven't iterated to yet.
144
+ // Relations that can be surfaced in the type layer, computed once per table
145
+ // (relations that would shadow a scalar column field are excluded + warned).
146
+ const safeRelationsByTable = new Map();
147
+ for (const t of Object.values(schema.tables)) {
148
+ safeRelationsByTable.set(t.name, typeSafeRelations(t));
149
+ }
116
150
  const tablesWithRelations = new Set();
117
151
  for (const t of Object.values(schema.tables)) {
118
- if (Object.keys(t.relations).length > 0)
152
+ if ((safeRelationsByTable.get(t.name) ?? []).length > 0)
119
153
  tablesWithRelations.add(t.name);
120
154
  }
121
155
  // Generate enum types
@@ -180,11 +214,12 @@ function generateTypes(schema) {
180
214
  // any depth — `RelationRelations<R[K]>` reads the third type parameter
181
215
  // and threads it into the next recursion step. If the target table has
182
216
  // no relations of its own, the descriptor uses `{}` (the default).
183
- const hasRelations = Object.keys(table.relations).length > 0;
217
+ const safeRelations = safeRelationsByTable.get(table.name) ?? [];
218
+ const hasRelations = safeRelations.length > 0;
184
219
  if (hasRelations) {
185
220
  lines.push(`/** Available relations for the \`${table.name}\` table */`);
186
221
  lines.push(`export interface ${typeName}Relations {`);
187
- for (const [relName, rel] of Object.entries(table.relations)) {
222
+ for (const [relName, rel] of safeRelations) {
188
223
  const targetType = entityName(rel.to);
189
224
  // manyToMany is a collection too → 'many' cardinality (same as hasMany).
190
225
  const cardinality = rel.type === 'hasMany' || rel.type === 'manyToMany' ? "'many'" : "'one'";
@@ -194,7 +229,7 @@ function generateTypes(schema) {
194
229
  lines.push('}');
195
230
  lines.push('');
196
231
  // --- Legacy per-relation interfaces (kept for backward compatibility) ---
197
- for (const [relName, rel] of Object.entries(table.relations)) {
232
+ for (const [relName, rel] of safeRelations) {
198
233
  const targetType = entityName(rel.to);
199
234
  if (rel.type === 'hasMany' || rel.type === 'manyToMany') {
200
235
  lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
@@ -218,7 +253,8 @@ function generateTypes(schema) {
218
253
  // ---------------------------------------------------------------------------
219
254
  for (const table of Object.values(schema.tables)) {
220
255
  const typeName = entityName(table.name);
221
- const hasRels = Object.keys(table.relations).length > 0;
256
+ const safeRelations = safeRelationsByTable.get(table.name) ?? [];
257
+ const hasRels = safeRelations.length > 0;
222
258
  // WhereUnique — union of unique constraint shapes, deduplicating PK
223
259
  const seen = new Set();
224
260
  const uniqueSets = [];
@@ -250,14 +286,14 @@ function generateTypes(schema) {
250
286
  // CreateInput / UpdateInput — extends base type with optional relation fields
251
287
  if (hasRels) {
252
288
  lines.push(`export type ${typeName}CreateInput = ${typeName}Create & {`);
253
- for (const [relName, rel] of Object.entries(table.relations)) {
289
+ for (const [relName, rel] of safeRelations) {
254
290
  const targetType = entityName(rel.to);
255
291
  lines.push(` ${relName}?: ${targetType}NestedCreateInput;`);
256
292
  }
257
293
  lines.push('};');
258
294
  lines.push('');
259
295
  lines.push(`export type ${typeName}UpdateInput = ${typeName}Update & {`);
260
- for (const [relName, rel] of Object.entries(table.relations)) {
296
+ for (const [relName, rel] of safeRelations) {
261
297
  const targetType = entityName(rel.to);
262
298
  if (rel.type === 'hasMany') {
263
299
  lines.push(` ${relName}?: ${targetType}NestedUpdateInput;`);
@@ -273,7 +309,7 @@ function generateTypes(schema) {
273
309
  // Emit NestedCreateInput, NestedUpdateInput, ConnectOrCreate for every table
274
310
  for (const table of Object.values(schema.tables)) {
275
311
  const typeName = entityName(table.name);
276
- const hasRels = Object.keys(table.relations).length > 0;
312
+ const hasRels = (safeRelationsByTable.get(table.name) ?? []).length > 0;
277
313
  // NestedCreateInput uses *CreateInput (which includes relation fields) when
278
314
  // the table has relations, otherwise falls back to the plain *Create type.
279
315
  const createRefType = hasRels ? `${typeName}CreateInput` : `${typeName}Create`;
@@ -358,8 +394,8 @@ function zodBaseType(col, enums) {
358
394
  * columns omitted, every remaining column optional). Exported so tests can pin
359
395
  * the output without writing files.
360
396
  */
361
- function generateZod(schema) {
362
- const lines = [...generatedFileHeader()];
397
+ function generateZod(schema, options) {
398
+ const lines = [...generatedFileHeader(options)];
363
399
  // `zod` is a USER dependency — this generated file imports it, but the Turbine
364
400
  // library runtime never does, so Zod stays out of the package's dep graph.
365
401
  lines.push("import { z } from 'zod';");
@@ -416,9 +452,9 @@ function generateZod(schema) {
416
452
  // ---------------------------------------------------------------------------
417
453
  // metadata.ts generator
418
454
  // ---------------------------------------------------------------------------
419
- function generateMetadata(schema) {
455
+ function generateMetadata(schema, options) {
420
456
  const lines = [
421
- ...generatedFileHeader(),
457
+ ...generatedFileHeader(options),
422
458
  "import type { SchemaMetadata } from 'turbine-orm';",
423
459
  '',
424
460
  'export const SCHEMA: SchemaMetadata = {',
@@ -518,10 +554,15 @@ function generateMetadata(schema) {
518
554
  // ---------------------------------------------------------------------------
519
555
  // index.ts generator (configured client with typed table accessors)
520
556
  // ---------------------------------------------------------------------------
521
- function generateIndex(schema) {
557
+ function generateIndex(schema, options) {
522
558
  const tableEntries = Object.values(schema.tables);
559
+ // Must mirror generateTypes: `XRelations` only exists in types.ts when the
560
+ // table has at least one type-safe (non-column-shadowing) relation.
561
+ const hasSafeRelations = new Map();
562
+ for (const t of tableEntries)
563
+ hasSafeRelations.set(t.name, typeSafeRelations(t, false).length > 0);
523
564
  const lines = [
524
- ...generatedFileHeader(),
565
+ ...generatedFileHeader(options),
525
566
  "import { TurbineClient as BaseTurbineClient, TransactionClient as BaseTransactionClient, QueryInterface } from 'turbine-orm';",
526
567
  "import type { TurbineConfig, TransactionOptions, DeferredQuery, PipelineResults } from 'turbine-orm';",
527
568
  "import { SCHEMA } from './metadata.js';",
@@ -530,7 +571,7 @@ function generateIndex(schema) {
530
571
  const typeImports = [];
531
572
  for (const t of tableEntries) {
532
573
  typeImports.push(entityName(t.name));
533
- if (Object.keys(t.relations).length > 0) {
574
+ if (hasSafeRelations.get(t.name)) {
534
575
  typeImports.push(`${entityName(t.name)}Relations`);
535
576
  }
536
577
  }
@@ -552,7 +593,7 @@ function generateIndex(schema) {
552
593
  for (const table of tableEntries) {
553
594
  const typeName = entityName(table.name);
554
595
  const accessor = snakeToCamelStr(table.name);
555
- const hasRelations = Object.keys(table.relations).length > 0;
596
+ const hasRelations = hasSafeRelations.get(table.name) === true;
556
597
  const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
557
598
  lines.push(` /** Query interface for the \`${table.name}\` table (transaction-scoped) */`);
558
599
  lines.push(` declare readonly ${accessor}: ${accessorType(table, genericArgs)};`);
@@ -594,7 +635,7 @@ function generateIndex(schema) {
594
635
  for (const table of tableEntries) {
595
636
  const typeName = entityName(table.name);
596
637
  const accessor = snakeToCamelStr(table.name);
597
- const hasRelations = Object.keys(table.relations).length > 0;
638
+ const hasRelations = hasSafeRelations.get(table.name) === true;
598
639
  const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
599
640
  lines.push(` /** Query interface for the \`${table.name}\` table */`);
600
641
  lines.push(` declare readonly ${accessor}: ${accessorType(table, genericArgs)};`);
@@ -677,6 +718,11 @@ function serializeColumn(col) {
677
718
  `arrayType: '${escSQ(col.arrayType ?? col.pgArrayType)}'`,
678
719
  `pgArrayType: '${escSQ(col.pgArrayType)}'`,
679
720
  ];
721
+ // Cross-schema type marker — introspection records it only for types living
722
+ // outside the introspected schema; it must survive codegen or the runtime
723
+ // enum-cast guard in query/builder.ts loses the signal (N-5).
724
+ if (col.pgTypeSchema !== undefined)
725
+ parts.push(`pgTypeSchema: '${escSQ(col.pgTypeSchema)}'`);
680
726
  // Emit isGenerated only when set (server-generated serial/identity), so the
681
727
  // output stays byte-identical for the common client-default columns.
682
728
  if (col.isGenerated)
package/dist/cjs/index.js CHANGED
@@ -35,7 +35,7 @@
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.ColumnBuilder = exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UnsupportedFeatureError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
- exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.table = exports.defineSchema = exports.column = void 0;
38
+ exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.defineSeed = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.schemaDefToMetadata = exports.table = exports.defineSchema = exports.column = void 0;
39
39
  var index_js_1 = require("./adapters/index.js");
40
40
  Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
41
41
  Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
@@ -112,6 +112,9 @@ Object.defineProperty(exports, "column", { enumerable: true, get: function () {
112
112
  Object.defineProperty(exports, "defineSchema", { enumerable: true, get: function () { return schema_builder_js_1.defineSchema; } });
113
113
  // Legacy compat (deprecated — use object format with defineSchema)
114
114
  Object.defineProperty(exports, "table", { enumerable: true, get: function () { return schema_builder_js_1.table; } });
115
+ // Schema metadata bridge — defineSchema() → SchemaMetadata without a live DB
116
+ var schema_metadata_js_1 = require("./schema-metadata.js");
117
+ Object.defineProperty(exports, "schemaDefToMetadata", { enumerable: true, get: function () { return schema_metadata_js_1.schemaDefToMetadata; } });
115
118
  // Schema SQL — generate DDL, diff, and push
116
119
  var schema_sql_js_1 = require("./schema-sql.js");
117
120
  Object.defineProperty(exports, "schemaDiff", { enumerable: true, get: function () { return schema_sql_js_1.schemaDiff; } });