turbine-orm 0.29.0 → 0.31.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 (48) 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 +47 -6
  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 +42 -136
  9. package/dist/cjs/mysql.js +16 -129
  10. package/dist/cjs/optional-peer-import.cjs +122 -0
  11. package/dist/cjs/powdb.js +579 -89
  12. package/dist/cjs/powql.js +56 -26
  13. package/dist/cjs/query/builder.js +601 -86
  14. package/dist/cjs/query/filters.js +80 -2
  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 +19 -2
  22. package/dist/client.js +47 -6
  23. package/dist/generate.d.ts +16 -4
  24. package/dist/generate.js +71 -25
  25. package/dist/index.d.ts +2 -1
  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 +40 -104
  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 +118 -23
  34. package/dist/powdb.js +574 -88
  35. package/dist/powql.d.ts +6 -0
  36. package/dist/powql.js +58 -28
  37. package/dist/query/builder.d.ts +145 -8
  38. package/dist/query/builder.js +602 -87
  39. package/dist/query/deferred.d.ts +7 -2
  40. package/dist/query/filters.d.ts +46 -1
  41. package/dist/query/filters.js +76 -1
  42. package/dist/query/index.d.ts +1 -1
  43. package/dist/query/types.d.ts +85 -11
  44. package/dist/schema-metadata.d.ts +77 -0
  45. package/dist/schema-metadata.js +313 -0
  46. package/dist/schema.d.ts +10 -0
  47. package/dist/sqlite.js +9 -90
  48. package/package.json +3 -3
package/dist/cli/mcp.js CHANGED
@@ -3,8 +3,9 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import pg from 'pg';
5
5
  import { findMissingRelationIndexes } from '../index-advisor.js';
6
+ import { addAutoManyToManyRelations, buildRelationsFromForeignKeys, isUnknownTsType, } from '../introspect.js';
6
7
  import { QueryInterface, quoteIdent } from '../query/index.js';
7
- import { isDateType, pgArrayType, pgTypeToTs, singularize, snakeToCamel, } from '../schema.js';
8
+ import { isDateType, pgArrayType, pgTypeToTs, snakeToCamel, } from '../schema.js';
8
9
  import { listMigrationFiles } from './migrate.js';
9
10
  /**
10
11
  * Walk up from the running script to find turbine-orm's own package.json.
@@ -550,7 +551,7 @@ async function loadSchemaMetadata(client, options) {
550
551
  labels.push(row.enumlabel);
551
552
  enums[row.typname] = labels;
552
553
  }
553
- const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows);
554
+ const relationsByTable = buildRelations(tableNames, columnsByTable, pkByTable, fkResult.rows, enums);
554
555
  const tables = {};
555
556
  for (const tableName of tableNames) {
556
557
  const columns = columnsByTable.get(tableName) ?? [];
@@ -589,7 +590,15 @@ async function loadSchemaMetadata(client, options) {
589
590
  }
590
591
  return { tables, enums };
591
592
  }
592
- function buildRelations(tableNames, columnsByTable, pkByTable, rows) {
593
+ /**
594
+ * Group raw FK rows into constraint-level entries and delegate relation
595
+ * naming to the SHARED introspection builder (`buildRelationsFromForeignKeys`
596
+ * + `addAutoManyToManyRelations` in ../introspect.ts). MCP previously carried
597
+ * a stale copy of a retired naming scheme, so `turbine mcp` and `turbine
598
+ * generate` derived DIFFERENT relation names from the same database (N-3).
599
+ * Exported for the parity unit test.
600
+ */
601
+ export function buildRelations(tableNames, columnsByTable, pkByTable, rows, enums = {}) {
593
602
  const tableSet = new Set(tableNames);
594
603
  const groups = new Map();
595
604
  for (const row of rows) {
@@ -607,95 +616,18 @@ function buildRelations(tableNames, columnsByTable, pkByTable, rows) {
607
616
  groups.set(row.constraint_name, group);
608
617
  }
609
618
  const foreignKeys = [...groups.values()];
610
- const fkCounts = new Map();
611
- for (const fk of foreignKeys) {
612
- const key = `${fk.sourceTable}→${fk.targetTable}`;
613
- fkCounts.set(key, (fkCounts.get(key) ?? 0) + 1);
614
- }
615
- const relations = new Map();
616
- for (const fk of foreignKeys) {
617
- const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
618
- const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
619
- const foreignKey = oneOrMany(fk.sourceColumns);
620
- const referenceKey = oneOrMany(fk.targetColumns);
621
- const belongsToName = needsDisambiguation
622
- ? fk.sourceColumns.length === 1
623
- ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
624
- : snakeToCamel(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
625
- : singularize(snakeToCamel(fk.targetTable));
626
- const hasManyName = needsDisambiguation
627
- ? fk.sourceColumns.length === 1
628
- ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
629
- : snakeToCamel(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
630
- : snakeToCamel(fk.sourceTable);
631
- const sourceRels = relations.get(fk.sourceTable) ?? {};
632
- sourceRels[belongsToName] = {
633
- type: 'belongsTo',
634
- name: belongsToName,
635
- from: fk.sourceTable,
636
- to: fk.targetTable,
637
- foreignKey,
638
- referenceKey,
639
- };
640
- relations.set(fk.sourceTable, sourceRels);
641
- const targetRels = relations.get(fk.targetTable) ?? {};
642
- targetRels[hasManyName] = {
643
- type: 'hasMany',
644
- name: hasManyName,
645
- from: fk.targetTable,
646
- to: fk.sourceTable,
647
- foreignKey,
648
- referenceKey,
649
- };
650
- relations.set(fk.targetTable, targetRels);
651
- }
652
- addManyToManyRelations(tableNames, columnsByTable, pkByTable, foreignKeys, relations);
619
+ const columnFieldsByTable = new Map();
620
+ const unknownTypedFieldsByTable = new Map();
621
+ for (const [tbl, cols] of columnsByTable) {
622
+ columnFieldsByTable.set(tbl, new Set(cols.map((c) => c.field)));
623
+ // Enum-typed columns also report tsType 'unknown', but the generated type
624
+ // layer gives them a concrete union — only json/jsonb qualify as shadows.
625
+ unknownTypedFieldsByTable.set(tbl, new Set(cols.filter((c) => isUnknownTsType(c.tsType) && !Object.hasOwn(enums, c.pgType)).map((c) => c.field)));
626
+ }
627
+ const relations = buildRelationsFromForeignKeys(foreignKeys, columnFieldsByTable, undefined, unknownTypedFieldsByTable);
628
+ addAutoManyToManyRelations(tableNames, foreignKeys, pkByTable, new Map(Array.from(columnsByTable, ([tbl, cols]) => [tbl, cols.map((c) => c.name)])), relations, columnFieldsByTable, unknownTypedFieldsByTable);
653
629
  return relations;
654
630
  }
655
- function addManyToManyRelations(tableNames, columnsByTable, pkByTable, foreignKeys, relations) {
656
- for (const tableName of tableNames) {
657
- const pk = pkByTable.get(tableName) ?? [];
658
- if (pk.length !== 2)
659
- continue;
660
- const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
661
- if (tableFks.length !== 2 || tableFks.some((fk) => fk.sourceColumns.length !== 1))
662
- continue;
663
- const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
664
- const pkSet = new Set(pk);
665
- if (!fkCols.every((column) => pkSet.has(column)) || new Set(fkCols).size !== 2)
666
- continue;
667
- const [fkA, fkB] = tableFks;
668
- if (fkA.targetTable === fkB.targetTable)
669
- continue;
670
- const junctionColumns = (columnsByTable.get(tableName) ?? []).map((column) => column.name);
671
- if (junctionColumns.length !== 2)
672
- continue;
673
- addManyToManyDirection(relations, tableName, fkA, fkB);
674
- addManyToManyDirection(relations, tableName, fkB, fkA);
675
- }
676
- }
677
- function addManyToManyDirection(relations, junctionTable, self, other) {
678
- const sourceTable = self.targetTable;
679
- const targetTable = other.targetTable;
680
- const relName = snakeToCamel(targetTable);
681
- const tableRelations = relations.get(sourceTable) ?? {};
682
- if (tableRelations[relName])
683
- return;
684
- tableRelations[relName] = {
685
- type: 'manyToMany',
686
- name: relName,
687
- from: sourceTable,
688
- to: targetTable,
689
- referenceKey: oneOrMany(self.targetColumns),
690
- foreignKey: oneOrMany(self.targetColumns),
691
- through: {
692
- table: junctionTable,
693
- sourceKey: self.sourceColumns[0],
694
- targetKey: other.sourceColumns[0],
695
- },
696
- };
697
- relations.set(sourceTable, tableRelations);
698
- }
699
631
  async function estimateRows(client, schema) {
700
632
  const result = await client.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
701
633
  FROM pg_class c
@@ -723,9 +655,6 @@ function extractIndexColumns(indexdef) {
723
655
  .replace(/ (ASC|DESC)$/i, '')
724
656
  .replace(/^"|"$/g, ''));
725
657
  }
726
- function oneOrMany(columns) {
727
- return columns.length === 1 ? columns[0] : columns;
728
- }
729
658
  function optionalLimit(value) {
730
659
  if (value === undefined)
731
660
  return 50;
package/dist/client.d.ts CHANGED
@@ -68,6 +68,19 @@ export interface PgCompatPoolClient {
68
68
  * sequential.
69
69
  */
70
70
  readonly supportsPipelining?: boolean;
71
+ /**
72
+ * Optional engine seam: scope a transaction's user callback to its own
73
+ * async subtree. When present, `TurbineClient.transaction` / `$transaction`
74
+ * invoke the callback as `wrapTransactionCallback(() => fn(tx))` instead of
75
+ * `fn(tx)` directly. Single-writer engines (PowDB) implement it with
76
+ * `AsyncLocalStorage.run()` to plant their re-entrancy marker so that it
77
+ * exists ONLY inside the callback's async subtree: a transaction opened
78
+ * from inside the callback is detected as re-entrant (typed E017), while
79
+ * the CALLER's context stays unmarked, so same-tick sibling transactions
80
+ * queue FIFO instead of being falsely flagged. Absent on pg and every other
81
+ * engine, in which case the callback runs unwrapped (zero behavior change).
82
+ */
83
+ wrapTransactionCallback?<R>(fn: () => Promise<R>): Promise<R>;
71
84
  }
72
85
  /**
73
86
  * Minimal pg-compatible pool. Pass any driver that satisfies this interface
@@ -149,8 +162,12 @@ export interface TurbineConfig {
149
162
  logging?: boolean;
150
163
  /** Default LIMIT applied to findMany() when no limit is specified (opt-in, default: undefined) */
151
164
  defaultLimit?: number;
152
- /** Log a warning when findMany() is called without a limit (default: false) */
153
- warnOnUnlimited?: boolean;
165
+ /**
166
+ * Log a warning when findMany() is called without a limit (default: false).
167
+ * Pass a per-table map (`{ users: false }`) to override the default for
168
+ * specific tables; per-call `warnOnUnlimited` on findMany args wins over both.
169
+ */
170
+ warnOnUnlimited?: boolean | Record<string, boolean>;
154
171
  /**
155
172
  * Interpret Postgres `timestamp` (without time zone) values as UTC — both
156
173
  * at the driver level (OID 1114 type parser, registered only when Turbine
package/dist/client.js CHANGED
@@ -763,14 +763,36 @@ export class TurbineClient {
763
763
  */
764
764
  async transaction(fn) {
765
765
  const client = await this.pool.connect();
766
+ /**
767
+ * Only true once BEGIN has actually succeeded. If BEGIN itself throws
768
+ * (e.g. a single-writer engine's transaction gate times out or rejects a
769
+ * re-entrant begin), issuing a "best-effort" ROLLBACK would be a stray
770
+ * statement from a context that never opened a transaction — on a driver
771
+ * with one shared engine handle (PowDB embedded) it would roll back a
772
+ * DIFFERENT caller's open transaction.
773
+ */
774
+ let began = false;
766
775
  try {
767
776
  await client.query(this.dialect.beginStatement());
768
- const result = await fn(client);
777
+ began = true;
778
+ // Engine seam: single-writer engines scope their transaction re-entrancy
779
+ // marker to the callback's async subtree (see
780
+ // PgCompatPoolClient.wrapTransactionCallback). Absent everywhere else.
781
+ const wrap = client.wrapTransactionCallback;
782
+ // `.call` erases the generic, so the callback's Promise<T> is re-asserted.
783
+ const result = wrap ? (await wrap.call(client, () => fn(client))) : await fn(client);
769
784
  await client.query(this.dialect.commitStatement());
770
785
  return result;
771
786
  }
772
787
  catch (err) {
773
- await client.query(this.dialect.rollbackStatement());
788
+ if (began) {
789
+ try {
790
+ await client.query(this.dialect.rollbackStatement());
791
+ }
792
+ catch {
793
+ // Best-effort rollback — the connection may have died mid-query.
794
+ }
795
+ }
774
796
  throw err;
775
797
  }
776
798
  finally {
@@ -804,11 +826,21 @@ export class TurbineClient {
804
826
  }
805
827
  };
806
828
  let timedOut = false;
829
+ /**
830
+ * Only true once BEGIN has actually succeeded. If BEGIN itself throws —
831
+ * e.g. a single-writer engine's transaction gate times out in its FIFO
832
+ * queue or rejects a re-entrant begin (PowDB, E002/E017) — this context
833
+ * never opened a transaction, so the catch below must NOT issue its
834
+ * best-effort ROLLBACK: on a driver with one shared engine handle that
835
+ * stray ROLLBACK would tear down a DIFFERENT caller's open transaction.
836
+ */
837
+ let began = false;
807
838
  try {
808
839
  // BEGIN with optional isolation level — the dialect owns the keyword and
809
840
  // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
810
841
  const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
811
842
  await client.query(this.dialect.beginStatement(isolationSql));
843
+ began = true;
812
844
  // Apply transaction-local session context (RLS / multi-tenant GUCs).
813
845
  // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
814
846
  // user fn. Any error here propagates to the catch below and rolls back
@@ -841,6 +873,12 @@ export class TurbineClient {
841
873
  }
842
874
  }
843
875
  let result;
876
+ // Engine seam: when the checked-out connection exposes
877
+ // wrapTransactionCallback (single-writer engines such as PowDB), run the user
878
+ // callback through it so the engine can scope its re-entrancy marker to
879
+ // the callback's async subtree. All other drivers: plain fn(tx).
880
+ const wrap = client.wrapTransactionCallback;
881
+ const runCallback = () => (wrap ? wrap.call(client, () => fn(tx)) : fn(tx));
844
882
  if (timeout) {
845
883
  // Race between the function and a timeout. If the timeout fires we
846
884
  // need to actually abort the in-flight query — otherwise the backend
@@ -862,14 +900,14 @@ export class TurbineClient {
862
900
  }, timeout);
863
901
  });
864
902
  try {
865
- result = await Promise.race([fn(tx), timeoutPromise]);
903
+ result = await Promise.race([runCallback(), timeoutPromise]);
866
904
  }
867
905
  finally {
868
906
  clearTimeout(timer);
869
907
  }
870
908
  }
871
909
  else {
872
- result = await fn(tx);
910
+ result = await runCallback();
873
911
  }
874
912
  await client.query(this.dialect.commitStatement());
875
913
  if (this.logging) {
@@ -881,8 +919,11 @@ export class TurbineClient {
881
919
  // If the timeout fired we already destroyed the connection — issuing a
882
920
  // ROLLBACK on a released client would throw "Client has already been
883
921
  // released". Skip the rollback in that case (the backend rolled back
884
- // when its socket was closed).
885
- if (!timedOut && !released) {
922
+ // when its socket was closed). Likewise skip it when BEGIN never
923
+ // succeeded (`began` false) — there is no transaction to roll back and
924
+ // the stray statement could hit another caller's transaction on a
925
+ // shared-handle engine.
926
+ if (began && !timedOut && !released) {
886
927
  try {
887
928
  await client.query(this.dialect.rollbackStatement());
888
929
  }
@@ -23,6 +23,18 @@ export interface GenerateOptions {
23
23
  * library's dependency graph. Default: `false`.
24
24
  */
25
25
  zod?: boolean;
26
+ /**
27
+ * Omit the `Generated at: <ISO timestamp>` line from every generated file
28
+ * header (T-8b — reproducible codegen). With this set, byte-identical
29
+ * schemas regenerate to byte-identical output, so regens produce empty
30
+ * diffs. Default: `false` (timestamp included, unchanged behavior).
31
+ */
32
+ noTimestamp?: boolean;
33
+ }
34
+ /** Per-file generator options (subset of {@link GenerateOptions} the emitters need). */
35
+ export interface GenerateFileOptions {
36
+ /** Omit the `Generated at:` header line for reproducible output. */
37
+ noTimestamp?: boolean;
26
38
  }
27
39
  export declare function generate(options: GenerateOptions): {
28
40
  outDir: string;
@@ -33,7 +45,7 @@ export declare function generate(options: GenerateOptions): {
33
45
  * and *Relations brand-field interfaces). Exported so tests can pin the
34
46
  * generator output without writing files to disk.
35
47
  */
36
- export declare function generateTypes(schema: SchemaMetadata): string;
48
+ export declare function generateTypes(schema: SchemaMetadata, options?: GenerateFileOptions): string;
37
49
  /**
38
50
  * Generate the contents of `zod.ts`. Emits, per table, `XSchema` (the full
39
51
  * row), `XCreateSchema` (PK/defaulted/nullable columns optional, STORED
@@ -41,6 +53,6 @@ export declare function generateTypes(schema: SchemaMetadata): string;
41
53
  * columns omitted, every remaining column optional). Exported so tests can pin
42
54
  * the output without writing files.
43
55
  */
44
- export declare function generateZod(schema: SchemaMetadata): string;
45
- export declare function generateMetadata(schema: SchemaMetadata): string;
46
- export declare function generateIndex(schema: SchemaMetadata): string;
56
+ export declare function generateZod(schema: SchemaMetadata, options?: GenerateFileOptions): string;
57
+ export declare function generateMetadata(schema: SchemaMetadata, options?: GenerateFileOptions): string;
58
+ export declare function generateIndex(schema: SchemaMetadata, options?: GenerateFileOptions): string;
package/dist/generate.js CHANGED
@@ -50,21 +50,22 @@ export function generate(options) {
50
50
  }
51
51
  mkdirSync(outDir, { recursive: true });
52
52
  const files = [];
53
+ const fileOptions = { noTimestamp: options.noTimestamp };
53
54
  // Generate types.ts
54
- const typesContent = generateTypes(options.schema);
55
+ const typesContent = generateTypes(options.schema, fileOptions);
55
56
  writeFileSync(join(outDir, 'types.ts'), typesContent, 'utf-8');
56
57
  files.push('types.ts');
57
58
  // Generate metadata.ts
58
- const metadataContent = generateMetadata(options.schema);
59
+ const metadataContent = generateMetadata(options.schema, fileOptions);
59
60
  writeFileSync(join(outDir, 'metadata.ts'), metadataContent, 'utf-8');
60
61
  files.push('metadata.ts');
61
62
  // Generate index.ts (configured client)
62
- const indexContent = generateIndex(options.schema);
63
+ const indexContent = generateIndex(options.schema, fileOptions);
63
64
  writeFileSync(join(outDir, 'index.ts'), indexContent, 'utf-8');
64
65
  files.push('index.ts');
65
66
  // Generate zod.ts (optional — --zod flag)
66
67
  if (options.zod) {
67
- const zodContent = generateZod(options.schema);
68
+ const zodContent = generateZod(options.schema, fileOptions);
68
69
  writeFileSync(join(outDir, 'zod.ts'), zodContent, 'utf-8');
69
70
  files.push('zod.ts');
70
71
  }
@@ -73,24 +74,51 @@ export function generate(options) {
73
74
  // ---------------------------------------------------------------------------
74
75
  // types.ts generator
75
76
  // ---------------------------------------------------------------------------
76
- function generatedFileHeader() {
77
+ function generatedFileHeader(options) {
78
+ // `noTimestamp` omits the volatile line entirely (T-8b) so regenerating an
79
+ // unchanged schema produces byte-identical files.
77
80
  return [
78
81
  '/**',
79
82
  ' * Auto-generated by turbine-orm — DO NOT EDIT',
80
83
  ' *',
81
- ` * Generated at: ${new Date().toISOString()}`,
84
+ ...(options?.noTimestamp ? [] : [` * Generated at: ${new Date().toISOString()}`]),
82
85
  ' * @see https://turbineorm.dev',
83
86
  ' */',
84
87
  '',
85
88
  ];
86
89
  }
90
+ /**
91
+ * The relations of a table that are safe to surface in the generated TYPE
92
+ * layer. A relation whose name equals a scalar column field would shadow the
93
+ * column: `interface XWithY extends X` becomes TS2430, the `XCreate & { y?: … }`
94
+ * intersection collapses (TS2322), and neither the column nor the relation is
95
+ * targetable. Introspection no longer produces such names (they are
96
+ * disambiguated at the source), but hand-written or legacy metadata may —
97
+ * skip those relations here with a warning instead of emitting broken types.
98
+ * The runtime metadata (metadata.ts) still carries every relation.
99
+ */
100
+ function typeSafeRelations(table, warn = true) {
101
+ const columnFields = new Set(table.columns.map((c) => c.field));
102
+ const usable = [];
103
+ for (const [relName, rel] of Object.entries(table.relations)) {
104
+ if (columnFields.has(relName)) {
105
+ if (warn) {
106
+ console.warn(`[turbine] Relation "${relName}" on table "${table.name}" shadows a column field of the same name — ` +
107
+ `omitting it from the generated types. Rename the relation (or the column) to expose it.`);
108
+ }
109
+ continue;
110
+ }
111
+ usable.push([relName, rel]);
112
+ }
113
+ return usable;
114
+ }
87
115
  /**
88
116
  * Generate the contents of `types.ts` (entity interfaces, *Create / *Update,
89
117
  * and *Relations brand-field interfaces). Exported so tests can pin the
90
118
  * generator output without writing files to disk.
91
119
  */
92
- export function generateTypes(schema) {
93
- const lines = [...generatedFileHeader()];
120
+ export function generateTypes(schema, options) {
121
+ const lines = [...generatedFileHeader(options)];
94
122
  // We import UpdateOperatorInput so generated *Update types can express
95
123
  // atomic increment / decrement / multiply / divide / set operators on
96
124
  // numeric columns (TASK-3.4).
@@ -106,9 +134,15 @@ export function generateTypes(schema) {
106
134
  // `${TargetType}Relations` (for deep inference) or `{}` (the no-relations
107
135
  // default) into each `RelationDescriptor`. Built once up-front because
108
136
  // relations can point at tables we haven't iterated to yet.
137
+ // Relations that can be surfaced in the type layer, computed once per table
138
+ // (relations that would shadow a scalar column field are excluded + warned).
139
+ const safeRelationsByTable = new Map();
140
+ for (const t of Object.values(schema.tables)) {
141
+ safeRelationsByTable.set(t.name, typeSafeRelations(t));
142
+ }
109
143
  const tablesWithRelations = new Set();
110
144
  for (const t of Object.values(schema.tables)) {
111
- if (Object.keys(t.relations).length > 0)
145
+ if ((safeRelationsByTable.get(t.name) ?? []).length > 0)
112
146
  tablesWithRelations.add(t.name);
113
147
  }
114
148
  // Generate enum types
@@ -173,11 +207,12 @@ export function generateTypes(schema) {
173
207
  // any depth — `RelationRelations<R[K]>` reads the third type parameter
174
208
  // and threads it into the next recursion step. If the target table has
175
209
  // no relations of its own, the descriptor uses `{}` (the default).
176
- const hasRelations = Object.keys(table.relations).length > 0;
210
+ const safeRelations = safeRelationsByTable.get(table.name) ?? [];
211
+ const hasRelations = safeRelations.length > 0;
177
212
  if (hasRelations) {
178
213
  lines.push(`/** Available relations for the \`${table.name}\` table */`);
179
214
  lines.push(`export interface ${typeName}Relations {`);
180
- for (const [relName, rel] of Object.entries(table.relations)) {
215
+ for (const [relName, rel] of safeRelations) {
181
216
  const targetType = entityName(rel.to);
182
217
  // manyToMany is a collection too → 'many' cardinality (same as hasMany).
183
218
  const cardinality = rel.type === 'hasMany' || rel.type === 'manyToMany' ? "'many'" : "'one'";
@@ -187,7 +222,7 @@ export function generateTypes(schema) {
187
222
  lines.push('}');
188
223
  lines.push('');
189
224
  // --- Legacy per-relation interfaces (kept for backward compatibility) ---
190
- for (const [relName, rel] of Object.entries(table.relations)) {
225
+ for (const [relName, rel] of safeRelations) {
191
226
  const targetType = entityName(rel.to);
192
227
  if (rel.type === 'hasMany' || rel.type === 'manyToMany') {
193
228
  lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
@@ -211,7 +246,8 @@ export function generateTypes(schema) {
211
246
  // ---------------------------------------------------------------------------
212
247
  for (const table of Object.values(schema.tables)) {
213
248
  const typeName = entityName(table.name);
214
- const hasRels = Object.keys(table.relations).length > 0;
249
+ const safeRelations = safeRelationsByTable.get(table.name) ?? [];
250
+ const hasRels = safeRelations.length > 0;
215
251
  // WhereUnique — union of unique constraint shapes, deduplicating PK
216
252
  const seen = new Set();
217
253
  const uniqueSets = [];
@@ -243,14 +279,14 @@ export function generateTypes(schema) {
243
279
  // CreateInput / UpdateInput — extends base type with optional relation fields
244
280
  if (hasRels) {
245
281
  lines.push(`export type ${typeName}CreateInput = ${typeName}Create & {`);
246
- for (const [relName, rel] of Object.entries(table.relations)) {
282
+ for (const [relName, rel] of safeRelations) {
247
283
  const targetType = entityName(rel.to);
248
284
  lines.push(` ${relName}?: ${targetType}NestedCreateInput;`);
249
285
  }
250
286
  lines.push('};');
251
287
  lines.push('');
252
288
  lines.push(`export type ${typeName}UpdateInput = ${typeName}Update & {`);
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
  if (rel.type === 'hasMany') {
256
292
  lines.push(` ${relName}?: ${targetType}NestedUpdateInput;`);
@@ -266,7 +302,7 @@ export function generateTypes(schema) {
266
302
  // Emit NestedCreateInput, NestedUpdateInput, ConnectOrCreate for every table
267
303
  for (const table of Object.values(schema.tables)) {
268
304
  const typeName = entityName(table.name);
269
- const hasRels = Object.keys(table.relations).length > 0;
305
+ const hasRels = (safeRelationsByTable.get(table.name) ?? []).length > 0;
270
306
  // NestedCreateInput uses *CreateInput (which includes relation fields) when
271
307
  // the table has relations, otherwise falls back to the plain *Create type.
272
308
  const createRefType = hasRels ? `${typeName}CreateInput` : `${typeName}Create`;
@@ -351,8 +387,8 @@ function zodBaseType(col, enums) {
351
387
  * columns omitted, every remaining column optional). Exported so tests can pin
352
388
  * the output without writing files.
353
389
  */
354
- export function generateZod(schema) {
355
- const lines = [...generatedFileHeader()];
390
+ export function generateZod(schema, options) {
391
+ const lines = [...generatedFileHeader(options)];
356
392
  // `zod` is a USER dependency — this generated file imports it, but the Turbine
357
393
  // library runtime never does, so Zod stays out of the package's dep graph.
358
394
  lines.push("import { z } from 'zod';");
@@ -409,9 +445,9 @@ export function generateZod(schema) {
409
445
  // ---------------------------------------------------------------------------
410
446
  // metadata.ts generator
411
447
  // ---------------------------------------------------------------------------
412
- export function generateMetadata(schema) {
448
+ export function generateMetadata(schema, options) {
413
449
  const lines = [
414
- ...generatedFileHeader(),
450
+ ...generatedFileHeader(options),
415
451
  "import type { SchemaMetadata } from 'turbine-orm';",
416
452
  '',
417
453
  'export const SCHEMA: SchemaMetadata = {',
@@ -511,10 +547,15 @@ export function generateMetadata(schema) {
511
547
  // ---------------------------------------------------------------------------
512
548
  // index.ts generator (configured client with typed table accessors)
513
549
  // ---------------------------------------------------------------------------
514
- export function generateIndex(schema) {
550
+ export function generateIndex(schema, options) {
515
551
  const tableEntries = Object.values(schema.tables);
552
+ // Must mirror generateTypes: `XRelations` only exists in types.ts when the
553
+ // table has at least one type-safe (non-column-shadowing) relation.
554
+ const hasSafeRelations = new Map();
555
+ for (const t of tableEntries)
556
+ hasSafeRelations.set(t.name, typeSafeRelations(t, false).length > 0);
516
557
  const lines = [
517
- ...generatedFileHeader(),
558
+ ...generatedFileHeader(options),
518
559
  "import { TurbineClient as BaseTurbineClient, TransactionClient as BaseTransactionClient, QueryInterface } from 'turbine-orm';",
519
560
  "import type { TurbineConfig, TransactionOptions, DeferredQuery, PipelineResults } from 'turbine-orm';",
520
561
  "import { SCHEMA } from './metadata.js';",
@@ -523,7 +564,7 @@ export function generateIndex(schema) {
523
564
  const typeImports = [];
524
565
  for (const t of tableEntries) {
525
566
  typeImports.push(entityName(t.name));
526
- if (Object.keys(t.relations).length > 0) {
567
+ if (hasSafeRelations.get(t.name)) {
527
568
  typeImports.push(`${entityName(t.name)}Relations`);
528
569
  }
529
570
  }
@@ -545,7 +586,7 @@ export function generateIndex(schema) {
545
586
  for (const table of tableEntries) {
546
587
  const typeName = entityName(table.name);
547
588
  const accessor = snakeToCamelStr(table.name);
548
- const hasRelations = Object.keys(table.relations).length > 0;
589
+ const hasRelations = hasSafeRelations.get(table.name) === true;
549
590
  const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
550
591
  lines.push(` /** Query interface for the \`${table.name}\` table (transaction-scoped) */`);
551
592
  lines.push(` declare readonly ${accessor}: ${accessorType(table, genericArgs)};`);
@@ -587,7 +628,7 @@ export function generateIndex(schema) {
587
628
  for (const table of tableEntries) {
588
629
  const typeName = entityName(table.name);
589
630
  const accessor = snakeToCamelStr(table.name);
590
- const hasRelations = Object.keys(table.relations).length > 0;
631
+ const hasRelations = hasSafeRelations.get(table.name) === true;
591
632
  const genericArgs = hasRelations ? `${typeName}, ${typeName}Relations` : typeName;
592
633
  lines.push(` /** Query interface for the \`${table.name}\` table */`);
593
634
  lines.push(` declare readonly ${accessor}: ${accessorType(table, genericArgs)};`);
@@ -670,6 +711,11 @@ function serializeColumn(col) {
670
711
  `arrayType: '${escSQ(col.arrayType ?? col.pgArrayType)}'`,
671
712
  `pgArrayType: '${escSQ(col.pgArrayType)}'`,
672
713
  ];
714
+ // Cross-schema type marker — introspection records it only for types living
715
+ // outside the introspected schema; it must survive codegen or the runtime
716
+ // enum-cast guard in query/builder.ts loses the signal (N-5).
717
+ if (col.pgTypeSchema !== undefined)
718
+ parts.push(`pgTypeSchema: '${escSQ(col.pgTypeSchema)}'`);
673
719
  // Emit isGenerated only when set (server-generated serial/identity), so the
674
720
  // output stays byte-identical for the common client-default columns.
675
721
  if (col.isGenerated)
package/dist/index.d.ts CHANGED
@@ -43,11 +43,12 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export type { ObserveConfig, ObserveHandle } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByArgs, type HavingClause, type JsonFilter, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
46
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByArgs, type HavingClause, type JsonFilter, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type SkipGlobalFilters, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
50
50
  export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, defineSchema, type ManyToManyDef, type ReferenceDef, type SchemaDef, type TableDef, table, } from './schema-builder.js';
51
+ export { schemaDefToMetadata } from './schema-metadata.js';
51
52
  export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, type SchemaSqlOptions, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
52
53
  export { type DefinedSeed, defineSeed, type SeedFunction } from './seed.js';
53
54
  export { type TurbineHttpOptions, turbineHttp } from './serverless.js';
package/dist/index.js CHANGED
@@ -56,6 +56,8 @@ export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs,
56
56
  export { applyManyToManyRelations, ColumnBuilder, column, defineSchema,
57
57
  // Legacy compat (deprecated — use object format with defineSchema)
58
58
  table, } from './schema-builder.js';
59
+ // Schema metadata bridge — defineSchema() → SchemaMetadata without a live DB
60
+ export { schemaDefToMetadata } from './schema-metadata.js';
59
61
  // Schema SQL — generate DDL, diff, and push
60
62
  export { schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
61
63
  // Seed helper