turbine-orm 0.9.2 → 0.11.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 (45) hide show
  1. package/README.md +34 -16
  2. package/dist/adapters/cockroachdb.d.ts +40 -0
  3. package/dist/adapters/cockroachdb.js +172 -0
  4. package/dist/adapters/index.d.ts +107 -0
  5. package/dist/adapters/index.js +83 -0
  6. package/dist/adapters/yugabytedb.d.ts +52 -0
  7. package/dist/adapters/yugabytedb.js +156 -0
  8. package/dist/cjs/adapters/cockroachdb.js +174 -0
  9. package/dist/cjs/adapters/index.js +87 -0
  10. package/dist/cjs/adapters/yugabytedb.js +158 -0
  11. package/dist/cjs/cli/index.js +2 -1
  12. package/dist/cjs/cli/migrate.js +18 -12
  13. package/dist/cjs/cli/studio.js +5 -4
  14. package/dist/cjs/client.js +1 -0
  15. package/dist/cjs/dialect.js +57 -0
  16. package/dist/cjs/generate.js +8 -1
  17. package/dist/cjs/index.js +12 -3
  18. package/dist/cjs/introspect.js +46 -18
  19. package/dist/cjs/query/builder.js +129 -96
  20. package/dist/cjs/query/index.js +4 -1
  21. package/dist/cjs/query/utils.js +18 -0
  22. package/dist/cjs/schema.js +8 -0
  23. package/dist/cli/config.d.ts +11 -0
  24. package/dist/cli/index.js +2 -1
  25. package/dist/cli/migrate.d.ts +3 -0
  26. package/dist/cli/migrate.js +16 -10
  27. package/dist/cli/studio.d.ts +4 -0
  28. package/dist/cli/studio.js +5 -4
  29. package/dist/client.d.ts +3 -0
  30. package/dist/client.js +1 -0
  31. package/dist/dialect.d.ts +61 -0
  32. package/dist/dialect.js +55 -0
  33. package/dist/generate.js +8 -1
  34. package/dist/index.d.ts +5 -1
  35. package/dist/index.js +3 -1
  36. package/dist/introspect.js +46 -18
  37. package/dist/query/builder.d.ts +9 -1
  38. package/dist/query/builder.js +130 -97
  39. package/dist/query/index.d.ts +3 -1
  40. package/dist/query/index.js +2 -1
  41. package/dist/query/utils.d.ts +8 -0
  42. package/dist/query/utils.js +17 -0
  43. package/dist/schema.d.ts +6 -4
  44. package/dist/schema.js +7 -0
  45. package/package.json +8 -3
@@ -7,8 +7,11 @@
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 = exports.postgresDialect = void 0;
11
+ var dialect_js_1 = require("../dialect.js");
12
+ Object.defineProperty(exports, "postgresDialect", { enumerable: true, get: function () { return dialect_js_1.postgresDialect; } });
11
13
  var utils_js_1 = require("./utils.js");
14
+ Object.defineProperty(exports, "buildCorrelation", { enumerable: true, get: function () { return utils_js_1.buildCorrelation; } });
12
15
  Object.defineProperty(exports, "escapeLike", { enumerable: true, get: function () { return utils_js_1.escapeLike; } });
13
16
  Object.defineProperty(exports, "escSingleQuote", { enumerable: true, get: function () { return utils_js_1.escSingleQuote; } });
14
17
  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/client.d.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  * ```
23
23
  */
24
24
  import pg from 'pg';
25
+ import type { Dialect } from './dialect.js';
25
26
  import { type ErrorMessageMode } from './errors.js';
26
27
  import { type PipelineOptions, type PipelineResults } from './pipeline.js';
27
28
  import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
@@ -142,6 +143,8 @@ export interface TurbineConfig {
142
143
  * Default: `true`. Set to `false` as a nuclear kill switch.
143
144
  */
144
145
  sqlCache?: boolean;
146
+ /** SQL dialect implementation. Defaults to PostgreSQL. Internal Phase-1 seam for dialect packages. */
147
+ dialect?: Dialect;
145
148
  }
146
149
  /** Parameters passed to middleware functions */
147
150
  export interface MiddlewareParams {
package/dist/client.js CHANGED
@@ -197,6 +197,7 @@ export class TurbineClient {
197
197
  warnOnUnlimited: config.warnOnUnlimited,
198
198
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
199
199
  sqlCache: config.sqlCache ?? true,
200
+ dialect: config.dialect,
200
201
  };
201
202
  // Apply NotFoundError message redaction mode (default: safe — values are
202
203
  // stripped from messages to avoid leaking PII into error logs).
@@ -0,0 +1,61 @@
1
+ /**
2
+ * turbine-orm — SQL dialect contract
3
+ *
4
+ * Phase-1 seam for future database packages. The current package remains
5
+ * PostgreSQL-native by default, but query generation now depends on this
6
+ * contract for the SQL primitives that vary across MySQL and SQLite.
7
+ */
8
+ import type { SchemaMetadata } from './schema.js';
9
+ export type DialectName = 'postgresql' | 'mysql' | 'sqlite' | (string & {});
10
+ export interface Dialect {
11
+ /** Dialect identifier. */
12
+ readonly name: DialectName;
13
+ /** Parameter placeholder for the Nth value, using a 1-indexed public count. */
14
+ paramPlaceholder(index: number): string;
15
+ /** Quote a SQL identifier (table, column, cursor, alias). */
16
+ quoteIdentifier(name: string): string;
17
+ /** Escape a string literal body for SQL single-quoted strings. */
18
+ escapeStringLiteral(value: string): string;
19
+ /** Empty JSON array literal used as a fallback for to-many relations. */
20
+ readonly emptyJsonArrayLiteral: string;
21
+ /** JSON null literal/fallback for to-one relations. */
22
+ readonly nullJsonLiteral: string;
23
+ /** Build a JSON object expression from output keys and SQL expressions. */
24
+ buildJsonObject(pairs: [key: string, expr: string][]): string;
25
+ /** Build a JSON array aggregation expression with a dialect-specific empty-array fallback. */
26
+ buildJsonArrayAgg(jsonObjectExpr: string, orderBy?: string): string;
27
+ /** Whether INSERT/UPDATE/DELETE support RETURNING rows. */
28
+ readonly supportsReturning: boolean;
29
+ /** Whether native ILIKE is supported. */
30
+ readonly supportsILike: boolean;
31
+ /** Build a case-insensitive LIKE equivalent. */
32
+ buildInsensitiveLike(column: string, paramRef: string): string;
33
+ /** JSON operator support level for this dialect. */
34
+ readonly jsonPathSupport: 'native' | 'function' | 'limited';
35
+ /** Build a JSON containment check. */
36
+ buildJsonContains(column: string, paramRef: string): string;
37
+ /** Build a JSON path text extraction expression. */
38
+ buildJsonPathExtract(column: string, pathParamRef: string): string;
39
+ /** Build a correlation clause across single or composite keys. */
40
+ buildCorrelation(leftRef: string, leftColumns: string | string[], rightRef: string, rightColumns: string | string[]): string;
41
+ /** Type mapping hook for code generation. */
42
+ typeToTypeScript(dialectType: string, nullable: boolean): string;
43
+ /** Optional array-cast hook for bulk insert implementations. */
44
+ arrayType?(baseType: string): string;
45
+ }
46
+ export interface DialectIntrospector {
47
+ introspect(options: IntrospectOptions): Promise<SchemaMetadata>;
48
+ }
49
+ export interface IntrospectOptions {
50
+ connectionString: string;
51
+ schema?: string;
52
+ include?: string[];
53
+ exclude?: string[];
54
+ }
55
+ export interface DialectMigrator {
56
+ acquireLock(lockId: number): Promise<boolean>;
57
+ releaseLock(lockId: number): Promise<void>;
58
+ }
59
+ /** PostgreSQL implementation of the dialect contract. */
60
+ export declare const postgresDialect: Dialect;
61
+ //# sourceMappingURL=dialect.d.ts.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * turbine-orm — SQL dialect contract
3
+ *
4
+ * Phase-1 seam for future database packages. The current package remains
5
+ * PostgreSQL-native by default, but query generation now depends on this
6
+ * contract for the SQL primitives that vary across MySQL and SQLite.
7
+ */
8
+ /** PostgreSQL implementation of the dialect contract. */
9
+ export const postgresDialect = {
10
+ name: 'postgresql',
11
+ supportsReturning: true,
12
+ supportsILike: true,
13
+ jsonPathSupport: 'native',
14
+ emptyJsonArrayLiteral: "'[]'::json",
15
+ nullJsonLiteral: 'NULL',
16
+ paramPlaceholder(index) {
17
+ return `$${index}`;
18
+ },
19
+ quoteIdentifier(name) {
20
+ return `"${name.replace(/"/g, '""')}"`;
21
+ },
22
+ escapeStringLiteral(value) {
23
+ return value.replace(/'/g, "''");
24
+ },
25
+ buildJsonObject(pairs) {
26
+ const args = pairs.map(([key, expr]) => `'${this.escapeStringLiteral(key)}', ${expr}`);
27
+ return `json_build_object(${args.join(', ')})`;
28
+ },
29
+ buildJsonArrayAgg(jsonObjectExpr, orderBy) {
30
+ const suffix = orderBy ? ` ${orderBy}` : '';
31
+ return `COALESCE(json_agg(${jsonObjectExpr}${suffix}), ${this.emptyJsonArrayLiteral})`;
32
+ },
33
+ buildInsensitiveLike(column, paramRef) {
34
+ return `${column} ILIKE ${paramRef}`;
35
+ },
36
+ buildJsonContains(column, paramRef) {
37
+ return `${column} @> ${paramRef}::jsonb`;
38
+ },
39
+ buildJsonPathExtract(column, pathParamRef) {
40
+ return `${column} #>> ${pathParamRef}::text[]`;
41
+ },
42
+ buildCorrelation(leftRef, leftColumns, rightRef, rightColumns) {
43
+ const leftCols = Array.isArray(leftColumns) ? leftColumns : [leftColumns];
44
+ const rightCols = Array.isArray(rightColumns) ? rightColumns : [rightColumns];
45
+ return leftCols
46
+ .map((col, i) => `${leftRef}.${this.quoteIdentifier(col)} = ${rightRef}.${this.quoteIdentifier(rightCols[i])}`)
47
+ .join(' AND ');
48
+ },
49
+ typeToTypeScript(_dialectType, _nullable) {
50
+ // Existing PostgreSQL type mapping remains in schema.ts/generate.ts for now.
51
+ // This hook is the package boundary MySQL/SQLite implementations will fill.
52
+ return 'unknown';
53
+ },
54
+ };
55
+ //# sourceMappingURL=dialect.js.map
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,14 +32,18 @@
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';
38
+ export type { Dialect, DialectIntrospector, DialectMigrator, DialectName, IntrospectOptions as DialectIntrospectOptions, } from './dialect.js';
39
+ export { postgresDialect } from './dialect.js';
36
40
  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
41
  export { type GenerateOptions, generate } from './generate.js';
38
42
  export { type IntrospectOptions, introspect } from './introspect.js';
39
43
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
40
44
  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
45
  export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
42
- export { camelToSnake, isDateType, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
46
+ export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
43
47
  export { ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type SchemaDef, type TableDef, table, } from './schema-builder.js';
44
48
  export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
45
49
  export { type TurbineHttpOptions, turbineHttp } from './serverless.js';
package/dist/index.js CHANGED
@@ -32,8 +32,10 @@
32
32
  * await db.disconnect();
33
33
  * ```
34
34
  */
35
+ export { alloydb, cockroachdb, postgresql, timescale, yugabytedb } from './adapters/index.js';
35
36
  // Client
36
37
  export { TransactionClient, TurbineClient, } from './client.js';
38
+ export { postgresDialect } from './dialect.js';
37
39
  // Error types
38
40
  export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockError, ForeignKeyError, getErrorMessageMode, MigrationError, NotFoundError, NotNullViolationError, PipelineError, RelationError, SerializationFailureError, setErrorMessageMode, TimeoutError, TurbineError, TurbineErrorCode, UniqueConstraintError, ValidationError, wrapPgError, } from './errors.js';
39
41
  // Code generation
@@ -45,7 +47,7 @@ export { executePipeline, pipelineSupported } from './pipeline.js';
45
47
  // Query builder
46
48
  export { QueryInterface, } from './query/index.js';
47
49
  // Schema utilities
48
- export { camelToSnake, isDateType, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
50
+ export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
49
51
  // Schema builder — define schemas in TypeScript
50
52
  export { ColumnBuilder, column, defineSchema,
51
53
  // Legacy compat (deprecated — use object format with defineSchema)
@@ -66,13 +66,16 @@ const SQL_FOREIGN_KEYS = `
66
66
  const SQL_UNIQUE_CONSTRAINTS = `
67
67
  SELECT
68
68
  tc.table_name,
69
- kcu.column_name
69
+ tc.constraint_name,
70
+ kcu.column_name,
71
+ kcu.ordinal_position
70
72
  FROM information_schema.table_constraints tc
71
73
  JOIN information_schema.key_column_usage kcu
72
74
  ON tc.constraint_name = kcu.constraint_name
73
75
  AND tc.table_schema = kcu.table_schema
74
76
  WHERE tc.constraint_type = 'UNIQUE'
75
77
  AND tc.table_schema = $1
78
+ ORDER BY tc.table_name, tc.constraint_name, kcu.ordinal_position
76
79
  `;
77
80
  const SQL_INDEXES = `
78
81
  SELECT tablename, indexname, indexdef
@@ -153,14 +156,22 @@ export async function introspect(options) {
153
156
  pkByTable.get(row.table_name).push(row.column_name);
154
157
  }
155
158
  // ----- Group unique constraints by table -----
159
+ // Group rows by (table_name, constraint_name) to correctly handle multi-column unique constraints
156
160
  const uniqueByTable = new Map();
161
+ const uniqueConstraintGroups = new Map();
157
162
  for (const row of uniqueResult.rows) {
158
163
  if (!tableSet.has(row.table_name))
159
164
  continue;
160
- if (!uniqueByTable.has(row.table_name))
161
- uniqueByTable.set(row.table_name, []);
162
- // Each unique constraint may be multi-column; for simplicity, treat as single-col here
163
- uniqueByTable.get(row.table_name).push([row.column_name]);
165
+ const key = `${row.table_name}::${row.constraint_name}`;
166
+ if (!uniqueConstraintGroups.has(key)) {
167
+ uniqueConstraintGroups.set(key, { table: row.table_name, columns: [] });
168
+ }
169
+ uniqueConstraintGroups.get(key).columns.push(row.column_name);
170
+ }
171
+ for (const { table, columns } of uniqueConstraintGroups.values()) {
172
+ if (!uniqueByTable.has(table))
173
+ uniqueByTable.set(table, []);
174
+ uniqueByTable.get(table).push(columns);
164
175
  }
165
176
  // ----- Group indexes by table -----
166
177
  const indexesByTable = new Map();
@@ -187,17 +198,25 @@ export async function introspect(options) {
187
198
  enums[row.typname] = [];
188
199
  enums[row.typname].push(row.enumlabel);
189
200
  }
190
- const foreignKeys = [];
201
+ const fkGroups = new Map();
191
202
  for (const row of fkResult.rows) {
192
203
  if (!tableSet.has(row.source_table) || !tableSet.has(row.target_table))
193
204
  continue;
194
- foreignKeys.push({
195
- sourceTable: row.source_table,
196
- sourceColumn: row.source_column,
197
- targetTable: row.target_table,
198
- targetColumn: row.target_column,
199
- });
205
+ const key = row.constraint_name;
206
+ if (!fkGroups.has(key)) {
207
+ fkGroups.set(key, {
208
+ sourceTable: row.source_table,
209
+ sourceColumns: [],
210
+ targetTable: row.target_table,
211
+ targetColumns: [],
212
+ constraintName: key,
213
+ });
214
+ }
215
+ const entry = fkGroups.get(key);
216
+ entry.sourceColumns.push(row.source_column);
217
+ entry.targetColumns.push(row.target_column);
200
218
  }
219
+ const foreignKeys = Array.from(fkGroups.values());
201
220
  // ----- Build relations from foreign keys -----
202
221
  // Count FKs per (source, target) pair for disambiguation
203
222
  const fkCounts = new Map();
@@ -209,10 +228,17 @@ export async function introspect(options) {
209
228
  for (const fk of foreignKeys) {
210
229
  const pairKey = `${fk.sourceTable}→${fk.targetTable}`;
211
230
  const needsDisambiguation = (fkCounts.get(pairKey) ?? 0) > 1;
231
+ // For single-column FKs, keep string form for backwards compatibility.
232
+ // For multi-column (composite) FKs, use array form.
233
+ const foreignKey = fk.sourceColumns.length === 1 ? fk.sourceColumns[0] : fk.sourceColumns;
234
+ const referenceKey = fk.targetColumns.length === 1 ? fk.targetColumns[0] : fk.targetColumns;
212
235
  // --- belongsTo on the source (child) table ---
213
236
  // e.g. posts.user_id → users.id creates posts.user (belongsTo)
237
+ // For composite FKs with disambiguation, use the constraint name
214
238
  const belongsToName = needsDisambiguation
215
- ? snakeToCamel(fk.sourceColumn.replace(/_id$/, ''))
239
+ ? fk.sourceColumns.length === 1
240
+ ? snakeToCamel(fk.sourceColumns[0].replace(/_id$/, ''))
241
+ : snakeToCamel(fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, ''))
216
242
  : singularize(snakeToCamel(fk.targetTable));
217
243
  if (!relationsByTable.has(fk.sourceTable))
218
244
  relationsByTable.set(fk.sourceTable, {});
@@ -221,13 +247,15 @@ export async function introspect(options) {
221
247
  name: belongsToName,
222
248
  from: fk.sourceTable,
223
249
  to: fk.targetTable,
224
- foreignKey: fk.sourceColumn,
225
- referenceKey: fk.targetColumn,
250
+ foreignKey,
251
+ referenceKey,
226
252
  };
227
253
  // --- hasMany on the target (parent) table ---
228
254
  // e.g. posts.user_id → users.id creates users.posts (hasMany)
229
255
  const hasManyName = needsDisambiguation
230
- ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumn.replace(/_id$/, '')}`)
256
+ ? fk.sourceColumns.length === 1
257
+ ? snakeToCamel(`${fk.sourceTable}_by_${fk.sourceColumns[0].replace(/_id$/, '')}`)
258
+ : snakeToCamel(`${fk.sourceTable}_by_${fk.constraintName.replace(/^fk_/, '').replace(/_fkey$/, '')}`)
231
259
  : snakeToCamel(fk.sourceTable);
232
260
  if (!relationsByTable.has(fk.targetTable))
233
261
  relationsByTable.set(fk.targetTable, {});
@@ -236,8 +264,8 @@ export async function introspect(options) {
236
264
  name: hasManyName,
237
265
  from: fk.targetTable,
238
266
  to: fk.sourceTable,
239
- foreignKey: fk.sourceColumn,
240
- referenceKey: fk.targetColumn,
267
+ foreignKey,
268
+ referenceKey,
241
269
  };
242
270
  }
243
271
  // ----- Assemble TableMetadata for each table -----
@@ -11,6 +11,7 @@
11
11
  * metadata — nothing is hardcoded.
12
12
  */
13
13
  import type pg from 'pg';
14
+ import type { Dialect } from '../dialect.js';
14
15
  import type { SchemaMetadata } from '../schema.js';
15
16
  import type { AggregateArgs, AggregateResult, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, TypedWithClause, UpdateArgs, UpdateManyArgs, UpsertArgs, WithClause, WithResult } from './types.js';
16
17
  export interface DeferredQuery<T> {
@@ -64,6 +65,8 @@ export interface QueryInterfaceOptions {
64
65
  * Default: `true`. Set to `false` as a nuclear kill switch.
65
66
  */
66
67
  sqlCache?: boolean;
68
+ /** SQL dialect implementation. Defaults to PostgreSQL. */
69
+ dialect?: Dialect;
67
70
  }
68
71
  export declare class QueryInterface<T extends object, R extends object = {}> {
69
72
  private readonly pool;
@@ -77,6 +80,7 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
77
80
  private readonly warnOnUnlimited;
78
81
  private readonly preparedStatementsEnabled;
79
82
  private readonly sqlCacheEnabled;
83
+ private readonly dialect;
80
84
  /**
81
85
  * Tracks tables that have already triggered an unlimited-query warning so
82
86
  * the user is not spammed once per row. Per-instance state — each
@@ -95,6 +99,10 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
95
99
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
96
100
  private readonly deepWithWarned;
97
101
  constructor(pool: pg.Pool, table: string, schema: SchemaMetadata, middlewares?: MiddlewareFn[], options?: QueryInterfaceOptions);
102
+ /** Quote an identifier through the active SQL dialect. */
103
+ private q;
104
+ /** Return the active dialect's placeholder for a 1-indexed parameter position. */
105
+ private p;
98
106
  /**
99
107
  * Return cache hit/miss statistics for this QueryInterface instance.
100
108
  * Useful for monitoring and benchmarking.
@@ -433,7 +441,7 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
433
441
  * 8. **Parameter threading:** All user-supplied values (where filters, limit) are
434
442
  * pushed to the shared `params` array with `$N` placeholders. No string
435
443
  * interpolation of user data ever occurs -- all identifiers go through
436
- * `quoteIdent()` and all values are parameterized.
444
+ * `this.q()` and all values are parameterized.
437
445
  *
438
446
  * ### Example output (hasMany with nested relation)
439
447
  * ```sql