turbine-orm 0.49.0 → 0.50.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 +122 -39
- package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
- package/dist/cjs/adapters/index.d.ts +110 -0
- package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
- package/dist/cjs/cli/config.d.ts +181 -0
- package/dist/cjs/cli/config.js +32 -6
- package/dist/cjs/cli/destructive.d.ts +38 -0
- package/dist/cjs/cli/index.d.ts +359 -0
- package/dist/cjs/cli/index.js +228 -56
- package/dist/cjs/cli/loader.d.ts +61 -0
- package/dist/cjs/cli/mcp.d.ts +42 -0
- package/dist/cjs/cli/migrate.d.ts +356 -0
- package/dist/cjs/cli/migrate.js +131 -40
- package/dist/cjs/cli/observe-ui.d.ts +1 -0
- package/dist/cjs/cli/observe-ui.js +14 -5
- package/dist/cjs/cli/observe.d.ts +25 -0
- package/dist/cjs/cli/observe.js +49 -12
- package/dist/cjs/cli/pii-tags.d.ts +53 -0
- package/dist/cjs/cli/prisma-report.d.ts +33 -0
- package/dist/cjs/cli/prisma-report.js +73 -0
- package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
- package/dist/cjs/cli/prisma-resolve.js +1 -0
- package/dist/cjs/cli/prisma-schema.d.ts +176 -0
- package/dist/cjs/cli/prisma-schema.js +82 -4
- package/dist/cjs/cli/rate-limit.d.ts +32 -0
- package/dist/cjs/cli/rate-limit.js +45 -0
- package/dist/cjs/cli/studio-demo.d.ts +43 -0
- package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
- package/dist/cjs/cli/studio.d.ts +207 -0
- package/dist/cjs/cli/studio.js +136 -71
- package/dist/cjs/cli/ui.d.ts +73 -0
- package/dist/cjs/cli/ui.js +51 -9
- package/dist/cjs/client.d.ts +837 -0
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/dialect.d.ts +516 -0
- package/dist/cjs/dialect.js +37 -12
- package/dist/cjs/errors.d.ts +370 -0
- package/dist/cjs/generate.d.ts +137 -0
- package/dist/cjs/generate.js +39 -6
- package/dist/cjs/index-advisor.d.ts +153 -0
- package/dist/cjs/index-stats.d.ts +384 -0
- package/dist/cjs/index.d.ts +55 -0
- package/dist/cjs/index.js +7 -2
- package/dist/cjs/introspect.d.ts +269 -0
- package/dist/cjs/mssql.d.ts +232 -0
- package/dist/cjs/mssql.js +6 -0
- package/dist/cjs/mysql.d.ts +173 -0
- package/dist/cjs/mysql.js +16 -0
- package/dist/cjs/nested-write.d.ts +96 -0
- package/dist/cjs/nested-write.js +414 -24
- package/dist/cjs/observe.d.ts +115 -0
- package/dist/cjs/optional-peer-import.d.cts +72 -0
- package/dist/cjs/pipeline-submittable.d.ts +93 -0
- package/dist/cjs/pipeline.d.ts +71 -0
- package/dist/cjs/powdb-introspect.d.ts +84 -0
- package/dist/cjs/powdb.d.ts +931 -0
- package/dist/cjs/powdb.js +106 -21
- package/dist/cjs/powql.d.ts +592 -0
- package/dist/cjs/powql.js +42 -6
- package/dist/cjs/prisma-compat.d.ts +283 -0
- package/dist/cjs/prisma-compat.js +167 -9
- package/dist/cjs/query/aggregates.d.ts +92 -0
- package/dist/cjs/query/aggregates.js +7 -3
- package/dist/cjs/query/batched-loader.d.ts +193 -0
- package/dist/cjs/query/builder.d.ts +849 -0
- package/dist/cjs/query/builder.js +571 -65
- package/dist/cjs/query/compound-unique.d.ts +51 -0
- package/dist/cjs/query/deferred.d.ts +223 -0
- package/dist/cjs/query/filters.d.ts +201 -0
- package/dist/cjs/query/index.d.ts +14 -0
- package/dist/cjs/query/index.js +6 -1
- package/dist/cjs/query/relations.d.ts +609 -0
- package/dist/cjs/query/relations.js +693 -46
- package/dist/cjs/query/types.d.ts +1300 -0
- package/dist/cjs/query/utils.d.ts +209 -0
- package/dist/cjs/query/utils.js +208 -1
- package/dist/cjs/query/warn-registry.d.ts +68 -0
- package/dist/cjs/query/warn-registry.js +9 -0
- package/dist/cjs/query/where-compile.d.ts +139 -0
- package/dist/cjs/query/where.d.ts +548 -0
- package/dist/cjs/query/where.js +58 -22
- package/dist/cjs/query/writes.d.ts +172 -0
- package/dist/cjs/query/writes.js +105 -12
- package/dist/cjs/realtime.d.ts +70 -0
- package/dist/cjs/schema-builder.d.ts +354 -0
- package/dist/cjs/schema-metadata.d.ts +83 -0
- package/dist/cjs/schema-sql.d.ts +217 -0
- package/dist/cjs/schema-sql.js +23 -5
- package/dist/cjs/schema.d.ts +356 -0
- package/dist/cjs/schema.js +125 -0
- package/dist/cjs/seed.d.ts +15 -0
- package/dist/cjs/serverless.d.ts +142 -0
- package/dist/cjs/sqlite.d.ts +143 -0
- package/dist/cjs/sqlite.js +4 -0
- package/dist/cjs/typed-sql.d.ts +102 -0
- package/dist/cli/config.d.ts +18 -4
- package/dist/cli/config.js +31 -6
- package/dist/cli/index.d.ts +123 -0
- package/dist/cli/index.js +223 -58
- package/dist/cli/migrate.d.ts +59 -10
- package/dist/cli/migrate.js +128 -41
- package/dist/cli/observe-ui.d.ts +1 -1
- package/dist/cli/observe-ui.js +14 -5
- package/dist/cli/observe.d.ts +7 -1
- package/dist/cli/observe.js +48 -12
- package/dist/cli/prisma-report.d.ts +14 -0
- package/dist/cli/prisma-report.js +72 -0
- package/dist/cli/prisma-resolve.d.ts +6 -0
- package/dist/cli/prisma-resolve.js +1 -0
- package/dist/cli/prisma-schema.d.ts +62 -2
- package/dist/cli/prisma-schema.js +81 -4
- package/dist/cli/rate-limit.d.ts +32 -0
- package/dist/cli/rate-limit.js +40 -0
- package/dist/cli/studio.d.ts +5 -5
- package/dist/cli/studio.js +135 -70
- package/dist/cli/ui.d.ts +1 -1
- package/dist/cli/ui.js +51 -9
- package/dist/client.d.ts +40 -0
- package/dist/client.js +3 -0
- package/dist/dialect.d.ts +17 -1
- package/dist/dialect.js +37 -12
- package/dist/generate.js +40 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mssql.js +6 -0
- package/dist/mysql.js +16 -0
- package/dist/nested-write.d.ts +2 -0
- package/dist/nested-write.js +415 -25
- package/dist/powdb.d.ts +4 -2
- package/dist/powdb.js +106 -21
- package/dist/powql.d.ts +5 -0
- package/dist/powql.js +42 -6
- package/dist/prisma-compat.d.ts +2 -0
- package/dist/prisma-compat.js +166 -8
- package/dist/query/aggregates.js +7 -3
- package/dist/query/builder.d.ts +292 -21
- package/dist/query/builder.js +570 -64
- package/dist/query/deferred.d.ts +39 -0
- package/dist/query/index.d.ts +1 -1
- package/dist/query/index.js +1 -1
- package/dist/query/relations.d.ts +173 -5
- package/dist/query/relations.js +688 -47
- package/dist/query/types.d.ts +123 -39
- package/dist/query/utils.d.ts +116 -0
- package/dist/query/utils.js +198 -0
- package/dist/query/warn-registry.d.ts +9 -0
- package/dist/query/warn-registry.js +9 -0
- package/dist/query/where.d.ts +38 -1
- package/dist/query/where.js +58 -23
- package/dist/query/writes.d.ts +42 -1
- package/dist/query/writes.js +104 -13
- package/dist/schema-sql.d.ts +14 -0
- package/dist/schema-sql.js +23 -5
- package/dist/schema.d.ts +38 -0
- package/dist/schema.js +123 -0
- package/dist/sqlite.js +4 -0
- package/package.json +77 -28
package/dist/cli/studio.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* turbine-orm CLI
|
|
2
|
+
* turbine-orm CLI: Studio
|
|
3
3
|
*
|
|
4
4
|
* A local web UI for browsing databases, exploring relations, and composing
|
|
5
5
|
* queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
|
|
@@ -46,7 +46,11 @@ import pg from 'pg';
|
|
|
46
46
|
import { ValidationError } from '../errors.js';
|
|
47
47
|
import { introspect } from '../introspect.js';
|
|
48
48
|
import { QueryInterface, quoteIdent } from '../query/index.js';
|
|
49
|
+
// `ownLookup` is not re-exported from the query barrel, so it is imported from
|
|
50
|
+
// its defining leaf module rather than duplicated here.
|
|
51
|
+
import { ownLookup } from '../query/utils.js';
|
|
49
52
|
import { applyPiiTags, loadPiiTags } from './pii-tags.js';
|
|
53
|
+
import { callerKey, checkRateLimit } from './rate-limit.js';
|
|
50
54
|
import { createDemoContext } from './studio-demo.js';
|
|
51
55
|
import { STUDIO_HTML } from './studio-ui.generated.js';
|
|
52
56
|
// ---------------------------------------------------------------------------
|
|
@@ -84,10 +88,10 @@ export async function startStudio(options) {
|
|
|
84
88
|
// seam in client.ts); the cast keeps one typed pool field for both modes.
|
|
85
89
|
pool = new pg.Pool({
|
|
86
90
|
connectionString: options.url,
|
|
87
|
-
max: 4, // small pool
|
|
91
|
+
max: 4, // small pool, single-user tool
|
|
88
92
|
idleTimeoutMillis: 10_000,
|
|
89
93
|
});
|
|
90
|
-
// Verify connectivity before starting the server
|
|
94
|
+
// Verify connectivity before starting the server, fail fast.
|
|
91
95
|
const probe = await pool.connect();
|
|
92
96
|
try {
|
|
93
97
|
await probe.query('SELECT 1');
|
|
@@ -182,7 +186,7 @@ function originFor(host, port) {
|
|
|
182
186
|
// ---------------------------------------------------------------------------
|
|
183
187
|
export async function handleRequest(req, res, ctx) {
|
|
184
188
|
const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
|
|
185
|
-
// CORS: not needed
|
|
189
|
+
// CORS: not needed, same-origin only. Explicitly refuse cross-origin.
|
|
186
190
|
const origin = req.headers.origin;
|
|
187
191
|
if (origin && origin !== expectedOrigin) {
|
|
188
192
|
sendJson(res, 403, { error: 'cross-origin requests not allowed' });
|
|
@@ -211,26 +215,32 @@ export async function handleRequest(req, res, ctx) {
|
|
|
211
215
|
sendHtml(res, 200, STUDIO_HTML, cspNonce());
|
|
212
216
|
return;
|
|
213
217
|
}
|
|
214
|
-
// Favicon
|
|
218
|
+
// Favicon: answered before the auth gate so the browser's automatic request
|
|
215
219
|
// doesn't 401/404 on every load. No icon body needed (204).
|
|
216
220
|
if (pathname === '/favicon.ico') {
|
|
217
221
|
res.writeHead(204, { 'Content-Length': '0' });
|
|
218
222
|
res.end();
|
|
219
223
|
return;
|
|
220
224
|
}
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
const
|
|
225
|
+
// Rate limiting: 100 requests per 60 seconds, applied BEFORE the auth gate so
|
|
226
|
+
// an unauthenticated caller is throttled too (it used to run after, leaving
|
|
227
|
+
// token guessing unmetered). Keyed per caller rather than on `ctx.authToken`:
|
|
228
|
+
// that token is a single constant, so keying on it made one global bucket that
|
|
229
|
+
// every session shared. Authenticated and unauthenticated callers get separate
|
|
230
|
+
// buckets so a probing client cannot spend the real session's budget.
|
|
231
|
+
const authorized = isAuthorized(req, ctx.authToken);
|
|
232
|
+
const rateLimitResult = checkRateLimit(ctx.rateLimiter, `${authorized ? 'session' : 'anon'}:${callerKey(req)}`);
|
|
228
233
|
if (!rateLimitResult.allowed) {
|
|
229
234
|
const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000);
|
|
230
235
|
res.setHeader('Retry-After', String(retryAfter));
|
|
231
236
|
sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter });
|
|
232
237
|
return;
|
|
233
238
|
}
|
|
239
|
+
// API routes: all require auth.
|
|
240
|
+
if (!authorized) {
|
|
241
|
+
sendJson(res, 401, { error: 'unauthorized: use the URL printed in the terminal' });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
234
244
|
if (pathname === '/api/schema' && req.method === 'GET') {
|
|
235
245
|
return apiSchema(res, ctx);
|
|
236
246
|
}
|
|
@@ -313,32 +323,15 @@ function isAuthorized(req, expectedToken) {
|
|
|
313
323
|
return true;
|
|
314
324
|
}
|
|
315
325
|
const cookieHeader = req.headers.cookie ?? '';
|
|
316
|
-
|
|
326
|
+
// Anchored on a cookie boundary: unanchored, a decoy cookie named
|
|
327
|
+
// `x_turbine_studio_token=...` matched first and the real cookie was never
|
|
328
|
+
// compared, locking the legitimate session out of its own Studio.
|
|
329
|
+
const match = /(?:^|;\s*)turbine_studio_token=([a-f0-9]+)/.exec(cookieHeader);
|
|
317
330
|
if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
|
|
318
331
|
return true;
|
|
319
332
|
}
|
|
320
333
|
return false;
|
|
321
334
|
}
|
|
322
|
-
// ---------------------------------------------------------------------------
|
|
323
|
-
// Rate limiting
|
|
324
|
-
// ---------------------------------------------------------------------------
|
|
325
|
-
const RATE_LIMIT_WINDOW_MS = 60_000; // 60 seconds
|
|
326
|
-
const RATE_LIMIT_MAX_REQUESTS = 100;
|
|
327
|
-
function checkRateLimit(limiter, token) {
|
|
328
|
-
const now = Date.now();
|
|
329
|
-
const entry = limiter.get(token);
|
|
330
|
-
if (!entry || now >= entry.resetAt) {
|
|
331
|
-
// Start a new window
|
|
332
|
-
const resetAt = now + RATE_LIMIT_WINDOW_MS;
|
|
333
|
-
limiter.set(token, { count: 1, resetAt });
|
|
334
|
-
return { allowed: true, resetAt };
|
|
335
|
-
}
|
|
336
|
-
entry.count++;
|
|
337
|
-
if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
|
|
338
|
-
return { allowed: false, resetAt: entry.resetAt };
|
|
339
|
-
}
|
|
340
|
-
return { allowed: true, resetAt: entry.resetAt };
|
|
341
|
-
}
|
|
342
335
|
function constantTimeEqual(a, b) {
|
|
343
336
|
// Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
|
|
344
337
|
// This makes the comparison constant-length (timingSafeEqual never throws on a
|
|
@@ -418,10 +411,6 @@ async function apiSchema(res, ctx) {
|
|
|
418
411
|
demo: ctx.demo === true,
|
|
419
412
|
});
|
|
420
413
|
}
|
|
421
|
-
/** Own-property lookup: a key like `constructor` must not resolve off the prototype. */
|
|
422
|
-
function ownLookup(map, key) {
|
|
423
|
-
return Object.hasOwn(map, key) ? map[key] : undefined;
|
|
424
|
-
}
|
|
425
414
|
/** True when `columnName` on `table` is PII-tagged and currently redacted. */
|
|
426
415
|
function isRedactedColumn(table, columnName, showPii) {
|
|
427
416
|
if (showPii)
|
|
@@ -507,7 +496,9 @@ export function relationLinksForTable(table, metadata, showPii) {
|
|
|
507
496
|
// API: /api/tables/:name?limit=&offset=&orderBy=&dir=
|
|
508
497
|
// ---------------------------------------------------------------------------
|
|
509
498
|
export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
510
|
-
|
|
499
|
+
// Own-property lookup: `/api/tables/constructor` must 404 like any other
|
|
500
|
+
// unknown table rather than resolving Object.prototype and crashing.
|
|
501
|
+
const table = ownLookup(ctx.metadata.tables, rawTableName);
|
|
511
502
|
if (!table) {
|
|
512
503
|
sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
|
|
513
504
|
return;
|
|
@@ -516,7 +507,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
516
507
|
const offset = clampInt(params.get('offset'), 0, 0, 10_000_000);
|
|
517
508
|
const orderByRaw = params.get('orderBy');
|
|
518
509
|
const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
519
|
-
// orderBy
|
|
510
|
+
// orderBy: accept either the Postgres column name (snake) or the TS field
|
|
520
511
|
// name (camel). Always emit the Postgres column in the SQL.
|
|
521
512
|
// When redaction is on, PII columns are excluded from orderBy (and from the
|
|
522
513
|
// search OR-set below): a redacted value must not be inferable through sort
|
|
@@ -543,7 +534,7 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
543
534
|
// Per-column filters: `filters` is a JSON array of { column, op, value }
|
|
544
535
|
// composed by the Data tab's filter bar. Every column is validated against
|
|
545
536
|
// the metadata, every op against a fixed whitelist, and every value is a
|
|
546
|
-
// parameter
|
|
537
|
+
// parameter, same discipline as the builder route.
|
|
547
538
|
let filters;
|
|
548
539
|
try {
|
|
549
540
|
filters = parseTableFilters(params.get('filters'), table, redactedPii);
|
|
@@ -682,7 +673,7 @@ const FILTER_OPS = {
|
|
|
682
673
|
lte: '<=',
|
|
683
674
|
};
|
|
684
675
|
const FILTER_OP_NAMES = new Set([...Object.keys(FILTER_OPS), 'contains', 'isNull', 'notNull']);
|
|
685
|
-
/** Hard cap on filter clauses per request
|
|
676
|
+
/** Hard cap on filter clauses per request; the UI never composes more. */
|
|
686
677
|
const MAX_TABLE_FILTERS = 10;
|
|
687
678
|
/**
|
|
688
679
|
* Parse + validate the Data tab's `filters` query param. Throws with a clear
|
|
@@ -742,53 +733,127 @@ function parseTableFilters(raw, table, redactedPii) {
|
|
|
742
733
|
return out;
|
|
743
734
|
}
|
|
744
735
|
// ---------------------------------------------------------------------------
|
|
745
|
-
// API: /api/builder
|
|
736
|
+
// API: /api/builder: Turbine ORM findMany spec runner
|
|
746
737
|
// ---------------------------------------------------------------------------
|
|
738
|
+
/** Relation-filter wrappers whose body is a clause against the relation's target. */
|
|
739
|
+
const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
|
|
740
|
+
/**
|
|
741
|
+
* Recursion bound for the PII guard walk.
|
|
742
|
+
*
|
|
743
|
+
* This number is NOT the security boundary: reaching it REFUSES the request
|
|
744
|
+
* (see `assertWithinDepth`). It only bounds the walk on a pathological payload.
|
|
745
|
+
* It sits well above the query builder's own depth-10 relation cap
|
|
746
|
+
* (`CircularRelationError`) and far above any hand-composed boolean nesting, so
|
|
747
|
+
* nothing the builder would accept is refused here for depth alone.
|
|
748
|
+
*/
|
|
749
|
+
const PII_GUARD_MAX_DEPTH = 32;
|
|
747
750
|
/**
|
|
748
|
-
* Refuse a builder query that FILTERS or
|
|
751
|
+
* Refuse a builder query that FILTERS, SORTS, PAGES, or DE-DUPLICATES on a
|
|
752
|
+
* redacted PII column.
|
|
749
753
|
*
|
|
750
754
|
* Redacting the cells is not enough on its own: `where: { email: { startsWith:
|
|
751
755
|
* 'a' } }` answers a question about the hidden value, and so does an `isNull`,
|
|
752
|
-
* and so does an `orderBy`.
|
|
753
|
-
* (`
|
|
754
|
-
*
|
|
756
|
+
* and so does an `orderBy`. So does a `cursor`, which compiles into a WHERE
|
|
757
|
+
* range comparison (`"email" > $1`) and gives a clean binary search over the
|
|
758
|
+
* byte range even though every returned cell is redacted. So, more weakly, does
|
|
759
|
+
* `distinct`, which reports the cardinality of the hidden values. The Data tab
|
|
760
|
+
* already refuses its own equivalents (`parseTableFilters`); the builder route
|
|
761
|
+
* has to refuse all of them too.
|
|
762
|
+
*
|
|
763
|
+
* Walks the whole args tree: `where`, `orderBy` (object AND array form),
|
|
764
|
+
* `cursor`, `distinct`, boolean combinators, relation filters, and each `with`
|
|
765
|
+
* level against that relation's target table. `select` / `omit` are NOT
|
|
766
|
+
* refused: they return values, and those values are redacted on the way out.
|
|
755
767
|
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
* the
|
|
768
|
+
* Every depth check FAILS CLOSED. Returning quietly at the cap (what this used
|
|
769
|
+
* to do) meant padding a payload with, for example, eleven nested `NOT`
|
|
770
|
+
* wrappers walked the guard off the end of its own recursion and then handed
|
|
771
|
+
* the untouched predicate to the builder.
|
|
760
772
|
*/
|
|
761
773
|
function assertNoPiiPredicates(args, tableName, metadata, showPii) {
|
|
762
774
|
if (showPii)
|
|
763
775
|
return;
|
|
776
|
+
const assertWithinDepth = (depth) => {
|
|
777
|
+
if (depth <= PII_GUARD_MAX_DEPTH)
|
|
778
|
+
return;
|
|
779
|
+
throw new ValidationError(`[turbine] Query is nested more than ${PII_GUARD_MAX_DEPTH} levels deep, which is past the point where ` +
|
|
780
|
+
`Studio can prove it does not filter or sort on a PII-tagged and redacted column, so it is refused. ` +
|
|
781
|
+
`Flatten the query, or restart Studio with --show-pii.`);
|
|
782
|
+
};
|
|
783
|
+
const refuse = (table, column) => {
|
|
784
|
+
throw new ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
|
|
785
|
+
`in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
|
|
786
|
+
`value reveals it. Restart Studio with --show-pii to query it.`);
|
|
787
|
+
};
|
|
764
788
|
const visitClause = (node, table, depth) => {
|
|
765
|
-
|
|
789
|
+
assertWithinDepth(depth);
|
|
790
|
+
if (!table || node === null || typeof node !== 'object')
|
|
791
|
+
return;
|
|
792
|
+
// `orderBy` accepts a Prisma-style array of single-key objects, and so does
|
|
793
|
+
// a `NOT` list. Element order carries no nesting, so the depth is unchanged.
|
|
794
|
+
if (Array.isArray(node)) {
|
|
795
|
+
for (const item of node)
|
|
796
|
+
visitClause(item, table, depth);
|
|
766
797
|
return;
|
|
798
|
+
}
|
|
767
799
|
for (const [key, value] of Object.entries(node)) {
|
|
768
800
|
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
769
|
-
|
|
770
|
-
visitClause(item, table, depth + 1);
|
|
801
|
+
visitClause(value, table, depth + 1);
|
|
771
802
|
continue;
|
|
772
803
|
}
|
|
773
804
|
const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
|
|
774
805
|
if (relation) {
|
|
775
|
-
|
|
776
|
-
visitClause(value, metadata.tables[relation.to], depth + 1);
|
|
806
|
+
visitRelationValue(value, metadata.tables[relation.to], depth + 1);
|
|
777
807
|
continue;
|
|
778
808
|
}
|
|
779
809
|
const column = ownLookup(table.columnMap, key) ?? key;
|
|
780
|
-
if (isRedactedColumn(table, column, showPii))
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
810
|
+
if (isRedactedColumn(table, column, showPii))
|
|
811
|
+
refuse(table, column);
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
/**
|
|
815
|
+
* A relation predicate arrives in one of two shapes, and BOTH resolve against
|
|
816
|
+
* the relation's target table: bare (`{ user: { email: {...} } }`) or wrapped
|
|
817
|
+
* in a cardinality / to-one operator (`{ user: { is: { email: {...} } } }`,
|
|
818
|
+
* likewise `some` / `none` / `every` / `isNot`). Handing the wrapper straight
|
|
819
|
+
* to `visitClause` walked its keys as if `is` were a column of the target, so
|
|
820
|
+
* the inner clause was never visited and a PII predicate slipped through.
|
|
821
|
+
* Descend into every wrapper member AND into the value itself.
|
|
822
|
+
*/
|
|
823
|
+
const visitRelationValue = (value, target, depth) => {
|
|
824
|
+
assertWithinDepth(depth);
|
|
825
|
+
if (!target || value === null || typeof value !== 'object')
|
|
826
|
+
return;
|
|
827
|
+
const node = value;
|
|
828
|
+
for (const wrapper of RELATION_FILTER_WRAPPERS) {
|
|
829
|
+
if (Object.hasOwn(node, wrapper))
|
|
830
|
+
visitClause(node[wrapper], target, depth + 1);
|
|
831
|
+
}
|
|
832
|
+
visitClause(node, target, depth);
|
|
833
|
+
};
|
|
834
|
+
/** Field-name lists (`distinct`) name columns directly rather than in a clause. */
|
|
835
|
+
const visitFieldList = (value, table) => {
|
|
836
|
+
if (!Array.isArray(value))
|
|
837
|
+
return;
|
|
838
|
+
for (const field of value) {
|
|
839
|
+
if (typeof field !== 'string')
|
|
840
|
+
continue;
|
|
841
|
+
const column = ownLookup(table.columnMap, field) ?? field;
|
|
842
|
+
if (isRedactedColumn(table, column, showPii))
|
|
843
|
+
refuse(table, column);
|
|
785
844
|
}
|
|
786
845
|
};
|
|
787
846
|
const visitLevel = (level, table, depth) => {
|
|
788
|
-
|
|
847
|
+
assertWithinDepth(depth);
|
|
848
|
+
if (!table)
|
|
789
849
|
return;
|
|
790
850
|
visitClause(level.where, table, depth);
|
|
791
851
|
visitClause(level.orderBy, table, depth);
|
|
852
|
+
// `cursor` is a flat `{ field: value }` seek key that the builder turns into
|
|
853
|
+
// a WHERE range comparison against the sort key, so it reads exactly like a
|
|
854
|
+
// where on the same column.
|
|
855
|
+
visitClause(level.cursor, table, depth);
|
|
856
|
+
visitFieldList(level.distinct, table);
|
|
792
857
|
const withClause = level.with;
|
|
793
858
|
if (!withClause || typeof withClause !== 'object')
|
|
794
859
|
return;
|
|
@@ -805,7 +870,7 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
805
870
|
const body = await readJsonBody(req);
|
|
806
871
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
807
872
|
const args = (body?.args ?? {});
|
|
808
|
-
if (!tableName || !ctx.metadata.tables
|
|
873
|
+
if (!tableName || !ownLookup(ctx.metadata.tables, tableName)) {
|
|
809
874
|
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
810
875
|
return;
|
|
811
876
|
}
|
|
@@ -884,18 +949,18 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
884
949
|
// from those PK values alone, so a statement can only ever touch one row.
|
|
885
950
|
//
|
|
886
951
|
// Bulk form (insert/delete only): pass `rows: [...]` instead of `data`/`where`
|
|
887
|
-
//
|
|
952
|
+
// (an array of data objects for insert, or PK-where objects for delete). Each entry
|
|
888
953
|
// goes through the exact same per-row validation and compiles to its own
|
|
889
954
|
// single-row statement; all statements run in ONE transaction (all-or-nothing,
|
|
890
955
|
// capped at MAX_BULK_ROWS). Predicate-based bulk writes stay deliberately
|
|
891
|
-
// unsupported
|
|
956
|
+
// unsupported: every row is still addressed by its full primary key.
|
|
892
957
|
// ---------------------------------------------------------------------------
|
|
893
958
|
/** Hard cap on rows per bulk insert/delete request (matches the max page size). */
|
|
894
959
|
const MAX_BULK_ROWS = 500;
|
|
895
960
|
export async function apiRowWrite(req, res, ctx, op) {
|
|
896
961
|
const body = await readJsonBody(req);
|
|
897
962
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
898
|
-
const table = ctx.metadata.tables
|
|
963
|
+
const table = ownLookup(ctx.metadata.tables, tableName);
|
|
899
964
|
if (!table) {
|
|
900
965
|
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
901
966
|
return;
|
|
@@ -1119,7 +1184,7 @@ function savedQueriesPath(ctx) {
|
|
|
1119
1184
|
/** One-shot flag so the legacy saved-query notice isn't logged on every request. */
|
|
1120
1185
|
let legacyDropNoticeShown = false;
|
|
1121
1186
|
function loadSavedQueries(ctx) {
|
|
1122
|
-
// Demo mode: in-memory only
|
|
1187
|
+
// Demo mode: in-memory only, never read the user's real saved-query file.
|
|
1123
1188
|
if (ctx.demo) {
|
|
1124
1189
|
if (!ctx.memorySavedQueries)
|
|
1125
1190
|
ctx.memorySavedQueries = { version: 1, queries: [] };
|
|
@@ -1133,14 +1198,14 @@ function loadSavedQueries(ctx) {
|
|
|
1133
1198
|
const parsed = JSON.parse(raw);
|
|
1134
1199
|
if (!parsed.queries || !Array.isArray(parsed.queries))
|
|
1135
1200
|
return { version: 1, queries: [] };
|
|
1136
|
-
// Drop any legacy raw-SQL entries
|
|
1201
|
+
// Drop any legacy raw-SQL entries: Studio is builder-only now. Tell the
|
|
1137
1202
|
// user instead of silently discarding their saved work (the file on disk
|
|
1138
1203
|
// is only rewritten when a new query is saved, so this is recoverable).
|
|
1139
1204
|
const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
|
|
1140
1205
|
const dropped = parsed.queries.length - queries.length;
|
|
1141
1206
|
if (dropped > 0 && !legacyDropNoticeShown) {
|
|
1142
1207
|
legacyDropNoticeShown = true;
|
|
1143
|
-
console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file}
|
|
1208
|
+
console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file}. ` +
|
|
1144
1209
|
'Studio is builder-only since v0.19. The entries remain in the file until a new query is saved.');
|
|
1145
1210
|
}
|
|
1146
1211
|
return { version: 1, queries };
|
|
@@ -1150,7 +1215,7 @@ function loadSavedQueries(ctx) {
|
|
|
1150
1215
|
}
|
|
1151
1216
|
}
|
|
1152
1217
|
function writeSavedQueries(ctx, data) {
|
|
1153
|
-
// Demo mode: in-memory only
|
|
1218
|
+
// Demo mode: in-memory only, nothing is ever written to disk.
|
|
1154
1219
|
if (ctx.demo) {
|
|
1155
1220
|
ctx.memorySavedQueries = data;
|
|
1156
1221
|
return;
|
|
@@ -1452,6 +1517,6 @@ function openUrl(url) {
|
|
|
1452
1517
|
child.unref();
|
|
1453
1518
|
}
|
|
1454
1519
|
catch {
|
|
1455
|
-
// Non-fatal
|
|
1520
|
+
// Non-fatal: user can click the URL manually.
|
|
1456
1521
|
}
|
|
1457
1522
|
}
|
package/dist/cli/ui.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export declare const symbols: {
|
|
|
35
35
|
readonly dot: "." | "∙";
|
|
36
36
|
readonly line: "-" | "─";
|
|
37
37
|
readonly vertLine: "|" | "│";
|
|
38
|
-
readonly topLeft: "
|
|
38
|
+
readonly topLeft: "+" | "╭";
|
|
39
39
|
readonly topRight: "+" | "╮";
|
|
40
40
|
readonly bottomLeft: "+" | "╰";
|
|
41
41
|
readonly bottomRight: "+" | "╯";
|
package/dist/cli/ui.js
CHANGED
|
@@ -211,16 +211,58 @@ export function stripAnsi(s) {
|
|
|
211
211
|
// Redact password from connection URL
|
|
212
212
|
// ---------------------------------------------------------------------------
|
|
213
213
|
export function redactUrl(url) {
|
|
214
|
-
return (url
|
|
215
|
-
// Userinfo credentials. Anchored on `<scheme>://<user>:` and consuming up
|
|
216
|
-
// to the LAST `@` before the next `/` (or end of authority), because a
|
|
217
|
-
// password may legally contain `:`, `/`, and even `@` in percent-decoded
|
|
218
|
-
// form. The previous `:([^@/:]+)@` could not span any of those, so
|
|
219
|
-
// `postgres://u:pa/ss@host/db` came through completely unredacted and
|
|
220
|
-
// `postgres://u:a@b@host/db` leaked the tail. Global: one string may
|
|
221
|
-
// carry several URLs (a primary + replica pair).
|
|
222
|
-
.replace(/(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))/g, '$1***@')
|
|
214
|
+
return (redactUserinfo(url)
|
|
223
215
|
// Query-string password params: `password=`, `sslpassword=`, and similar,
|
|
224
216
|
// case-insensitive. Value runs up to the next `&`, `#`, or end of string.
|
|
225
217
|
.replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
|
|
226
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* Replace `<scheme>://<user>:<password>@` with `<scheme>://<user>:***@`, for
|
|
221
|
+
* every occurrence in the string (one message may carry a primary + replica
|
|
222
|
+
* pair). A password may legally contain `:`, `/`, and even `@` in
|
|
223
|
+
* percent-decoded form, so the credential runs to the LAST `@` of the authority.
|
|
224
|
+
*
|
|
225
|
+
* Deliberately a single forward scan rather than one regex: the previous
|
|
226
|
+
* `(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))` nested two lazy
|
|
227
|
+
* quantifiers under a lookahead and backtracked quadratically on input with no
|
|
228
|
+
* `@` at all (a 60 KB error message took ~16s). `redactUrl` runs on error text
|
|
229
|
+
* echoed back from Postgres, so that stalled the CLI. This version visits every
|
|
230
|
+
* character at most once.
|
|
231
|
+
*/
|
|
232
|
+
function redactUserinfo(input) {
|
|
233
|
+
const scheme = /\w+:\/\//g;
|
|
234
|
+
let out = '';
|
|
235
|
+
let copied = 0;
|
|
236
|
+
for (let m = scheme.exec(input); m; m = scheme.exec(input)) {
|
|
237
|
+
const start = m.index + m[0].length;
|
|
238
|
+
// Scan the run of non-whitespace characters that could hold userinfo,
|
|
239
|
+
// remembering the last `@`. A `/`, `?`, or `#` only ends the run once an `@`
|
|
240
|
+
// has been seen, so a password containing one is still covered; a URL with
|
|
241
|
+
// no credentials simply reaches the end of the run with no `@` recorded.
|
|
242
|
+
let i = start;
|
|
243
|
+
let lastAt = -1;
|
|
244
|
+
for (; i < input.length; i++) {
|
|
245
|
+
const ch = input.charAt(i);
|
|
246
|
+
if (/\s/.test(ch))
|
|
247
|
+
break;
|
|
248
|
+
if (ch === '@') {
|
|
249
|
+
lastAt = i;
|
|
250
|
+
}
|
|
251
|
+
else if (lastAt !== -1 && (ch === '/' || ch === '?' || ch === '#')) {
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (lastAt !== -1) {
|
|
256
|
+
const colon = input.slice(start, lastAt).indexOf(':');
|
|
257
|
+
if (colon !== -1) {
|
|
258
|
+
out += `${input.slice(copied, start + colon + 1)}***@`;
|
|
259
|
+
copied = lastAt + 1;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// Resume after the scanned run. Every character in it has been accounted
|
|
263
|
+
// for, and a credential cannot span whitespace, so nothing is missed and the
|
|
264
|
+
// total work stays linear however many `scheme://` prefixes the input holds.
|
|
265
|
+
scheme.lastIndex = Math.max(i, start);
|
|
266
|
+
}
|
|
267
|
+
return copied === 0 ? input : out + input.slice(copied);
|
|
268
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -216,6 +216,46 @@ export interface TurbineConfig {
|
|
|
216
216
|
* when off). SQL engines only. See {@link RelationLoadStrategy}.
|
|
217
217
|
*/
|
|
218
218
|
stableRelationOrder?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* When `true`, a `findMany` that paginates (`limit` / `take` / `offset`) but
|
|
221
|
+
* declares no `orderBy` is ordered by the table's primary key ascending
|
|
222
|
+
* (every column of a composite PK, in declaration order). An unordered
|
|
223
|
+
* `LIMIT` is non-deterministic: the same query can return different rows once
|
|
224
|
+
* the heap changes underneath it, so a row may appear on two pages or on
|
|
225
|
+
* none. An explicit `orderBy` always wins, PK-less tables are left alone, and
|
|
226
|
+
* `distinct` / `cursor` shapes are skipped.
|
|
227
|
+
*
|
|
228
|
+
* Default `false` in core, because switching it on would add an `ORDER BY` to
|
|
229
|
+
* SQL that existing applications already emit, changing both the rows a page
|
|
230
|
+
* returns and the plan the engine picks: a silent behavior change that waits
|
|
231
|
+
* for a major. `turbine-orm/prisma-compat` defaults it ON instead, since
|
|
232
|
+
* reproducing Prisma's semantics is that layer's contract. With it off the
|
|
233
|
+
* emitted SQL is byte-identical to before, and a dev-mode warning points at
|
|
234
|
+
* the affected queries.
|
|
235
|
+
*/
|
|
236
|
+
implicitPkOrdering?: boolean;
|
|
237
|
+
/**
|
|
238
|
+
* Parent-row ceiling for the `relationLoadStrategy: 'auto'` to-one rule: a
|
|
239
|
+
* to-one relation stays in the single-statement join when the query's `limit`
|
|
240
|
+
* bounds the parent set at or under this many rows, and loads batched when the
|
|
241
|
+
* query is unbounded or bounded above it. Defaults to 1000 (see
|
|
242
|
+
* `AUTO_TO_ONE_JOIN_MAX_ROWS`). Only consulted under `'auto'`.
|
|
243
|
+
*/
|
|
244
|
+
autoToOneJoinMaxRows?: number;
|
|
245
|
+
/**
|
|
246
|
+
* Round-trip time to the database, in milliseconds, used to DERIVE the
|
|
247
|
+
* `relationLoadStrategy: 'auto'` to-one threshold instead of guessing a row
|
|
248
|
+
* count. Prefer this over `autoToOneJoinMaxRows`: the break-even between the
|
|
249
|
+
* single-statement join and the batched follow-up is
|
|
250
|
+
* `roundTripMs / AUTO_JOIN_PENALTY_MS_PER_ROW`, and measurement shows the
|
|
251
|
+
* per-row penalty is a constant of the plan while the break-even moves ~17x
|
|
252
|
+
* between a loopback link and a 2.7ms one. Set it to what `ping` says (a
|
|
253
|
+
* Unix socket is ~0.05, same-region managed Postgres ~0.5-2, cross-region
|
|
254
|
+
* ~30-60). Defaults to `AUTO_ASSUMED_ROUND_TRIP_MS` (0.7ms, same-region),
|
|
255
|
+
* which reproduces the historical 1000-row threshold exactly. Overridden by
|
|
256
|
+
* an explicit `autoToOneJoinMaxRows`; only consulted under `'auto'`.
|
|
257
|
+
*/
|
|
258
|
+
autoRoundTripMs?: number;
|
|
219
259
|
/**
|
|
220
260
|
* How nested-relation subqueries encode each row's JSON.
|
|
221
261
|
*
|
package/dist/client.js
CHANGED
|
@@ -383,6 +383,9 @@ export class TurbineClient {
|
|
|
383
383
|
utcTimestamps: config.utcTimestamps,
|
|
384
384
|
relationLoadStrategy: config.relationLoadStrategy,
|
|
385
385
|
stableRelationOrder: config.stableRelationOrder,
|
|
386
|
+
implicitPkOrdering: config.implicitPkOrdering,
|
|
387
|
+
autoToOneJoinMaxRows: config.autoToOneJoinMaxRows,
|
|
388
|
+
autoRoundTripMs: config.autoRoundTripMs,
|
|
386
389
|
jsonEncoding: config.jsonEncoding,
|
|
387
390
|
globalFilters: config.globalFilters,
|
|
388
391
|
preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
|
package/dist/dialect.d.ts
CHANGED
|
@@ -132,6 +132,17 @@ export interface StreamableConnection {
|
|
|
132
132
|
rows: Record<string, unknown>[];
|
|
133
133
|
}>;
|
|
134
134
|
}
|
|
135
|
+
/** Options for {@link Dialect.openStream}. */
|
|
136
|
+
export interface OpenStreamOptions {
|
|
137
|
+
/**
|
|
138
|
+
* The connection is already inside a caller-owned transaction. Dialects that
|
|
139
|
+
* would otherwise wrap the stream in their own transaction MUST emit no
|
|
140
|
+
* BEGIN / COMMIT / ROLLBACK when this is true: the caller opened the
|
|
141
|
+
* transaction and owns ending it. Cursor cleanup (`CLOSE`) still runs, since
|
|
142
|
+
* the cursor lives on the caller's connection until their transaction ends.
|
|
143
|
+
*/
|
|
144
|
+
ambientTransaction?: boolean;
|
|
145
|
+
}
|
|
135
146
|
/**
|
|
136
147
|
* Inputs for {@link Dialect.buildUpdateStatement} — full UPDATE assembly. Used by
|
|
137
148
|
* engines whose returning shape is injected MID-statement rather than as a trailing
|
|
@@ -435,8 +446,13 @@ export interface Dialect {
|
|
|
435
446
|
* row batches of up to `batchSize`. PostgreSQL uses a `DECLARE CURSOR` /
|
|
436
447
|
* `FETCH` / `CLOSE` loop inside a transaction; other engines use their
|
|
437
448
|
* driver's native streaming iterator.
|
|
449
|
+
*
|
|
450
|
+
* `opts.ambientTransaction` tells the dialect the connection is ALREADY inside
|
|
451
|
+
* a caller-owned transaction, so it must not emit its own BEGIN / COMMIT /
|
|
452
|
+
* ROLLBACK (doing so would commit or discard the caller's uncommitted work
|
|
453
|
+
* mid-stream). Engines that emit no transaction control here ignore it.
|
|
438
454
|
*/
|
|
439
|
-
openStream(connection: StreamableConnection, sql: string, params: unknown[], batchSize: number): AsyncGenerator<Record<string, unknown>[], void, undefined>;
|
|
455
|
+
openStream(connection: StreamableConnection, sql: string, params: unknown[], batchSize: number, opts?: OpenStreamOptions): AsyncGenerator<Record<string, unknown>[], void, undefined>;
|
|
440
456
|
/**
|
|
441
457
|
* Schema introspector for this engine. `introspect()` routes through it so
|
|
442
458
|
* engines can override the catalog SQL. PostgreSQL wraps the
|
package/dist/dialect.js
CHANGED
|
@@ -202,13 +202,18 @@ export const postgresDialect = {
|
|
|
202
202
|
params: [name, value],
|
|
203
203
|
};
|
|
204
204
|
},
|
|
205
|
-
async *openStream(connection, sql, params, batchSize) {
|
|
206
|
-
// Cursors require a single connection inside a transaction
|
|
207
|
-
//
|
|
208
|
-
//
|
|
205
|
+
async *openStream(connection, sql, params, batchSize, opts) {
|
|
206
|
+
// Cursors require a single connection inside a transaction: BEGIN → DECLARE
|
|
207
|
+
// … NO SCROLL CURSOR FOR → FETCH n (loop) → CLOSE → COMMIT; ROLLBACK on
|
|
208
|
+
// error. Under an ambient (caller-owned) transaction the three transaction
|
|
209
|
+
// statements are skipped, but the cursor is still CLOSEd: it would otherwise
|
|
210
|
+
// sit open on the caller's connection until their transaction ends.
|
|
211
|
+
const ambient = opts?.ambientTransaction === true;
|
|
209
212
|
const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
210
213
|
const quotedCursor = this.quoteIdentifier(cursorName);
|
|
211
|
-
|
|
214
|
+
if (!ambient)
|
|
215
|
+
await connection.query(this.beginStatement());
|
|
216
|
+
let failed = false;
|
|
212
217
|
try {
|
|
213
218
|
await connection.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${sql}`, params);
|
|
214
219
|
while (true) {
|
|
@@ -219,18 +224,38 @@ export const postgresDialect = {
|
|
|
219
224
|
if (batch.rows.length < batchSize)
|
|
220
225
|
break;
|
|
221
226
|
}
|
|
222
|
-
await connection.query(`CLOSE ${quotedCursor}`);
|
|
223
|
-
await connection.query(this.commitStatement());
|
|
224
227
|
}
|
|
225
228
|
catch (err) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
// The transaction is aborted, so CLOSE would fail too: skip cleanup and
|
|
230
|
+
// let the rollback (ours, or the caller's on an ambient transaction) drop
|
|
231
|
+
// the cursor. Never let a cleanup failure mask this error.
|
|
232
|
+
failed = true;
|
|
233
|
+
if (!ambient) {
|
|
234
|
+
try {
|
|
235
|
+
await connection.query(this.rollbackStatement());
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Connection may already be broken, ignore rollback error.
|
|
239
|
+
}
|
|
231
240
|
}
|
|
232
241
|
throw err;
|
|
233
242
|
}
|
|
243
|
+
finally {
|
|
244
|
+
// Reached on normal completion AND on early exit (`break` from the
|
|
245
|
+
// consumer's `for await` runs the generator's return path through here),
|
|
246
|
+
// which is the case the old try-only cleanup leaked.
|
|
247
|
+
if (!failed) {
|
|
248
|
+
try {
|
|
249
|
+
await connection.query(`CLOSE ${quotedCursor}`);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Best-effort: a broken CLOSE must not replace a clean drain with an
|
|
253
|
+
// error. Ending the transaction drops the cursor regardless.
|
|
254
|
+
}
|
|
255
|
+
if (!ambient)
|
|
256
|
+
await connection.query(this.commitStatement());
|
|
257
|
+
}
|
|
258
|
+
}
|
|
234
259
|
},
|
|
235
260
|
// Postgres introspection wraps the information_schema / pg_catalog reader in
|
|
236
261
|
// introspect.ts. A dynamic import keeps the static graph acyclic (introspect.ts
|