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
package/dist/client.d.ts CHANGED
@@ -57,6 +57,17 @@ export interface PgCompatQueryResult<R = Record<string, unknown>> {
57
57
  export interface PgCompatPoolClient {
58
58
  query<R = Record<string, unknown>>(text: string, values?: unknown[]): Promise<PgCompatQueryResult<R>>;
59
59
  release(err?: Error | boolean): void;
60
+ /**
61
+ * Optional driver capability: `true` when `query()` may be called again on
62
+ * this connection while earlier calls are still in flight, with replies
63
+ * delivered to callers in FIFO submission order. Drivers that set this let
64
+ * the batch `$transaction([...])` overload dispatch every statement in one
65
+ * write burst (~1 network round trip plus server time) instead of awaiting
66
+ * each reply before sending the next (N round trips). Leave unset for
67
+ * drivers (node-postgres included) whose batch path must stay strictly
68
+ * sequential.
69
+ */
70
+ readonly supportsPipelining?: boolean;
60
71
  }
61
72
  /**
62
73
  * Minimal pg-compatible pool. Pass any driver that satisfies this interface
@@ -138,8 +149,12 @@ export interface TurbineConfig {
138
149
  logging?: boolean;
139
150
  /** Default LIMIT applied to findMany() when no limit is specified (opt-in, default: undefined) */
140
151
  defaultLimit?: number;
141
- /** Log a warning when findMany() is called without a limit (default: false) */
142
- warnOnUnlimited?: boolean;
152
+ /**
153
+ * Log a warning when findMany() is called without a limit (default: false).
154
+ * Pass a per-table map (`{ users: false }`) to override the default for
155
+ * specific tables; per-call `warnOnUnlimited` on findMany args wins over both.
156
+ */
157
+ warnOnUnlimited?: boolean | Record<string, boolean>;
143
158
  /**
144
159
  * Interpret Postgres `timestamp` (without time zone) values as UTC — both
145
160
  * at the driver level (OID 1114 type parser, registered only when Turbine
@@ -564,8 +579,13 @@ export declare class TurbineClient {
564
579
  * of each query's transformed result; any failure rolls the whole batch back.
565
580
  *
566
581
  * Unlike {@link pipeline}, this never uses the extended-query pipeline
567
- * protocol it executes sequentially on the transaction connection, so it is
568
- * safe on every driver (including HTTP/serverless pools).
582
+ * protocol. Statements run on the single transaction connection: strictly
583
+ * sequentially by default (safe on every driver, including HTTP/serverless
584
+ * pools), or — when the checked-out connection advertises
585
+ * {@link PgCompatPoolClient.supportsPipelining} — dispatched in one write
586
+ * burst with replies collected in order, saving a network round trip per
587
+ * statement. Either way the failure contract is identical: the first
588
+ * (lowest-index) failure aborts the batch and rolls everything back.
569
589
  *
570
590
  * @example
571
591
  * ```ts
@@ -580,8 +600,26 @@ export declare class TurbineClient {
580
600
  * Execute a batch of {@link DeferredQuery} objects atomically inside one
581
601
  * transaction. Backs the `$transaction([...])` array overload. Reuses the raw
582
602
  * {@link transaction} machinery (BEGIN/COMMIT/ROLLBACK + connection release);
583
- * queries run sequentially on the single transaction connection and each
584
- * result is passed through its query's `transform`.
603
+ * each result is passed through its query's `transform`.
604
+ *
605
+ * Execution strategy on the single transaction connection:
606
+ * - **Sequential (default).** Await each statement's reply before sending
607
+ * the next. Safe on every driver; on a networked driver a batch of N
608
+ * costs N round trips.
609
+ * - **Pipelined.** When the checked-out connection advertises
610
+ * {@link PgCompatPoolClient.supportsPipelining} (its `query()` accepts
611
+ * concurrent calls and completes them in FIFO submission order), all
612
+ * statements are dispatched in one write burst and the replies are
613
+ * collected in order — ~1 round trip plus server time. Only taken when
614
+ * the dialect's writes surface rows directly (`resultStrategy` !==
615
+ * 'reselect'): a reselect plan is itself a sequential write+read pair.
616
+ *
617
+ * The two paths share one failure contract: the first (lowest-index) failed
618
+ * statement's error is thrown (wrapped via {@link wrapPgError}) and the
619
+ * surrounding transaction rolls back, so no statement's effect survives. The
620
+ * pipelined path drains every in-flight reply (`Promise.allSettled`) before
621
+ * rethrowing, which keeps the connection's request/reply pairing intact and
622
+ * means ROLLBACK is only issued once no statement is still in flight.
585
623
  */
586
624
  private transactionBatch;
587
625
  /**
package/dist/client.js CHANGED
@@ -763,14 +763,31 @@ 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());
777
+ began = true;
768
778
  const result = await fn(client);
769
779
  await client.query(this.dialect.commitStatement());
770
780
  return result;
771
781
  }
772
782
  catch (err) {
773
- await client.query(this.dialect.rollbackStatement());
783
+ if (began) {
784
+ try {
785
+ await client.query(this.dialect.rollbackStatement());
786
+ }
787
+ catch {
788
+ // Best-effort rollback — the connection may have died mid-query.
789
+ }
790
+ }
774
791
  throw err;
775
792
  }
776
793
  finally {
@@ -804,11 +821,21 @@ export class TurbineClient {
804
821
  }
805
822
  };
806
823
  let timedOut = false;
824
+ /**
825
+ * Only true once BEGIN has actually succeeded. If BEGIN itself throws —
826
+ * e.g. a single-writer engine's transaction gate times out in its FIFO
827
+ * queue or rejects a re-entrant begin (PowDB, E002/E017) — this context
828
+ * never opened a transaction, so the catch below must NOT issue its
829
+ * best-effort ROLLBACK: on a driver with one shared engine handle that
830
+ * stray ROLLBACK would tear down a DIFFERENT caller's open transaction.
831
+ */
832
+ let began = false;
807
833
  try {
808
834
  // BEGIN with optional isolation level — the dialect owns the keyword and
809
835
  // BEGIN+isolation composition (Postgres appends ` ISOLATION LEVEL …`).
810
836
  const isolationSql = options?.isolationLevel ? ISOLATION_LEVELS[options.isolationLevel] : undefined;
811
837
  await client.query(this.dialect.beginStatement(isolationSql));
838
+ began = true;
812
839
  // Apply transaction-local session context (RLS / multi-tenant GUCs).
813
840
  // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
814
841
  // user fn. Any error here propagates to the catch below and rolls back
@@ -881,8 +908,11 @@ export class TurbineClient {
881
908
  // If the timeout fired we already destroyed the connection — issuing a
882
909
  // ROLLBACK on a released client would throw "Client has already been
883
910
  // released". Skip the rollback in that case (the backend rolled back
884
- // when its socket was closed).
885
- if (!timedOut && !released) {
911
+ // when its socket was closed). Likewise skip it when BEGIN never
912
+ // succeeded (`began` false) — there is no transaction to roll back and
913
+ // the stray statement could hit another caller's transaction on a
914
+ // shared-handle engine.
915
+ if (began && !timedOut && !released) {
886
916
  try {
887
917
  await client.query(this.dialect.rollbackStatement());
888
918
  }
@@ -903,14 +933,48 @@ export class TurbineClient {
903
933
  * Execute a batch of {@link DeferredQuery} objects atomically inside one
904
934
  * transaction. Backs the `$transaction([...])` array overload. Reuses the raw
905
935
  * {@link transaction} machinery (BEGIN/COMMIT/ROLLBACK + connection release);
906
- * queries run sequentially on the single transaction connection and each
907
- * result is passed through its query's `transform`.
936
+ * each result is passed through its query's `transform`.
937
+ *
938
+ * Execution strategy on the single transaction connection:
939
+ * - **Sequential (default).** Await each statement's reply before sending
940
+ * the next. Safe on every driver; on a networked driver a batch of N
941
+ * costs N round trips.
942
+ * - **Pipelined.** When the checked-out connection advertises
943
+ * {@link PgCompatPoolClient.supportsPipelining} (its `query()` accepts
944
+ * concurrent calls and completes them in FIFO submission order), all
945
+ * statements are dispatched in one write burst and the replies are
946
+ * collected in order — ~1 round trip plus server time. Only taken when
947
+ * the dialect's writes surface rows directly (`resultStrategy` !==
948
+ * 'reselect'): a reselect plan is itself a sequential write+read pair.
949
+ *
950
+ * The two paths share one failure contract: the first (lowest-index) failed
951
+ * statement's error is thrown (wrapped via {@link wrapPgError}) and the
952
+ * surrounding transaction rolls back, so no statement's effect survives. The
953
+ * pipelined path drains every in-flight reply (`Promise.allSettled`) before
954
+ * rethrowing, which keeps the connection's request/reply pairing intact and
955
+ * means ROLLBACK is only issued once no statement is still in flight.
908
956
  */
909
957
  async transactionBatch(queries) {
910
958
  if (queries.length === 0) {
911
959
  return [];
912
960
  }
913
961
  return this.transaction(async (client) => {
962
+ const pipelined = client.supportsPipelining === true &&
963
+ this.dialect.resultStrategy !== 'reselect';
964
+ if (pipelined) {
965
+ // Dispatch every statement before awaiting any reply. The driver's
966
+ // FIFO guarantee makes settled[i] the reply to queries[i].
967
+ const settled = await Promise.allSettled(queries.map((dq) => client.query(dq.sql, dq.params)));
968
+ const results = [];
969
+ for (let i = 0; i < settled.length; i++) {
970
+ const outcome = settled[i];
971
+ if (outcome.status === 'rejected') {
972
+ throw wrapPgError(outcome.reason);
973
+ }
974
+ results.push(queries[i].transform(outcome.value));
975
+ }
976
+ return results;
977
+ }
914
978
  const results = [];
915
979
  for (const dq of queries) {
916
980
  let raw;
@@ -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
@@ -48,6 +48,7 @@ export { type ActiveSubscription, type NotificationHandler, type Subscription, v
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
@@ -8,7 +8,7 @@
8
8
  * This is the foundation of `npx turbine generate`.
9
9
  */
10
10
  import { type Dialect } from './dialect.js';
11
- import { type ReferentialAction, type SchemaMetadata } from './schema.js';
11
+ import { type ColumnMetadata, type ReferentialAction, type RelationDef, type SchemaMetadata } from './schema.js';
12
12
  /**
13
13
  * Map a `pg_constraint.confdeltype` / `confupdtype` character to a
14
14
  * {@link ReferentialAction}. Postgres encodes: `a` = NO ACTION, `r` = RESTRICT,
@@ -56,3 +56,96 @@ export declare function introspectPostgresCatalog(options: IntrospectOptions): P
56
56
  * balanced outer paren pair; leaves anything unexpected untouched.
57
57
  */
58
58
  export declare function stripCheckWrapper(def: string): string;
59
+ /** A foreign-key constraint grouped by constraint name (composite FKs carry column arrays). */
60
+ export interface ForeignKeyEntry {
61
+ sourceTable: string;
62
+ sourceColumns: string[];
63
+ targetTable: string;
64
+ targetColumns: string[];
65
+ constraintName: string;
66
+ }
67
+ /**
68
+ * Derive a belongsTo relation name from its FK column. Strips a trailing
69
+ * `_id` (snake_case) or `Id` (camelCase column names — common in Prisma-ported
70
+ * schemas where columns are quoted camelCase identifiers), then camelCases:
71
+ * `current_version_id` and `currentVersionId` both yield `currentVersion`.
72
+ * Stripping is what keeps the scalar FK field (`currentVersionId`) targetable
73
+ * alongside the relation. A column literally named `id` (nothing left after
74
+ * stripping) keeps its own name.
75
+ */
76
+ export declare function relationNameFromColumn(column: string): string;
77
+ /**
78
+ * True for the tsType forms a json/jsonb column maps to (`unknown`, nullable
79
+ * `unknown | null`). A relation shadowing such a column is a HISTORICAL shadow
80
+ * that worked at runtime and compiled (`unknown` absorbs the relation
81
+ * payload), so the legacy-first naming keeps it instead of renaming.
82
+ */
83
+ export declare function isUnknownTsType(tsType: string): boolean;
84
+ /**
85
+ * Build the belongsTo/hasMany relation maps for every table from its foreign
86
+ * keys. Naming rules (LEGACY-FIRST — a relation name that previously worked at
87
+ * runtime must never change out from under a regenerating app):
88
+ *
89
+ * 1. First compute the historical derivation exactly as it shipped before
90
+ * the collision guard existed: belongsTo strips a case-SENSITIVE `_id`
91
+ * suffix (`snakeToCamel(col.replace(/_id$/, ''))` when several FKs point
92
+ * at the same target, else the singularized target table), and hasMany is
93
+ * `snakeToCamel(`${source}_by_${strippedColumn}`)` (else the source
94
+ * table). If that legacy name is free, KEEP IT — even when it looks odd
95
+ * (`blogPostsByAuthorId`, `postsBy_Author`): those names were collision-
96
+ * free and worked, so regenerating must not rename them.
97
+ * 2. If the legacy name collides ONLY with a scalar column whose tsType is
98
+ * `unknown` (json/jsonb), keep it anyway with a warning: the shadow is
99
+ * historical, ran fine at runtime, and compiled (`unknown` absorbs the
100
+ * relation payload; generate.ts's typeSafeRelations omits the relation
101
+ * from the type layer).
102
+ * 3. On a genuine collision (concrete-typed column shadow, or a previously
103
+ * assigned relation), fall back to the modern derivation — the `_id`/`Id`
104
+ * case-insensitive strip of {@link relationNameFromColumn} plus the
105
+ * `By`-composed reverse name — which fixes the camelCase-FK shadowing
106
+ * shapes that were actually BROKEN before (relation name === scalar FK
107
+ * field → unusable types).
108
+ * 4. Last resort: deterministic `Rel`/`Rel2` suffix + warning.
109
+ *
110
+ * @param columnFieldsByTable camelCase column *fields* per table — used to
111
+ * guarantee relations never shadow concrete-typed scalar columns.
112
+ * @param unknownTypedFieldsByTable subset of the column fields whose tsType is
113
+ * `unknown` (json/jsonb) — legacy shadows of these are preserved (rule 2).
114
+ */
115
+ export declare function buildRelationsFromForeignKeys(foreignKeys: ForeignKeyEntry[], columnFieldsByTable: Map<string, Set<string>>, fkActions?: Map<string, {
116
+ onDelete: ReferentialAction;
117
+ onUpdate: ReferentialAction;
118
+ }>, unknownTypedFieldsByTable?: Map<string, Set<string>>): Map<string, Record<string, RelationDef>>;
119
+ /**
120
+ * Conservative auto-`manyToMany` detection over pure junction tables, shared
121
+ * by the Postgres introspector, the engine introspectors (SQLite / MySQL /
122
+ * MSSQL), the MCP server, and `schemaDefToMetadata()` so all surfaces derive
123
+ * IDENTICAL relation names for the same logical schema.
124
+ *
125
+ * A table J is a PURE junction only when ALL of these hold:
126
+ * 1. J's primary key is exactly two columns.
127
+ * 2. J has exactly two FKs, each single-column.
128
+ * 3. Each FK's source column is one of J's two PK columns.
129
+ * 4. The two FKs target two DISTINCT tables (A and B).
130
+ * 5. J has no payload columns beyond the two FK/PK columns.
131
+ *
132
+ * For such a J linking A and B this ADDS a `manyToMany` on A → B and B → A
133
+ * routed `through` J. It never removes or renames an existing relation:
134
+ * - an already-assigned relation with the same name → SKIP (additive-only,
135
+ * unchanged historical behavior);
136
+ * - a shadowed json/jsonb (`unknown`-typed) column → keep the historical
137
+ * name + warn (it worked at runtime and compiled);
138
+ * - a shadowed concrete-typed column → deterministic `Rel` suffix + warn
139
+ * instead of silently dropping the relation.
140
+ */
141
+ export declare function addAutoManyToManyRelations(tableNames: Iterable<string>, foreignKeys: ForeignKeyEntry[], pkByTable: Map<string, string[]>, columnNamesByTable: Map<string, string[]>, relationsByTable: Map<string, Record<string, RelationDef>>, columnFieldsByTable?: Map<string, Set<string>>, unknownTypedFieldsByTable?: Map<string, Set<string>>): void;
142
+ /**
143
+ * One-stop relation derivation for the engine introspectors (SQLite / MySQL /
144
+ * MSSQL): filters the FK list to the introspected table set, seeds the
145
+ * taken-name / json-shadow maps from the engine's column metadata, and runs
146
+ * the SAME `buildRelationsFromForeignKeys` + `addAutoManyToManyRelations`
147
+ * pipeline as the Postgres introspector — so every engine derives identical
148
+ * relation names for the same logical schema (the engines previously carried
149
+ * stale copies of a retired naming scheme).
150
+ */
151
+ export declare function deriveEngineRelations(tableNames: string[], foreignKeys: ForeignKeyEntry[], pkByTable: Map<string, string[]>, columnsByTable: Map<string, Pick<ColumnMetadata, 'name' | 'field' | 'tsType'>[]>): Map<string, Record<string, RelationDef>>;