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,180 @@
1
+ // Embedded HTML for the Turbine Observe dashboard.
2
+ // Same pattern as studio-ui.generated.ts but hand-authored (no build step needed).
3
+ export const OBSERVE_HTML = `<!doctype html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="utf-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+ <meta name="color-scheme" content="dark" />
9
+ <title>Turbine Observe</title>
10
+ <style>
11
+ :root {
12
+ --bg: #0a0a0b;
13
+ --bg-elev: #111113;
14
+ --bg-hover: #1a1a1d;
15
+ --border: #26262b;
16
+ --text: #e6e6ea;
17
+ --text-dim: #8a8a93;
18
+ --accent: #60a5fa;
19
+ --green: #4ade80;
20
+ --red: #f87171;
21
+ --orange: #fb923c;
22
+ --purple: #a78bfa;
23
+ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
24
+ --sans: system-ui, -apple-system, sans-serif;
25
+ --radius: 6px;
26
+ }
27
+ * { margin: 0; padding: 0; box-sizing: border-box; }
28
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; padding: 24px; }
29
+ h1 { font-size: 20px; margin-bottom: 4px; }
30
+ .subtitle { color: var(--text-dim); margin-bottom: 24px; }
31
+ .controls { display: flex; gap: 8px; margin-bottom: 24px; }
32
+ .controls button {
33
+ background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
34
+ color: var(--text); padding: 6px 12px; cursor: pointer; font-size: 13px;
35
+ }
36
+ .controls button.active { border-color: var(--accent); color: var(--accent); }
37
+ .card {
38
+ background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
39
+ padding: 16px; margin-bottom: 16px;
40
+ }
41
+ .card h2 { font-size: 14px; color: var(--text-dim); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
42
+ table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
43
+ th { text-align: left; padding: 6px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }
44
+ td { padding: 6px 8px; border-bottom: 1px solid var(--border); }
45
+ .num { text-align: right; }
46
+ .error-rate { color: var(--red); }
47
+ .low-error { color: var(--green); }
48
+ svg { width: 100%; height: 200px; }
49
+ .chart-line { fill: none; stroke-width: 1.5; }
50
+ .line-avg { stroke: var(--accent); }
51
+ .line-p95 { stroke: var(--orange); }
52
+ .line-p99 { stroke: var(--red); }
53
+ .legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--text-dim); }
54
+ .legend span::before { content: ''; display: inline-block; width: 12px; height: 2px; margin-right: 4px; vertical-align: middle; }
55
+ .legend .l-avg::before { background: var(--accent); }
56
+ .legend .l-p95::before { background: var(--orange); }
57
+ .legend .l-p99::before { background: var(--red); }
58
+ .empty { color: var(--text-dim); text-align: center; padding: 40px; }
59
+ </style>
60
+ </head>
61
+ <body>
62
+ <h1>Turbine Observe</h1>
63
+ <p class="subtitle">Query performance metrics</p>
64
+ <div class="controls">
65
+ <button data-range="1h" class="active">1h</button>
66
+ <button data-range="6h">6h</button>
67
+ <button data-range="24h">24h</button>
68
+ <button data-range="7d">7d</button>
69
+ </div>
70
+ <div class="card" id="latency-card">
71
+ <h2>Latency over time</h2>
72
+ <div id="chart"></div>
73
+ <div class="legend">
74
+ <span class="l-avg">avg</span>
75
+ <span class="l-p95">p95</span>
76
+ <span class="l-p99">p99</span>
77
+ </div>
78
+ </div>
79
+ <div class="card" id="models-card">
80
+ <h2>Top models</h2>
81
+ <div id="models-table"></div>
82
+ </div>
83
+ <div class="card" id="errors-card">
84
+ <h2>Error rates</h2>
85
+ <div id="errors-table"></div>
86
+ </div>
87
+ <script>
88
+ let currentRange = '1h';
89
+ const token = document.cookie.match(/turbine_observe_token=([a-f0-9]+)/)?.[1] || '';
90
+ const headers = { 'x-turbine-token': token };
91
+
92
+ document.querySelector('.controls').addEventListener('click', e => {
93
+ if (e.target.tagName !== 'BUTTON') return;
94
+ document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));
95
+ e.target.classList.add('active');
96
+ currentRange = e.target.dataset.range;
97
+ refresh();
98
+ });
99
+
100
+ async function fetchJson(path) {
101
+ const res = await fetch(path, { headers });
102
+ if (!res.ok) return null;
103
+ return res.json();
104
+ }
105
+
106
+ function buildSvgPath(points, width, height, maxY) {
107
+ if (points.length === 0) return '';
108
+ const xStep = width / Math.max(points.length - 1, 1);
109
+ return points.map((y, i) => {
110
+ const px = i * xStep;
111
+ const py = height - (y / maxY) * height;
112
+ return (i === 0 ? 'M' : 'L') + px.toFixed(1) + ',' + py.toFixed(1);
113
+ }).join(' ');
114
+ }
115
+
116
+ function renderChart(data) {
117
+ const el = document.getElementById('chart');
118
+ if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
119
+ const width = 800; const height = 180;
120
+ const allVals = data.flatMap(d => [d.avg_ms, d.p95_ms, d.p99_ms]);
121
+ const maxY = Math.max(...allVals, 1) * 1.1;
122
+ const avgPath = buildSvgPath(data.map(d => d.avg_ms), width, height, maxY);
123
+ const p95Path = buildSvgPath(data.map(d => d.p95_ms), width, height, maxY);
124
+ const p99Path = buildSvgPath(data.map(d => d.p99_ms), width, height, maxY);
125
+ el.innerHTML = '<svg viewBox="0 0 ' + width + ' ' + height + '" preserveAspectRatio="none">'
126
+ + '<path class="chart-line line-avg" d="' + avgPath + '"/>'
127
+ + '<path class="chart-line line-p95" d="' + p95Path + '"/>'
128
+ + '<path class="chart-line line-p99" d="' + p99Path + '"/>'
129
+ + '</svg>';
130
+ }
131
+
132
+ function renderModels(data) {
133
+ const el = document.getElementById('models-table');
134
+ if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No data yet</p>'; return; }
135
+ let html = '<table><thead><tr><th>Model</th><th>Action</th><th class="num">Count</th><th class="num">Avg (ms)</th><th class="num">P95 (ms)</th><th class="num">P99 (ms)</th></tr></thead><tbody>';
136
+ for (const row of data) {
137
+ html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
138
+ + '<td class="num">' + row.count + '</td>'
139
+ + '<td class="num">' + row.avg_ms.toFixed(1) + '</td>'
140
+ + '<td class="num">' + row.p95_ms.toFixed(1) + '</td>'
141
+ + '<td class="num">' + row.p99_ms.toFixed(1) + '</td></tr>';
142
+ }
143
+ html += '</tbody></table>';
144
+ el.innerHTML = html;
145
+ }
146
+
147
+ function renderErrors(data) {
148
+ const el = document.getElementById('errors-table');
149
+ if (!data || data.length === 0) { el.innerHTML = '<p class="empty">No errors</p>'; return; }
150
+ let html = '<table><thead><tr><th>Model</th><th>Action</th><th class="num">Total</th><th class="num">Errors</th><th class="num">Rate</th></tr></thead><tbody>';
151
+ for (const row of data) {
152
+ const rate = row.count > 0 ? (row.error_count / row.count * 100).toFixed(1) : '0.0';
153
+ const cls = parseFloat(rate) > 5 ? 'error-rate' : 'low-error';
154
+ html += '<tr><td>' + row.model + '</td><td>' + row.action + '</td>'
155
+ + '<td class="num">' + row.count + '</td>'
156
+ + '<td class="num">' + row.error_count + '</td>'
157
+ + '<td class="num ' + cls + '">' + rate + '%</td></tr>';
158
+ }
159
+ html += '</tbody></table>';
160
+ el.innerHTML = html;
161
+ }
162
+
163
+ async function refresh() {
164
+ const [latency, models] = await Promise.all([
165
+ fetchJson('/api/latency?range=' + currentRange),
166
+ fetchJson('/api/models?range=' + currentRange),
167
+ ]);
168
+ renderChart(latency);
169
+ renderModels(models);
170
+ // Derive errors from models data
171
+ const withErrors = (models || []).filter(m => m.error_count > 0);
172
+ renderErrors(withErrors);
173
+ }
174
+
175
+ refresh();
176
+ setInterval(refresh, 60000);
177
+ </script>
178
+ </body>
179
+ </html>`;
180
+ //# sourceMappingURL=observe-ui.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * turbine-orm CLI — Observe
3
+ *
4
+ * A local, read-only dashboard for viewing query metrics stored in
5
+ * _turbine_metrics. Same security model as Studio: loopback binding,
6
+ * random token, HttpOnly cookie, CSP headers, read-only transactions.
7
+ */
8
+ export interface ObserveOptions {
9
+ url: string;
10
+ port: number;
11
+ host: string;
12
+ openBrowser: boolean;
13
+ }
14
+ export interface ObserveServerHandle {
15
+ dispose: () => Promise<void>;
16
+ authToken: string;
17
+ url: string;
18
+ }
19
+ export declare function startObserve(options: ObserveOptions): Promise<ObserveServerHandle>;
20
+ //# sourceMappingURL=observe.d.ts.map
@@ -0,0 +1,237 @@
1
+ /**
2
+ * turbine-orm CLI — Observe
3
+ *
4
+ * A local, read-only dashboard for viewing query metrics stored in
5
+ * _turbine_metrics. Same security model as Studio: loopback binding,
6
+ * random token, HttpOnly cookie, CSP headers, read-only transactions.
7
+ */
8
+ import { randomBytes } from 'node:crypto';
9
+ import { createServer } from 'node:http';
10
+ import pg from 'pg';
11
+ import { OBSERVE_HTML } from './observe-ui.js';
12
+ // ---------------------------------------------------------------------------
13
+ // Main entry point
14
+ // ---------------------------------------------------------------------------
15
+ export async function startObserve(options) {
16
+ const pool = new pg.Pool({
17
+ connectionString: options.url,
18
+ max: 2,
19
+ idleTimeoutMillis: 10_000,
20
+ });
21
+ const probe = await pool.connect();
22
+ try {
23
+ await probe.query('SELECT 1');
24
+ }
25
+ finally {
26
+ probe.release();
27
+ }
28
+ const authToken = randomBytes(24).toString('hex');
29
+ const server = createServer((req, res) => {
30
+ handleRequest(req, res, pool, options, authToken).catch((err) => {
31
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
32
+ });
33
+ });
34
+ await new Promise((resolve, reject) => {
35
+ server.once('error', reject);
36
+ server.listen(options.port, options.host, () => {
37
+ server.off('error', reject);
38
+ resolve();
39
+ });
40
+ });
41
+ const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
42
+ const url = `http://${hostPart}:${options.port}/?token=${authToken}`;
43
+ if (options.openBrowser) {
44
+ openUrl(url);
45
+ }
46
+ return {
47
+ authToken,
48
+ url,
49
+ dispose: async () => {
50
+ await new Promise((resolve) => server.close(() => resolve()));
51
+ await pool.end();
52
+ },
53
+ };
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Request routing
57
+ // ---------------------------------------------------------------------------
58
+ async function handleRequest(req, res, pool, options, authToken) {
59
+ const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
60
+ const expectedOrigin = `http://${hostPart}:${options.port}`;
61
+ const origin = req.headers.origin;
62
+ if (origin && origin !== expectedOrigin) {
63
+ sendJson(res, 403, { error: 'cross-origin requests not allowed' });
64
+ return;
65
+ }
66
+ const url = new URL(req.url ?? '/', expectedOrigin);
67
+ const pathname = url.pathname;
68
+ if (pathname === '/' || pathname === '/index.html') {
69
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
70
+ sendText(res, 405, 'Method Not Allowed');
71
+ return;
72
+ }
73
+ const queryToken = url.searchParams.get('token');
74
+ if (queryToken && constantTimeEqual(queryToken, authToken)) {
75
+ res.writeHead(302, {
76
+ Location: '/',
77
+ 'Set-Cookie': `turbine_observe_token=${authToken}; Path=/; HttpOnly; SameSite=Strict`,
78
+ });
79
+ res.end();
80
+ return;
81
+ }
82
+ sendHtml(res, 200, OBSERVE_HTML);
83
+ return;
84
+ }
85
+ if (!isAuthorized(req, authToken)) {
86
+ sendJson(res, 401, { error: 'unauthorized' });
87
+ return;
88
+ }
89
+ if (pathname === '/api/latency' && req.method === 'GET') {
90
+ return apiLatency(res, pool, url.searchParams);
91
+ }
92
+ if (pathname === '/api/models' && req.method === 'GET') {
93
+ return apiModels(res, pool, url.searchParams);
94
+ }
95
+ sendJson(res, 404, { error: 'not found' });
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Auth
99
+ // ---------------------------------------------------------------------------
100
+ function isAuthorized(req, expectedToken) {
101
+ const headerToken = req.headers['x-turbine-token'];
102
+ if (typeof headerToken === 'string' && constantTimeEqual(headerToken, expectedToken)) {
103
+ return true;
104
+ }
105
+ const cookieHeader = req.headers.cookie ?? '';
106
+ const match = /turbine_observe_token=([a-f0-9]+)/.exec(cookieHeader);
107
+ if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ function constantTimeEqual(a, b) {
113
+ if (a.length !== b.length)
114
+ return false;
115
+ let result = 0;
116
+ for (let i = 0; i < a.length; i++) {
117
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
118
+ }
119
+ return result === 0;
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // API handlers
123
+ // ---------------------------------------------------------------------------
124
+ function rangeToInterval(range) {
125
+ switch (range) {
126
+ case '6h':
127
+ return '6 hours';
128
+ case '24h':
129
+ return '24 hours';
130
+ case '7d':
131
+ return '7 days';
132
+ default:
133
+ return '1 hour';
134
+ }
135
+ }
136
+ async function apiLatency(res, pool, params) {
137
+ const range = params.get('range') ?? '1h';
138
+ const interval = rangeToInterval(range);
139
+ const client = await pool.connect();
140
+ try {
141
+ await client.query('BEGIN READ ONLY');
142
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
143
+ const result = await client.query(`SELECT bucket, SUM(count) as count,
144
+ SUM(avg_ms * count) / NULLIF(SUM(count), 0) as avg_ms,
145
+ MAX(p95_ms) as p95_ms,
146
+ MAX(p99_ms) as p99_ms
147
+ FROM _turbine_metrics
148
+ WHERE bucket >= NOW() - $1::interval
149
+ GROUP BY bucket
150
+ ORDER BY bucket`, [interval]);
151
+ await client.query('COMMIT');
152
+ sendJson(res, 200, result.rows);
153
+ }
154
+ catch (err) {
155
+ try {
156
+ await client.query('ROLLBACK');
157
+ }
158
+ catch { }
159
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
160
+ }
161
+ finally {
162
+ client.release();
163
+ }
164
+ }
165
+ async function apiModels(res, pool, params) {
166
+ const range = params.get('range') ?? '1h';
167
+ const interval = rangeToInterval(range);
168
+ const client = await pool.connect();
169
+ try {
170
+ await client.query('BEGIN READ ONLY');
171
+ await client.query(`SET LOCAL statement_timeout = '30s'`);
172
+ const result = await client.query(`SELECT model, action,
173
+ SUM(count)::int as count,
174
+ SUM(avg_ms * count) / NULLIF(SUM(count), 0) as avg_ms,
175
+ MAX(p95_ms) as p95_ms,
176
+ MAX(p99_ms) as p99_ms,
177
+ SUM(error_count)::int as error_count
178
+ FROM _turbine_metrics
179
+ WHERE bucket >= NOW() - $1::interval
180
+ GROUP BY model, action
181
+ ORDER BY MAX(p95_ms) DESC
182
+ LIMIT 50`, [interval]);
183
+ await client.query('COMMIT');
184
+ sendJson(res, 200, result.rows);
185
+ }
186
+ catch (err) {
187
+ try {
188
+ await client.query('ROLLBACK');
189
+ }
190
+ catch { }
191
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
192
+ }
193
+ finally {
194
+ client.release();
195
+ }
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // Response helpers
199
+ // ---------------------------------------------------------------------------
200
+ const SECURITY_HEADERS = {
201
+ 'X-Content-Type-Options': 'nosniff',
202
+ 'X-Frame-Options': 'DENY',
203
+ 'Referrer-Policy': 'no-referrer',
204
+ 'Content-Security-Policy': "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
205
+ };
206
+ function sendJson(res, status, body) {
207
+ const payload = JSON.stringify(body);
208
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' });
209
+ res.end(payload);
210
+ }
211
+ function sendHtml(res, status, html) {
212
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/html; charset=utf-8' });
213
+ res.end(html);
214
+ }
215
+ function sendText(res, status, text) {
216
+ res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Type': 'text/plain' });
217
+ res.end(text);
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // Browser open
221
+ // ---------------------------------------------------------------------------
222
+ function openUrl(url) {
223
+ const { platform: os } = process;
224
+ const { spawn } = require('node:child_process');
225
+ try {
226
+ if (os === 'darwin')
227
+ spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
228
+ else if (os === 'win32')
229
+ spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
230
+ else
231
+ spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
232
+ }
233
+ catch {
234
+ // Best effort
235
+ }
236
+ }
237
+ //# sourceMappingURL=observe.js.map
@@ -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();
package/dist/client.d.ts CHANGED
@@ -24,9 +24,12 @@
24
24
  import pg from 'pg';
25
25
  import type { Dialect } from './dialect.js';
26
26
  import { type ErrorMessageMode } from './errors.js';
27
+ import { type ObserveConfig, type ObserveHandle } from './observe.js';
27
28
  import { type PipelineOptions, type PipelineResults } from './pipeline.js';
28
- import { type DeferredQuery, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
29
+ import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
30
+ import { type NotificationHandler, type Subscription } from './realtime.js';
29
31
  import type { SchemaMetadata } from './schema.js';
32
+ import { TypedSqlQuery } from './typed-sql.js';
30
33
  export interface RetryOptions {
31
34
  maxAttempts?: number;
32
35
  baseDelay?: number;
@@ -171,6 +174,30 @@ export interface TransactionOptions {
171
174
  timeout?: number;
172
175
  /** Isolation level for the transaction */
173
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>;
174
201
  }
175
202
  /**
176
203
  * A transaction-scoped client that provides the same table accessor API as TurbineClient.
@@ -219,9 +246,13 @@ export declare class TurbineClient {
219
246
  private readonly logging;
220
247
  private readonly tableCache;
221
248
  private readonly middlewares;
222
- private readonly queryOptions;
249
+ private readonly queryListeners;
250
+ private queryOptions;
251
+ private readonly errorMessagesSafe;
223
252
  /** True when Turbine created the pool and is responsible for tearing it down */
224
253
  private readonly ownsPool;
254
+ /** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
255
+ private readonly activeSubscriptions;
225
256
  constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
226
257
  /**
227
258
  * Register a middleware function that runs before/after every query.
@@ -255,6 +286,10 @@ export declare class TurbineClient {
255
286
  * ```
256
287
  */
257
288
  $use(middleware: Middleware): void;
289
+ $on(_event: 'query', listener: QueryEventListener): void;
290
+ $off(_event: 'query', listener: QueryEventListener): void;
291
+ private observeEngine?;
292
+ $observe(config: ObserveConfig): Promise<ObserveHandle>;
258
293
  /**
259
294
  * Get a QueryInterface for a table.
260
295
  * Results are cached — calling `table('users')` twice returns the same instance.
@@ -291,6 +326,37 @@ export declare class TurbineClient {
291
326
  * ```
292
327
  */
293
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>;
294
360
  /**
295
361
  * Execute a function within a database transaction (raw pg.PoolClient).
296
362
  * For the typed API, use `$transaction()` instead.
@@ -323,6 +389,67 @@ export declare class TurbineClient {
323
389
  * ```
324
390
  */
325
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>;
326
453
  /**
327
454
  * Execute an async function with automatic retry on retryable errors.
328
455
  *