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
@@ -60,7 +60,11 @@ export async function startStudio(options) {
60
60
  const authToken = randomBytes(24).toString('hex');
61
61
  const stateDir = pathResolve(options.stateDir ?? '.turbine');
62
62
  const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
63
- sql: `SET LOCAL statement_timeout = $1`,
63
+ // Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
64
+ // syntax error). `set_config(name, value, is_local=true)` is the
65
+ // parameterizable, transaction-local equivalent and works on every
66
+ // Postgres-compatible engine.
67
+ sql: `SELECT set_config('statement_timeout', $1, true)`,
64
68
  params: ['30s'],
65
69
  };
66
70
  const rateLimiter = new Map();
@@ -136,6 +140,13 @@ async function handleRequest(req, res, ctx) {
136
140
  sendHtml(res, 200, STUDIO_HTML);
137
141
  return;
138
142
  }
143
+ // Favicon — answered before the auth gate so the browser's automatic request
144
+ // doesn't 401/404 on every load. No icon body needed (204).
145
+ if (pathname === '/favicon.ico') {
146
+ res.writeHead(204, { 'Content-Length': '0' });
147
+ res.end();
148
+ return;
149
+ }
139
150
  // API routes — all require auth.
140
151
  if (!isAuthorized(req, ctx.authToken)) {
141
152
  sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
@@ -156,9 +167,6 @@ async function handleRequest(req, res, ctx) {
156
167
  const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
157
168
  return apiTableRows(res, ctx, rawName, url.searchParams);
158
169
  }
159
- if (pathname === '/api/query' && req.method === 'POST') {
160
- return apiQuery(req, res, ctx);
161
- }
162
170
  if (pathname === '/api/builder' && req.method === 'POST') {
163
171
  return apiBuilder(req, res, ctx);
164
172
  }
@@ -218,6 +226,15 @@ function constantTimeEqual(a, b) {
218
226
  }
219
227
  return result === 0;
220
228
  }
229
+ /**
230
+ * Build a helpful "unknown table" error that lists the available tables so the
231
+ * caller can spot a typo or schema mismatch immediately.
232
+ */
233
+ function unknownTableMessage(name, ctx) {
234
+ const available = Object.keys(ctx.metadata.tables);
235
+ const list = available.length ? available.join(', ') : '(none)';
236
+ return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
237
+ }
221
238
  // ---------------------------------------------------------------------------
222
239
  // API: /api/schema
223
240
  // ---------------------------------------------------------------------------
@@ -250,7 +267,9 @@ async function apiSchema(res, ctx) {
250
267
  WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
251
268
  const counts = new Map();
252
269
  for (const row of countsResult.rows) {
253
- counts.set(row.relname, Number(row.reltuples));
270
+ // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
271
+ // sidebar never shows a negative estimate.
272
+ counts.set(row.relname, Math.max(0, Number(row.reltuples)));
254
273
  }
255
274
  sendJson(res, 200, {
256
275
  schema: ctx.options.schema,
@@ -264,7 +283,7 @@ async function apiSchema(res, ctx) {
264
283
  export async function apiTableRows(res, ctx, rawTableName, params) {
265
284
  const table = ctx.metadata.tables[rawTableName];
266
285
  if (!table) {
267
- sendJson(res, 404, { error: `unknown table: ${rawTableName}` });
286
+ sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
268
287
  return;
269
288
  }
270
289
  const limit = clampInt(params.get('limit'), 50, 1, 500);
@@ -359,54 +378,6 @@ export function escapeLikePattern(s) {
359
378
  return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
360
379
  }
361
380
  // ---------------------------------------------------------------------------
362
- // API: /api/query — read-only SELECT/WITH runner
363
- // ---------------------------------------------------------------------------
364
- async function apiQuery(req, res, ctx) {
365
- const body = await readJsonBody(req);
366
- const rawSql = typeof body?.sql === 'string' ? body.sql.trim() : '';
367
- if (!rawSql) {
368
- sendJson(res, 400, { error: 'missing sql' });
369
- return;
370
- }
371
- if (rawSql.length > 10_000) {
372
- sendJson(res, 400, { error: 'query too long — maximum 10,000 characters allowed' });
373
- return;
374
- }
375
- if (!isReadOnlyStatement(rawSql)) {
376
- sendJson(res, 400, {
377
- error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
378
- });
379
- return;
380
- }
381
- const client = await ctx.pool.connect();
382
- try {
383
- await client.query('BEGIN READ ONLY');
384
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
385
- const started = Date.now();
386
- const result = await client.query(rawSql);
387
- const elapsedMs = Date.now() - started;
388
- await client.query('COMMIT');
389
- sendJson(res, 200, {
390
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
391
- rows: result.rows.map((r) => serializeRow(r)),
392
- rowCount: result.rowCount ?? result.rows.length,
393
- elapsedMs,
394
- });
395
- }
396
- catch (err) {
397
- try {
398
- await client.query('ROLLBACK');
399
- }
400
- catch {
401
- /* ignore */
402
- }
403
- sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
404
- }
405
- finally {
406
- client.release();
407
- }
408
- }
409
- // ---------------------------------------------------------------------------
410
381
  // API: /api/builder — Turbine ORM findMany spec runner
411
382
  // ---------------------------------------------------------------------------
412
383
  export async function apiBuilder(req, res, ctx) {
@@ -414,7 +385,7 @@ export async function apiBuilder(req, res, ctx) {
414
385
  const tableName = typeof body?.table === 'string' ? body.table : '';
415
386
  const args = (body?.args ?? {});
416
387
  if (!tableName || !ctx.metadata.tables[tableName]) {
417
- sendJson(res, 400, { error: `unknown table: ${tableName}` });
388
+ sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
418
389
  return;
419
390
  }
420
391
  let deferred;
@@ -471,7 +442,9 @@ function loadSavedQueries(ctx) {
471
442
  const parsed = JSON.parse(raw);
472
443
  if (!parsed.queries || !Array.isArray(parsed.queries))
473
444
  return { version: 1, queries: [] };
474
- return { version: 1, queries: parsed.queries };
445
+ // Drop any legacy raw-SQL entries — Studio is builder-only now.
446
+ const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
447
+ return { version: 1, queries };
475
448
  }
476
449
  catch {
477
450
  return { version: 1, queries: [] };
@@ -494,27 +467,17 @@ export async function apiCreateSavedQuery(req, res, ctx) {
494
467
  const body = await readJsonBody(req);
495
468
  const table = typeof body?.table === 'string' ? body.table : '';
496
469
  const name = typeof body?.name === 'string' ? body.name.trim() : '';
497
- const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
498
470
  if (!table || !ctx.metadata.tables[table]) {
499
- sendJson(res, 400, { error: `unknown table: ${table}` });
471
+ sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
500
472
  return;
501
473
  }
502
474
  if (!name) {
503
475
  sendJson(res, 400, { error: 'name is required' });
504
476
  return;
505
477
  }
506
- if (!kind) {
507
- sendJson(res, 400, { error: 'kind must be "sql" or "builder"' });
508
- return;
509
- }
510
- const sql = kind === 'sql' && typeof body?.sql === 'string' ? body.sql : undefined;
511
- const args = kind === 'builder' ? body?.args : undefined;
512
- if (kind === 'sql' && !sql) {
513
- sendJson(res, 400, { error: 'sql is required for kind=sql' });
514
- return;
515
- }
516
- if (kind === 'sql' && sql && !isReadOnlyStatement(sql)) {
517
- sendJson(res, 400, { error: 'saved sql must be SELECT/WITH only' });
478
+ // Studio only persists visual-builder queries (no raw SQL surface).
479
+ if (body?.kind !== 'builder') {
480
+ sendJson(res, 400, { error: 'kind must be "builder"' });
518
481
  return;
519
482
  }
520
483
  const data = loadSavedQueries(ctx);
@@ -522,9 +485,8 @@ export async function apiCreateSavedQuery(req, res, ctx) {
522
485
  id: randomUUID(),
523
486
  table,
524
487
  name,
525
- kind,
526
- sql,
527
- args,
488
+ kind: 'builder',
489
+ args: body?.args,
528
490
  createdAt: new Date().toISOString(),
529
491
  };
530
492
  data.queries.push(entry);
package/dist/client.d.ts CHANGED
@@ -27,7 +27,9 @@ import { type ErrorMessageMode } from './errors.js';
27
27
  import { type ObserveConfig, type ObserveHandle } from './observe.js';
28
28
  import { type PipelineOptions, type PipelineResults } from './pipeline.js';
29
29
  import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
30
+ import { type NotificationHandler, type Subscription } from './realtime.js';
30
31
  import type { SchemaMetadata } from './schema.js';
32
+ import { TypedSqlQuery } from './typed-sql.js';
31
33
  export interface RetryOptions {
32
34
  maxAttempts?: number;
33
35
  baseDelay?: number;
@@ -172,6 +174,30 @@ export interface TransactionOptions {
172
174
  timeout?: number;
173
175
  /** Isolation level for the transaction */
174
176
  isolationLevel?: 'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable';
177
+ /**
178
+ * Transaction-local session GUCs to set after BEGIN. The canonical use case
179
+ * is multi-tenant Postgres row-level security (RLS): your policies filter on
180
+ * `current_setting('app.current_tenant')`, and you set that value here so
181
+ * every query inside the transaction sees it.
182
+ *
183
+ * Each entry is applied via `SELECT set_config($1, $2, true)` — `is_local=true`
184
+ * scopes the value to this transaction, so it auto-resets on COMMIT/ROLLBACK
185
+ * and never leaks onto the pooled connection. Both the name and value are
186
+ * bound parameters (never interpolated); the GUC name is additionally
187
+ * validated against a strict identifier regex.
188
+ *
189
+ * @example
190
+ * ```ts
191
+ * await db.$transaction(
192
+ * async (tx) => {
193
+ * // every query here sees current_setting('app.current_tenant') = '42'
194
+ * return tx.invoices.findMany();
195
+ * },
196
+ * { sessionContext: { 'app.current_tenant': '42', 'app.current_user': userId } },
197
+ * );
198
+ * ```
199
+ */
200
+ sessionContext?: Record<string, string | number | boolean>;
175
201
  }
176
202
  /**
177
203
  * A transaction-scoped client that provides the same table accessor API as TurbineClient.
@@ -225,6 +251,8 @@ export declare class TurbineClient {
225
251
  private readonly errorMessagesSafe;
226
252
  /** True when Turbine created the pool and is responsible for tearing it down */
227
253
  private readonly ownsPool;
254
+ /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
255
+ private readonly activeSubscriptions;
228
256
  constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
229
257
  /**
230
258
  * Register a middleware function that runs before/after every query.
@@ -298,6 +326,37 @@ export declare class TurbineClient {
298
326
  * ```
299
327
  */
300
328
  raw<T extends Record<string, unknown> = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
329
+ /**
330
+ * Execute a **typed** raw SQL query — Turbine's answer to Prisma's TypedSQL.
331
+ *
332
+ * Like {@link raw}, every interpolated `${value}` becomes a `$N` parameter
333
+ * (never string-concatenated), so it is injection-safe by construction. The
334
+ * difference is the caller-supplied row type and the chainable result: the
335
+ * returned {@link TypedSqlQuery} can be `await`ed directly for `T[]`, or
336
+ * refined with `.one()` (→ `T | null`) or `.scalar<V>()` (→ `V | null`).
337
+ *
338
+ * Rows are returned as-is — no snake→camel mapping (matching `raw()`). Alias
339
+ * columns in SQL if you want camelCase keys.
340
+ *
341
+ * @example
342
+ * ```ts
343
+ * // rows
344
+ * const rows = await db.sql<{ id: number; name: string }>`
345
+ * SELECT id, name FROM users WHERE org_id = ${orgId}
346
+ * `;
347
+ *
348
+ * // single row or null
349
+ * const user = await db.sql<{ id: number; name: string }>`
350
+ * SELECT id, name FROM users WHERE id = ${userId}
351
+ * `.one();
352
+ *
353
+ * // scalar
354
+ * const total = await db.sql<{ count: number }>`
355
+ * SELECT COUNT(*)::int AS count FROM users
356
+ * `.scalar();
357
+ * ```
358
+ */
359
+ sql<T extends Record<string, unknown> = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): TypedSqlQuery<T>;
301
360
  /**
302
361
  * Execute a function within a database transaction (raw pg.PoolClient).
303
362
  * For the typed API, use `$transaction()` instead.
@@ -330,6 +389,67 @@ export declare class TurbineClient {
330
389
  * ```
331
390
  */
332
391
  $transaction<R>(fn: (tx: TransactionClient) => Promise<R>, options?: TransactionOptions): Promise<R>;
392
+ /**
393
+ * Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
394
+ * runs `fn` inside a transaction with the given session GUCs applied via
395
+ * `set_config(..., is_local=true)`. Equivalent to
396
+ * `$transaction(fn, { sessionContext: context })`.
397
+ *
398
+ * @example
399
+ * ```ts
400
+ * const invoices = await db.$withSession(
401
+ * { 'app.current_tenant': tenantId },
402
+ * (tx) => tx.invoices.findMany(),
403
+ * );
404
+ * ```
405
+ */
406
+ $withSession<R>(context: Record<string, string | number | boolean>, fn: (tx: TransactionClient) => Promise<R>): Promise<R>;
407
+ /**
408
+ * Subscribe to a Postgres NOTIFY channel. The handler fires with each
409
+ * notification's payload string (the empty string when a payload-less
410
+ * NOTIFY is sent) for as long as the subscription is active.
411
+ *
412
+ * Each `$listen` checks out its OWN dedicated long-lived connection from the
413
+ * pool and runs `LISTEN "channel"` on it; `subscription.unsubscribe()`
414
+ * UNLISTENs, detaches the handler, and releases that connection. Active
415
+ * subscriptions are tracked and force-released on `disconnect()` so shutdown
416
+ * never hangs.
417
+ *
418
+ * The channel name CANNOT be a bound parameter (`LISTEN $1` is a syntax
419
+ * error), so it is validated against a strict identifier regex AND quoted via
420
+ * `quoteIdent` before interpolation — it is the only identifier this method
421
+ * places into SQL text.
422
+ *
423
+ * **Serverless caveat:** LISTEN needs a persistent connection that can push
424
+ * async notifications. Stateless HTTP drivers (Neon HTTP, Vercel Postgres)
425
+ * cannot do this — `$listen` throws a `ConnectionError` rather than hang.
426
+ * `$notify` works on every driver.
427
+ *
428
+ * @example
429
+ * ```ts
430
+ * const sub = await db.$listen('order_created', (payload) => {
431
+ * const order = JSON.parse(payload);
432
+ * console.log('new order', order.id);
433
+ * });
434
+ * // ...later
435
+ * await sub.unsubscribe();
436
+ * ```
437
+ */
438
+ $listen(channel: string, handler: NotificationHandler): Promise<Subscription>;
439
+ /**
440
+ * Send a Postgres NOTIFY on `channel` with an optional payload string.
441
+ *
442
+ * Issued as `SELECT pg_notify($1, $2)` — both the channel and payload are
443
+ * BOUND parameters (no quoting/injection concern). The channel is still
444
+ * validated against the identifier regex for parity with `$listen` and to
445
+ * catch typos loudly. Works on every driver, including serverless HTTP pools.
446
+ *
447
+ * @example
448
+ * ```ts
449
+ * await db.$notify('order_created', JSON.stringify({ id: 7 }));
450
+ * ```
451
+ */
452
+ $notify(channel: string, payload?: string): Promise<void>;
333
453
  /**
334
454
  * Execute an async function with automatic retry on retryable errors.
335
455
  *
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
  }