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/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();
|
|
@@ -151,6 +155,13 @@ async function handleRequest(req, res, ctx) {
|
|
|
151
155
|
sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML);
|
|
152
156
|
return;
|
|
153
157
|
}
|
|
158
|
+
// Favicon — answered before the auth gate so the browser's automatic request
|
|
159
|
+
// doesn't 401/404 on every load. No icon body needed (204).
|
|
160
|
+
if (pathname === '/favicon.ico') {
|
|
161
|
+
res.writeHead(204, { 'Content-Length': '0' });
|
|
162
|
+
res.end();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
154
165
|
// API routes — all require auth.
|
|
155
166
|
if (!isAuthorized(req, ctx.authToken)) {
|
|
156
167
|
sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
|
|
@@ -171,9 +182,6 @@ async function handleRequest(req, res, ctx) {
|
|
|
171
182
|
const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
|
|
172
183
|
return apiTableRows(res, ctx, rawName, url.searchParams);
|
|
173
184
|
}
|
|
174
|
-
if (pathname === '/api/query' && req.method === 'POST') {
|
|
175
|
-
return apiQuery(req, res, ctx);
|
|
176
|
-
}
|
|
177
185
|
if (pathname === '/api/builder' && req.method === 'POST') {
|
|
178
186
|
return apiBuilder(req, res, ctx);
|
|
179
187
|
}
|
|
@@ -233,6 +241,15 @@ function constantTimeEqual(a, b) {
|
|
|
233
241
|
}
|
|
234
242
|
return result === 0;
|
|
235
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Build a helpful "unknown table" error that lists the available tables so the
|
|
246
|
+
* caller can spot a typo or schema mismatch immediately.
|
|
247
|
+
*/
|
|
248
|
+
function unknownTableMessage(name, ctx) {
|
|
249
|
+
const available = Object.keys(ctx.metadata.tables);
|
|
250
|
+
const list = available.length ? available.join(', ') : '(none)';
|
|
251
|
+
return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
|
|
252
|
+
}
|
|
236
253
|
// ---------------------------------------------------------------------------
|
|
237
254
|
// API: /api/schema
|
|
238
255
|
// ---------------------------------------------------------------------------
|
|
@@ -265,7 +282,9 @@ async function apiSchema(res, ctx) {
|
|
|
265
282
|
WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
|
|
266
283
|
const counts = new Map();
|
|
267
284
|
for (const row of countsResult.rows) {
|
|
268
|
-
|
|
285
|
+
// pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
|
|
286
|
+
// sidebar never shows a negative estimate.
|
|
287
|
+
counts.set(row.relname, Math.max(0, Number(row.reltuples)));
|
|
269
288
|
}
|
|
270
289
|
sendJson(res, 200, {
|
|
271
290
|
schema: ctx.options.schema,
|
|
@@ -279,7 +298,7 @@ async function apiSchema(res, ctx) {
|
|
|
279
298
|
async function apiTableRows(res, ctx, rawTableName, params) {
|
|
280
299
|
const table = ctx.metadata.tables[rawTableName];
|
|
281
300
|
if (!table) {
|
|
282
|
-
sendJson(res, 404, { error:
|
|
301
|
+
sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
|
|
283
302
|
return;
|
|
284
303
|
}
|
|
285
304
|
const limit = clampInt(params.get('limit'), 50, 1, 500);
|
|
@@ -374,54 +393,6 @@ function escapeLikePattern(s) {
|
|
|
374
393
|
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
375
394
|
}
|
|
376
395
|
// ---------------------------------------------------------------------------
|
|
377
|
-
// API: /api/query — read-only SELECT/WITH runner
|
|
378
|
-
// ---------------------------------------------------------------------------
|
|
379
|
-
async function apiQuery(req, res, ctx) {
|
|
380
|
-
const body = await readJsonBody(req);
|
|
381
|
-
const rawSql = typeof body?.sql === 'string' ? body.sql.trim() : '';
|
|
382
|
-
if (!rawSql) {
|
|
383
|
-
sendJson(res, 400, { error: 'missing sql' });
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
if (rawSql.length > 10_000) {
|
|
387
|
-
sendJson(res, 400, { error: 'query too long — maximum 10,000 characters allowed' });
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (!isReadOnlyStatement(rawSql)) {
|
|
391
|
-
sendJson(res, 400, {
|
|
392
|
-
error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
|
|
393
|
-
});
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
const client = await ctx.pool.connect();
|
|
397
|
-
try {
|
|
398
|
-
await client.query('BEGIN READ ONLY');
|
|
399
|
-
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
400
|
-
const started = Date.now();
|
|
401
|
-
const result = await client.query(rawSql);
|
|
402
|
-
const elapsedMs = Date.now() - started;
|
|
403
|
-
await client.query('COMMIT');
|
|
404
|
-
sendJson(res, 200, {
|
|
405
|
-
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
406
|
-
rows: result.rows.map((r) => serializeRow(r)),
|
|
407
|
-
rowCount: result.rowCount ?? result.rows.length,
|
|
408
|
-
elapsedMs,
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
catch (err) {
|
|
412
|
-
try {
|
|
413
|
-
await client.query('ROLLBACK');
|
|
414
|
-
}
|
|
415
|
-
catch {
|
|
416
|
-
/* ignore */
|
|
417
|
-
}
|
|
418
|
-
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
419
|
-
}
|
|
420
|
-
finally {
|
|
421
|
-
client.release();
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
// ---------------------------------------------------------------------------
|
|
425
396
|
// API: /api/builder — Turbine ORM findMany spec runner
|
|
426
397
|
// ---------------------------------------------------------------------------
|
|
427
398
|
async function apiBuilder(req, res, ctx) {
|
|
@@ -429,7 +400,7 @@ async function apiBuilder(req, res, ctx) {
|
|
|
429
400
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
430
401
|
const args = (body?.args ?? {});
|
|
431
402
|
if (!tableName || !ctx.metadata.tables[tableName]) {
|
|
432
|
-
sendJson(res, 400, { error:
|
|
403
|
+
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
433
404
|
return;
|
|
434
405
|
}
|
|
435
406
|
let deferred;
|
|
@@ -486,7 +457,9 @@ function loadSavedQueries(ctx) {
|
|
|
486
457
|
const parsed = JSON.parse(raw);
|
|
487
458
|
if (!parsed.queries || !Array.isArray(parsed.queries))
|
|
488
459
|
return { version: 1, queries: [] };
|
|
489
|
-
|
|
460
|
+
// Drop any legacy raw-SQL entries — Studio is builder-only now.
|
|
461
|
+
const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
|
|
462
|
+
return { version: 1, queries };
|
|
490
463
|
}
|
|
491
464
|
catch {
|
|
492
465
|
return { version: 1, queries: [] };
|
|
@@ -509,27 +482,17 @@ async function apiCreateSavedQuery(req, res, ctx) {
|
|
|
509
482
|
const body = await readJsonBody(req);
|
|
510
483
|
const table = typeof body?.table === 'string' ? body.table : '';
|
|
511
484
|
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
512
|
-
const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
|
|
513
485
|
if (!table || !ctx.metadata.tables[table]) {
|
|
514
|
-
sendJson(res, 400, { error:
|
|
486
|
+
sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
|
|
515
487
|
return;
|
|
516
488
|
}
|
|
517
489
|
if (!name) {
|
|
518
490
|
sendJson(res, 400, { error: 'name is required' });
|
|
519
491
|
return;
|
|
520
492
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
const sql = kind === 'sql' && typeof body?.sql === 'string' ? body.sql : undefined;
|
|
526
|
-
const args = kind === 'builder' ? body?.args : undefined;
|
|
527
|
-
if (kind === 'sql' && !sql) {
|
|
528
|
-
sendJson(res, 400, { error: 'sql is required for kind=sql' });
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
if (kind === 'sql' && sql && !isReadOnlyStatement(sql)) {
|
|
532
|
-
sendJson(res, 400, { error: 'saved sql must be SELECT/WITH only' });
|
|
493
|
+
// Studio only persists visual-builder queries (no raw SQL surface).
|
|
494
|
+
if (body?.kind !== 'builder') {
|
|
495
|
+
sendJson(res, 400, { error: 'kind must be "builder"' });
|
|
533
496
|
return;
|
|
534
497
|
}
|
|
535
498
|
const data = loadSavedQueries(ctx);
|
|
@@ -537,9 +500,8 @@ async function apiCreateSavedQuery(req, res, ctx) {
|
|
|
537
500
|
id: (0, node_crypto_1.randomUUID)(),
|
|
538
501
|
table,
|
|
539
502
|
name,
|
|
540
|
-
kind,
|
|
541
|
-
|
|
542
|
-
args,
|
|
503
|
+
kind: 'builder',
|
|
504
|
+
args: body?.args,
|
|
543
505
|
createdAt: new Date().toISOString(),
|
|
544
506
|
};
|
|
545
507
|
data.queries.push(entry);
|
package/dist/cjs/client.js
CHANGED
|
@@ -33,6 +33,9 @@ const errors_js_1 = require("./errors.js");
|
|
|
33
33
|
const observe_js_1 = require("./observe.js");
|
|
34
34
|
const pipeline_js_1 = require("./pipeline.js");
|
|
35
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");
|
|
36
39
|
async function withRetry(fn, options) {
|
|
37
40
|
const maxAttempts = options?.maxAttempts ?? 3;
|
|
38
41
|
const baseDelay = options?.baseDelay ?? 50;
|
|
@@ -64,6 +67,13 @@ const ISOLATION_LEVELS = {
|
|
|
64
67
|
RepeatableRead: 'REPEATABLE READ',
|
|
65
68
|
Serializable: 'SERIALIZABLE',
|
|
66
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_]*)?$/;
|
|
67
77
|
// ---------------------------------------------------------------------------
|
|
68
78
|
// TransactionClient — provides typed table accessors within a transaction
|
|
69
79
|
// ---------------------------------------------------------------------------
|
|
@@ -197,6 +207,8 @@ class TurbineClient {
|
|
|
197
207
|
errorMessagesSafe;
|
|
198
208
|
/** True when Turbine created the pool and is responsible for tearing it down */
|
|
199
209
|
ownsPool = true;
|
|
210
|
+
/** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
|
|
211
|
+
activeSubscriptions = new Set();
|
|
200
212
|
constructor(config = {}, schema) {
|
|
201
213
|
/**
|
|
202
214
|
* Parse int8 (bigint, OID 20) as JavaScript number instead of string.
|
|
@@ -468,6 +480,40 @@ class TurbineClient {
|
|
|
468
480
|
throw (0, errors_js_1.wrapPgError)(err);
|
|
469
481
|
}
|
|
470
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
|
+
}
|
|
471
517
|
// -------------------------------------------------------------------------
|
|
472
518
|
// Transaction support (raw — legacy)
|
|
473
519
|
// -------------------------------------------------------------------------
|
|
@@ -550,6 +596,21 @@ class TurbineClient {
|
|
|
550
596
|
beginSQL += ` ISOLATION LEVEL ${level}`;
|
|
551
597
|
}
|
|
552
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
|
+
}
|
|
553
614
|
// Create the transaction client with typed table accessors
|
|
554
615
|
const tx = new TransactionClient(client, this.schema, this.middlewares, this.queryOptions);
|
|
555
616
|
// Dynamically attach table accessors to tx
|
|
@@ -621,6 +682,94 @@ class TurbineClient {
|
|
|
621
682
|
releaseOnce();
|
|
622
683
|
}
|
|
623
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
|
+
}
|
|
624
773
|
// -------------------------------------------------------------------------
|
|
625
774
|
// Retry — automatic retry for retryable errors (deadlock, serialization)
|
|
626
775
|
// -------------------------------------------------------------------------
|
|
@@ -668,6 +817,21 @@ class TurbineClient {
|
|
|
668
817
|
* method is a no-op — the caller is responsible for the pool's lifecycle.
|
|
669
818
|
*/
|
|
670
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
|
+
}
|
|
671
835
|
if (!this.ownsPool) {
|
|
672
836
|
if (this.logging) {
|
|
673
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
|
package/dist/cjs/index.js
CHANGED
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
exports.
|
|
38
|
-
exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = void 0;
|
|
37
|
+
exports.column = exports.ColumnBuilder = exports.applyManyToManyRelations = exports.snakeToPascal = exports.snakeToCamel = exports.singularize = exports.pgTypeToTs = exports.pgArrayType = exports.normalizeKeyColumns = exports.isDateType = exports.camelToSnake = exports.validateChannel = exports.QueryInterface = exports.pipelineSupported = exports.executePipeline = exports.hasRelationFields = exports.executeNestedUpdate = exports.executeNestedCreate = exports.introspect = exports.generate = exports.wrapPgError = exports.ValidationError = exports.UniqueConstraintError = exports.TurbineErrorCode = exports.TurbineError = exports.TimeoutError = exports.setErrorMessageMode = exports.SerializationFailureError = exports.RelationError = exports.PipelineError = exports.OptimisticLockError = exports.NotNullViolationError = exports.NotFoundError = exports.MigrationError = exports.getErrorMessageMode = exports.ForeignKeyError = exports.ExclusionConstraintError = exports.DeadlockError = exports.ConnectionError = exports.CircularRelationError = exports.CheckConstraintError = exports.postgresDialect = exports.withRetry = exports.TurbineClient = exports.TransactionClient = exports.yugabytedb = exports.timescale = exports.postgresql = exports.cockroachdb = exports.alloydb = void 0;
|
|
38
|
+
exports.TypedSqlQuery = exports.buildTypedSql = exports.turbineHttp = exports.schemaToSQLString = exports.schemaToSQL = exports.schemaPush = exports.schemaDiff = exports.table = exports.defineSchema = void 0;
|
|
39
39
|
var index_js_1 = require("./adapters/index.js");
|
|
40
40
|
Object.defineProperty(exports, "alloydb", { enumerable: true, get: function () { return index_js_1.alloydb; } });
|
|
41
41
|
Object.defineProperty(exports, "cockroachdb", { enumerable: true, get: function () { return index_js_1.cockroachdb; } });
|
|
@@ -90,6 +90,9 @@ Object.defineProperty(exports, "pipelineSupported", { enumerable: true, get: fun
|
|
|
90
90
|
// Query builder
|
|
91
91
|
var index_js_2 = require("./query/index.js");
|
|
92
92
|
Object.defineProperty(exports, "QueryInterface", { enumerable: true, get: function () { return index_js_2.QueryInterface; } });
|
|
93
|
+
// Realtime — LISTEN/NOTIFY pub/sub
|
|
94
|
+
var realtime_js_1 = require("./realtime.js");
|
|
95
|
+
Object.defineProperty(exports, "validateChannel", { enumerable: true, get: function () { return realtime_js_1.validateChannel; } });
|
|
93
96
|
// Schema utilities
|
|
94
97
|
var schema_js_1 = require("./schema.js");
|
|
95
98
|
Object.defineProperty(exports, "camelToSnake", { enumerable: true, get: function () { return schema_js_1.camelToSnake; } });
|
|
@@ -102,6 +105,7 @@ Object.defineProperty(exports, "snakeToCamel", { enumerable: true, get: function
|
|
|
102
105
|
Object.defineProperty(exports, "snakeToPascal", { enumerable: true, get: function () { return schema_js_1.snakeToPascal; } });
|
|
103
106
|
// Schema builder — define schemas in TypeScript
|
|
104
107
|
var schema_builder_js_1 = require("./schema-builder.js");
|
|
108
|
+
Object.defineProperty(exports, "applyManyToManyRelations", { enumerable: true, get: function () { return schema_builder_js_1.applyManyToManyRelations; } });
|
|
105
109
|
Object.defineProperty(exports, "ColumnBuilder", { enumerable: true, get: function () { return schema_builder_js_1.ColumnBuilder; } });
|
|
106
110
|
Object.defineProperty(exports, "column", { enumerable: true, get: function () { return schema_builder_js_1.column; } });
|
|
107
111
|
Object.defineProperty(exports, "defineSchema", { enumerable: true, get: function () { return schema_builder_js_1.defineSchema; } });
|
|
@@ -116,3 +120,7 @@ Object.defineProperty(exports, "schemaToSQLString", { enumerable: true, get: fun
|
|
|
116
120
|
// Serverless / edge factory
|
|
117
121
|
var serverless_js_1 = require("./serverless.js");
|
|
118
122
|
Object.defineProperty(exports, "turbineHttp", { enumerable: true, get: function () { return serverless_js_1.turbineHttp; } });
|
|
123
|
+
// Typed raw SQL — Turbine's TypedSQL escape hatch
|
|
124
|
+
var typed_sql_js_1 = require("./typed-sql.js");
|
|
125
|
+
Object.defineProperty(exports, "buildTypedSql", { enumerable: true, get: function () { return typed_sql_js_1.buildTypedSql; } });
|
|
126
|
+
Object.defineProperty(exports, "TypedSqlQuery", { enumerable: true, get: function () { return typed_sql_js_1.TypedSqlQuery; } });
|
package/dist/cjs/introspect.js
CHANGED
|
@@ -281,6 +281,87 @@ async function introspect(options) {
|
|
|
281
281
|
referenceKey,
|
|
282
282
|
};
|
|
283
283
|
}
|
|
284
|
+
// ----- Conservative many-to-many auto-detection (PURELY ADDITIVE) -----
|
|
285
|
+
//
|
|
286
|
+
// Auto-detecting m2m is a footgun: any table with two FKs *looks* like a
|
|
287
|
+
// junction, but a `enrollments(student_id, course_id, grade, enrolled_at)`
|
|
288
|
+
// table is a first-class entity, not a join table. Prisma and Drizzle both
|
|
289
|
+
// require explicit m2m declaration for exactly this reason.
|
|
290
|
+
//
|
|
291
|
+
// We only treat a table J as a PURE junction when ALL of these hold:
|
|
292
|
+
// 1. J's primary key is exactly two columns.
|
|
293
|
+
// 2. J has exactly two FKs, each single-column.
|
|
294
|
+
// 3. Each FK's source column is one of J's two PK columns (the PK *is* the
|
|
295
|
+
// two FK columns — no surrogate PK, no extra identity).
|
|
296
|
+
// 4. The two FKs target two DISTINCT tables (A and B).
|
|
297
|
+
// 5. J has no columns beyond those two FK/PK columns (no payload columns
|
|
298
|
+
// like `grade` or `created_at`).
|
|
299
|
+
//
|
|
300
|
+
// For such a J linking A and B we ADD a `manyToMany` relation on A → B and
|
|
301
|
+
// symmetrically on B → A, both routed `through` J. The existing belongsTo /
|
|
302
|
+
// hasMany relations derived from J's FKs are left untouched — this block
|
|
303
|
+
// never removes or renames anything. If the chosen relation name already
|
|
304
|
+
// exists on the source table (e.g. another relation grabbed it), we SKIP to
|
|
305
|
+
// stay additive.
|
|
306
|
+
for (const tableName of tableNames) {
|
|
307
|
+
const pk = pkByTable.get(tableName) ?? [];
|
|
308
|
+
if (pk.length !== 2)
|
|
309
|
+
continue;
|
|
310
|
+
// FKs whose source is this table.
|
|
311
|
+
const tableFks = foreignKeys.filter((fk) => fk.sourceTable === tableName);
|
|
312
|
+
if (tableFks.length !== 2)
|
|
313
|
+
continue;
|
|
314
|
+
// Both FKs must be single-column.
|
|
315
|
+
if (tableFks.some((fk) => fk.sourceColumns.length !== 1))
|
|
316
|
+
continue;
|
|
317
|
+
const fkCols = tableFks.map((fk) => fk.sourceColumns[0]);
|
|
318
|
+
const pkSet = new Set(pk);
|
|
319
|
+
// Both FK columns must be the PK columns (and vice-versa).
|
|
320
|
+
if (!fkCols.every((c) => pkSet.has(c)))
|
|
321
|
+
continue;
|
|
322
|
+
if (new Set(fkCols).size !== 2)
|
|
323
|
+
continue;
|
|
324
|
+
// Two DISTINCT target tables.
|
|
325
|
+
const [fkA, fkB] = tableFks;
|
|
326
|
+
if (fkA.targetTable === fkB.targetTable)
|
|
327
|
+
continue;
|
|
328
|
+
// No payload columns: J's columns are exactly the two FK/PK columns.
|
|
329
|
+
const jCols = (columnsByTable.get(tableName) ?? []).map((c) => c.name);
|
|
330
|
+
if (jCols.length !== 2)
|
|
331
|
+
continue;
|
|
332
|
+
// For each direction, the m2m `referenceKey` is the *targeted* table's
|
|
333
|
+
// referenced column(s); the junction's sourceKey is the FK column pointing
|
|
334
|
+
// to that table; the targetKey is the FK column pointing to the OTHER table.
|
|
335
|
+
const addM2M = (self, other) => {
|
|
336
|
+
const sourceTbl = self.targetTable; // A
|
|
337
|
+
const targetTbl = other.targetTable; // B
|
|
338
|
+
const relName = (0, schema_js_1.snakeToCamel)(targetTbl); // plural table name → e.g. "tags"
|
|
339
|
+
if (!relationsByTable.has(sourceTbl))
|
|
340
|
+
relationsByTable.set(sourceTbl, {});
|
|
341
|
+
const existing = relationsByTable.get(sourceTbl);
|
|
342
|
+
// Additive-only: never clobber an existing relation name.
|
|
343
|
+
if (existing[relName])
|
|
344
|
+
return;
|
|
345
|
+
existing[relName] = {
|
|
346
|
+
type: 'manyToMany',
|
|
347
|
+
name: relName,
|
|
348
|
+
from: sourceTbl,
|
|
349
|
+
to: targetTbl,
|
|
350
|
+
// referenceKey = A's referenced column(s) that J's sourceKey points at.
|
|
351
|
+
referenceKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
352
|
+
// foreignKey is unused for m2m correlation but kept for shape parity
|
|
353
|
+
// (mirrors the source-side reference for back-compat consumers).
|
|
354
|
+
foreignKey: self.targetColumns.length === 1 ? self.targetColumns[0] : self.targetColumns,
|
|
355
|
+
through: {
|
|
356
|
+
table: tableName,
|
|
357
|
+
sourceKey: self.sourceColumns[0], // J col → A
|
|
358
|
+
targetKey: other.sourceColumns[0], // J col → B
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
addM2M(fkA, fkB); // A → B
|
|
363
|
+
addM2M(fkB, fkA); // B → A
|
|
364
|
+
}
|
|
284
365
|
// ----- Assemble TableMetadata for each table -----
|
|
285
366
|
const tables = {};
|
|
286
367
|
for (const tableName of tableNames) {
|