turbine-orm 0.15.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 (54) 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/index.js +64 -0
  9. package/dist/cjs/cli/observe-ui.js +182 -0
  10. package/dist/cjs/cli/observe.js +242 -0
  11. package/dist/cjs/cli/studio.js +5 -1
  12. package/dist/cjs/client.js +218 -0
  13. package/dist/cjs/errors.js +35 -5
  14. package/dist/cjs/generate.js +14 -3
  15. package/dist/cjs/index.js +10 -2
  16. package/dist/cjs/introspect.js +81 -0
  17. package/dist/cjs/nested-write.js +164 -10
  18. package/dist/cjs/observe.js +145 -0
  19. package/dist/cjs/query/builder.js +604 -25
  20. package/dist/cjs/realtime.js +147 -0
  21. package/dist/cjs/schema-builder.js +86 -0
  22. package/dist/cjs/schema.js +10 -0
  23. package/dist/cjs/typed-sql.js +149 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +64 -0
  26. package/dist/cli/observe-ui.d.ts +2 -0
  27. package/dist/cli/observe-ui.js +180 -0
  28. package/dist/cli/observe.d.ts +20 -0
  29. package/dist/cli/observe.js +237 -0
  30. package/dist/cli/studio.js +5 -1
  31. package/dist/client.d.ts +129 -2
  32. package/dist/client.js +220 -2
  33. package/dist/errors.js +35 -5
  34. package/dist/generate.js +14 -3
  35. package/dist/index.d.ts +5 -2
  36. package/dist/index.js +5 -1
  37. package/dist/introspect.js +81 -0
  38. package/dist/nested-write.d.ts +2 -2
  39. package/dist/nested-write.js +164 -10
  40. package/dist/observe.d.ts +36 -0
  41. package/dist/observe.js +141 -0
  42. package/dist/query/builder.d.ts +121 -1
  43. package/dist/query/builder.js +605 -26
  44. package/dist/query/index.d.ts +2 -2
  45. package/dist/query/types.d.ts +126 -2
  46. package/dist/realtime.d.ts +71 -0
  47. package/dist/realtime.js +144 -0
  48. package/dist/schema-builder.d.ts +68 -1
  49. package/dist/schema-builder.js +85 -0
  50. package/dist/schema.d.ts +18 -1
  51. package/dist/schema.js +10 -0
  52. package/dist/typed-sql.d.ts +101 -0
  53. package/dist/typed-sql.js +145 -0
  54. package/package.json +18 -16
package/dist/client.js CHANGED
@@ -22,9 +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
+ import { ObserveEngine } from './observe.js';
26
27
  import { executePipeline, pipelineSupported } from './pipeline.js';
27
- import { QueryInterface } from './query/index.js';
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';
28
32
  export async function withRetry(fn, options) {
29
33
  const maxAttempts = options?.maxAttempts ?? 3;
30
34
  const baseDelay = options?.baseDelay ?? 50;
@@ -56,6 +60,13 @@ const ISOLATION_LEVELS = {
56
60
  RepeatableRead: 'REPEATABLE READ',
57
61
  Serializable: 'SERIALIZABLE',
58
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_]*)?$/;
59
70
  // ---------------------------------------------------------------------------
60
71
  // TransactionClient — provides typed table accessors within a transaction
61
72
  // ---------------------------------------------------------------------------
@@ -183,9 +194,13 @@ export class TurbineClient {
183
194
  logging;
184
195
  tableCache = new Map();
185
196
  middlewares = [];
197
+ queryListeners = new Set();
186
198
  queryOptions;
199
+ errorMessagesSafe;
187
200
  /** True when Turbine created the pool and is responsible for tearing it down */
188
201
  ownsPool = true;
202
+ /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
203
+ activeSubscriptions = new Set();
189
204
  constructor(config = {}, schema) {
190
205
  /**
191
206
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
@@ -217,12 +232,27 @@ export class TurbineClient {
217
232
  this.schema = schema;
218
233
  // Respect env var kill switch
219
234
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
235
+ this.errorMessagesSafe = (config.errorMessages ?? 'safe') === 'safe';
220
236
  this.queryOptions = {
221
237
  defaultLimit: config.defaultLimit,
222
238
  warnOnUnlimited: config.warnOnUnlimited,
223
239
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
224
240
  sqlCache: config.sqlCache ?? true,
225
241
  dialect: config.dialect,
242
+ _onQuery: (event) => {
243
+ if (this.queryListeners.size === 0)
244
+ return;
245
+ const emitted = this.errorMessagesSafe ? { ...event, params: event.params.map(() => '[REDACTED]') } : event;
246
+ for (const listener of this.queryListeners) {
247
+ try {
248
+ listener(emitted);
249
+ }
250
+ catch (e) {
251
+ if (this.logging)
252
+ console.error('[turbine] Query listener error:', e);
253
+ }
254
+ }
255
+ },
226
256
  };
227
257
  // Apply NotFoundError message redaction mode (default: safe — values are
228
258
  // stripped from messages to avoid leaking PII into error logs).
@@ -275,6 +305,11 @@ export class TurbineClient {
275
305
  });
276
306
  }
277
307
  }
308
+ // Auto-start observability from env var
309
+ const observeUrl = typeof process !== 'undefined' ? process.env?.TURBINE_OBSERVE_URL : undefined;
310
+ if (observeUrl) {
311
+ this.$observe({ connectionString: observeUrl }).catch(() => { });
312
+ }
278
313
  }
279
314
  // -------------------------------------------------------------------------
280
315
  // Middleware — intercept all queries
@@ -316,6 +351,37 @@ export class TurbineClient {
316
351
  this.tableCache.clear();
317
352
  }
318
353
  // -------------------------------------------------------------------------
354
+ // Event emitter — subscribe to query lifecycle events
355
+ // -------------------------------------------------------------------------
356
+ $on(_event, listener) {
357
+ this.queryListeners.add(listener);
358
+ }
359
+ $off(_event, listener) {
360
+ this.queryListeners.delete(listener);
361
+ }
362
+ // -------------------------------------------------------------------------
363
+ // Observability — automatic metrics collection
364
+ // -------------------------------------------------------------------------
365
+ observeEngine;
366
+ async $observe(config) {
367
+ if (this.observeEngine) {
368
+ await this.observeEngine.stop();
369
+ this.$off('query', this.observeEngine.getListener());
370
+ }
371
+ const engine = new ObserveEngine(config);
372
+ this.observeEngine = engine;
373
+ await engine.init();
374
+ this.$on('query', engine.getListener());
375
+ return {
376
+ stop: async () => {
377
+ this.$off('query', engine.getListener());
378
+ await engine.stop();
379
+ if (this.observeEngine === engine)
380
+ this.observeEngine = undefined;
381
+ },
382
+ };
383
+ }
384
+ // -------------------------------------------------------------------------
319
385
  // Table accessor — creates QueryInterface for any table
320
386
  // -------------------------------------------------------------------------
321
387
  /**
@@ -406,6 +472,40 @@ export class TurbineClient {
406
472
  throw wrapPgError(err);
407
473
  }
408
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
+ }
409
509
  // -------------------------------------------------------------------------
410
510
  // Transaction support (raw — legacy)
411
511
  // -------------------------------------------------------------------------
@@ -488,6 +588,21 @@ export class TurbineClient {
488
588
  beginSQL += ` ISOLATION LEVEL ${level}`;
489
589
  }
490
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
+ }
491
606
  // Create the transaction client with typed table accessors
492
607
  const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
493
608
  // Dynamically attach table accessors to tx
@@ -559,6 +674,94 @@ export class TurbineClient {
559
674
  releaseOnce();
560
675
  }
561
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
+ }
562
765
  // -------------------------------------------------------------------------
563
766
  // Retry — automatic retry for retryable errors (deadlock, serialization)
564
767
  // -------------------------------------------------------------------------
@@ -606,6 +809,21 @@ export class TurbineClient {
606
809
  * method is a no-op — the caller is responsible for the pool's lifecycle.
607
810
  */
608
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
+ }
609
827
  if (!this.ownsPool) {
610
828
  if (this.logging) {
611
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
@@ -41,11 +41,14 @@ export { CheckConstraintError, CircularRelationError, ConnectionError, DeadlockE
41
41
  export { type GenerateOptions, generate } from './generate.js';
42
42
  export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
+ export type { ObserveConfig, ObserveHandle } from './observe.js';
44
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
45
- 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, 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';
46
48
  export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
47
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
48
- 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';
49
51
  export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, type SchemaSqlOptions, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
50
52
  export { type TurbineHttpOptions, turbineHttp } from './serverless.js';
53
+ export { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
51
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) {
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Tree-walking create/update that resolves relation fields in `data` into
5
5
  * batched SQL operations within a transaction. Supports create, connect,
6
- * connectOrCreate, disconnect, set, and delete on related records at
7
- * arbitrary depth (capped at 10).
6
+ * connectOrCreate, disconnect, set, delete, update, and upsert on related
7
+ * records at arbitrary depth (capped at 10).
8
8
  *
9
9
  * This module is imported by `query/builder.ts` when the `data` argument
10
10
  * of `create()` or `update()` contains relation fields. It never imports