turbine-orm 0.16.0 → 0.19.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 (43) 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-ui.generated.js +1 -1
  9. package/dist/cjs/cli/studio.js +35 -73
  10. package/dist/cjs/client.js +164 -0
  11. package/dist/cjs/errors.js +35 -5
  12. package/dist/cjs/generate.js +14 -3
  13. package/dist/cjs/index.js +10 -2
  14. package/dist/cjs/introspect.js +81 -0
  15. package/dist/cjs/nested-write.js +70 -6
  16. package/dist/cjs/query/builder.js +581 -17
  17. package/dist/cjs/realtime.js +147 -0
  18. package/dist/cjs/schema-builder.js +86 -0
  19. package/dist/cjs/schema.js +10 -0
  20. package/dist/cjs/typed-sql.js +149 -0
  21. package/dist/cli/studio-ui.generated.js +1 -1
  22. package/dist/cli/studio.js +35 -73
  23. package/dist/client.d.ts +120 -0
  24. package/dist/client.js +165 -1
  25. package/dist/errors.js +35 -5
  26. package/dist/generate.js +14 -3
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.js +5 -1
  29. package/dist/introspect.js +81 -0
  30. package/dist/nested-write.js +70 -6
  31. package/dist/query/builder.d.ts +104 -1
  32. package/dist/query/builder.js +582 -18
  33. package/dist/query/index.d.ts +1 -1
  34. package/dist/query/types.d.ts +126 -2
  35. package/dist/realtime.d.ts +71 -0
  36. package/dist/realtime.js +144 -0
  37. package/dist/schema-builder.d.ts +68 -1
  38. package/dist/schema-builder.js +85 -0
  39. package/dist/schema.d.ts +18 -1
  40. package/dist/schema.js +10 -0
  41. package/dist/typed-sql.d.ts +101 -0
  42. package/dist/typed-sql.js +145 -0
  43. package/package.json +17 -15
@@ -75,7 +75,11 @@ async function startStudio(options) {
75
75
  const authToken = (0, node_crypto_1.randomBytes)(24).toString('hex');
76
76
  const stateDir = (0, node_path_1.resolve)(options.stateDir ?? '.turbine');
77
77
  const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
78
- sql: `SET LOCAL statement_timeout = $1`,
78
+ // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
79
+ // syntax error). `set_config(name, value, is_local=true)` is the
80
+ // parameterizable, transaction-local equivalent and works on every
81
+ // Postgres-compatible engine.
82
+ sql: `SELECT set_config('statement_timeout', $1, true)`,
79
83
  params: ['30s'],
80
84
  };
81
85
  const rateLimiter = new Map();
@@ -151,6 +155,13 @@ async function handleRequest(req, res, ctx) {
151
155
  sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML);
152
156
  return;
153
157
  }
158
+ // Favicon — answered before the auth gate so the browser's automatic request
159
+ // doesn't 401/404 on every load. No icon body needed (204).
160
+ if (pathname === '/favicon.ico') {
161
+ res.writeHead(204, { 'Content-Length': '0' });
162
+ res.end();
163
+ return;
164
+ }
154
165
  // API routes — all require auth.
155
166
  if (!isAuthorized(req, ctx.authToken)) {
156
167
  sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
@@ -171,9 +182,6 @@ async function handleRequest(req, res, ctx) {
171
182
  const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
172
183
  return apiTableRows(res, ctx, rawName, url.searchParams);
173
184
  }
174
- if (pathname === '/api/query' && req.method === 'POST') {
175
- return apiQuery(req, res, ctx);
176
- }
177
185
  if (pathname === '/api/builder' && req.method === 'POST') {
178
186
  return apiBuilder(req, res, ctx);
179
187
  }
@@ -233,6 +241,15 @@ function constantTimeEqual(a, b) {
233
241
  }
234
242
  return result === 0;
235
243
  }
244
+ /**
245
+ * Build a helpful "unknown table" error that lists the available tables so the
246
+ * caller can spot a typo or schema mismatch immediately.
247
+ */
248
+ function unknownTableMessage(name, ctx) {
249
+ const available = Object.keys(ctx.metadata.tables);
250
+ const list = available.length ? available.join(', ') : '(none)';
251
+ return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
252
+ }
236
253
  // ---------------------------------------------------------------------------
237
254
  // API: /api/schema
238
255
  // ---------------------------------------------------------------------------
@@ -265,7 +282,9 @@ async function apiSchema(res, ctx) {
265
282
  WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
266
283
  const counts = new Map();
267
284
  for (const row of countsResult.rows) {
268
- counts.set(row.relname, Number(row.reltuples));
285
+ // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
286
+ // sidebar never shows a negative estimate.
287
+ counts.set(row.relname, Math.max(0, Number(row.reltuples)));
269
288
  }
270
289
  sendJson(res, 200, {
271
290
  schema: ctx.options.schema,
@@ -279,7 +298,7 @@ async function apiSchema(res, ctx) {
279
298
  async function apiTableRows(res, ctx, rawTableName, params) {
280
299
  const table = ctx.metadata.tables[rawTableName];
281
300
  if (!table) {
282
- sendJson(res, 404, { error: `unknown table: ${rawTableName}` });
301
+ sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
283
302
  return;
284
303
  }
285
304
  const limit = clampInt(params.get('limit'), 50, 1, 500);
@@ -374,54 +393,6 @@ function escapeLikePattern(s) {
374
393
  return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
375
394
  }
376
395
  // ---------------------------------------------------------------------------
377
- // API: /api/query — read-only SELECT/WITH runner
378
- // ---------------------------------------------------------------------------
379
- async function apiQuery(req, res, ctx) {
380
- const body = await readJsonBody(req);
381
- const rawSql = typeof body?.sql === 'string' ? body.sql.trim() : '';
382
- if (!rawSql) {
383
- sendJson(res, 400, { error: 'missing sql' });
384
- return;
385
- }
386
- if (rawSql.length > 10_000) {
387
- sendJson(res, 400, { error: 'query too long — maximum 10,000 characters allowed' });
388
- return;
389
- }
390
- if (!isReadOnlyStatement(rawSql)) {
391
- sendJson(res, 400, {
392
- error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
393
- });
394
- return;
395
- }
396
- const client = await ctx.pool.connect();
397
- try {
398
- await client.query('BEGIN READ ONLY');
399
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
400
- const started = Date.now();
401
- const result = await client.query(rawSql);
402
- const elapsedMs = Date.now() - started;
403
- await client.query('COMMIT');
404
- sendJson(res, 200, {
405
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
406
- rows: result.rows.map((r) => serializeRow(r)),
407
- rowCount: result.rowCount ?? result.rows.length,
408
- elapsedMs,
409
- });
410
- }
411
- catch (err) {
412
- try {
413
- await client.query('ROLLBACK');
414
- }
415
- catch {
416
- /* ignore */
417
- }
418
- sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
419
- }
420
- finally {
421
- client.release();
422
- }
423
- }
424
- // ---------------------------------------------------------------------------
425
396
  // API: /api/builder — Turbine ORM findMany spec runner
426
397
  // ---------------------------------------------------------------------------
427
398
  async function apiBuilder(req, res, ctx) {
@@ -429,7 +400,7 @@ async function apiBuilder(req, res, ctx) {
429
400
  const tableName = typeof body?.table === 'string' ? body.table : '';
430
401
  const args = (body?.args ?? {});
431
402
  if (!tableName || !ctx.metadata.tables[tableName]) {
432
- sendJson(res, 400, { error: `unknown table: ${tableName}` });
403
+ sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
433
404
  return;
434
405
  }
435
406
  let deferred;
@@ -486,7 +457,9 @@ function loadSavedQueries(ctx) {
486
457
  const parsed = JSON.parse(raw);
487
458
  if (!parsed.queries || !Array.isArray(parsed.queries))
488
459
  return { version: 1, queries: [] };
489
- return { version: 1, queries: parsed.queries };
460
+ // Drop any legacy raw-SQL entries — Studio is builder-only now.
461
+ const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
462
+ return { version: 1, queries };
490
463
  }
491
464
  catch {
492
465
  return { version: 1, queries: [] };
@@ -509,27 +482,17 @@ async function apiCreateSavedQuery(req, res, ctx) {
509
482
  const body = await readJsonBody(req);
510
483
  const table = typeof body?.table === 'string' ? body.table : '';
511
484
  const name = typeof body?.name === 'string' ? body.name.trim() : '';
512
- const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
513
485
  if (!table || !ctx.metadata.tables[table]) {
514
- sendJson(res, 400, { error: `unknown table: ${table}` });
486
+ sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
515
487
  return;
516
488
  }
517
489
  if (!name) {
518
490
  sendJson(res, 400, { error: 'name is required' });
519
491
  return;
520
492
  }
521
- if (!kind) {
522
- sendJson(res, 400, { error: 'kind must be "sql" or "builder"' });
523
- return;
524
- }
525
- const sql = kind === 'sql' && typeof body?.sql === 'string' ? body.sql : undefined;
526
- const args = kind === 'builder' ? body?.args : undefined;
527
- if (kind === 'sql' && !sql) {
528
- sendJson(res, 400, { error: 'sql is required for kind=sql' });
529
- return;
530
- }
531
- if (kind === 'sql' && sql && !isReadOnlyStatement(sql)) {
532
- sendJson(res, 400, { error: 'saved sql must be SELECT/WITH only' });
493
+ // Studio only persists visual-builder queries (no raw SQL surface).
494
+ if (body?.kind !== 'builder') {
495
+ sendJson(res, 400, { error: 'kind must be "builder"' });
533
496
  return;
534
497
  }
535
498
  const data = loadSavedQueries(ctx);
@@ -537,9 +500,8 @@ async function apiCreateSavedQuery(req, res, ctx) {
537
500
  id: (0, node_crypto_1.randomUUID)(),
538
501
  table,
539
502
  name,
540
- kind,
541
- sql,
542
- args,
503
+ kind: 'builder',
504
+ args: body?.args,
543
505
  createdAt: new Date().toISOString(),
544
506
  };
545
507
  data.queries.push(entry);
@@ -33,6 +33,9 @@ const errors_js_1 = require("./errors.js");
33
33
  const observe_js_1 = require("./observe.js");
34
34
  const pipeline_js_1 = require("./pipeline.js");
35
35
  const index_js_1 = require("./query/index.js");
36
+ const utils_js_1 = require("./query/utils.js");
37
+ const realtime_js_1 = require("./realtime.js");
38
+ const typed_sql_js_1 = require("./typed-sql.js");
36
39
  async function withRetry(fn, options) {
37
40
  const maxAttempts = options?.maxAttempts ?? 3;
38
41
  const baseDelay = options?.baseDelay ?? 50;
@@ -64,6 +67,13 @@ const ISOLATION_LEVELS = {
64
67
  RepeatableRead: 'REPEATABLE READ',
65
68
  Serializable: 'SERIALIZABLE',
66
69
  };
70
+ /**
71
+ * Strict GUC (session variable) name: an optionally namespaced identifier such
72
+ * as `app.current_tenant` or `search_path`. Even though the name is passed as a
73
+ * bound parameter to `set_config`, a malformed name is a programmer error worth
74
+ * rejecting loudly before it reaches the database.
75
+ */
76
+ const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
67
77
  // ---------------------------------------------------------------------------
68
78
  // TransactionClient — provides typed table accessors within a transaction
69
79
  // ---------------------------------------------------------------------------
@@ -197,6 +207,8 @@ class TurbineClient {
197
207
  errorMessagesSafe;
198
208
  /** True when Turbine created the pool and is responsible for tearing it down */
199
209
  ownsPool = true;
210
+ /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
211
+ activeSubscriptions = new Set();
200
212
  constructor(config = {}, schema) {
201
213
  /**
202
214
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
@@ -468,6 +480,40 @@ class TurbineClient {
468
480
  throw (0, errors_js_1.wrapPgError)(err);
469
481
  }
470
482
  }
483
+ /**
484
+ * Execute a **typed** raw SQL query — Turbine's answer to Prisma's TypedSQL.
485
+ *
486
+ * Like {@link raw}, every interpolated `${value}` becomes a `$N` parameter
487
+ * (never string-concatenated), so it is injection-safe by construction. The
488
+ * difference is the caller-supplied row type and the chainable result: the
489
+ * returned {@link TypedSqlQuery} can be `await`ed directly for `T[]`, or
490
+ * refined with `.one()` (→ `T | null`) or `.scalar<V>()` (→ `V | null`).
491
+ *
492
+ * Rows are returned as-is — no snake→camel mapping (matching `raw()`). Alias
493
+ * columns in SQL if you want camelCase keys.
494
+ *
495
+ * @example
496
+ * ```ts
497
+ * // rows
498
+ * const rows = await db.sql<{ id: number; name: string }>`
499
+ * SELECT id, name FROM users WHERE org_id = ${orgId}
500
+ * `;
501
+ *
502
+ * // single row or null
503
+ * const user = await db.sql<{ id: number; name: string }>`
504
+ * SELECT id, name FROM users WHERE id = ${userId}
505
+ * `.one();
506
+ *
507
+ * // scalar
508
+ * const total = await db.sql<{ count: number }>`
509
+ * SELECT COUNT(*)::int AS count FROM users
510
+ * `.scalar();
511
+ * ```
512
+ */
513
+ sql(strings, ...values) {
514
+ const { sql, params } = (0, typed_sql_js_1.buildTypedSql)(strings, values);
515
+ return new typed_sql_js_1.TypedSqlQuery(this.pool, sql, params, this.logging);
516
+ }
471
517
  // -------------------------------------------------------------------------
472
518
  // Transaction support (raw — legacy)
473
519
  // -------------------------------------------------------------------------
@@ -550,6 +596,21 @@ class TurbineClient {
550
596
  beginSQL += ` ISOLATION LEVEL ${level}`;
551
597
  }
552
598
  await client.query(beginSQL);
599
+ // Apply transaction-local session context (RLS / multi-tenant GUCs).
600
+ // Order matters: BEGIN -> isolation level (above) -> set_config loop ->
601
+ // user fn. Any error here propagates to the catch below and rolls back
602
+ // like any other transaction failure. We use set_config(name, value,
603
+ // is_local=true) — the parameterizable, transaction-scoped equivalent of
604
+ // SET LOCAL — so both name and value are BOUND params, never interpolated.
605
+ if (options?.sessionContext) {
606
+ for (const [name, value] of Object.entries(options.sessionContext)) {
607
+ if (!GUC_NAME_REGEX.test(name)) {
608
+ throw new errors_js_1.ValidationError(`[turbine] Invalid session-context GUC name "${name}" — must match ` +
609
+ '/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
610
+ }
611
+ await client.query('SELECT set_config($1, $2, true)', [name, String(value)]);
612
+ }
613
+ }
553
614
  // Create the transaction client with typed table accessors
554
615
  const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
555
616
  // Dynamically attach table accessors to tx
@@ -621,6 +682,94 @@ class TurbineClient {
621
682
  releaseOnce();
622
683
  }
623
684
  }
685
+ /**
686
+ * Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
687
+ * runs `fn` inside a transaction with the given session GUCs applied via
688
+ * `set_config(..., is_local=true)`. Equivalent to
689
+ * `$transaction(fn, { sessionContext: context })`.
690
+ *
691
+ * @example
692
+ * ```ts
693
+ * const invoices = await db.$withSession(
694
+ * { 'app.current_tenant': tenantId },
695
+ * (tx) => tx.invoices.findMany(),
696
+ * );
697
+ * ```
698
+ */
699
+ async $withSession(context, fn) {
700
+ return this.$transaction(fn, { sessionContext: context });
701
+ }
702
+ // -------------------------------------------------------------------------
703
+ // LISTEN / NOTIFY — Postgres realtime pub/sub
704
+ // -------------------------------------------------------------------------
705
+ /**
706
+ * Subscribe to a Postgres NOTIFY channel. The handler fires with each
707
+ * notification's payload string (the empty string when a payload-less
708
+ * NOTIFY is sent) for as long as the subscription is active.
709
+ *
710
+ * Each `$listen` checks out its OWN dedicated long-lived connection from the
711
+ * pool and runs `LISTEN "channel"` on it; `subscription.unsubscribe()`
712
+ * UNLISTENs, detaches the handler, and releases that connection. Active
713
+ * subscriptions are tracked and force-released on `disconnect()` so shutdown
714
+ * never hangs.
715
+ *
716
+ * The channel name CANNOT be a bound parameter (`LISTEN $1` is a syntax
717
+ * error), so it is validated against a strict identifier regex AND quoted via
718
+ * `quoteIdent` before interpolation — it is the only identifier this method
719
+ * places into SQL text.
720
+ *
721
+ * **Serverless caveat:** LISTEN needs a persistent connection that can push
722
+ * async notifications. Stateless HTTP drivers (Neon HTTP, Vercel Postgres)
723
+ * cannot do this — `$listen` throws a `ConnectionError` rather than hang.
724
+ * `$notify` works on every driver.
725
+ *
726
+ * @example
727
+ * ```ts
728
+ * const sub = await db.$listen('order_created', (payload) => {
729
+ * const order = JSON.parse(payload);
730
+ * console.log('new order', order.id);
731
+ * });
732
+ * // ...later
733
+ * await sub.unsubscribe();
734
+ * ```
735
+ */
736
+ async $listen(channel, handler) {
737
+ (0, realtime_js_1.validateChannel)(channel);
738
+ const quoted = (0, utils_js_1.quoteIdent)(channel);
739
+ if (this.logging) {
740
+ console.log(`[turbine] LISTEN ${quoted}`);
741
+ }
742
+ const sub = await (0, realtime_js_1.createSubscription)(this.pool, channel, quoted, handler, (closed) => {
743
+ this.activeSubscriptions.delete(closed);
744
+ });
745
+ this.activeSubscriptions.add(sub);
746
+ return sub;
747
+ }
748
+ /**
749
+ * Send a Postgres NOTIFY on `channel` with an optional payload string.
750
+ *
751
+ * Issued as `SELECT pg_notify($1, $2)` — both the channel and payload are
752
+ * BOUND parameters (no quoting/injection concern). The channel is still
753
+ * validated against the identifier regex for parity with `$listen` and to
754
+ * catch typos loudly. Works on every driver, including serverless HTTP pools.
755
+ *
756
+ * @example
757
+ * ```ts
758
+ * await db.$notify('order_created', JSON.stringify({ id: 7 }));
759
+ * ```
760
+ */
761
+ async $notify(channel, payload) {
762
+ (0, realtime_js_1.validateChannel)(channel);
763
+ if (this.logging) {
764
+ console.log(`[turbine] NOTIFY ${channel}`);
765
+ }
766
+ try {
767
+ await this.pool.query('SELECT pg_notify($1, $2)', [channel, payload ?? '']);
768
+ }
769
+ catch (err) {
770
+ throw (0, errors_js_1.wrapPgError)(err);
771
+ }
772
+ }
624
773
  // -------------------------------------------------------------------------
625
774
  // Retry — automatic retry for retryable errors (deadlock, serialization)
626
775
  // -------------------------------------------------------------------------
@@ -668,6 +817,21 @@ class TurbineClient {
668
817
  * method is a no-op — the caller is responsible for the pool's lifecycle.
669
818
  */
670
819
  async disconnect() {
820
+ // Tear down any live LISTEN subscriptions first. Each holds a dedicated
821
+ // pooled connection checked out; if we ended the pool (or returned for an
822
+ // external pool) without releasing them, pool.end() would wait forever for
823
+ // those connections to return. _forceRelease() detaches the handler and
824
+ // releases the client WITHOUT issuing UNLISTEN (pointless if we're ending
825
+ // the pool / the connection is going away anyway). This runs for both
826
+ // owned and external pools so subscriptions never leak.
827
+ if (this.activeSubscriptions.size > 0) {
828
+ // _forceRelease mutates activeSubscriptions via the onClosed callback,
829
+ // so iterate a snapshot.
830
+ for (const sub of [...this.activeSubscriptions]) {
831
+ sub._forceRelease();
832
+ }
833
+ this.activeSubscriptions.clear();
834
+ }
671
835
  if (!this.ownsPool) {
672
836
  if (this.logging) {
673
837
  console.log('[turbine] disconnect() skipped — external pool is not owned by Turbine');
@@ -211,7 +211,13 @@ class UniqueConstraintError extends TurbineError {
211
211
  const constraintPart = constraint ? ` on ${constraint}` : '';
212
212
  const columnsPart = columns && columns.length > 0 ? ` (${columns.join(', ')})` : '';
213
213
  message = `[turbine] Unique constraint violation${constraintPart}${columnsPart}`;
214
- const detail = detailFromCause(cause);
214
+ // PII-safe by default: the raw pg `detail` string contains the
215
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
216
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
217
+ // message carries keys/constraint/column names only — the structured
218
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
219
+ // the full detail for programmatic use.
220
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
215
221
  if (detail)
216
222
  message += `: ${detail}`;
217
223
  }
@@ -233,7 +239,13 @@ class ForeignKeyError extends TurbineError {
233
239
  if (!message) {
234
240
  const constraintPart = constraint ? ` on ${constraint}` : '';
235
241
  message = `[turbine] Foreign key constraint violation${constraintPart}`;
236
- const detail = detailFromCause(cause);
242
+ // PII-safe by default: the raw pg `detail` string contains the
243
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
244
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
245
+ // message carries keys/constraint/column names only — the structured
246
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
247
+ // the full detail for programmatic use.
248
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
237
249
  if (detail)
238
250
  message += `: ${detail}`;
239
251
  }
@@ -254,7 +266,13 @@ class NotNullViolationError extends TurbineError {
254
266
  if (!message) {
255
267
  const columnPart = column ? ` on column "${column}"` : '';
256
268
  message = `[turbine] NOT NULL constraint violation${columnPart}`;
257
- const detail = detailFromCause(cause);
269
+ // PII-safe by default: the raw pg `detail` string contains the
270
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
271
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
272
+ // message carries keys/constraint/column names only — the structured
273
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
274
+ // the full detail for programmatic use.
275
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
258
276
  if (detail)
259
277
  message += `: ${detail}`;
260
278
  }
@@ -342,7 +360,13 @@ class CheckConstraintError extends TurbineError {
342
360
  if (!message) {
343
361
  const constraintPart = constraint ? ` on ${constraint}` : '';
344
362
  message = `[turbine] Check constraint violation${constraintPart}`;
345
- const detail = detailFromCause(cause);
363
+ // PII-safe by default: the raw pg `detail` string contains the
364
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
365
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
366
+ // message carries keys/constraint/column names only — the structured
367
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
368
+ // the full detail for programmatic use.
369
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
346
370
  if (detail)
347
371
  message += `: ${detail}`;
348
372
  }
@@ -362,7 +386,13 @@ class ExclusionConstraintError extends TurbineError {
362
386
  if (!message) {
363
387
  const constraintPart = constraint ? ` on ${constraint}` : '';
364
388
  message = `[turbine] Exclusion constraint violation${constraintPart}`;
365
- const detail = detailFromCause(cause);
389
+ // PII-safe by default: the raw pg `detail` string contains the
390
+ // conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
391
+ // exists.`). Only append it in 'verbose' mode. In 'safe' mode the
392
+ // message carries keys/constraint/column names only — the structured
393
+ // `.columns`/`.constraint`/`.column` fields and `.cause` still expose
394
+ // the full detail for programmatic use.
395
+ const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
366
396
  if (detail)
367
397
  message += `: ${detail}`;
368
398
  }
@@ -156,7 +156,8 @@ function generateTypes(schema) {
156
156
  lines.push(`export interface ${typeName}Relations {`);
157
157
  for (const [relName, rel] of Object.entries(table.relations)) {
158
158
  const targetType = entityName(rel.to);
159
- const cardinality = rel.type === 'hasMany' ? "'many'" : "'one'";
159
+ // manyToMany is a collection too 'many' cardinality (same as hasMany).
160
+ const cardinality = rel.type === 'hasMany' || rel.type === 'manyToMany' ? "'many'" : "'one'";
160
161
  const targetRelations = tablesWithRelations.has(rel.to) ? `${targetType}Relations` : '{}';
161
162
  lines.push(` ${relName}: RelationDescriptor<${targetType}, ${cardinality}, ${targetRelations}>;`);
162
163
  }
@@ -165,7 +166,7 @@ function generateTypes(schema) {
165
166
  // --- Legacy per-relation interfaces (kept for backward compatibility) ---
166
167
  for (const [relName, rel] of Object.entries(table.relations)) {
167
168
  const targetType = entityName(rel.to);
168
- if (rel.type === 'hasMany') {
169
+ if (rel.type === 'hasMany' || rel.type === 'manyToMany') {
169
170
  lines.push(`/** ${typeName} with \`${relName}\` relation loaded (${rel.type}: ${rel.to}) */`);
170
171
  lines.push(`export interface ${typeName}With${(0, schema_js_1.snakeToPascal)(relName)} extends ${typeName} {`);
171
172
  lines.push(` ${relName}: ${targetType}[];`);
@@ -332,7 +333,17 @@ function generateMetadata(schema) {
332
333
  const refLiteral = Array.isArray(rel.referenceKey)
333
334
  ? `[${rel.referenceKey.map((c) => `'${escSQ(c)}'`).join(', ')}]`
334
335
  : `'${escSQ(rel.referenceKey)}'`;
335
- lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral} },`);
336
+ // manyToMany relations carry a `through` junction descriptor emit it so
337
+ // the runtime query builder can JOIN through the junction table.
338
+ let throughLiteral = '';
339
+ if (rel.through) {
340
+ const keyLiteral = (k) => Array.isArray(k) ? `[${k.map((c) => `'${escSQ(c)}'`).join(', ')}]` : `'${escSQ(k)}'`;
341
+ throughLiteral =
342
+ `, through: { table: '${escSQ(rel.through.table)}', ` +
343
+ `sourceKey: ${keyLiteral(rel.through.sourceKey)}, ` +
344
+ `targetKey: ${keyLiteral(rel.through.targetKey)} }`;
345
+ }
346
+ lines.push(` ${relName}: { type: '${escSQ(rel.type)}', name: '${escSQ(rel.name)}', from: '${escSQ(rel.from)}', to: '${escSQ(rel.to)}', foreignKey: ${fkLiteral}, referenceKey: ${refLiteral}${throughLiteral} },`);
336
347
  }
337
348
  lines.push(' },');
338
349
  // indexes
package/dist/cjs/index.js CHANGED
@@ -34,8 +34,8 @@
34
34
  * ```
35
35
  */
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.table = exports.defineSchema = exports.column = exports.ColumnBuilder = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
- exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = void 0;
37
+ exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
38
+ exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.table = exports.defineSchema = void 0;
39
39
  var index_js_1 = require("./adapters/index.js");
40
40
  Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
41
41
  Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
@@ -90,6 +90,9 @@ Object.defineProperty(exports, "pipelineSupported", { enumerable: true, get: fun
90
90
  // Query builder
91
91
  var index_js_2 = require("./query/index.js");
92
92
  Object.defineProperty(exports, "QueryInterface", { enumerable: true, get: function () { return index_js_2.QueryInterface; } });
93
+ // Realtime — LISTEN/NOTIFY pub/sub
94
+ var realtime_js_1 = require("./realtime.js");
95
+ Object.defineProperty(exports, "validateChannel", { enumerable: true, get: function () { return realtime_js_1.validateChannel; } });
93
96
  // Schema utilities
94
97
  var schema_js_1 = require("./schema.js");
95
98
  Object.defineProperty(exports, "camelToSnake", { enumerable: true, get: function () { return schema_js_1.camelToSnake; } });
@@ -102,6 +105,7 @@ Object.defineProperty(exports, "snakeToCamel", { enumerable: true, get: function
102
105
  Object.defineProperty(exports, "snakeToPascal", { enumerable: true, get: function () { return schema_js_1.snakeToPascal; } });
103
106
  // Schema builder — define schemas in TypeScript
104
107
  var schema_builder_js_1 = require("./schema-builder.js");
108
+ Object.defineProperty(exports, "applyManyToManyRelations", { enumerable: true, get: function () { return schema_builder_js_1.applyManyToManyRelations; } });
105
109
  Object.defineProperty(exports, "ColumnBuilder", { enumerable: true, get: function () { return schema_builder_js_1.ColumnBuilder; } });
106
110
  Object.defineProperty(exports, "column", { enumerable: true, get: function () { return schema_builder_js_1.column; } });
107
111
  Object.defineProperty(exports, "defineSchema", { enumerable: true, get: function () { return schema_builder_js_1.defineSchema; } });
@@ -116,3 +120,7 @@ Object.defineProperty(exports, "schemaToSQLString", { enumerable: true, get: fun
116
120
  // Serverless / edge factory
117
121
  var serverless_js_1 = require("./serverless.js");
118
122
  Object.defineProperty(exports, "turbineHttp", { enumerable: true, get: function () { return serverless_js_1.turbineHttp; } });
123
+ // Typed raw SQL — Turbine's TypedSQL escape hatch
124
+ var typed_sql_js_1 = require("./typed-sql.js");
125
+ Object.defineProperty(exports, "buildTypedSql", { enumerable: true, get: function () { return typed_sql_js_1.buildTypedSql; } });
126
+ Object.defineProperty(exports, "TypedSqlQuery", { enumerable: true, get: function () { return typed_sql_js_1.TypedSqlQuery; } });
@@ -281,6 +281,87 @@ async function introspect(options) {
281
281
  referenceKey,
282
282
  };
283
283
  }
284
+ // ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
285
+ //
286
+ // Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
287
+ // junction, but a `enrollments(student_id, course_id, grade, enrolled_at)`
288
+ // table is a first-class entity, not a join table. Prisma and Drizzle both
289
+ // require explicit m2m declaration for exactly this reason.
290
+ //
291
+ // We only treat a table J as a PURE junction when ALL of these hold:
292
+ // 1. J's primary key is exactly two columns.
293
+ // 2. J has exactly two FKs, each single-column.
294
+ // 3. Each FK's source column is one of J's two PK columns (the PK *is* the
295
+ // two FK columns — no surrogate PK, no extra identity).
296
+ // 4. The two FKs target two DISTINCT tables (A and B).
297
+ // 5. J has no columns beyond those two FK/PK columns (no payload columns
298
+ // like `grade` or `created_at`).
299
+ //
300
+ // For such a J linking A and B we ADD a `manyToMany` relation on A → B and
301
+ // symmetrically on B → A, both routed `through` J. The existing belongsTo /
302
+ // hasMany relations derived from J's FKs are left untouched — this block
303
+ // never removes or renames anything. If the chosen relation name already
304
+ // exists on the source table (e.g. another relation grabbed it), we SKIP to
305
+ // stay additive.
306
+ for (const tableName of tableNames) {
307
+ const pk = pkByTable.get(tableName) ?? [];
308
+ if (pk.length !== 2)
309
+ continue;
310
+ // FKs whose source is this table.
311
+ const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
312
+ if (tableFks.length !== 2)
313
+ continue;
314
+ // Both FKs must be single-column.
315
+ if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
316
+ continue;
317
+ const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
318
+ const pkSet = new Set(pk);
319
+ // Both FK columns must be the PK columns (and vice-versa).
320
+ if (!fkCols.every((c) => pkSet.has(c)))
321
+ continue;
322
+ if (new Set(fkCols).size !== 2)
323
+ continue;
324
+ // Two DISTINCT target tables.
325
+ const [fkA, fkB] = tableFks;
326
+ if (fkA.targetTable === fkB.targetTable)
327
+ continue;
328
+ // No payload columns: J's columns are exactly the two FK/PK columns.
329
+ const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
330
+ if (jCols.length !== 2)
331
+ continue;
332
+ // For each direction, the m2m `referenceKey` is the *targeted* table's
333
+ // referenced column(s); the junction's sourceKey is the FK column pointing
334
+ // to that table; the targetKey is the FK column pointing to the OTHER table.
335
+ const addM2M = (self, other) => {
336
+ const sourceTbl = self.targetTable; // A
337
+ const targetTbl = other.targetTable; // B
338
+ const relName = (0, schema_js_1.snakeToCamel)(targetTbl); // plural table name → e.g. "tags"
339
+ if (!relationsByTable.has(sourceTbl))
340
+ relationsByTable.set(sourceTbl, {});
341
+ const existing = relationsByTable.get(sourceTbl);
342
+ // Additive-only: never clobber an existing relation name.
343
+ if (existing[relName])
344
+ return;
345
+ existing[relName] = {
346
+ type: 'manyToMany',
347
+ name: relName,
348
+ from: sourceTbl,
349
+ to: targetTbl,
350
+ // referenceKey = A's referenced column(s) that J's sourceKey points at.
351
+ referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
352
+ // foreignKey is unused for m2m correlation but kept for shape parity
353
+ // (mirrors the source-side reference for back-compat consumers).
354
+ foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
355
+ through: {
356
+ table: tableName,
357
+ sourceKey: self.sourceColumns[0], // J col → A
358
+ targetKey: other.sourceColumns[0], // J col → B
359
+ },
360
+ };
361
+ };
362
+ addM2M(fkA, fkB); // A → B
363
+ addM2M(fkB, fkA); // B → A
364
+ }
284
365
  // ----- Assemble TableMetadata for each table -----
285
366
  const tables = {};
286
367
  for (const tableName of tableNames) {