turbine-orm 0.16.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +35 -73
- package/dist/cjs/client.js +164 -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 +70 -6
- package/dist/cjs/query/builder.js +581 -17
- 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/studio-ui.generated.js +1 -1
- package/dist/cli/studio.js +35 -73
- package/dist/client.d.ts +120 -0
- package/dist/client.js +165 -1
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.js +70 -6
- package/dist/query/builder.d.ts +104 -1
- package/dist/query/builder.js +582 -18
- package/dist/query/index.d.ts +1 -1
- 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 +17 -15
package/dist/cli/studio.js
CHANGED
|
@@ -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
|
-
|
|
63
|
+
// Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
|
|
64
|
+
// syntax error). `set_config(name, value, is_local=true)` is the
|
|
65
|
+
// parameterizable, transaction-local equivalent and works on every
|
|
66
|
+
// Postgres-compatible engine.
|
|
67
|
+
sql: `SELECT set_config('statement_timeout', $1, true)`,
|
|
64
68
|
params: ['30s'],
|
|
65
69
|
};
|
|
66
70
|
const rateLimiter = new Map();
|
|
@@ -136,6 +140,13 @@ async function handleRequest(req, res, ctx) {
|
|
|
136
140
|
sendHtml(res, 200, STUDIO_HTML);
|
|
137
141
|
return;
|
|
138
142
|
}
|
|
143
|
+
// Favicon — answered before the auth gate so the browser's automatic request
|
|
144
|
+
// doesn't 401/404 on every load. No icon body needed (204).
|
|
145
|
+
if (pathname === '/favicon.ico') {
|
|
146
|
+
res.writeHead(204, { 'Content-Length': '0' });
|
|
147
|
+
res.end();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
139
150
|
// API routes — all require auth.
|
|
140
151
|
if (!isAuthorized(req, ctx.authToken)) {
|
|
141
152
|
sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
|
|
@@ -156,9 +167,6 @@ async function handleRequest(req, res, ctx) {
|
|
|
156
167
|
const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
|
|
157
168
|
return apiTableRows(res, ctx, rawName, url.searchParams);
|
|
158
169
|
}
|
|
159
|
-
if (pathname === '/api/query' && req.method === 'POST') {
|
|
160
|
-
return apiQuery(req, res, ctx);
|
|
161
|
-
}
|
|
162
170
|
if (pathname === '/api/builder' && req.method === 'POST') {
|
|
163
171
|
return apiBuilder(req, res, ctx);
|
|
164
172
|
}
|
|
@@ -218,6 +226,15 @@ function constantTimeEqual(a, b) {
|
|
|
218
226
|
}
|
|
219
227
|
return result === 0;
|
|
220
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Build a helpful "unknown table" error that lists the available tables so the
|
|
231
|
+
* caller can spot a typo or schema mismatch immediately.
|
|
232
|
+
*/
|
|
233
|
+
function unknownTableMessage(name, ctx) {
|
|
234
|
+
const available = Object.keys(ctx.metadata.tables);
|
|
235
|
+
const list = available.length ? available.join(', ') : '(none)';
|
|
236
|
+
return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
|
|
237
|
+
}
|
|
221
238
|
// ---------------------------------------------------------------------------
|
|
222
239
|
// API: /api/schema
|
|
223
240
|
// ---------------------------------------------------------------------------
|
|
@@ -250,7 +267,9 @@ async function apiSchema(res, ctx) {
|
|
|
250
267
|
WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
|
|
251
268
|
const counts = new Map();
|
|
252
269
|
for (const row of countsResult.rows) {
|
|
253
|
-
|
|
270
|
+
// pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
|
|
271
|
+
// sidebar never shows a negative estimate.
|
|
272
|
+
counts.set(row.relname, Math.max(0, Number(row.reltuples)));
|
|
254
273
|
}
|
|
255
274
|
sendJson(res, 200, {
|
|
256
275
|
schema: ctx.options.schema,
|
|
@@ -264,7 +283,7 @@ async function apiSchema(res, ctx) {
|
|
|
264
283
|
export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
265
284
|
const table = ctx.metadata.tables[rawTableName];
|
|
266
285
|
if (!table) {
|
|
267
|
-
sendJson(res, 404, { error:
|
|
286
|
+
sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
|
|
268
287
|
return;
|
|
269
288
|
}
|
|
270
289
|
const limit = clampInt(params.get('limit'), 50, 1, 500);
|
|
@@ -359,54 +378,6 @@ export function escapeLikePattern(s) {
|
|
|
359
378
|
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
360
379
|
}
|
|
361
380
|
// ---------------------------------------------------------------------------
|
|
362
|
-
// API: /api/query — read-only SELECT/WITH runner
|
|
363
|
-
// ---------------------------------------------------------------------------
|
|
364
|
-
async function apiQuery(req, res, ctx) {
|
|
365
|
-
const body = await readJsonBody(req);
|
|
366
|
-
const rawSql = typeof body?.sql === 'string' ? body.sql.trim() : '';
|
|
367
|
-
if (!rawSql) {
|
|
368
|
-
sendJson(res, 400, { error: 'missing sql' });
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
if (rawSql.length > 10_000) {
|
|
372
|
-
sendJson(res, 400, { error: 'query too long — maximum 10,000 characters allowed' });
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
if (!isReadOnlyStatement(rawSql)) {
|
|
376
|
-
sendJson(res, 400, {
|
|
377
|
-
error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
|
|
378
|
-
});
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
const client = await ctx.pool.connect();
|
|
382
|
-
try {
|
|
383
|
-
await client.query('BEGIN READ ONLY');
|
|
384
|
-
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
385
|
-
const started = Date.now();
|
|
386
|
-
const result = await client.query(rawSql);
|
|
387
|
-
const elapsedMs = Date.now() - started;
|
|
388
|
-
await client.query('COMMIT');
|
|
389
|
-
sendJson(res, 200, {
|
|
390
|
-
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
391
|
-
rows: result.rows.map((r) => serializeRow(r)),
|
|
392
|
-
rowCount: result.rowCount ?? result.rows.length,
|
|
393
|
-
elapsedMs,
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
catch (err) {
|
|
397
|
-
try {
|
|
398
|
-
await client.query('ROLLBACK');
|
|
399
|
-
}
|
|
400
|
-
catch {
|
|
401
|
-
/* ignore */
|
|
402
|
-
}
|
|
403
|
-
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
404
|
-
}
|
|
405
|
-
finally {
|
|
406
|
-
client.release();
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
// ---------------------------------------------------------------------------
|
|
410
381
|
// API: /api/builder — Turbine ORM findMany spec runner
|
|
411
382
|
// ---------------------------------------------------------------------------
|
|
412
383
|
export async function apiBuilder(req, res, ctx) {
|
|
@@ -414,7 +385,7 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
414
385
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
415
386
|
const args = (body?.args ?? {});
|
|
416
387
|
if (!tableName || !ctx.metadata.tables[tableName]) {
|
|
417
|
-
sendJson(res, 400, { error:
|
|
388
|
+
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
418
389
|
return;
|
|
419
390
|
}
|
|
420
391
|
let deferred;
|
|
@@ -471,7 +442,9 @@ function loadSavedQueries(ctx) {
|
|
|
471
442
|
const parsed = JSON.parse(raw);
|
|
472
443
|
if (!parsed.queries || !Array.isArray(parsed.queries))
|
|
473
444
|
return { version: 1, queries: [] };
|
|
474
|
-
|
|
445
|
+
// Drop any legacy raw-SQL entries — Studio is builder-only now.
|
|
446
|
+
const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
|
|
447
|
+
return { version: 1, queries };
|
|
475
448
|
}
|
|
476
449
|
catch {
|
|
477
450
|
return { version: 1, queries: [] };
|
|
@@ -494,27 +467,17 @@ export async function apiCreateSavedQuery(req, res, ctx) {
|
|
|
494
467
|
const body = await readJsonBody(req);
|
|
495
468
|
const table = typeof body?.table === 'string' ? body.table : '';
|
|
496
469
|
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
497
|
-
const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
|
|
498
470
|
if (!table || !ctx.metadata.tables[table]) {
|
|
499
|
-
sendJson(res, 400, { error:
|
|
471
|
+
sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
|
|
500
472
|
return;
|
|
501
473
|
}
|
|
502
474
|
if (!name) {
|
|
503
475
|
sendJson(res, 400, { error: 'name is required' });
|
|
504
476
|
return;
|
|
505
477
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
}
|
|
510
|
-
const sql = kind === 'sql' && typeof body?.sql === 'string' ? body.sql : undefined;
|
|
511
|
-
const args = kind === 'builder' ? body?.args : undefined;
|
|
512
|
-
if (kind === 'sql' && !sql) {
|
|
513
|
-
sendJson(res, 400, { error: 'sql is required for kind=sql' });
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
if (kind === 'sql' && sql && !isReadOnlyStatement(sql)) {
|
|
517
|
-
sendJson(res, 400, { error: 'saved sql must be SELECT/WITH only' });
|
|
478
|
+
// Studio only persists visual-builder queries (no raw SQL surface).
|
|
479
|
+
if (body?.kind !== 'builder') {
|
|
480
|
+
sendJson(res, 400, { error: 'kind must be "builder"' });
|
|
518
481
|
return;
|
|
519
482
|
}
|
|
520
483
|
const data = loadSavedQueries(ctx);
|
|
@@ -522,9 +485,8 @@ export async function apiCreateSavedQuery(req, res, ctx) {
|
|
|
522
485
|
id: randomUUID(),
|
|
523
486
|
table,
|
|
524
487
|
name,
|
|
525
|
-
kind,
|
|
526
|
-
|
|
527
|
-
args,
|
|
488
|
+
kind: 'builder',
|
|
489
|
+
args: body?.args,
|
|
528
490
|
createdAt: new Date().toISOString(),
|
|
529
491
|
};
|
|
530
492
|
data.queries.push(entry);
|
package/dist/client.d.ts
CHANGED
|
@@ -27,7 +27,9 @@ import { type ErrorMessageMode } from './errors.js';
|
|
|
27
27
|
import { type ObserveConfig, type ObserveHandle } from './observe.js';
|
|
28
28
|
import { type PipelineOptions, type PipelineResults } from './pipeline.js';
|
|
29
29
|
import { type DeferredQuery, type QueryEventListener, QueryInterface, type QueryInterfaceOptions } from './query/index.js';
|
|
30
|
+
import { type NotificationHandler, type Subscription } from './realtime.js';
|
|
30
31
|
import type { SchemaMetadata } from './schema.js';
|
|
32
|
+
import { TypedSqlQuery } from './typed-sql.js';
|
|
31
33
|
export interface RetryOptions {
|
|
32
34
|
maxAttempts?: number;
|
|
33
35
|
baseDelay?: number;
|
|
@@ -172,6 +174,30 @@ export interface TransactionOptions {
|
|
|
172
174
|
timeout?: number;
|
|
173
175
|
/** Isolation level for the transaction */
|
|
174
176
|
isolationLevel?: 'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable';
|
|
177
|
+
/**
|
|
178
|
+
* Transaction-local session GUCs to set after BEGIN. The canonical use case
|
|
179
|
+
* is multi-tenant Postgres row-level security (RLS): your policies filter on
|
|
180
|
+
* `current_setting('app.current_tenant')`, and you set that value here so
|
|
181
|
+
* every query inside the transaction sees it.
|
|
182
|
+
*
|
|
183
|
+
* Each entry is applied via `SELECT set_config($1, $2, true)` — `is_local=true`
|
|
184
|
+
* scopes the value to this transaction, so it auto-resets on COMMIT/ROLLBACK
|
|
185
|
+
* and never leaks onto the pooled connection. Both the name and value are
|
|
186
|
+
* bound parameters (never interpolated); the GUC name is additionally
|
|
187
|
+
* validated against a strict identifier regex.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* await db.$transaction(
|
|
192
|
+
* async (tx) => {
|
|
193
|
+
* // every query here sees current_setting('app.current_tenant') = '42'
|
|
194
|
+
* return tx.invoices.findMany();
|
|
195
|
+
* },
|
|
196
|
+
* { sessionContext: { 'app.current_tenant': '42', 'app.current_user': userId } },
|
|
197
|
+
* );
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
sessionContext?: Record<string, string | number | boolean>;
|
|
175
201
|
}
|
|
176
202
|
/**
|
|
177
203
|
* A transaction-scoped client that provides the same table accessor API as TurbineClient.
|
|
@@ -225,6 +251,8 @@ export declare class TurbineClient {
|
|
|
225
251
|
private readonly errorMessagesSafe;
|
|
226
252
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
227
253
|
private readonly ownsPool;
|
|
254
|
+
/** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
|
|
255
|
+
private readonly activeSubscriptions;
|
|
228
256
|
constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
|
|
229
257
|
/**
|
|
230
258
|
* Register a middleware function that runs before/after every query.
|
|
@@ -298,6 +326,37 @@ export declare class TurbineClient {
|
|
|
298
326
|
* ```
|
|
299
327
|
*/
|
|
300
328
|
raw<T extends Record<string, unknown> = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
|
|
329
|
+
/**
|
|
330
|
+
* Execute a **typed** raw SQL query — Turbine's answer to Prisma's TypedSQL.
|
|
331
|
+
*
|
|
332
|
+
* Like {@link raw}, every interpolated `${value}` becomes a `$N` parameter
|
|
333
|
+
* (never string-concatenated), so it is injection-safe by construction. The
|
|
334
|
+
* difference is the caller-supplied row type and the chainable result: the
|
|
335
|
+
* returned {@link TypedSqlQuery} can be `await`ed directly for `T[]`, or
|
|
336
|
+
* refined with `.one()` (→ `T | null`) or `.scalar<V>()` (→ `V | null`).
|
|
337
|
+
*
|
|
338
|
+
* Rows are returned as-is — no snake→camel mapping (matching `raw()`). Alias
|
|
339
|
+
* columns in SQL if you want camelCase keys.
|
|
340
|
+
*
|
|
341
|
+
* @example
|
|
342
|
+
* ```ts
|
|
343
|
+
* // rows
|
|
344
|
+
* const rows = await db.sql<{ id: number; name: string }>`
|
|
345
|
+
* SELECT id, name FROM users WHERE org_id = ${orgId}
|
|
346
|
+
* `;
|
|
347
|
+
*
|
|
348
|
+
* // single row or null
|
|
349
|
+
* const user = await db.sql<{ id: number; name: string }>`
|
|
350
|
+
* SELECT id, name FROM users WHERE id = ${userId}
|
|
351
|
+
* `.one();
|
|
352
|
+
*
|
|
353
|
+
* // scalar
|
|
354
|
+
* const total = await db.sql<{ count: number }>`
|
|
355
|
+
* SELECT COUNT(*)::int AS count FROM users
|
|
356
|
+
* `.scalar();
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
359
|
+
sql<T extends Record<string, unknown> = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): TypedSqlQuery<T>;
|
|
301
360
|
/**
|
|
302
361
|
* Execute a function within a database transaction (raw pg.PoolClient).
|
|
303
362
|
* For the typed API, use `$transaction()` instead.
|
|
@@ -330,6 +389,67 @@ export declare class TurbineClient {
|
|
|
330
389
|
* ```
|
|
331
390
|
*/
|
|
332
391
|
$transaction<R>(fn: (tx: TransactionClient) => Promise<R>, options?: TransactionOptions): Promise<R>;
|
|
392
|
+
/**
|
|
393
|
+
* Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
|
|
394
|
+
* runs `fn` inside a transaction with the given session GUCs applied via
|
|
395
|
+
* `set_config(..., is_local=true)`. Equivalent to
|
|
396
|
+
* `$transaction(fn, { sessionContext: context })`.
|
|
397
|
+
*
|
|
398
|
+
* @example
|
|
399
|
+
* ```ts
|
|
400
|
+
* const invoices = await db.$withSession(
|
|
401
|
+
* { 'app.current_tenant': tenantId },
|
|
402
|
+
* (tx) => tx.invoices.findMany(),
|
|
403
|
+
* );
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
$withSession<R>(context: Record<string, string | number | boolean>, fn: (tx: TransactionClient) => Promise<R>): Promise<R>;
|
|
407
|
+
/**
|
|
408
|
+
* Subscribe to a Postgres NOTIFY channel. The handler fires with each
|
|
409
|
+
* notification's payload string (the empty string when a payload-less
|
|
410
|
+
* NOTIFY is sent) for as long as the subscription is active.
|
|
411
|
+
*
|
|
412
|
+
* Each `$listen` checks out its OWN dedicated long-lived connection from the
|
|
413
|
+
* pool and runs `LISTEN "channel"` on it; `subscription.unsubscribe()`
|
|
414
|
+
* UNLISTENs, detaches the handler, and releases that connection. Active
|
|
415
|
+
* subscriptions are tracked and force-released on `disconnect()` so shutdown
|
|
416
|
+
* never hangs.
|
|
417
|
+
*
|
|
418
|
+
* The channel name CANNOT be a bound parameter (`LISTEN $1` is a syntax
|
|
419
|
+
* error), so it is validated against a strict identifier regex AND quoted via
|
|
420
|
+
* `quoteIdent` before interpolation — it is the only identifier this method
|
|
421
|
+
* places into SQL text.
|
|
422
|
+
*
|
|
423
|
+
* **Serverless caveat:** LISTEN needs a persistent connection that can push
|
|
424
|
+
* async notifications. Stateless HTTP drivers (Neon HTTP, Vercel Postgres)
|
|
425
|
+
* cannot do this — `$listen` throws a `ConnectionError` rather than hang.
|
|
426
|
+
* `$notify` works on every driver.
|
|
427
|
+
*
|
|
428
|
+
* @example
|
|
429
|
+
* ```ts
|
|
430
|
+
* const sub = await db.$listen('order_created', (payload) => {
|
|
431
|
+
* const order = JSON.parse(payload);
|
|
432
|
+
* console.log('new order', order.id);
|
|
433
|
+
* });
|
|
434
|
+
* // ...later
|
|
435
|
+
* await sub.unsubscribe();
|
|
436
|
+
* ```
|
|
437
|
+
*/
|
|
438
|
+
$listen(channel: string, handler: NotificationHandler): Promise<Subscription>;
|
|
439
|
+
/**
|
|
440
|
+
* Send a Postgres NOTIFY on `channel` with an optional payload string.
|
|
441
|
+
*
|
|
442
|
+
* Issued as `SELECT pg_notify($1, $2)` — both the channel and payload are
|
|
443
|
+
* BOUND parameters (no quoting/injection concern). The channel is still
|
|
444
|
+
* validated against the identifier regex for parity with `$listen` and to
|
|
445
|
+
* catch typos loudly. Works on every driver, including serverless HTTP pools.
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```ts
|
|
449
|
+
* await db.$notify('order_created', JSON.stringify({ id: 7 }));
|
|
450
|
+
* ```
|
|
451
|
+
*/
|
|
452
|
+
$notify(channel: string, payload?: string): Promise<void>;
|
|
333
453
|
/**
|
|
334
454
|
* Execute an async function with automatic retry on retryable errors.
|
|
335
455
|
*
|
package/dist/client.js
CHANGED
|
@@ -22,10 +22,13 @@
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
import pg from 'pg';
|
|
25
|
-
import { setErrorMessageMode, TimeoutError, wrapPgError } from './errors.js';
|
|
25
|
+
import { setErrorMessageMode, TimeoutError, ValidationError, wrapPgError } from './errors.js';
|
|
26
26
|
import { ObserveEngine } from './observe.js';
|
|
27
27
|
import { executePipeline, pipelineSupported } from './pipeline.js';
|
|
28
28
|
import { QueryInterface, } from './query/index.js';
|
|
29
|
+
import { quoteIdent } from './query/utils.js';
|
|
30
|
+
import { createSubscription, validateChannel, } from './realtime.js';
|
|
31
|
+
import { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
|
|
29
32
|
export async function withRetry(fn, options) {
|
|
30
33
|
const maxAttempts = options?.maxAttempts ?? 3;
|
|
31
34
|
const baseDelay = options?.baseDelay ?? 50;
|
|
@@ -57,6 +60,13 @@ const ISOLATION_LEVELS = {
|
|
|
57
60
|
RepeatableRead: 'REPEATABLE READ',
|
|
58
61
|
Serializable: 'SERIALIZABLE',
|
|
59
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* Strict GUC (session variable) name: an optionally namespaced identifier such
|
|
65
|
+
* as `app.current_tenant` or `search_path`. Even though the name is passed as a
|
|
66
|
+
* bound parameter to `set_config`, a malformed name is a programmer error worth
|
|
67
|
+
* rejecting loudly before it reaches the database.
|
|
68
|
+
*/
|
|
69
|
+
const GUC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
|
|
60
70
|
// ---------------------------------------------------------------------------
|
|
61
71
|
// TransactionClient — provides typed table accessors within a transaction
|
|
62
72
|
// ---------------------------------------------------------------------------
|
|
@@ -189,6 +199,8 @@ export class TurbineClient {
|
|
|
189
199
|
errorMessagesSafe;
|
|
190
200
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
191
201
|
ownsPool = true;
|
|
202
|
+
/** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
|
|
203
|
+
activeSubscriptions = new Set();
|
|
192
204
|
constructor(config = {}, schema) {
|
|
193
205
|
/**
|
|
194
206
|
* Parse int8 (bigint, OID 20) as JavaScript number instead of string.
|
|
@@ -460,6 +472,40 @@ export class TurbineClient {
|
|
|
460
472
|
throw wrapPgError(err);
|
|
461
473
|
}
|
|
462
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Execute a **typed** raw SQL query — Turbine's answer to Prisma's TypedSQL.
|
|
477
|
+
*
|
|
478
|
+
* Like {@link raw}, every interpolated `${value}` becomes a `$N` parameter
|
|
479
|
+
* (never string-concatenated), so it is injection-safe by construction. The
|
|
480
|
+
* difference is the caller-supplied row type and the chainable result: the
|
|
481
|
+
* returned {@link TypedSqlQuery} can be `await`ed directly for `T[]`, or
|
|
482
|
+
* refined with `.one()` (→ `T | null`) or `.scalar<V>()` (→ `V | null`).
|
|
483
|
+
*
|
|
484
|
+
* Rows are returned as-is — no snake→camel mapping (matching `raw()`). Alias
|
|
485
|
+
* columns in SQL if you want camelCase keys.
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* ```ts
|
|
489
|
+
* // rows
|
|
490
|
+
* const rows = await db.sql<{ id: number; name: string }>`
|
|
491
|
+
* SELECT id, name FROM users WHERE org_id = ${orgId}
|
|
492
|
+
* `;
|
|
493
|
+
*
|
|
494
|
+
* // single row or null
|
|
495
|
+
* const user = await db.sql<{ id: number; name: string }>`
|
|
496
|
+
* SELECT id, name FROM users WHERE id = ${userId}
|
|
497
|
+
* `.one();
|
|
498
|
+
*
|
|
499
|
+
* // scalar
|
|
500
|
+
* const total = await db.sql<{ count: number }>`
|
|
501
|
+
* SELECT COUNT(*)::int AS count FROM users
|
|
502
|
+
* `.scalar();
|
|
503
|
+
* ```
|
|
504
|
+
*/
|
|
505
|
+
sql(strings, ...values) {
|
|
506
|
+
const { sql, params } = buildTypedSql(strings, values);
|
|
507
|
+
return new TypedSqlQuery(this.pool, sql, params, this.logging);
|
|
508
|
+
}
|
|
463
509
|
// -------------------------------------------------------------------------
|
|
464
510
|
// Transaction support (raw — legacy)
|
|
465
511
|
// -------------------------------------------------------------------------
|
|
@@ -542,6 +588,21 @@ export class TurbineClient {
|
|
|
542
588
|
beginSQL += ` ISOLATION LEVEL ${level}`;
|
|
543
589
|
}
|
|
544
590
|
await client.query(beginSQL);
|
|
591
|
+
// Apply transaction-local session context (RLS / multi-tenant GUCs).
|
|
592
|
+
// Order matters: BEGIN -> isolation level (above) -> set_config loop ->
|
|
593
|
+
// user fn. Any error here propagates to the catch below and rolls back
|
|
594
|
+
// like any other transaction failure. We use set_config(name, value,
|
|
595
|
+
// is_local=true) — the parameterizable, transaction-scoped equivalent of
|
|
596
|
+
// SET LOCAL — so both name and value are BOUND params, never interpolated.
|
|
597
|
+
if (options?.sessionContext) {
|
|
598
|
+
for (const [name, value] of Object.entries(options.sessionContext)) {
|
|
599
|
+
if (!GUC_NAME_REGEX.test(name)) {
|
|
600
|
+
throw new ValidationError(`[turbine] Invalid session-context GUC name "${name}" — must match ` +
|
|
601
|
+
'/^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)?$/ (optionally namespaced, e.g. "app.current_tenant")');
|
|
602
|
+
}
|
|
603
|
+
await client.query('SELECT set_config($1, $2, true)', [name, String(value)]);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
545
606
|
// Create the transaction client with typed table accessors
|
|
546
607
|
const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
|
|
547
608
|
// Dynamically attach table accessors to tx
|
|
@@ -613,6 +674,94 @@ export class TurbineClient {
|
|
|
613
674
|
releaseOnce();
|
|
614
675
|
}
|
|
615
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* Convenience wrapper around `$transaction` for the multi-tenant / RLS case:
|
|
679
|
+
* runs `fn` inside a transaction with the given session GUCs applied via
|
|
680
|
+
* `set_config(..., is_local=true)`. Equivalent to
|
|
681
|
+
* `$transaction(fn, { sessionContext: context })`.
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* ```ts
|
|
685
|
+
* const invoices = await db.$withSession(
|
|
686
|
+
* { 'app.current_tenant': tenantId },
|
|
687
|
+
* (tx) => tx.invoices.findMany(),
|
|
688
|
+
* );
|
|
689
|
+
* ```
|
|
690
|
+
*/
|
|
691
|
+
async $withSession(context, fn) {
|
|
692
|
+
return this.$transaction(fn, { sessionContext: context });
|
|
693
|
+
}
|
|
694
|
+
// -------------------------------------------------------------------------
|
|
695
|
+
// LISTEN / NOTIFY — Postgres realtime pub/sub
|
|
696
|
+
// -------------------------------------------------------------------------
|
|
697
|
+
/**
|
|
698
|
+
* Subscribe to a Postgres NOTIFY channel. The handler fires with each
|
|
699
|
+
* notification's payload string (the empty string when a payload-less
|
|
700
|
+
* NOTIFY is sent) for as long as the subscription is active.
|
|
701
|
+
*
|
|
702
|
+
* Each `$listen` checks out its OWN dedicated long-lived connection from the
|
|
703
|
+
* pool and runs `LISTEN "channel"` on it; `subscription.unsubscribe()`
|
|
704
|
+
* UNLISTENs, detaches the handler, and releases that connection. Active
|
|
705
|
+
* subscriptions are tracked and force-released on `disconnect()` so shutdown
|
|
706
|
+
* never hangs.
|
|
707
|
+
*
|
|
708
|
+
* The channel name CANNOT be a bound parameter (`LISTEN $1` is a syntax
|
|
709
|
+
* error), so it is validated against a strict identifier regex AND quoted via
|
|
710
|
+
* `quoteIdent` before interpolation — it is the only identifier this method
|
|
711
|
+
* places into SQL text.
|
|
712
|
+
*
|
|
713
|
+
* **Serverless caveat:** LISTEN needs a persistent connection that can push
|
|
714
|
+
* async notifications. Stateless HTTP drivers (Neon HTTP, Vercel Postgres)
|
|
715
|
+
* cannot do this — `$listen` throws a `ConnectionError` rather than hang.
|
|
716
|
+
* `$notify` works on every driver.
|
|
717
|
+
*
|
|
718
|
+
* @example
|
|
719
|
+
* ```ts
|
|
720
|
+
* const sub = await db.$listen('order_created', (payload) => {
|
|
721
|
+
* const order = JSON.parse(payload);
|
|
722
|
+
* console.log('new order', order.id);
|
|
723
|
+
* });
|
|
724
|
+
* // ...later
|
|
725
|
+
* await sub.unsubscribe();
|
|
726
|
+
* ```
|
|
727
|
+
*/
|
|
728
|
+
async $listen(channel, handler) {
|
|
729
|
+
validateChannel(channel);
|
|
730
|
+
const quoted = quoteIdent(channel);
|
|
731
|
+
if (this.logging) {
|
|
732
|
+
console.log(`[turbine] LISTEN ${quoted}`);
|
|
733
|
+
}
|
|
734
|
+
const sub = await createSubscription(this.pool, channel, quoted, handler, (closed) => {
|
|
735
|
+
this.activeSubscriptions.delete(closed);
|
|
736
|
+
});
|
|
737
|
+
this.activeSubscriptions.add(sub);
|
|
738
|
+
return sub;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Send a Postgres NOTIFY on `channel` with an optional payload string.
|
|
742
|
+
*
|
|
743
|
+
* Issued as `SELECT pg_notify($1, $2)` — both the channel and payload are
|
|
744
|
+
* BOUND parameters (no quoting/injection concern). The channel is still
|
|
745
|
+
* validated against the identifier regex for parity with `$listen` and to
|
|
746
|
+
* catch typos loudly. Works on every driver, including serverless HTTP pools.
|
|
747
|
+
*
|
|
748
|
+
* @example
|
|
749
|
+
* ```ts
|
|
750
|
+
* await db.$notify('order_created', JSON.stringify({ id: 7 }));
|
|
751
|
+
* ```
|
|
752
|
+
*/
|
|
753
|
+
async $notify(channel, payload) {
|
|
754
|
+
validateChannel(channel);
|
|
755
|
+
if (this.logging) {
|
|
756
|
+
console.log(`[turbine] NOTIFY ${channel}`);
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
await this.pool.query('SELECT pg_notify($1, $2)', [channel, payload ?? '']);
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
throw wrapPgError(err);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
616
765
|
// -------------------------------------------------------------------------
|
|
617
766
|
// Retry — automatic retry for retryable errors (deadlock, serialization)
|
|
618
767
|
// -------------------------------------------------------------------------
|
|
@@ -660,6 +809,21 @@ export class TurbineClient {
|
|
|
660
809
|
* method is a no-op — the caller is responsible for the pool's lifecycle.
|
|
661
810
|
*/
|
|
662
811
|
async disconnect() {
|
|
812
|
+
// Tear down any live LISTEN subscriptions first. Each holds a dedicated
|
|
813
|
+
// pooled connection checked out; if we ended the pool (or returned for an
|
|
814
|
+
// external pool) without releasing them, pool.end() would wait forever for
|
|
815
|
+
// those connections to return. _forceRelease() detaches the handler and
|
|
816
|
+
// releases the client WITHOUT issuing UNLISTEN (pointless if we're ending
|
|
817
|
+
// the pool / the connection is going away anyway). This runs for both
|
|
818
|
+
// owned and external pools so subscriptions never leak.
|
|
819
|
+
if (this.activeSubscriptions.size > 0) {
|
|
820
|
+
// _forceRelease mutates activeSubscriptions via the onClosed callback,
|
|
821
|
+
// so iterate a snapshot.
|
|
822
|
+
for (const sub of [...this.activeSubscriptions]) {
|
|
823
|
+
sub._forceRelease();
|
|
824
|
+
}
|
|
825
|
+
this.activeSubscriptions.clear();
|
|
826
|
+
}
|
|
663
827
|
if (!this.ownsPool) {
|
|
664
828
|
if (this.logging) {
|
|
665
829
|
console.log('[turbine] disconnect() skipped — external pool is not owned by Turbine');
|
package/dist/errors.js
CHANGED
|
@@ -197,7 +197,13 @@ export class UniqueConstraintError extends TurbineError {
|
|
|
197
197
|
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
198
198
|
const columnsPart = columns && columns.length > 0 ? ` (${columns.join(', ')})` : '';
|
|
199
199
|
message = `[turbine] Unique constraint violation${constraintPart}${columnsPart}`;
|
|
200
|
-
|
|
200
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
201
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
202
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
203
|
+
// message carries keys/constraint/column names only — the structured
|
|
204
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
205
|
+
// the full detail for programmatic use.
|
|
206
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
201
207
|
if (detail)
|
|
202
208
|
message += `: ${detail}`;
|
|
203
209
|
}
|
|
@@ -218,7 +224,13 @@ export class ForeignKeyError extends TurbineError {
|
|
|
218
224
|
if (!message) {
|
|
219
225
|
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
220
226
|
message = `[turbine] Foreign key constraint violation${constraintPart}`;
|
|
221
|
-
|
|
227
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
228
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
229
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
230
|
+
// message carries keys/constraint/column names only — the structured
|
|
231
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
232
|
+
// the full detail for programmatic use.
|
|
233
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
222
234
|
if (detail)
|
|
223
235
|
message += `: ${detail}`;
|
|
224
236
|
}
|
|
@@ -238,7 +250,13 @@ export class NotNullViolationError extends TurbineError {
|
|
|
238
250
|
if (!message) {
|
|
239
251
|
const columnPart = column ? ` on column "${column}"` : '';
|
|
240
252
|
message = `[turbine] NOT NULL constraint violation${columnPart}`;
|
|
241
|
-
|
|
253
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
254
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
255
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
256
|
+
// message carries keys/constraint/column names only — the structured
|
|
257
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
258
|
+
// the full detail for programmatic use.
|
|
259
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
242
260
|
if (detail)
|
|
243
261
|
message += `: ${detail}`;
|
|
244
262
|
}
|
|
@@ -323,7 +341,13 @@ export class CheckConstraintError extends TurbineError {
|
|
|
323
341
|
if (!message) {
|
|
324
342
|
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
325
343
|
message = `[turbine] Check constraint violation${constraintPart}`;
|
|
326
|
-
|
|
344
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
345
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
346
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
347
|
+
// message carries keys/constraint/column names only — the structured
|
|
348
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
349
|
+
// the full detail for programmatic use.
|
|
350
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
327
351
|
if (detail)
|
|
328
352
|
message += `: ${detail}`;
|
|
329
353
|
}
|
|
@@ -342,7 +366,13 @@ export class ExclusionConstraintError extends TurbineError {
|
|
|
342
366
|
if (!message) {
|
|
343
367
|
const constraintPart = constraint ? ` on ${constraint}` : '';
|
|
344
368
|
message = `[turbine] Exclusion constraint violation${constraintPart}`;
|
|
345
|
-
|
|
369
|
+
// PII-safe by default: the raw pg `detail` string contains the
|
|
370
|
+
// conflicting row VALUES (e.g. `Key (email)=(alice@x.com) already
|
|
371
|
+
// exists.`). Only append it in 'verbose' mode. In 'safe' mode the
|
|
372
|
+
// message carries keys/constraint/column names only — the structured
|
|
373
|
+
// `.columns`/`.constraint`/`.column` fields and `.cause` still expose
|
|
374
|
+
// the full detail for programmatic use.
|
|
375
|
+
const detail = errorMessageMode === 'verbose' ? detailFromCause(cause) : undefined;
|
|
346
376
|
if (detail)
|
|
347
377
|
message += `: ${detail}`;
|
|
348
378
|
}
|