turbine-orm 0.16.0 → 0.18.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 (41) hide show
  1. package/README.md +180 -12
  2. package/dist/adapters/cockroachdb.js +4 -2
  3. package/dist/adapters/index.js +4 -1
  4. package/dist/adapters/yugabytedb.js +4 -2
  5. package/dist/cjs/adapters/cockroachdb.js +4 -2
  6. package/dist/cjs/adapters/index.js +4 -1
  7. package/dist/cjs/adapters/yugabytedb.js +4 -2
  8. package/dist/cjs/cli/studio.js +5 -1
  9. package/dist/cjs/client.js +164 -0
  10. package/dist/cjs/errors.js +35 -5
  11. package/dist/cjs/generate.js +14 -3
  12. package/dist/cjs/index.js +10 -2
  13. package/dist/cjs/introspect.js +81 -0
  14. package/dist/cjs/nested-write.js +70 -6
  15. package/dist/cjs/query/builder.js +538 -12
  16. package/dist/cjs/realtime.js +147 -0
  17. package/dist/cjs/schema-builder.js +86 -0
  18. package/dist/cjs/schema.js +10 -0
  19. package/dist/cjs/typed-sql.js +149 -0
  20. package/dist/cli/studio.js +5 -1
  21. package/dist/client.d.ts +120 -0
  22. package/dist/client.js +165 -1
  23. package/dist/errors.js +35 -5
  24. package/dist/generate.js +14 -3
  25. package/dist/index.d.ts +4 -2
  26. package/dist/index.js +5 -1
  27. package/dist/introspect.js +81 -0
  28. package/dist/nested-write.js +70 -6
  29. package/dist/query/builder.d.ts +104 -1
  30. package/dist/query/builder.js +539 -13
  31. package/dist/query/index.d.ts +1 -1
  32. package/dist/query/types.d.ts +126 -2
  33. package/dist/realtime.d.ts +71 -0
  34. package/dist/realtime.js +144 -0
  35. package/dist/schema-builder.d.ts +68 -1
  36. package/dist/schema-builder.js +85 -0
  37. package/dist/schema.d.ts +18 -1
  38. package/dist/schema.js +10 -0
  39. package/dist/typed-sql.d.ts +101 -0
  40. package/dist/typed-sql.js +145 -0
  41. package/package.json +17 -15
package/dist/client.js CHANGED
@@ -22,10 +22,13 @@
22
22
  * ```
23
23
  */
24
24
  import pg from 'pg';
25
- import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
25
+ import { setErrorMessageMode, TimeoutError, ValidationError, wrapPgError } from './errors.js';
26
26
  import { ObserveEngine } from './observe.js';
27
27
  import { executePipeline, pipelineSupported } from './pipeline.js';
28
28
  import { QueryInterface, } from './query/index.js';
29
+ import { quoteIdent } from './query/utils.js';
30
+ import { createSubscription, validateChannel, } from './realtime.js';
31
+ import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
29
32
  export async function withRetry(fn, options) {
30
33
  const maxAttempts = options?.maxAttempts ?? 3;
31
34
  const baseDelay = options?.baseDelay ?? 50;
@@ -57,6 +60,13 @@ const ISOLATION_LEVELS = {
57
60
  RepeatableRead: 'REPEATABLE READ',
58
61
  Serializable: 'SERIALIZABLE',
59
62
  };
63
+ /**
64
+ * Strict GUC (session variable) name: an optionally namespaced identifier such
65
+ * as `app.current_tenant` or `search_path`. Even though the name is passed as a
66
+ * bound parameter to `set_config`, a malformed name is a programmer error worth
67
+ * rejecting loudly before it reaches the database.
68
+ */
69
+ const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
60
70
  // ---------------------------------------------------------------------------
61
71
  // TransactionClient — provides typed table accessors within a transaction
62
72
  // ---------------------------------------------------------------------------
@@ -189,6 +199,8 @@ export class TurbineClient {
189
199
  errorMessagesSafe;
190
200
  /** True when Turbine created the pool and is responsible for tearing it down */
191
201
  ownsPool = true;
202
+ /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
203
+ activeSubscriptions = new Set();
192
204
  constructor(config = {}, schema) {
193
205
  /**
194
206
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
@@ -460,6 +472,40 @@ export class TurbineClient {
460
472
  throw wrapPgError(err);
461
473
  }
462
474
  }
475
+ /**
476
+ * Execute a **typed** raw SQL query — Turbine's answer to Prisma's TypedSQL.
477
+ *
478
+ * Like {@link raw}, every interpolated `${value}` becomes a `$N` parameter
479
+ * (never string-concatenated), so it is injection-safe by construction. The
480
+ * difference is the caller-supplied row type and the chainable result: the
481
+ * returned {@link TypedSqlQuery} can be `await`ed directly for `T[]`, or
482
+ * refined with `.one()` (→ `T | null`) or `.scalar<V>()` (→ `V | null`).
483
+ *
484
+ * Rows are returned as-is — no snake→camel mapping (matching `raw()`). Alias
485
+ * columns in SQL if you want camelCase keys.
486
+ *
487
+ * @example
488
+ * ```ts
489
+ * // rows
490
+ * const rows = await db.sql<{ id: number; name: string }>`
491
+ * SELECT id, name FROM users WHERE org_id = ${orgId}
492
+ * `;
493
+ *
494
+ * // single row or null
495
+ * const user = await db.sql<{ id: number; name: string }>`
496
+ * SELECT id, name FROM users WHERE id = ${userId}
497
+ * `.one();
498
+ *
499
+ * // scalar
500
+ * const total = await db.sql<{ count: number }>`
501
+ * SELECT COUNT(*)::int AS count FROM users
502
+ * `.scalar();
503
+ * ```
504
+ */
505
+ sql(strings, ...values) {
506
+ const { sql, params } = buildTypedSql(strings, values);
507
+ return new TypedSqlQuery(this.pool, sql, params, this.logging);
508
+ }
463
509
  // -------------------------------------------------------------------------
464
510
  // Transaction support (raw — legacy)
465
511
  // -------------------------------------------------------------------------
@@ -542,6 +588,21 @@ export class TurbineClient {
542
588
  beginSQL += ` ISOLATION LEVEL ${level}`;
543
589
  }
544
590
  await client.query(beginSQL);
591
+ // Apply transaction-local session context (RLS / multi-tenant GUCs).
592
+ // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
593
+ // user fn. Any error here propagates to the catch below and rolls back
594
+ // like any other transaction failure. We use set_config(name, value,
595
+ // is_local=true) — the parameterizable, transaction-scoped equivalent of
596
+ // SET LOCAL — so both name and value are BOUND params, never interpolated.
597
+ if (options?.sessionContext) {
598
+ for (const [name, value] of Object.entries(options.sessionContext)) {
599
+ if (!GUC_NAME_REGEX.test(name)) {
600
+ throw new ValidationError(`[turbine] Invalid session-context GUC name "${name}" — must match ` +
601
+ '/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
602
+ }
603
+ await client.query('SELECT set_config($1, $2, true)', [name, String(value)]);
604
+ }
605
+ }
545
606
  // Create the transaction client with typed table accessors
546
607
  const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
547
608
  // Dynamically attach table accessors to tx
@@ -613,6 +674,94 @@ export class TurbineClient {
613
674
  releaseOnce();
614
675
  }
615
676
  }
677
+ /**
678
+ * Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
679
+ * runs `fn` inside a transaction with the given session GUCs applied via
680
+ * `set_config(..., is_local=true)`. Equivalent to
681
+ * `$transaction(fn, { sessionContext: context })`.
682
+ *
683
+ * @example
684
+ * ```ts
685
+ * const invoices = await db.$withSession(
686
+ * { 'app.current_tenant': tenantId },
687
+ * (tx) => tx.invoices.findMany(),
688
+ * );
689
+ * ```
690
+ */
691
+ async $withSession(context, fn) {
692
+ return this.$transaction(fn, { sessionContext: context });
693
+ }
694
+ // -------------------------------------------------------------------------
695
+ // LISTEN / NOTIFY — Postgres realtime pub/sub
696
+ // -------------------------------------------------------------------------
697
+ /**
698
+ * Subscribe to a Postgres NOTIFY channel. The handler fires with each
699
+ * notification's payload string (the empty string when a payload-less
700
+ * NOTIFY is sent) for as long as the subscription is active.
701
+ *
702
+ * Each `$listen` checks out its OWN dedicated long-lived connection from the
703
+ * pool and runs `LISTEN "channel"` on it; `subscription.unsubscribe()`
704
+ * UNLISTENs, detaches the handler, and releases that connection. Active
705
+ * subscriptions are tracked and force-released on `disconnect()` so shutdown
706
+ * never hangs.
707
+ *
708
+ * The channel name CANNOT be a bound parameter (`LISTEN $1` is a syntax
709
+ * error), so it is validated against a strict identifier regex AND quoted via
710
+ * `quoteIdent` before interpolation — it is the only identifier this method
711
+ * places into SQL text.
712
+ *
713
+ * **Serverless caveat:** LISTEN needs a persistent connection that can push
714
+ * async notifications. Stateless HTTP drivers (Neon HTTP, Vercel Postgres)
715
+ * cannot do this — `$listen` throws a `ConnectionError` rather than hang.
716
+ * `$notify` works on every driver.
717
+ *
718
+ * @example
719
+ * ```ts
720
+ * const sub = await db.$listen('order_created', (payload) => {
721
+ * const order = JSON.parse(payload);
722
+ * console.log('new order', order.id);
723
+ * });
724
+ * // ...later
725
+ * await sub.unsubscribe();
726
+ * ```
727
+ */
728
+ async $listen(channel, handler) {
729
+ validateChannel(channel);
730
+ const quoted = quoteIdent(channel);
731
+ if (this.logging) {
732
+ console.log(`[turbine] LISTEN ${quoted}`);
733
+ }
734
+ const sub = await createSubscription(this.pool, channel, quoted, handler, (closed) => {
735
+ this.activeSubscriptions.delete(closed);
736
+ });
737
+ this.activeSubscriptions.add(sub);
738
+ return sub;
739
+ }
740
+ /**
741
+ * Send a Postgres NOTIFY on `channel` with an optional payload string.
742
+ *
743
+ * Issued as `SELECT pg_notify($1, $2)` — both the channel and payload are
744
+ * BOUND parameters (no quoting/injection concern). The channel is still
745
+ * validated against the identifier regex for parity with `$listen` and to
746
+ * catch typos loudly. Works on every driver, including serverless HTTP pools.
747
+ *
748
+ * @example
749
+ * ```ts
750
+ * await db.$notify('order_created', JSON.stringify({ id: 7 }));
751
+ * ```
752
+ */
753
+ async $notify(channel, payload) {
754
+ validateChannel(channel);
755
+ if (this.logging) {
756
+ console.log(`[turbine] NOTIFY ${channel}`);
757
+ }
758
+ try {
759
+ await this.pool.query('SELECT pg_notify($1, $2)', [channel, payload ?? '']);
760
+ }
761
+ catch (err) {
762
+ throw wrapPgError(err);
763
+ }
764
+ }
616
765
  // -------------------------------------------------------------------------
617
766
  // Retry — automatic retry for retryable errors (deadlock, serialization)
618
767
  // -------------------------------------------------------------------------
@@ -660,6 +809,21 @@ export class TurbineClient {
660
809
  * method is a no-op — the caller is responsible for the pool's lifecycle.
661
810
  */
662
811
  async disconnect() {
812
+ // Tear down any live LISTEN subscriptions first. Each holds a dedicated
813
+ // pooled connection checked out; if we ended the pool (or returned for an
814
+ // external pool) without releasing them, pool.end() would wait forever for
815
+ // those connections to return. _forceRelease() detaches the handler and
816
+ // releases the client WITHOUT issuing UNLISTEN (pointless if we're ending
817
+ // the pool / the connection is going away anyway). This runs for both
818
+ // owned and external pools so subscriptions never leak.
819
+ if (this.activeSubscriptions.size > 0) {
820
+ // _forceRelease mutates activeSubscriptions via the onClosed callback,
821
+ // so iterate a snapshot.
822
+ for (const sub of [...this.activeSubscriptions]) {
823
+ sub._forceRelease();
824
+ }
825
+ this.activeSubscriptions.clear();
826
+ }
663
827
  if (!this.ownsPool) {
664
828
  if (this.logging) {
665
829
  console.log('[turbine] disconnect() skipped — external pool is not owned by Turbine');
package/dist/errors.js CHANGED
@@ -197,7 +197,13 @@ export class UniqueConstraintError extends TurbineError {
197
197
  const constraintPart = constraint ? ` on ${constraint}` : '';
198
198
  const columnsPart = columns && columns.length > 0 ? ` (${columns.join(', ')})` : '';
199
199
  message = `[turbine] Unique constraint violation${constraintPart}${columnsPart}`;
200
- const detail = detailFromCause(cause);
200
+ // PII-safe by default: the raw pg `detail` string contains the
201
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
202
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
203
+ // message carries keys/constraint/column names only — the structured
204
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
205
+ // the full detail for programmatic use.
206
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
201
207
  if (detail)
202
208
  message += `: ${detail}`;
203
209
  }
@@ -218,7 +224,13 @@ export class ForeignKeyError extends TurbineError {
218
224
  if (!message) {
219
225
  const constraintPart = constraint ? ` on ${constraint}` : '';
220
226
  message = `[turbine] Foreign key constraint violation${constraintPart}`;
221
- const detail = detailFromCause(cause);
227
+ // PII-safe by default: the raw pg `detail` string contains the
228
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
229
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
230
+ // message carries keys/constraint/column names only — the structured
231
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
232
+ // the full detail for programmatic use.
233
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
222
234
  if (detail)
223
235
  message += `: ${detail}`;
224
236
  }
@@ -238,7 +250,13 @@ export class NotNullViolationError extends TurbineError {
238
250
  if (!message) {
239
251
  const columnPart = column ? ` on column "${column}"` : '';
240
252
  message = `[turbine] NOT NULL constraint violation${columnPart}`;
241
- const detail = detailFromCause(cause);
253
+ // PII-safe by default: the raw pg `detail` string contains the
254
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
255
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
256
+ // message carries keys/constraint/column names only — the structured
257
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
258
+ // the full detail for programmatic use.
259
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
242
260
  if (detail)
243
261
  message += `: ${detail}`;
244
262
  }
@@ -323,7 +341,13 @@ export class CheckConstraintError extends TurbineError {
323
341
  if (!message) {
324
342
  const constraintPart = constraint ? ` on ${constraint}` : '';
325
343
  message = `[turbine] Check constraint violation${constraintPart}`;
326
- const detail = detailFromCause(cause);
344
+ // PII-safe by default: the raw pg `detail` string contains the
345
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
346
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
347
+ // message carries keys/constraint/column names only — the structured
348
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
349
+ // the full detail for programmatic use.
350
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
327
351
  if (detail)
328
352
  message += `: ${detail}`;
329
353
  }
@@ -342,7 +366,13 @@ export class ExclusionConstraintError extends TurbineError {
342
366
  if (!message) {
343
367
  const constraintPart = constraint ? ` on ${constraint}` : '';
344
368
  message = `[turbine] Exclusion constraint violation${constraintPart}`;
345
- const detail = detailFromCause(cause);
369
+ // PII-safe by default: the raw pg `detail` string contains the
370
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
371
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
372
+ // message carries keys/constraint/column names only — the structured
373
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
374
+ // the full detail for programmatic use.
375
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
346
376
  if (detail)
347
377
  message += `: ${detail}`;
348
378
  }
package/dist/generate.js CHANGED
@@ -152,7 +152,8 @@ export function generateTypes(schema) {
152
152
  lines.push(`export interface ${typeName}Relations {`);
153
153
  for (const [relName, rel] of Object.entries(table.relations)) {
154
154
  const targetType = entityName(rel.to);
155
- const cardinality = rel.type === 'hasMany' ? "'many'" : "'one'";
155
+ // manyToMany is a collection too 'many' cardinality (same as hasMany).
156
+ const cardinality = rel.type === 'hasMany' || rel.type === 'manyToMany' ? "'many'" : "'one'";
156
157
  const targetRelations = tablesWithRelations.has(rel.to) ? `${targetType}Relations` : '{}';
157
158
  lines.push(` ${relName}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
158
159
  }
@@ -161,7 +162,7 @@ export function generateTypes(schema) {
161
162
  // --- Legacy per-relation interfaces (kept for backward compatibility) ---
162
163
  for (const [relName, rel] of Object.entries(table.relations)) {
163
164
  const targetType = entityName(rel.to);
164
- if (rel.type === 'hasMany') {
165
+ if (rel.type === 'hasMany' || rel.type === 'manyToMany') {
165
166
  lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
166
167
  lines.push(`export interface ${typeName}With${snakeToPascal(relName)} extends ${typeName} {`);
167
168
  lines.push(` ${relName}: ${targetType}[];`);
@@ -328,7 +329,17 @@ function generateMetadata(schema) {
328
329
  const refLiteral = Array.isArray(rel.referenceKey)
329
330
  ? `[${rel.referenceKey.map((c) => `'${escSQ(c)}'`).join(', ')}]`
330
331
  : `'${escSQ(rel.referenceKey)}'`;
331
- lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral} },`);
332
+ // manyToMany relations carry a `through` junction descriptor emit it so
333
+ // the runtime query builder can JOIN through the junction table.
334
+ let throughLiteral = '';
335
+ if (rel.through) {
336
+ const keyLiteral = (k) => Array.isArray(k) ? `[${k.map((c) => `'${escSQ(c)}'`).join(', ')}]` : `'${escSQ(k)}'`;
337
+ throughLiteral =
338
+ `, through: { table: '${escSQ(rel.through.table)}', ` +
339
+ `sourceKey: ${keyLiteral(rel.through.sourceKey)}, ` +
340
+ `targetKey: ${keyLiteral(rel.through.targetKey)} }`;
341
+ }
342
+ lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral}${throughLiteral} },`);
332
343
  }
333
344
  lines.push(' },');
334
345
  // indexes
package/dist/index.d.ts CHANGED
@@ -43,10 +43,12 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export type { ObserveConfig, ObserveHandle } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
46
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type JsonFilter, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
47
+ export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
47
48
  export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
48
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
49
- export { ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type SchemaDef, type TableDef, table, } from './schema-builder.js';
50
+ export { applyManyToManyRelations, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnType, type ColumnTypeName, column, defineSchema, type ManyToManyDef, type SchemaDef, type TableDef, table, } from './schema-builder.js';
50
51
  export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, type SchemaSqlOptions, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
51
52
  export { type TurbineHttpOptions, turbineHttp } from './serverless.js';
53
+ export { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
52
54
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -48,14 +48,18 @@ export { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './
48
48
  export { executePipeline, pipelineSupported } from './pipeline.js';
49
49
  // Query builder
50
50
  export { QueryInterface, } from './query/index.js';
51
+ // Realtime — LISTEN/NOTIFY pub/sub
52
+ export { validateChannel } from './realtime.js';
51
53
  // Schema utilities
52
54
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
53
55
  // Schema builder — define schemas in TypeScript
54
- export { ColumnBuilder, column, defineSchema,
56
+ export { applyManyToManyRelations, ColumnBuilder, column, defineSchema,
55
57
  // Legacy compat (deprecated — use object format with defineSchema)
56
58
  table, } from './schema-builder.js';
57
59
  // Schema SQL — generate DDL, diff, and push
58
60
  export { schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
59
61
  // Serverless / edge factory
60
62
  export { turbineHttp } from './serverless.js';
63
+ // Typed raw SQL — Turbine's TypedSQL escape hatch
64
+ export { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
61
65
  //# sourceMappingURL=index.js.map
@@ -275,6 +275,87 @@ export async function introspect(options) {
275
275
  referenceKey,
276
276
  };
277
277
  }
278
+ // ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
279
+ //
280
+ // Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
281
+ // junction, but a `enrollments(student_id, course_id, grade, enrolled_at)`
282
+ // table is a first-class entity, not a join table. Prisma and Drizzle both
283
+ // require explicit m2m declaration for exactly this reason.
284
+ //
285
+ // We only treat a table J as a PURE junction when ALL of these hold:
286
+ // 1. J's primary key is exactly two columns.
287
+ // 2. J has exactly two FKs, each single-column.
288
+ // 3. Each FK's source column is one of J's two PK columns (the PK *is* the
289
+ // two FK columns — no surrogate PK, no extra identity).
290
+ // 4. The two FKs target two DISTINCT tables (A and B).
291
+ // 5. J has no columns beyond those two FK/PK columns (no payload columns
292
+ // like `grade` or `created_at`).
293
+ //
294
+ // For such a J linking A and B we ADD a `manyToMany` relation on A → B and
295
+ // symmetrically on B → A, both routed `through` J. The existing belongsTo /
296
+ // hasMany relations derived from J's FKs are left untouched — this block
297
+ // never removes or renames anything. If the chosen relation name already
298
+ // exists on the source table (e.g. another relation grabbed it), we SKIP to
299
+ // stay additive.
300
+ for (const tableName of tableNames) {
301
+ const pk = pkByTable.get(tableName) ?? [];
302
+ if (pk.length !== 2)
303
+ continue;
304
+ // FKs whose source is this table.
305
+ const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
306
+ if (tableFks.length !== 2)
307
+ continue;
308
+ // Both FKs must be single-column.
309
+ if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
310
+ continue;
311
+ const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
312
+ const pkSet = new Set(pk);
313
+ // Both FK columns must be the PK columns (and vice-versa).
314
+ if (!fkCols.every((c) => pkSet.has(c)))
315
+ continue;
316
+ if (new Set(fkCols).size !== 2)
317
+ continue;
318
+ // Two DISTINCT target tables.
319
+ const [fkA, fkB] = tableFks;
320
+ if (fkA.targetTable === fkB.targetTable)
321
+ continue;
322
+ // No payload columns: J's columns are exactly the two FK/PK columns.
323
+ const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
324
+ if (jCols.length !== 2)
325
+ continue;
326
+ // For each direction, the m2m `referenceKey` is the *targeted* table's
327
+ // referenced column(s); the junction's sourceKey is the FK column pointing
328
+ // to that table; the targetKey is the FK column pointing to the OTHER table.
329
+ const addM2M = (self, other) => {
330
+ const sourceTbl = self.targetTable; // A
331
+ const targetTbl = other.targetTable; // B
332
+ const relName = snakeToCamel(targetTbl); // plural table name → e.g. "tags"
333
+ if (!relationsByTable.has(sourceTbl))
334
+ relationsByTable.set(sourceTbl, {});
335
+ const existing = relationsByTable.get(sourceTbl);
336
+ // Additive-only: never clobber an existing relation name.
337
+ if (existing[relName])
338
+ return;
339
+ existing[relName] = {
340
+ type: 'manyToMany',
341
+ name: relName,
342
+ from: sourceTbl,
343
+ to: targetTbl,
344
+ // referenceKey = A's referenced column(s) that J's sourceKey points at.
345
+ referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
346
+ // foreignKey is unused for m2m correlation but kept for shape parity
347
+ // (mirrors the source-side reference for back-compat consumers).
348
+ foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
349
+ through: {
350
+ table: tableName,
351
+ sourceKey: self.sourceColumns[0], // J col → A
352
+ targetKey: other.sourceColumns[0], // J col → B
353
+ },
354
+ };
355
+ };
356
+ addM2M(fkA, fkB); // A → B
357
+ addM2M(fkB, fkA); // B → A
358
+ }
278
359
  // ----- Assemble TableMetadata for each table -----
279
360
  const tables = {};
280
361
  for (const tableName of tableNames) {
@@ -137,17 +137,29 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
137
137
  }
138
138
  validateOps(relName, ops, false);
139
139
  }
140
- // Insert the parent row
141
- const parentRow = (await ctx.tx.table(tableName).create({ data: scalars }));
142
- // Process each relation
140
+ // belongsTo relations put the foreign key on the PARENT row, so they must be
141
+ // resolved BEFORE the parent is inserted — otherwise a NOT NULL FK column
142
+ // fails on the initial INSERT. We resolve each belongsTo op (create/connect/
143
+ // connectOrCreate) to its referenced row and fold the FK values into the
144
+ // parent's own INSERT.
145
+ const belongsToFks = {};
146
+ for (const [relName, ops] of Object.entries(relations)) {
147
+ const rel = tableMeta.relations[relName];
148
+ if (rel.type === 'belongsTo') {
149
+ Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
150
+ }
151
+ }
152
+ // Insert the parent row (scalars + resolved belongsTo foreign keys)
153
+ const parentRow = (await ctx.tx.table(tableName).create({
154
+ data: { ...scalars, ...belongsToFks },
155
+ }));
156
+ // Process hasMany / hasOne relations — their FK lives on the CHILD, so they
157
+ // need the parent row to exist first.
143
158
  for (const [relName, ops] of Object.entries(relations)) {
144
159
  const rel = tableMeta.relations[relName];
145
160
  if (rel.type === 'hasMany' || rel.type === 'hasOne') {
146
161
  await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
147
162
  }
148
- else if (rel.type === 'belongsTo') {
149
- await processBelongsToCreate(ctx, rel, ops, parentRow, tableName, depth, path, relName);
150
- }
151
163
  }
152
164
  // Build the `with` clause for the final read to return the full tree
153
165
  const withClause = {};
@@ -312,6 +324,58 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
312
324
  // ---------------------------------------------------------------------------
313
325
  // belongsTo create operations
314
326
  // ---------------------------------------------------------------------------
327
+ /**
328
+ * Resolve a belongsTo relation's create/connect/connectOrCreate op to the
329
+ * foreign-key value(s) that belong on the PARENT row, returning them keyed by
330
+ * the parent's own field names so they can be merged into the parent INSERT.
331
+ *
332
+ * Used by the create path only. (The update path uses processBelongsToCreate,
333
+ * which UPDATEs the FK after the parent already exists.)
334
+ */
335
+ async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path, relName) {
336
+ const fks = normalizeKeyColumns(rel.foreignKey);
337
+ const refs = normalizeKeyColumns(rel.referenceKey);
338
+ const parentMeta = ctx.schema.tables[parentTable];
339
+ const relatedTable = ctx.schema.tables[rel.to];
340
+ let relatedRow = null;
341
+ if (ops.create !== undefined) {
342
+ const items = toArray(ops.create);
343
+ if (items.length > 0) {
344
+ relatedRow = (await executeNestedCreate(ctx, rel.to, items[0], depth + 1, [...path, relName]));
345
+ }
346
+ }
347
+ else if (ops.connect !== undefined) {
348
+ const items = toArray(ops.connect);
349
+ if (items.length > 0) {
350
+ const target = items[0];
351
+ relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
352
+ if (!relatedRow) {
353
+ throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
354
+ }
355
+ }
356
+ }
357
+ else if (ops.connectOrCreate !== undefined) {
358
+ const items = toArray(ops.connectOrCreate);
359
+ if (items.length > 0) {
360
+ const op = items[0];
361
+ relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: op.where }));
362
+ if (!relatedRow) {
363
+ // For belongsTo the FK lives on the parent, so the related row is
364
+ // created plainly (no FK injection) and we read its reference key.
365
+ relatedRow = (await ctx.tx.table(rel.to).create({ data: op.create }));
366
+ }
367
+ }
368
+ }
369
+ const fkScalars = {};
370
+ if (relatedRow) {
371
+ for (let i = 0; i < fks.length; i++) {
372
+ const fkField = parentMeta.reverseColumnMap[fks[i]] ?? fks[i];
373
+ const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
374
+ fkScalars[fkField] = relatedRow[refField];
375
+ }
376
+ }
377
+ return fkScalars;
378
+ }
315
379
  async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, depth, path, relName) {
316
380
  const fks = normalizeKeyColumns(rel.foreignKey);
317
381
  const refs = normalizeKeyColumns(rel.referenceKey);