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.
- package/README.md +180 -12
- package/dist/adapters/cockroachdb.js +4 -2
- package/dist/adapters/index.js +4 -1
- package/dist/adapters/yugabytedb.js +4 -2
- package/dist/cjs/adapters/cockroachdb.js +4 -2
- package/dist/cjs/adapters/index.js +4 -1
- package/dist/cjs/adapters/yugabytedb.js +4 -2
- package/dist/cjs/cli/index.js +64 -0
- package/dist/cjs/cli/observe-ui.js +182 -0
- package/dist/cjs/cli/observe.js +242 -0
- package/dist/cjs/cli/studio.js +5 -1
- package/dist/cjs/client.js +218 -0
- package/dist/cjs/errors.js +35 -5
- package/dist/cjs/generate.js +14 -3
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.js +81 -0
- package/dist/cjs/nested-write.js +164 -10
- package/dist/cjs/observe.js +145 -0
- package/dist/cjs/query/builder.js +604 -25
- package/dist/cjs/realtime.js +147 -0
- package/dist/cjs/schema-builder.js +86 -0
- package/dist/cjs/schema.js +10 -0
- package/dist/cjs/typed-sql.js +149 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +64 -0
- package/dist/cli/observe-ui.d.ts +2 -0
- package/dist/cli/observe-ui.js +180 -0
- package/dist/cli/observe.d.ts +20 -0
- package/dist/cli/observe.js +237 -0
- package/dist/cli/studio.js +5 -1
- package/dist/client.d.ts +129 -2
- package/dist/client.js +220 -2
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.d.ts +2 -2
- package/dist/nested-write.js +164 -10
- package/dist/observe.d.ts +36 -0
- package/dist/observe.js +141 -0
- package/dist/query/builder.d.ts +121 -1
- package/dist/query/builder.js +605 -26
- package/dist/query/index.d.ts +2 -2
- package/dist/query/types.d.ts +126 -2
- package/dist/realtime.d.ts +71 -0
- package/dist/realtime.js +144 -0
- package/dist/schema-builder.d.ts +68 -1
- package/dist/schema-builder.js +85 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +10 -0
- package/dist/typed-sql.d.ts +101 -0
- package/dist/typed-sql.js +145 -0
- 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
|
+
}
|
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/cjs/client.js
CHANGED
|
@@ -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');
|
package/dist/cjs/errors.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cjs/generate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|