turbine-orm 0.9.2 → 0.10.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.
@@ -74,7 +74,8 @@ async function startStudio(options) {
74
74
  });
75
75
  const authToken = (0, node_crypto_1.randomBytes)(24).toString('hex');
76
76
  const stateDir = (0, node_path_1.resolve)(options.stateDir ?? '.turbine');
77
- const ctx = { pool, metadata, options, authToken, stateDir };
77
+ const statementTimeoutSQL = options.adapter?.statementTimeout?.(30) ?? `SET LOCAL statement_timeout = '30s'`;
78
+ const ctx = { pool, metadata, options, authToken, stateDir, statementTimeoutSQL };
78
79
  const server = (0, node_http_1.createServer)((req, res) => {
79
80
  handleRequest(req, res, ctx).catch((err) => {
80
81
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
@@ -293,7 +294,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
293
294
  const client = await ctx.pool.connect();
294
295
  try {
295
296
  await client.query('BEGIN READ ONLY');
296
- await client.query(`SET LOCAL statement_timeout = '30s'`);
297
+ await client.query(ctx.statementTimeoutSQL);
297
298
  const result = await client.query(sql, mainValues);
298
299
  const countResult = await client.query(countSql, countValues);
299
300
  await client.query('COMMIT');
@@ -359,7 +360,7 @@ async function apiQuery(req, res, ctx) {
359
360
  const client = await ctx.pool.connect();
360
361
  try {
361
362
  await client.query('BEGIN READ ONLY');
362
- await client.query(`SET LOCAL statement_timeout = '30s'`);
363
+ await client.query(ctx.statementTimeoutSQL);
363
364
  const started = Date.now();
364
365
  const result = await client.query(rawSql);
365
366
  const elapsedMs = Date.now() - started;
@@ -411,7 +412,7 @@ async function apiBuilder(req, res, ctx) {
411
412
  const client = await ctx.pool.connect();
412
413
  try {
413
414
  await client.query('BEGIN READ ONLY');
414
- await client.query(`SET LOCAL statement_timeout = '30s'`);
415
+ await client.query(ctx.statementTimeoutSQL);
415
416
  const started = Date.now();
416
417
  const result = await client.query(deferred.sql, deferred.params);
417
418
  const elapsedMs = Date.now() - started;
@@ -233,7 +233,14 @@ function generateMetadata(schema) {
233
233
  // relations
234
234
  lines.push(' relations: {');
235
235
  for (const [relName, rel] of Object.entries(table.relations)) {
236
- lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: '${escSQ(rel.foreignKey)}', referenceKey: '${escSQ(rel.referenceKey)}' },`);
236
+ // Emit foreignKey/referenceKey as string for single-column, array for composite
237
+ const fkLiteral = Array.isArray(rel.foreignKey)
238
+ ? `[${rel.foreignKey.map((c) => `'${escSQ(c)}'`).join(', ')}]`
239
+ : `'${escSQ(rel.foreignKey)}'`;
240
+ const refLiteral = Array.isArray(rel.referenceKey)
241
+ ? `[${rel.referenceKey.map((c) => `'${escSQ(c)}'`).join(', ')}]`
242
+ : `'${escSQ(rel.referenceKey)}'`;
243
+ lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral} },`);
237
244
  }
238
245
  lines.push(' },');
239
246
  // indexes
package/dist/cjs/index.js CHANGED
@@ -34,7 +34,13 @@
34
34
  * ```
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.table = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.isDateType = exports.camelToSnake = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.TurbineClient = exports.TransactionClient = void 0;
37
+ exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.table = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
+ var index_js_1 = require("./adapters/index.js");
39
+ Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
40
+ Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
41
+ Object.defineProperty(exports, "postgresql", { enumerable: true, get: function () { return index_js_1.postgresql; } });
42
+ Object.defineProperty(exports, "timescale", { enumerable: true, get: function () { return index_js_1.timescale; } });
43
+ Object.defineProperty(exports, "yugabytedb", { enumerable: true, get: function () { return index_js_1.yugabytedb; } });
38
44
  // Client
39
45
  var client_js_1 = require("./client.js");
40
46
  Object.defineProperty(exports, "TransactionClient", { enumerable: true, get: function () { return client_js_1.TransactionClient; } });
@@ -71,12 +77,13 @@ var pipeline_js_1 = require("./pipeline.js");
71
77
  Object.defineProperty(exports, "executePipeline", { enumerable: true, get: function () { return pipeline_js_1.executePipeline; } });
72
78
  Object.defineProperty(exports, "pipelineSupported", { enumerable: true, get: function () { return pipeline_js_1.pipelineSupported; } });
73
79
  // Query builder
74
- var index_js_1 = require("./query/index.js");
75
- Object.defineProperty(exports, "QueryInterface", { enumerable: true, get: function () { return index_js_1.QueryInterface; } });
80
+ var index_js_2 = require("./query/index.js");
81
+ Object.defineProperty(exports, "QueryInterface", { enumerable: true, get: function () { return index_js_2.QueryInterface; } });
76
82
  // Schema utilities
77
83
  var schema_js_1 = require("./schema.js");
78
84
  Object.defineProperty(exports, "camelToSnake", { enumerable: true, get: function () { return schema_js_1.camelToSnake; } });
79
85
  Object.defineProperty(exports, "isDateType", { enumerable: true, get: function () { return schema_js_1.isDateType; } });
86
+ Object.defineProperty(exports, "normalizeKeyColumns", { enumerable: true, get: function () { return schema_js_1.normalizeKeyColumns; } });
80
87
  Object.defineProperty(exports, "pgArrayType", { enumerable: true, get: function () { return schema_js_1.pgArrayType; } });
81
88
  Object.defineProperty(exports, "pgTypeToTs", { enumerable: true, get: function () { return schema_js_1.pgTypeToTs; } });
82
89
  Object.defineProperty(exports, "singularize", { enumerable: true, get: function () { return schema_js_1.singularize; } });
@@ -72,13 +72,16 @@ const SQL_FOREIGN_KEYS = `
72
72
  const SQL_UNIQUE_CONSTRAINTS = `
73
73
  SELECT
74
74
  tc.table_name,
75
- kcu.column_name
75
+ tc.constraint_name,
76
+ kcu.column_name,
77
+ kcu.ordinal_position
76
78
  FROM information_schema.table_constraints tc
77
79
  JOIN information_schema.key_column_usage kcu
78
80
  ON tc.constraint_name = kcu.constraint_name
79
81
  AND tc.table_schema = kcu.table_schema
80
82
  WHERE tc.constraint_type = 'UNIQUE'
81
83
  AND tc.table_schema = $1
84
+ ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position
82
85
  `;
83
86
  const SQL_INDEXES = `
84
87
  SELECT tablename, indexname, indexdef
@@ -159,14 +162,22 @@ async function introspect(options) {
159
162
  pkByTable.get(row.table_name).push(row.column_name);
160
163
  }
161
164
  // ----- Group unique constraints by table -----
165
+ // Group rows by (table_name, constraint_name) to correctly handle multi-column unique constraints
162
166
  const uniqueByTable = new Map();
167
+ const uniqueConstraintGroups = new Map();
163
168
  for (const row of uniqueResult.rows) {
164
169
  if (!tableSet.has(row.table_name))
165
170
  continue;
166
- if (!uniqueByTable.has(row.table_name))
167
- uniqueByTable.set(row.table_name, []);
168
- // Each unique constraint may be multi-column; for simplicity, treat as single-col here
169
- uniqueByTable.get(row.table_name).push([row.column_name]);
171
+ const key = `${row.table_name}::${row.constraint_name}`;
172
+ if (!uniqueConstraintGroups.has(key)) {
173
+ uniqueConstraintGroups.set(key, { table: row.table_name, columns: [] });
174
+ }
175
+ uniqueConstraintGroups.get(key).columns.push(row.column_name);
176
+ }
177
+ for (const { table, columns } of uniqueConstraintGroups.values()) {
178
+ if (!uniqueByTable.has(table))
179
+ uniqueByTable.set(table, []);
180
+ uniqueByTable.get(table).push(columns);
170
181
  }
171
182
  // ----- Group indexes by table -----
172
183
  const indexesByTable = new Map();
@@ -193,17 +204,25 @@ async function introspect(options) {
193
204
  enums[row.typname] = [];
194
205
  enums[row.typname].push(row.enumlabel);
195
206
  }
196
- const foreignKeys = [];
207
+ const fkGroups = new Map();
197
208
  for (const row of fkResult.rows) {
198
209
  if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
199
210
  continue;
200
- foreignKeys.push({
201
- sourceTable: row.source_table,
202
- sourceColumn: row.source_column,
203
- targetTable: row.target_table,
204
- targetColumn: row.target_column,
205
- });
211
+ const key = row.constraint_name;
212
+ if (!fkGroups.has(key)) {
213
+ fkGroups.set(key, {
214
+ sourceTable: row.source_table,
215
+ sourceColumns: [],
216
+ targetTable: row.target_table,
217
+ targetColumns: [],
218
+ constraintName: key,
219
+ });
220
+ }
221
+ const entry = fkGroups.get(key);
222
+ entry.sourceColumns.push(row.source_column);
223
+ entry.targetColumns.push(row.target_column);
206
224
  }
225
+ const foreignKeys = Array.from(fkGroups.values());
207
226
  // ----- Build relations from foreign keys -----
208
227
  // Count FKs per (source, target) pair for disambiguation
209
228
  const fkCounts = new Map();
@@ -215,10 +234,17 @@ async function introspect(options) {
215
234
  for (const fk of foreignKeys) {
216
235
  const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
217
236
  const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
237
+ // For single-column FKs, keep string form for backwards compatibility.
238
+ // For multi-column (composite) FKs, use array form.
239
+ const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
240
+ const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
218
241
  // --- belongsTo on the source (child) table ---
219
242
  // e.g. posts.user_id → users.id creates posts.user (belongsTo)
243
+ // For composite FKs with disambiguation, use the constraint name
220
244
  const belongsToName = needsDisambiguation
221
- ? (0, schema_js_1.snakeToCamel)(fk.sourceColumn.replace(/_id$/, ''))
245
+ ? fk.sourceColumns.length === 1
246
+ ? (0, schema_js_1.snakeToCamel)(fk.sourceColumns[0].replace(/_id$/, ''))
247
+ : (0, schema_js_1.snakeToCamel)(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
222
248
  : (0, schema_js_1.singularize)((0, schema_js_1.snakeToCamel)(fk.targetTable));
223
249
  if (!relationsByTable.has(fk.sourceTable))
224
250
  relationsByTable.set(fk.sourceTable, {});
@@ -227,13 +253,15 @@ async function introspect(options) {
227
253
  name: belongsToName,
228
254
  from: fk.sourceTable,
229
255
  to: fk.targetTable,
230
- foreignKey: fk.sourceColumn,
231
- referenceKey: fk.targetColumn,
256
+ foreignKey,
257
+ referenceKey,
232
258
  };
233
259
  // --- hasMany on the target (parent) table ---
234
260
  // e.g. posts.user_id → users.id creates users.posts (hasMany)
235
261
  const hasManyName = needsDisambiguation
236
- ? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumn.replace(/_id$/, '')}`)
262
+ ? fk.sourceColumns.length === 1
263
+ ? (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
264
+ : (0, schema_js_1.snakeToCamel)(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
237
265
  : (0, schema_js_1.snakeToCamel)(fk.sourceTable);
238
266
  if (!relationsByTable.has(fk.targetTable))
239
267
  relationsByTable.set(fk.targetTable, {});
@@ -242,8 +270,8 @@ async function introspect(options) {
242
270
  name: hasManyName,
243
271
  from: fk.targetTable,
244
272
  to: fk.sourceTable,
245
- foreignKey: fk.sourceColumn,
246
- referenceKey: fk.targetColumn,
273
+ foreignKey,
274
+ referenceKey,
247
275
  };
248
276
  }
249
277
  // ----- Assemble TableMetadata for each table -----
@@ -99,6 +99,7 @@ function findArrayUniqueKey(value) {
99
99
  }
100
100
  return null;
101
101
  }
102
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
102
103
  class QueryInterface {
103
104
  pool;
104
105
  table;
@@ -261,6 +262,7 @@ class QueryInterface {
261
262
  // -------------------------------------------------------------------------
262
263
  // findUnique
263
264
  // -------------------------------------------------------------------------
265
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
264
266
  async findUnique(args) {
265
267
  return this.executeWithMiddleware('findUnique', args, async () => {
266
268
  const deferred = this.buildFindUnique(args);
@@ -268,6 +270,7 @@ class QueryInterface {
268
270
  return deferred.transform(result);
269
271
  });
270
272
  }
273
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
271
274
  buildFindUnique(args) {
272
275
  const columnsList = this.resolveColumns(args.select, args.omit);
273
276
  const whereObj = args.where;
@@ -363,6 +366,7 @@ class QueryInterface {
363
366
  // -------------------------------------------------------------------------
364
367
  // findMany
365
368
  // -------------------------------------------------------------------------
369
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
366
370
  async findMany(args) {
367
371
  this.maybeWarnUnlimited(args);
368
372
  // Dev-only: warn on deeply nested with clauses
@@ -420,6 +424,7 @@ class QueryInterface {
420
424
  }
421
425
  return maxDepth;
422
426
  }
427
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
423
428
  buildFindMany(args) {
424
429
  const columnsList = this.resolveColumns(args?.select, args?.omit);
425
430
  const colKey = columnsList ? columnsList.join(',') : '*';
@@ -563,6 +568,7 @@ class QueryInterface {
563
568
  * }
564
569
  * ```
565
570
  */
571
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
566
572
  async *findManyStream(args) {
567
573
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
568
574
  const hasRelations = !!args?.with;
@@ -619,6 +625,7 @@ class QueryInterface {
619
625
  // -------------------------------------------------------------------------
620
626
  // findFirst — like findMany but returns a single row or null
621
627
  // -------------------------------------------------------------------------
628
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
622
629
  async findFirst(args) {
623
630
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
624
631
  const deferred = this.buildFindFirst(args);
@@ -626,6 +633,7 @@ class QueryInterface {
626
633
  return deferred.transform(result);
627
634
  });
628
635
  }
636
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
629
637
  buildFindFirst(args) {
630
638
  // Reuse findMany's SQL builder but force LIMIT 1
631
639
  const findManyArgs = { ...args, limit: 1 };
@@ -643,6 +651,7 @@ class QueryInterface {
643
651
  // -------------------------------------------------------------------------
644
652
  // findFirstOrThrow — like findFirst but throws if no record found
645
653
  // -------------------------------------------------------------------------
654
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
646
655
  async findFirstOrThrow(args) {
647
656
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
648
657
  const deferred = this.buildFindFirstOrThrow(args);
@@ -650,6 +659,7 @@ class QueryInterface {
650
659
  return deferred.transform(result);
651
660
  });
652
661
  }
662
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
653
663
  buildFindFirstOrThrow(args) {
654
664
  const inner = this.buildFindFirst(args);
655
665
  return {
@@ -672,6 +682,7 @@ class QueryInterface {
672
682
  // -------------------------------------------------------------------------
673
683
  // findUniqueOrThrow — like findUnique but throws if no record found
674
684
  // -------------------------------------------------------------------------
685
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
675
686
  async findUniqueOrThrow(args) {
676
687
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
677
688
  const deferred = this.buildFindUniqueOrThrow(args);
@@ -679,6 +690,7 @@ class QueryInterface {
679
690
  return deferred.transform(result);
680
691
  });
681
692
  }
693
+ // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
682
694
  buildFindUniqueOrThrow(args) {
683
695
  const inner = this.buildFindUnique(args);
684
696
  return {
@@ -2016,15 +2028,15 @@ class QueryInterface {
2016
2028
  const qt = (0, utils_js_1.quoteIdent)(targetTable);
2017
2029
  const qSelf = (0, utils_js_1.quoteIdent)(this.table);
2018
2030
  const clauses = [];
2019
- // Correlation: link child table to parent table
2031
+ // Correlation: link child table to parent table (supports composite FKs)
2020
2032
  let correlation;
2021
2033
  if (relDef.type === 'hasMany' || relDef.type === 'hasOne') {
2022
2034
  // parent.pk = child.fk
2023
- correlation = `${qt}.${(0, utils_js_1.quoteIdent)(relDef.foreignKey)} = ${qSelf}.${(0, utils_js_1.quoteIdent)(relDef.referenceKey)}`;
2035
+ correlation = (0, utils_js_1.buildCorrelation)(qt, relDef.foreignKey, qSelf, relDef.referenceKey);
2024
2036
  }
2025
2037
  else {
2026
2038
  // belongsTo: parent.fk = child.pk
2027
- correlation = `${qt}.${(0, utils_js_1.quoteIdent)(relDef.referenceKey)} = ${qSelf}.${(0, utils_js_1.quoteIdent)(relDef.foreignKey)}`;
2039
+ correlation = (0, utils_js_1.buildCorrelation)(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
2028
2040
  }
2029
2041
  // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
2030
2042
  if (filterObj.some !== undefined) {
@@ -2067,6 +2079,10 @@ class QueryInterface {
2067
2079
  if (value === undefined)
2068
2080
  continue;
2069
2081
  const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2082
+ if (!meta.allColumns.includes(col)) {
2083
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${field}" in relation filter for table "${targetTable}". ` +
2084
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
2085
+ }
2070
2086
  const qCol = `${qt}.${(0, utils_js_1.quoteIdent)(col)}`;
2071
2087
  if (value === null) {
2072
2088
  conditions.push(`${qCol} IS NULL`);
@@ -2474,12 +2490,13 @@ class QueryInterface {
2474
2490
  // Build WHERE — correlate to parent via parentRef (alias or table name).
2475
2491
  // For hasMany: target has FK, so alias.fk = parentRef.pk
2476
2492
  // For belongsTo: source has FK, so alias.pk = parentRef.fk (reversed)
2493
+ // Supports composite foreign keys (string[]) via buildCorrelation.
2477
2494
  let whereClause;
2478
2495
  if (relDef.type === 'belongsTo' || relDef.type === 'hasOne') {
2479
- whereClause = `${alias}.${(0, utils_js_1.quoteIdent)(relDef.referenceKey)} = ${qParent}.${(0, utils_js_1.quoteIdent)(relDef.foreignKey)}`;
2496
+ whereClause = (0, utils_js_1.buildCorrelation)(alias, relDef.referenceKey, qParent, relDef.foreignKey);
2480
2497
  }
2481
2498
  else {
2482
- whereClause = `${alias}.${(0, utils_js_1.quoteIdent)(relDef.foreignKey)} = ${qParent}.${(0, utils_js_1.quoteIdent)(relDef.referenceKey)}`;
2499
+ whereClause = (0, utils_js_1.buildCorrelation)(alias, relDef.foreignKey, qParent, relDef.referenceKey);
2483
2500
  }
2484
2501
  // Additional filters — properly parameterized
2485
2502
  if (spec !== true && spec.where) {
@@ -7,8 +7,9 @@
7
7
  * former monolithic `import { … } from './query.js'`.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.QueryInterface = exports.sqlToPreparedName = exports.quoteIdent = exports.OPERATOR_KEYS = exports.LRUCache = exports.fnv1a64Hex = exports.escSingleQuote = exports.escapeLike = void 0;
10
+ exports.QueryInterface = exports.sqlToPreparedName = exports.quoteIdent = exports.OPERATOR_KEYS = exports.LRUCache = exports.fnv1a64Hex = exports.escSingleQuote = exports.escapeLike = exports.buildCorrelation = void 0;
11
11
  var utils_js_1 = require("./utils.js");
12
+ Object.defineProperty(exports, "buildCorrelation", { enumerable: true, get: function () { return utils_js_1.buildCorrelation; } });
12
13
  Object.defineProperty(exports, "escapeLike", { enumerable: true, get: function () { return utils_js_1.escapeLike; } });
13
14
  Object.defineProperty(exports, "escSingleQuote", { enumerable: true, get: function () { return utils_js_1.escSingleQuote; } });
14
15
  Object.defineProperty(exports, "fnv1a64Hex", { enumerable: true, get: function () { return utils_js_1.fnv1a64Hex; } });
@@ -11,6 +11,7 @@ exports.escSingleQuote = escSingleQuote;
11
11
  exports.escapeLike = escapeLike;
12
12
  exports.fnv1a64Hex = fnv1a64Hex;
13
13
  exports.sqlToPreparedName = sqlToPreparedName;
14
+ exports.buildCorrelation = buildCorrelation;
14
15
  // ---------------------------------------------------------------------------
15
16
  // Identifier quoting — prevents SQL injection via table/column names
16
17
  // ---------------------------------------------------------------------------
@@ -120,3 +121,20 @@ exports.OPERATOR_KEYS = new Set([
120
121
  'endsWith',
121
122
  'mode',
122
123
  ]);
124
+ // ---------------------------------------------------------------------------
125
+ // Composite key correlation helper
126
+ // ---------------------------------------------------------------------------
127
+ /**
128
+ * Build a correlation clause joining columns between two table references.
129
+ * Handles both single-column (string) and multi-column (string[]) foreign keys.
130
+ *
131
+ * For single-column: `"alias"."col" = "parent"."col"`
132
+ * For multi-column: `"alias"."col_a" = "parent"."ref_a" AND "alias"."col_b" = "parent"."ref_b"`
133
+ */
134
+ function buildCorrelation(leftRef, leftColumns, rightRef, rightColumns) {
135
+ const leftCols = Array.isArray(leftColumns) ? leftColumns : [leftColumns];
136
+ const rightCols = Array.isArray(rightColumns) ? rightColumns : [rightColumns];
137
+ return leftCols
138
+ .map((col, i) => `${leftRef}.${quoteIdent(col)} = ${rightRef}.${quoteIdent(rightCols[i])}`)
139
+ .join(' AND ');
140
+ }
@@ -6,6 +6,7 @@
6
6
  * They're used by the query builder, code generator, and CLI.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.normalizeKeyColumns = normalizeKeyColumns;
9
10
  exports.pgTypeToTs = pgTypeToTs;
10
11
  exports.isDateType = isDateType;
11
12
  exports.pgArrayType = pgArrayType;
@@ -14,6 +15,13 @@ exports.camelToSnake = camelToSnake;
14
15
  exports.snakeToPascal = snakeToPascal;
15
16
  exports.singularize = singularize;
16
17
  // ---------------------------------------------------------------------------
18
+ // Helpers for composite key handling
19
+ // ---------------------------------------------------------------------------
20
+ /** Normalize foreignKey/referenceKey to always be an array for uniform processing */
21
+ function normalizeKeyColumns(key) {
22
+ return Array.isArray(key) ? key : [key];
23
+ }
24
+ // ---------------------------------------------------------------------------
17
25
  // Type mapping: Postgres → TypeScript
18
26
  // ---------------------------------------------------------------------------
19
27
  const PG_TO_TS = {
@@ -21,6 +21,17 @@ export interface TurbineCliConfig {
21
21
  seedFile?: string;
22
22
  /** Schema builder file path (for push command) */
23
23
  schemaFile?: string;
24
+ /**
25
+ * Database adapter for PostgreSQL-compatible databases that need
26
+ * dialect-specific behavior (e.g. CockroachDB, YugabyteDB).
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * import { cockroachdb } from 'turbine-orm/adapters';
31
+ * export default { url: process.env.DATABASE_URL, adapter: cockroachdb };
32
+ * ```
33
+ */
34
+ adapter?: import('../adapters/index.js').DatabaseAdapter;
24
35
  }
25
36
  /**
26
37
  * Attempt to load a turbine config file from the current directory.
package/dist/cli/index.js CHANGED
@@ -888,7 +888,8 @@ async function cmdStatus(_args, config) {
888
888
  const isLast = i === rels.length - 1;
889
889
  const prefix = isLast ? symbols.teeEnd : symbols.tee;
890
890
  const relColor = rel.type === 'hasMany' ? blue : yellow;
891
- console.log(` ${dim(prefix)} ${relColor(relName)} ${dim(symbols.arrow)} ${rel.to} ${dim(`(${rel.type}, FK: ${rel.foreignKey})`)}`);
891
+ const fkDisplay = Array.isArray(rel.foreignKey) ? rel.foreignKey.join(', ') : rel.foreignKey;
892
+ console.log(` ${dim(prefix)} ${relColor(relName)} ${dim(symbols.arrow)} ${rel.to} ${dim(`(${rel.type}, FK: ${fkDisplay})`)}`);
892
893
  }
893
894
  }
894
895
  newline();
@@ -11,6 +11,7 @@
11
11
  * -- DOWN
12
12
  * DROP TABLE users;
13
13
  */
14
+ import type { DatabaseAdapter } from '../adapters/index.js';
14
15
  export interface MigrationFile {
15
16
  /** Full filename (e.g. "20260325120000_create_users.sql") */
16
17
  filename: string;
@@ -113,6 +114,7 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
113
114
  step?: number;
114
115
  allowDrift?: boolean /** @deprecated use allowDrift */;
115
116
  force?: boolean;
117
+ adapter?: DatabaseAdapter;
116
118
  }): Promise<{
117
119
  applied: MigrationFile[];
118
120
  errors: Array<{
@@ -130,6 +132,7 @@ export declare function migrateUp(connectionString: string, migrationsDir: strin
130
132
  */
131
133
  export declare function migrateDown(connectionString: string, migrationsDir: string, options?: {
132
134
  step?: number;
135
+ adapter?: DatabaseAdapter;
133
136
  }): Promise<{
134
137
  rolledBack: MigrationFile[];
135
138
  errors: Array<{
@@ -15,6 +15,7 @@ import { createHash } from 'node:crypto';
15
15
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import pg from 'pg';
18
+ import { postgresql } from '../adapters/index.js';
18
19
  import { MigrationError } from '../errors.js';
19
20
  import { quoteIdent } from '../query/index.js';
20
21
  // ---------------------------------------------------------------------------
@@ -234,12 +235,14 @@ async function getCurrentDatabaseName(client) {
234
235
  const result = await client.query(`SELECT current_database()`);
235
236
  return result.rows[0]?.current_database ?? '';
236
237
  }
237
- async function acquireLock(client, lockId) {
238
- const result = await client.query(`SELECT pg_try_advisory_lock($1) AS locked`, [lockId]);
239
- return result.rows[0]?.locked ?? false;
238
+ async function acquireLock(client, lockId, adapter) {
239
+ const a = adapter ?? postgresql;
240
+ // pg.Client satisfies PgCompatPoolClient (query + release)
241
+ return a.acquireLock(client, lockId);
240
242
  }
241
- async function releaseLock(client, lockId) {
242
- await client.query(`SELECT pg_advisory_unlock($1)`, [lockId]);
243
+ async function releaseLock(client, lockId, adapter) {
244
+ const a = adapter ?? postgresql;
245
+ await a.releaseLock(client, lockId);
243
246
  }
244
247
  /**
245
248
  * Validate that applied migration files have not been modified or deleted since they were run.
@@ -306,8 +309,10 @@ export async function migrateUp(connectionString, migrationsDir, options) {
306
309
  // sibling databases on the same Postgres cluster do not contend.
307
310
  const dbName = await getCurrentDatabaseName(client);
308
311
  const lockId = deriveLockId(dbName);
309
- // Acquire advisory lock to prevent concurrent migrations
310
- const gotLock = await acquireLock(client, lockId);
312
+ // Acquire lock to prevent concurrent migrations.
313
+ // The adapter determines the strategy (advisory lock vs table lock).
314
+ const adapter = options?.adapter;
315
+ const gotLock = await acquireLock(client, lockId, adapter);
311
316
  if (!gotLock) {
312
317
  throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
313
318
  }
@@ -379,7 +384,7 @@ export async function migrateUp(connectionString, migrationsDir, options) {
379
384
  return { applied: results, errors };
380
385
  }
381
386
  finally {
382
- await releaseLock(client, lockId);
387
+ await releaseLock(client, lockId, adapter);
383
388
  }
384
389
  }
385
390
  finally {
@@ -402,7 +407,8 @@ export async function migrateDown(connectionString, migrationsDir, options) {
402
407
  // sibling databases on the same cluster do not contend.
403
408
  const dbName = await getCurrentDatabaseName(client);
404
409
  const lockId = deriveLockId(dbName);
405
- const gotLock = await acquireLock(client, lockId);
410
+ const adapter = options?.adapter;
411
+ const gotLock = await acquireLock(client, lockId, adapter);
406
412
  if (!gotLock) {
407
413
  throw new MigrationError('[turbine] Could not acquire migration lock — another migration is already running');
408
414
  }
@@ -449,7 +455,7 @@ export async function migrateDown(connectionString, migrationsDir, options) {
449
455
  return { rolledBack: results, errors };
450
456
  }
451
457
  finally {
452
- await releaseLock(client, lockId);
458
+ await releaseLock(client, lockId, adapter);
453
459
  }
454
460
  }
455
461
  finally {
@@ -28,6 +28,8 @@ export interface StudioOptions {
28
28
  exclude?: string[];
29
29
  /** Directory where studio-queries.json is persisted. Defaults to `.turbine/` in cwd. */
30
30
  stateDir?: string;
31
+ /** Database adapter for dialect-specific behavior (e.g. statement timeout syntax). */
32
+ adapter?: import('../adapters/index.js').DatabaseAdapter;
31
33
  }
32
34
  export interface StudioHandle {
33
35
  /** Shut down the server + pool cleanly. */
@@ -43,6 +45,8 @@ export interface StudioContext {
43
45
  options: StudioOptions;
44
46
  authToken: string;
45
47
  stateDir: string;
48
+ /** Resolved statement timeout SQL string (adapter-aware). */
49
+ statementTimeoutSQL: string;
46
50
  }
47
51
  /**
48
52
  * Start the Studio server. Returns a handle with the session token, a pre-built
@@ -59,7 +59,8 @@ export async function startStudio(options) {
59
59
  });
60
60
  const authToken = randomBytes(24).toString('hex');
61
61
  const stateDir = pathResolve(options.stateDir ?? '.turbine');
62
- const ctx = { pool, metadata, options, authToken, stateDir };
62
+ const statementTimeoutSQL = options.adapter?.statementTimeout?.(30) ?? `SET LOCAL statement_timeout = '30s'`;
63
+ const ctx = { pool, metadata, options, authToken, stateDir, statementTimeoutSQL };
63
64
  const server = createServer((req, res) => {
64
65
  handleRequest(req, res, ctx).catch((err) => {
65
66
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
@@ -278,7 +279,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
278
279
  const client = await ctx.pool.connect();
279
280
  try {
280
281
  await client.query('BEGIN READ ONLY');
281
- await client.query(`SET LOCAL statement_timeout = '30s'`);
282
+ await client.query(ctx.statementTimeoutSQL);
282
283
  const result = await client.query(sql, mainValues);
283
284
  const countResult = await client.query(countSql, countValues);
284
285
  await client.query('COMMIT');
@@ -344,7 +345,7 @@ async function apiQuery(req, res, ctx) {
344
345
  const client = await ctx.pool.connect();
345
346
  try {
346
347
  await client.query('BEGIN READ ONLY');
347
- await client.query(`SET LOCAL statement_timeout = '30s'`);
348
+ await client.query(ctx.statementTimeoutSQL);
348
349
  const started = Date.now();
349
350
  const result = await client.query(rawSql);
350
351
  const elapsedMs = Date.now() - started;
@@ -396,7 +397,7 @@ export async function apiBuilder(req, res, ctx) {
396
397
  const client = await ctx.pool.connect();
397
398
  try {
398
399
  await client.query('BEGIN READ ONLY');
399
- await client.query(`SET LOCAL statement_timeout = '30s'`);
400
+ await client.query(ctx.statementTimeoutSQL);
400
401
  const started = Date.now();
401
402
  const result = await client.query(deferred.sql, deferred.params);
402
403
  const elapsedMs = Date.now() - started;
package/dist/generate.js CHANGED
@@ -229,7 +229,14 @@ function generateMetadata(schema) {
229
229
  // relations
230
230
  lines.push(' relations: {');
231
231
  for (const [relName, rel] of Object.entries(table.relations)) {
232
- lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: '${escSQ(rel.foreignKey)}', referenceKey: '${escSQ(rel.referenceKey)}' },`);
232
+ // Emit foreignKey/referenceKey as string for single-column, array for composite
233
+ const fkLiteral = Array.isArray(rel.foreignKey)
234
+ ? `[${rel.foreignKey.map((c) => `'${escSQ(c)}'`).join(', ')}]`
235
+ : `'${escSQ(rel.foreignKey)}'`;
236
+ const refLiteral = Array.isArray(rel.referenceKey)
237
+ ? `[${rel.referenceKey.map((c) => `'${escSQ(c)}'`).join(', ')}]`
238
+ : `'${escSQ(rel.referenceKey)}'`;
239
+ lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral} },`);
233
240
  }
234
241
  lines.push(' },');
235
242
  // indexes
package/dist/index.d.ts CHANGED
@@ -32,6 +32,8 @@
32
32
  * await db.disconnect();
33
33
  * ```
34
34
  */
35
+ export type { DatabaseAdapter, IntrospectionOverrides } from './adapters/index.js';
36
+ export { alloydb, cockroachdb, postgresql, timescale, yugabytedb } from './adapters/index.js';
35
37
  export { type Middleware, type MiddlewareNext, type MiddlewareParams, type PgCompatPool, type PgCompatPoolClient, type PgCompatQueryResult, TransactionClient, type TransactionOptions, TurbineClient, type TurbineConfig, } from './client.js';
36
38
  export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, type ErrorMessageMode, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, PipelineError, type PipelineResultSlot, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
37
39
  export { type GenerateOptions, generate } from './generate.js';
@@ -39,7 +41,7 @@ export { type IntrospectOptions, introspect } from './introspect.js';
39
41
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
40
42
  export { type AggregateArgs, type AggregateResult, type ArrayFilter, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type OrderDirection, QueryInterface, type RelationDescriptor, type RelationFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
41
43
  export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
42
- export { camelToSnake, isDateType, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
44
+ export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
43
45
  export { ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type SchemaDef, type TableDef, table, } from './schema-builder.js';
44
46
  export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
45
47
  export { type TurbineHttpOptions, turbineHttp } from './serverless.js';