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
@@ -0,0 +1,242 @@
1
+ "use strict";
2
+ /**
3
+ * turbine-orm CLI — Observe
4
+ *
5
+ * A local, read-only dashboard for viewing query metrics stored in
6
+ * _turbine_metrics. Same security model as Studio: loopback binding,
7
+ * random token, HttpOnly cookie, CSP headers, read-only transactions.
8
+ */
9
+ var __importDefault = (this && this.__importDefault) || function (mod) {
10
+ return (mod && mod.__esModule) ? mod : { "default": mod };
11
+ };
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.startObserve = startObserve;
14
+ const node_crypto_1 = require("node:crypto");
15
+ const node_http_1 = require("node:http");
16
+ const pg_1 = __importDefault(require("pg"));
17
+ const observe_ui_js_1 = require("./observe-ui.js");
18
+ // ---------------------------------------------------------------------------
19
+ // Main entry point
20
+ // ---------------------------------------------------------------------------
21
+ async function startObserve(options) {
22
+ const pool = new pg_1.default.Pool({
23
+ connectionString: options.url,
24
+ max: 2,
25
+ idleTimeoutMillis: 10_000,
26
+ });
27
+ const probe = await pool.connect();
28
+ try {
29
+ await probe.query('SELECT 1');
30
+ }
31
+ finally {
32
+ probe.release();
33
+ }
34
+ const authToken = (0, node_crypto_1.randomBytes)(24).toString('hex');
35
+ const server = (0, node_http_1.createServer)((req, res) => {
36
+ handleRequest(req, res, pool, options, authToken).catch((err) => {
37
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
38
+ });
39
+ });
40
+ await new Promise((resolve, reject) => {
41
+ server.once('error', reject);
42
+ server.listen(options.port, options.host, () => {
43
+ server.off('error', reject);
44
+ resolve();
45
+ });
46
+ });
47
+ const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
48
+ const url = `http://${hostPart}:${options.port}/?token=${authToken}`;
49
+ if (options.openBrowser) {
50
+ openUrl(url);
51
+ }
52
+ return {
53
+ authToken,
54
+ url,
55
+ dispose: async () => {
56
+ await new Promise((resolve) => server.close(() => resolve()));
57
+ await pool.end();
58
+ },
59
+ };
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Request routing
63
+ // ---------------------------------------------------------------------------
64
+ async function handleRequest(req, res, pool, options, authToken) {
65
+ const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
66
+ const expectedOrigin = `http://${hostPart}:${options.port}`;
67
+ const origin = req.headers.origin;
68
+ if (origin && origin !== expectedOrigin) {
69
+ sendJson(res, 403, { error: 'cross-origin requests not allowed' });
70
+ return;
71
+ }
72
+ const url = new URL(req.url ?? '/', expectedOrigin);
73
+ const pathname = url.pathname;
74
+ if (pathname === '/' || pathname === '/index.html') {
75
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
76
+ sendText(res, 405, 'Method Not Allowed');
77
+ return;
78
+ }
79
+ const queryToken = url.searchParams.get('token');
80
+ if (queryToken && constantTimeEqual(queryToken, authToken)) {
81
+ res.writeHead(302, {
82
+ Location: '/',
83
+ 'Set-Cookie': `turbine_observe_token=${authToken}; Path=/; HttpOnly; SameSite=Strict`,
84
+ });
85
+ res.end();
86
+ return;
87
+ }
88
+ sendHtml(res, 200, observe_ui_js_1.OBSERVE_HTML);
89
+ return;
90
+ }
91
+ if (!isAuthorized(req, authToken)) {
92
+ sendJson(res, 401, { error: 'unauthorized' });
93
+ return;
94
+ }
95
+ if (pathname === '/api/latency' && req.method === 'GET') {
96
+ return apiLatency(res, pool, url.searchParams);
97
+ }
98
+ if (pathname === '/api/models' && req.method === 'GET') {
99
+ return apiModels(res, pool, url.searchParams);
100
+ }
101
+ sendJson(res, 404, { error: 'not found' });
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Auth
105
+ // ---------------------------------------------------------------------------
106
+ function isAuthorized(req, expectedToken) {
107
+ const headerToken = req.headers['x-turbine-token'];
108
+ if (typeof headerToken === 'string' && constantTimeEqual(headerToken, expectedToken)) {
109
+ return true;
110
+ }
111
+ const cookieHeader = req.headers.cookie ?? '';
112
+ const match = /turbine_observe_token=([a-f0-9]+)/.exec(cookieHeader);
113
+ if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ function constantTimeEqual(a, b) {
119
+ if (a.length !== b.length)
120
+ return false;
121
+ let result = 0;
122
+ for (let i = 0; i < a.length; i++) {
123
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
124
+ }
125
+ return result === 0;
126
+ }
127
+ // ---------------------------------------------------------------------------
128
+ // API handlers
129
+ // ---------------------------------------------------------------------------
130
+ function rangeToInterval(range) {
131
+ switch (range) {
132
+ case '6h':
133
+ return '6 hours';
134
+ case '24h':
135
+ return '24 hours';
136
+ case '7d':
137
+ return '7 days';
138
+ default:
139
+ return '1 hour';
140
+ }
141
+ }
142
+ async function apiLatency(res, pool, params) {
143
+ const range = params.get('range') ?? '1h';
144
+ const interval = rangeToInterval(range);
145
+ const client = await pool.connect();
146
+ try {
147
+ await client.query('BEGIN READ ONLY');
148
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
149
+ const result = await client.query(`SELECT bucket, SUM(count) as count,
150
+ SUM(avg_ms * count) / NULLIF(SUM(count), 0) as avg_ms,
151
+ MAX(p95_ms) as p95_ms,
152
+ MAX(p99_ms) as p99_ms
153
+ FROM _turbine_metrics
154
+ WHERE bucket >= NOW() - $1::interval
155
+ GROUP BY bucket
156
+ ORDER BY bucket`, [interval]);
157
+ await client.query('COMMIT');
158
+ sendJson(res, 200, result.rows);
159
+ }
160
+ catch (err) {
161
+ try {
162
+ await client.query('ROLLBACK');
163
+ }
164
+ catch { }
165
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
166
+ }
167
+ finally {
168
+ client.release();
169
+ }
170
+ }
171
+ async function apiModels(res, pool, params) {
172
+ const range = params.get('range') ?? '1h';
173
+ const interval = rangeToInterval(range);
174
+ const client = await pool.connect();
175
+ try {
176
+ await client.query('BEGIN READ ONLY');
177
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
178
+ const result = await client.query(`SELECT model, action,
179
+ SUM(count)::int as count,
180
+ SUM(avg_ms * count) / NULLIF(SUM(count), 0) as avg_ms,
181
+ MAX(p95_ms) as p95_ms,
182
+ MAX(p99_ms) as p99_ms,
183
+ SUM(error_count)::int as error_count
184
+ FROM _turbine_metrics
185
+ WHERE bucket >= NOW() - $1::interval
186
+ GROUP BY model, action
187
+ ORDER BY MAX(p95_ms) DESC
188
+ LIMIT 50`, [interval]);
189
+ await client.query('COMMIT');
190
+ sendJson(res, 200, result.rows);
191
+ }
192
+ catch (err) {
193
+ try {
194
+ await client.query('ROLLBACK');
195
+ }
196
+ catch { }
197
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
198
+ }
199
+ finally {
200
+ client.release();
201
+ }
202
+ }
203
+ // ---------------------------------------------------------------------------
204
+ // Response helpers
205
+ // ---------------------------------------------------------------------------
206
+ const SECURITY_HEADERS = {
207
+ 'X-Content-Type-Options': 'nosniff',
208
+ 'X-Frame-Options': 'DENY',
209
+ 'Referrer-Policy': 'no-referrer',
210
+ 'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
211
+ };
212
+ function sendJson(res, status, body) {
213
+ const payload = JSON.stringify(body);
214
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
215
+ res.end(payload);
216
+ }
217
+ function sendHtml(res, status, html) {
218
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/html; charset=utf-8' });
219
+ res.end(html);
220
+ }
221
+ function sendText(res, status, text) {
222
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' });
223
+ res.end(text);
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Browser open
227
+ // ---------------------------------------------------------------------------
228
+ function openUrl(url) {
229
+ const { platform: os } = process;
230
+ const { spawn } = require('node:child_process');
231
+ try {
232
+ if (os === 'darwin')
233
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
234
+ else if (os === 'win32')
235
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
236
+ else
237
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
238
+ }
239
+ catch {
240
+ // Best effort
241
+ }
242
+ }
@@ -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();
@@ -30,8 +30,12 @@ exports.TurbineClient = exports.TransactionClient = void 0;
30
30
  exports.withRetry = withRetry;
31
31
  const pg_1 = __importDefault(require("pg"));
32
32
  const errors_js_1 = require("./errors.js");
33
+ const observe_js_1 = require("./observe.js");
33
34
  const pipeline_js_1 = require("./pipeline.js");
34
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");
35
39
  async function withRetry(fn, options) {
36
40
  const maxAttempts = options?.maxAttempts ?? 3;
37
41
  const baseDelay = options?.baseDelay ?? 50;
@@ -63,6 +67,13 @@ const ISOLATION_LEVELS = {
63
67
  RepeatableRead: 'REPEATABLE READ',
64
68
  Serializable: 'SERIALIZABLE',
65
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_]*)?$/;
66
77
  // ---------------------------------------------------------------------------
67
78
  // TransactionClient — provides typed table accessors within a transaction
68
79
  // ---------------------------------------------------------------------------
@@ -191,9 +202,13 @@ class TurbineClient {
191
202
  logging;
192
203
  tableCache = new Map();
193
204
  middlewares = [];
205
+ queryListeners = new Set();
194
206
  queryOptions;
207
+ errorMessagesSafe;
195
208
  /** True when Turbine created the pool and is responsible for tearing it down */
196
209
  ownsPool = true;
210
+ /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
211
+ activeSubscriptions = new Set();
197
212
  constructor(config = {}, schema) {
198
213
  /**
199
214
  * Parse int8 (bigint, OID 20) as JavaScript number instead of string.
@@ -225,12 +240,27 @@ class TurbineClient {
225
240
  this.schema = schema;
226
241
  // Respect env var kill switch
227
242
  const envDisablePrepared = typeof process !== 'undefined' && process.env?.TURBINE_DISABLE_PREPARED === '1';
243
+ this.errorMessagesSafe = (config.errorMessages ?? 'safe') === 'safe';
228
244
  this.queryOptions = {
229
245
  defaultLimit: config.defaultLimit,
230
246
  warnOnUnlimited: config.warnOnUnlimited,
231
247
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
232
248
  sqlCache: config.sqlCache ?? true,
233
249
  dialect: config.dialect,
250
+ _onQuery: (event) => {
251
+ if (this.queryListeners.size === 0)
252
+ return;
253
+ const emitted = this.errorMessagesSafe ? { ...event, params: event.params.map(() => '[REDACTED]') } : event;
254
+ for (const listener of this.queryListeners) {
255
+ try {
256
+ listener(emitted);
257
+ }
258
+ catch (e) {
259
+ if (this.logging)
260
+ console.error('[turbine] Query listener error:', e);
261
+ }
262
+ }
263
+ },
234
264
  };
235
265
  // Apply NotFoundError message redaction mode (default: safe — values are
236
266
  // stripped from messages to avoid leaking PII into error logs).
@@ -283,6 +313,11 @@ class TurbineClient {
283
313
  });
284
314
  }
285
315
  }
316
+ // Auto-start observability from env var
317
+ const observeUrl = typeof process !== 'undefined' ? process.env?.TURBINE_OBSERVE_URL : undefined;
318
+ if (observeUrl) {
319
+ this.$observe({ connectionString: observeUrl }).catch(() => { });
320
+ }
286
321
  }
287
322
  // -------------------------------------------------------------------------
288
323
  // Middleware — intercept all queries
@@ -324,6 +359,37 @@ class TurbineClient {
324
359
  this.tableCache.clear();
325
360
  }
326
361
  // -------------------------------------------------------------------------
362
+ // Event emitter — subscribe to query lifecycle events
363
+ // -------------------------------------------------------------------------
364
+ $on(_event, listener) {
365
+ this.queryListeners.add(listener);
366
+ }
367
+ $off(_event, listener) {
368
+ this.queryListeners.delete(listener);
369
+ }
370
+ // -------------------------------------------------------------------------
371
+ // Observability — automatic metrics collection
372
+ // -------------------------------------------------------------------------
373
+ observeEngine;
374
+ async $observe(config) {
375
+ if (this.observeEngine) {
376
+ await this.observeEngine.stop();
377
+ this.$off('query', this.observeEngine.getListener());
378
+ }
379
+ const engine = new observe_js_1.ObserveEngine(config);
380
+ this.observeEngine = engine;
381
+ await engine.init();
382
+ this.$on('query', engine.getListener());
383
+ return {
384
+ stop: async () => {
385
+ this.$off('query', engine.getListener());
386
+ await engine.stop();
387
+ if (this.observeEngine === engine)
388
+ this.observeEngine = undefined;
389
+ },
390
+ };
391
+ }
392
+ // -------------------------------------------------------------------------
327
393
  // Table accessor — creates QueryInterface for any table
328
394
  // -------------------------------------------------------------------------
329
395
  /**
@@ -414,6 +480,40 @@ class TurbineClient {
414
480
  throw (0, errors_js_1.wrapPgError)(err);
415
481
  }
416
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
+ }
417
517
  // -------------------------------------------------------------------------
418
518
  // Transaction support (raw — legacy)
419
519
  // -------------------------------------------------------------------------
@@ -496,6 +596,21 @@ class TurbineClient {
496
596
  beginSQL += ` ISOLATION LEVEL ${level}`;
497
597
  }
498
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
+ }
499
614
  // Create the transaction client with typed table accessors
500
615
  const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
501
616
  // Dynamically attach table accessors to tx
@@ -567,6 +682,94 @@ class TurbineClient {
567
682
  releaseOnce();
568
683
  }
569
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
+ }
570
773
  // -------------------------------------------------------------------------
571
774
  // Retry — automatic retry for retryable errors (deadlock, serialization)
572
775
  // -------------------------------------------------------------------------
@@ -614,6 +817,21 @@ class TurbineClient {
614
817
  * method is a no-op — the caller is responsible for the pool's lifecycle.
615
818
  */
616
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
+ }
617
835
  if (!this.ownsPool) {
618
836
  if (this.logging) {
619
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