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/cjs/cli/studio.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* turbine-orm CLI
|
|
3
|
+
* turbine-orm CLI: Studio
|
|
4
4
|
*
|
|
5
5
|
* A local web UI for browsing databases, exploring relations, and composing
|
|
6
6
|
* queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
|
|
@@ -65,7 +65,11 @@ const pg_1 = __importDefault(require("pg"));
|
|
|
65
65
|
const errors_js_1 = require("../errors.js");
|
|
66
66
|
const introspect_js_1 = require("../introspect.js");
|
|
67
67
|
const index_js_1 = require("../query/index.js");
|
|
68
|
+
// `ownLookup` is not re-exported from the query barrel, so it is imported from
|
|
69
|
+
// its defining leaf module rather than duplicated here.
|
|
70
|
+
const utils_js_1 = require("../query/utils.js");
|
|
68
71
|
const pii_tags_js_1 = require("./pii-tags.js");
|
|
72
|
+
const rate_limit_js_1 = require("./rate-limit.js");
|
|
69
73
|
const studio_demo_js_1 = require("./studio-demo.js");
|
|
70
74
|
const studio_ui_generated_js_1 = require("./studio-ui.generated.js");
|
|
71
75
|
// ---------------------------------------------------------------------------
|
|
@@ -103,10 +107,10 @@ async function startStudio(options) {
|
|
|
103
107
|
// seam in client.ts); the cast keeps one typed pool field for both modes.
|
|
104
108
|
pool = new pg_1.default.Pool({
|
|
105
109
|
connectionString: options.url,
|
|
106
|
-
max: 4, // small pool
|
|
110
|
+
max: 4, // small pool, single-user tool
|
|
107
111
|
idleTimeoutMillis: 10_000,
|
|
108
112
|
});
|
|
109
|
-
// Verify connectivity before starting the server
|
|
113
|
+
// Verify connectivity before starting the server, fail fast.
|
|
110
114
|
const probe = await pool.connect();
|
|
111
115
|
try {
|
|
112
116
|
await probe.query('SELECT 1');
|
|
@@ -201,7 +205,7 @@ function originFor(host, port) {
|
|
|
201
205
|
// ---------------------------------------------------------------------------
|
|
202
206
|
async function handleRequest(req, res, ctx) {
|
|
203
207
|
const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
|
|
204
|
-
// CORS: not needed
|
|
208
|
+
// CORS: not needed, same-origin only. Explicitly refuse cross-origin.
|
|
205
209
|
const origin = req.headers.origin;
|
|
206
210
|
if (origin && origin !== expectedOrigin) {
|
|
207
211
|
sendJson(res, 403, { error: 'cross-origin requests not allowed' });
|
|
@@ -230,26 +234,32 @@ async function handleRequest(req, res, ctx) {
|
|
|
230
234
|
sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML, cspNonce());
|
|
231
235
|
return;
|
|
232
236
|
}
|
|
233
|
-
// Favicon
|
|
237
|
+
// Favicon: answered before the auth gate so the browser's automatic request
|
|
234
238
|
// doesn't 401/404 on every load. No icon body needed (204).
|
|
235
239
|
if (pathname === '/favicon.ico') {
|
|
236
240
|
res.writeHead(204, { 'Content-Length': '0' });
|
|
237
241
|
res.end();
|
|
238
242
|
return;
|
|
239
243
|
}
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
const
|
|
244
|
+
// Rate limiting: 100 requests per 60 seconds, applied BEFORE the auth gate so
|
|
245
|
+
// an unauthenticated caller is throttled too (it used to run after, leaving
|
|
246
|
+
// token guessing unmetered). Keyed per caller rather than on `ctx.authToken`:
|
|
247
|
+
// that token is a single constant, so keying on it made one global bucket that
|
|
248
|
+
// every session shared. Authenticated and unauthenticated callers get separate
|
|
249
|
+
// buckets so a probing client cannot spend the real session's budget.
|
|
250
|
+
const authorized = isAuthorized(req, ctx.authToken);
|
|
251
|
+
const rateLimitResult = (0, rate_limit_js_1.checkRateLimit)(ctx.rateLimiter, `${authorized ? 'session' : 'anon'}:${(0, rate_limit_js_1.callerKey)(req)}`);
|
|
247
252
|
if (!rateLimitResult.allowed) {
|
|
248
253
|
const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000);
|
|
249
254
|
res.setHeader('Retry-After', String(retryAfter));
|
|
250
255
|
sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter });
|
|
251
256
|
return;
|
|
252
257
|
}
|
|
258
|
+
// API routes: all require auth.
|
|
259
|
+
if (!authorized) {
|
|
260
|
+
sendJson(res, 401, { error: 'unauthorized: use the URL printed in the terminal' });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
253
263
|
if (pathname === '/api/schema' && req.method === 'GET') {
|
|
254
264
|
return apiSchema(res, ctx);
|
|
255
265
|
}
|
|
@@ -332,32 +342,15 @@ function isAuthorized(req, expectedToken) {
|
|
|
332
342
|
return true;
|
|
333
343
|
}
|
|
334
344
|
const cookieHeader = req.headers.cookie ?? '';
|
|
335
|
-
|
|
345
|
+
// Anchored on a cookie boundary: unanchored, a decoy cookie named
|
|
346
|
+
// `x_turbine_studio_token=...` matched first and the real cookie was never
|
|
347
|
+
// compared, locking the legitimate session out of its own Studio.
|
|
348
|
+
const match = /(?:^|;\s*)turbine_studio_token=([a-f0-9]+)/.exec(cookieHeader);
|
|
336
349
|
if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
|
|
337
350
|
return true;
|
|
338
351
|
}
|
|
339
352
|
return false;
|
|
340
353
|
}
|
|
341
|
-
// ---------------------------------------------------------------------------
|
|
342
|
-
// Rate limiting
|
|
343
|
-
// ---------------------------------------------------------------------------
|
|
344
|
-
const RATE_LIMIT_WINDOW_MS = 60_000; // 60 seconds
|
|
345
|
-
const RATE_LIMIT_MAX_REQUESTS = 100;
|
|
346
|
-
function checkRateLimit(limiter, token) {
|
|
347
|
-
const now = Date.now();
|
|
348
|
-
const entry = limiter.get(token);
|
|
349
|
-
if (!entry || now >= entry.resetAt) {
|
|
350
|
-
// Start a new window
|
|
351
|
-
const resetAt = now + RATE_LIMIT_WINDOW_MS;
|
|
352
|
-
limiter.set(token, { count: 1, resetAt });
|
|
353
|
-
return { allowed: true, resetAt };
|
|
354
|
-
}
|
|
355
|
-
entry.count++;
|
|
356
|
-
if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
|
|
357
|
-
return { allowed: false, resetAt: entry.resetAt };
|
|
358
|
-
}
|
|
359
|
-
return { allowed: true, resetAt: entry.resetAt };
|
|
360
|
-
}
|
|
361
354
|
function constantTimeEqual(a, b) {
|
|
362
355
|
// Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
|
|
363
356
|
// This makes the comparison constant-length (timingSafeEqual never throws on a
|
|
@@ -437,10 +430,6 @@ async function apiSchema(res, ctx) {
|
|
|
437
430
|
demo: ctx.demo === true,
|
|
438
431
|
});
|
|
439
432
|
}
|
|
440
|
-
/** Own-property lookup: a key like `constructor` must not resolve off the prototype. */
|
|
441
|
-
function ownLookup(map, key) {
|
|
442
|
-
return Object.hasOwn(map, key) ? map[key] : undefined;
|
|
443
|
-
}
|
|
444
433
|
/** True when `columnName` on `table` is PII-tagged and currently redacted. */
|
|
445
434
|
function isRedactedColumn(table, columnName, showPii) {
|
|
446
435
|
if (showPii)
|
|
@@ -526,7 +515,9 @@ function relationLinksForTable(table, metadata, showPii) {
|
|
|
526
515
|
// API: /api/tables/:name?limit=&offset=&orderBy=&dir=
|
|
527
516
|
// ---------------------------------------------------------------------------
|
|
528
517
|
async function apiTableRows(res, ctx, rawTableName, params) {
|
|
529
|
-
|
|
518
|
+
// Own-property lookup: `/api/tables/constructor` must 404 like any other
|
|
519
|
+
// unknown table rather than resolving Object.prototype and crashing.
|
|
520
|
+
const table = (0, utils_js_1.ownLookup)(ctx.metadata.tables, rawTableName);
|
|
530
521
|
if (!table) {
|
|
531
522
|
sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
|
|
532
523
|
return;
|
|
@@ -535,7 +526,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
535
526
|
const offset = clampInt(params.get('offset'), 0, 0, 10_000_000);
|
|
536
527
|
const orderByRaw = params.get('orderBy');
|
|
537
528
|
const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
538
|
-
// orderBy
|
|
529
|
+
// orderBy: accept either the Postgres column name (snake) or the TS field
|
|
539
530
|
// name (camel). Always emit the Postgres column in the SQL.
|
|
540
531
|
// When redaction is on, PII columns are excluded from orderBy (and from the
|
|
541
532
|
// search OR-set below): a redacted value must not be inferable through sort
|
|
@@ -562,7 +553,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
562
553
|
// Per-column filters: `filters` is a JSON array of { column, op, value }
|
|
563
554
|
// composed by the Data tab's filter bar. Every column is validated against
|
|
564
555
|
// the metadata, every op against a fixed whitelist, and every value is a
|
|
565
|
-
// parameter
|
|
556
|
+
// parameter, same discipline as the builder route.
|
|
566
557
|
let filters;
|
|
567
558
|
try {
|
|
568
559
|
filters = parseTableFilters(params.get('filters'), table, redactedPii);
|
|
@@ -701,7 +692,7 @@ const FILTER_OPS = {
|
|
|
701
692
|
lte: '<=',
|
|
702
693
|
};
|
|
703
694
|
const FILTER_OP_NAMES = new Set([...Object.keys(FILTER_OPS), 'contains', 'isNull', 'notNull']);
|
|
704
|
-
/** Hard cap on filter clauses per request
|
|
695
|
+
/** Hard cap on filter clauses per request; the UI never composes more. */
|
|
705
696
|
const MAX_TABLE_FILTERS = 10;
|
|
706
697
|
/**
|
|
707
698
|
* Parse + validate the Data tab's `filters` query param. Throws with a clear
|
|
@@ -761,53 +752,127 @@ function parseTableFilters(raw, table, redactedPii) {
|
|
|
761
752
|
return out;
|
|
762
753
|
}
|
|
763
754
|
// ---------------------------------------------------------------------------
|
|
764
|
-
// API: /api/builder
|
|
755
|
+
// API: /api/builder: Turbine ORM findMany spec runner
|
|
765
756
|
// ---------------------------------------------------------------------------
|
|
757
|
+
/** Relation-filter wrappers whose body is a clause against the relation's target. */
|
|
758
|
+
const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
|
|
759
|
+
/**
|
|
760
|
+
* Recursion bound for the PII guard walk.
|
|
761
|
+
*
|
|
762
|
+
* This number is NOT the security boundary: reaching it REFUSES the request
|
|
763
|
+
* (see `assertWithinDepth`). It only bounds the walk on a pathological payload.
|
|
764
|
+
* It sits well above the query builder's own depth-10 relation cap
|
|
765
|
+
* (`CircularRelationError`) and far above any hand-composed boolean nesting, so
|
|
766
|
+
* nothing the builder would accept is refused here for depth alone.
|
|
767
|
+
*/
|
|
768
|
+
const PII_GUARD_MAX_DEPTH = 32;
|
|
766
769
|
/**
|
|
767
|
-
* Refuse a builder query that FILTERS or
|
|
770
|
+
* Refuse a builder query that FILTERS, SORTS, PAGES, or DE-DUPLICATES on a
|
|
771
|
+
* redacted PII column.
|
|
768
772
|
*
|
|
769
773
|
* Redacting the cells is not enough on its own: `where: { email: { startsWith:
|
|
770
774
|
* 'a' } }` answers a question about the hidden value, and so does an `isNull`,
|
|
771
|
-
* and so does an `orderBy`.
|
|
772
|
-
* (`
|
|
773
|
-
*
|
|
775
|
+
* and so does an `orderBy`. So does a `cursor`, which compiles into a WHERE
|
|
776
|
+
* range comparison (`"email" > $1`) and gives a clean binary search over the
|
|
777
|
+
* byte range even though every returned cell is redacted. So, more weakly, does
|
|
778
|
+
* `distinct`, which reports the cardinality of the hidden values. The Data tab
|
|
779
|
+
* already refuses its own equivalents (`parseTableFilters`); the builder route
|
|
780
|
+
* has to refuse all of them too.
|
|
781
|
+
*
|
|
782
|
+
* Walks the whole args tree: `where`, `orderBy` (object AND array form),
|
|
783
|
+
* `cursor`, `distinct`, boolean combinators, relation filters, and each `with`
|
|
784
|
+
* level against that relation's target table. `select` / `omit` are NOT
|
|
785
|
+
* refused: they return values, and those values are redacted on the way out.
|
|
774
786
|
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
* the
|
|
787
|
+
* Every depth check FAILS CLOSED. Returning quietly at the cap (what this used
|
|
788
|
+
* to do) meant padding a payload with, for example, eleven nested `NOT`
|
|
789
|
+
* wrappers walked the guard off the end of its own recursion and then handed
|
|
790
|
+
* the untouched predicate to the builder.
|
|
779
791
|
*/
|
|
780
792
|
function assertNoPiiPredicates(args, tableName, metadata, showPii) {
|
|
781
793
|
if (showPii)
|
|
782
794
|
return;
|
|
795
|
+
const assertWithinDepth = (depth) => {
|
|
796
|
+
if (depth <= PII_GUARD_MAX_DEPTH)
|
|
797
|
+
return;
|
|
798
|
+
throw new errors_js_1.ValidationError(`[turbine] Query is nested more than ${PII_GUARD_MAX_DEPTH} levels deep, which is past the point where ` +
|
|
799
|
+
`Studio can prove it does not filter or sort on a PII-tagged and redacted column, so it is refused. ` +
|
|
800
|
+
`Flatten the query, or restart Studio with --show-pii.`);
|
|
801
|
+
};
|
|
802
|
+
const refuse = (table, column) => {
|
|
803
|
+
throw new errors_js_1.ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
|
|
804
|
+
`in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
|
|
805
|
+
`value reveals it. Restart Studio with --show-pii to query it.`);
|
|
806
|
+
};
|
|
783
807
|
const visitClause = (node, table, depth) => {
|
|
784
|
-
|
|
808
|
+
assertWithinDepth(depth);
|
|
809
|
+
if (!table || node === null || typeof node !== 'object')
|
|
810
|
+
return;
|
|
811
|
+
// `orderBy` accepts a Prisma-style array of single-key objects, and so does
|
|
812
|
+
// a `NOT` list. Element order carries no nesting, so the depth is unchanged.
|
|
813
|
+
if (Array.isArray(node)) {
|
|
814
|
+
for (const item of node)
|
|
815
|
+
visitClause(item, table, depth);
|
|
785
816
|
return;
|
|
817
|
+
}
|
|
786
818
|
for (const [key, value] of Object.entries(node)) {
|
|
787
819
|
if (key === 'AND' || key === 'OR' || key === 'NOT') {
|
|
788
|
-
|
|
789
|
-
visitClause(item, table, depth + 1);
|
|
820
|
+
visitClause(value, table, depth + 1);
|
|
790
821
|
continue;
|
|
791
822
|
}
|
|
792
823
|
const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
|
|
793
824
|
if (relation) {
|
|
794
|
-
|
|
795
|
-
visitClause(value, metadata.tables[relation.to], depth + 1);
|
|
825
|
+
visitRelationValue(value, metadata.tables[relation.to], depth + 1);
|
|
796
826
|
continue;
|
|
797
827
|
}
|
|
798
|
-
const column = ownLookup(table.columnMap, key) ?? key;
|
|
799
|
-
if (isRedactedColumn(table, column, showPii))
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
828
|
+
const column = (0, utils_js_1.ownLookup)(table.columnMap, key) ?? key;
|
|
829
|
+
if (isRedactedColumn(table, column, showPii))
|
|
830
|
+
refuse(table, column);
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
/**
|
|
834
|
+
* A relation predicate arrives in one of two shapes, and BOTH resolve against
|
|
835
|
+
* the relation's target table: bare (`{ user: { email: {...} } }`) or wrapped
|
|
836
|
+
* in a cardinality / to-one operator (`{ user: { is: { email: {...} } } }`,
|
|
837
|
+
* likewise `some` / `none` / `every` / `isNot`). Handing the wrapper straight
|
|
838
|
+
* to `visitClause` walked its keys as if `is` were a column of the target, so
|
|
839
|
+
* the inner clause was never visited and a PII predicate slipped through.
|
|
840
|
+
* Descend into every wrapper member AND into the value itself.
|
|
841
|
+
*/
|
|
842
|
+
const visitRelationValue = (value, target, depth) => {
|
|
843
|
+
assertWithinDepth(depth);
|
|
844
|
+
if (!target || value === null || typeof value !== 'object')
|
|
845
|
+
return;
|
|
846
|
+
const node = value;
|
|
847
|
+
for (const wrapper of RELATION_FILTER_WRAPPERS) {
|
|
848
|
+
if (Object.hasOwn(node, wrapper))
|
|
849
|
+
visitClause(node[wrapper], target, depth + 1);
|
|
850
|
+
}
|
|
851
|
+
visitClause(node, target, depth);
|
|
852
|
+
};
|
|
853
|
+
/** Field-name lists (`distinct`) name columns directly rather than in a clause. */
|
|
854
|
+
const visitFieldList = (value, table) => {
|
|
855
|
+
if (!Array.isArray(value))
|
|
856
|
+
return;
|
|
857
|
+
for (const field of value) {
|
|
858
|
+
if (typeof field !== 'string')
|
|
859
|
+
continue;
|
|
860
|
+
const column = (0, utils_js_1.ownLookup)(table.columnMap, field) ?? field;
|
|
861
|
+
if (isRedactedColumn(table, column, showPii))
|
|
862
|
+
refuse(table, column);
|
|
804
863
|
}
|
|
805
864
|
};
|
|
806
865
|
const visitLevel = (level, table, depth) => {
|
|
807
|
-
|
|
866
|
+
assertWithinDepth(depth);
|
|
867
|
+
if (!table)
|
|
808
868
|
return;
|
|
809
869
|
visitClause(level.where, table, depth);
|
|
810
870
|
visitClause(level.orderBy, table, depth);
|
|
871
|
+
// `cursor` is a flat `{ field: value }` seek key that the builder turns into
|
|
872
|
+
// a WHERE range comparison against the sort key, so it reads exactly like a
|
|
873
|
+
// where on the same column.
|
|
874
|
+
visitClause(level.cursor, table, depth);
|
|
875
|
+
visitFieldList(level.distinct, table);
|
|
811
876
|
const withClause = level.with;
|
|
812
877
|
if (!withClause || typeof withClause !== 'object')
|
|
813
878
|
return;
|
|
@@ -824,7 +889,7 @@ async function apiBuilder(req, res, ctx) {
|
|
|
824
889
|
const body = await readJsonBody(req);
|
|
825
890
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
826
891
|
const args = (body?.args ?? {});
|
|
827
|
-
if (!tableName || !ctx.metadata.tables
|
|
892
|
+
if (!tableName || !(0, utils_js_1.ownLookup)(ctx.metadata.tables, tableName)) {
|
|
828
893
|
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
829
894
|
return;
|
|
830
895
|
}
|
|
@@ -903,18 +968,18 @@ async function apiBuilder(req, res, ctx) {
|
|
|
903
968
|
// from those PK values alone, so a statement can only ever touch one row.
|
|
904
969
|
//
|
|
905
970
|
// Bulk form (insert/delete only): pass `rows: [...]` instead of `data`/`where`
|
|
906
|
-
//
|
|
971
|
+
// (an array of data objects for insert, or PK-where objects for delete). Each entry
|
|
907
972
|
// goes through the exact same per-row validation and compiles to its own
|
|
908
973
|
// single-row statement; all statements run in ONE transaction (all-or-nothing,
|
|
909
974
|
// capped at MAX_BULK_ROWS). Predicate-based bulk writes stay deliberately
|
|
910
|
-
// unsupported
|
|
975
|
+
// unsupported: every row is still addressed by its full primary key.
|
|
911
976
|
// ---------------------------------------------------------------------------
|
|
912
977
|
/** Hard cap on rows per bulk insert/delete request (matches the max page size). */
|
|
913
978
|
const MAX_BULK_ROWS = 500;
|
|
914
979
|
async function apiRowWrite(req, res, ctx, op) {
|
|
915
980
|
const body = await readJsonBody(req);
|
|
916
981
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
917
|
-
const table = ctx.metadata.tables
|
|
982
|
+
const table = (0, utils_js_1.ownLookup)(ctx.metadata.tables, tableName);
|
|
918
983
|
if (!table) {
|
|
919
984
|
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
920
985
|
return;
|
|
@@ -1138,7 +1203,7 @@ function savedQueriesPath(ctx) {
|
|
|
1138
1203
|
/** One-shot flag so the legacy saved-query notice isn't logged on every request. */
|
|
1139
1204
|
let legacyDropNoticeShown = false;
|
|
1140
1205
|
function loadSavedQueries(ctx) {
|
|
1141
|
-
// Demo mode: in-memory only
|
|
1206
|
+
// Demo mode: in-memory only, never read the user's real saved-query file.
|
|
1142
1207
|
if (ctx.demo) {
|
|
1143
1208
|
if (!ctx.memorySavedQueries)
|
|
1144
1209
|
ctx.memorySavedQueries = { version: 1, queries: [] };
|
|
@@ -1152,14 +1217,14 @@ function loadSavedQueries(ctx) {
|
|
|
1152
1217
|
const parsed = JSON.parse(raw);
|
|
1153
1218
|
if (!parsed.queries || !Array.isArray(parsed.queries))
|
|
1154
1219
|
return { version: 1, queries: [] };
|
|
1155
|
-
// Drop any legacy raw-SQL entries
|
|
1220
|
+
// Drop any legacy raw-SQL entries: Studio is builder-only now. Tell the
|
|
1156
1221
|
// user instead of silently discarding their saved work (the file on disk
|
|
1157
1222
|
// is only rewritten when a new query is saved, so this is recoverable).
|
|
1158
1223
|
const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
|
|
1159
1224
|
const dropped = parsed.queries.length - queries.length;
|
|
1160
1225
|
if (dropped > 0 && !legacyDropNoticeShown) {
|
|
1161
1226
|
legacyDropNoticeShown = true;
|
|
1162
|
-
console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file}
|
|
1227
|
+
console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file}. ` +
|
|
1163
1228
|
'Studio is builder-only since v0.19. The entries remain in the file until a new query is saved.');
|
|
1164
1229
|
}
|
|
1165
1230
|
return { version: 1, queries };
|
|
@@ -1169,7 +1234,7 @@ function loadSavedQueries(ctx) {
|
|
|
1169
1234
|
}
|
|
1170
1235
|
}
|
|
1171
1236
|
function writeSavedQueries(ctx, data) {
|
|
1172
|
-
// Demo mode: in-memory only
|
|
1237
|
+
// Demo mode: in-memory only, nothing is ever written to disk.
|
|
1173
1238
|
if (ctx.demo) {
|
|
1174
1239
|
ctx.memorySavedQueries = data;
|
|
1175
1240
|
return;
|
|
@@ -1471,6 +1536,6 @@ function openUrl(url) {
|
|
|
1471
1536
|
child.unref();
|
|
1472
1537
|
}
|
|
1473
1538
|
catch {
|
|
1474
|
-
// Non-fatal
|
|
1539
|
+
// Non-fatal: user can click the URL manually.
|
|
1475
1540
|
}
|
|
1476
1541
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turbine-orm CLI — UI utilities
|
|
3
|
+
*
|
|
4
|
+
* ANSI colors, spinners, box-drawing, and formatting helpers.
|
|
5
|
+
* Zero dependencies — raw escape codes only.
|
|
6
|
+
*/
|
|
7
|
+
export declare const bold: (s: string) => string;
|
|
8
|
+
export declare const dim: (s: string) => string;
|
|
9
|
+
export declare const italic: (s: string) => string;
|
|
10
|
+
export declare const underline: (s: string) => string;
|
|
11
|
+
export declare const red: (s: string) => string;
|
|
12
|
+
export declare const green: (s: string) => string;
|
|
13
|
+
export declare const yellow: (s: string) => string;
|
|
14
|
+
export declare const blue: (s: string) => string;
|
|
15
|
+
export declare const magenta: (s: string) => string;
|
|
16
|
+
export declare const cyan: (s: string) => string;
|
|
17
|
+
export declare const white: (s: string) => string;
|
|
18
|
+
export declare const gray: (s: string) => string;
|
|
19
|
+
export declare const greenBright: (s: string) => string;
|
|
20
|
+
export declare const cyanBright: (s: string) => string;
|
|
21
|
+
export declare const yellowBright: (s: string) => string;
|
|
22
|
+
export declare const redBright: (s: string) => string;
|
|
23
|
+
export declare const bgGreen: (s: string) => string;
|
|
24
|
+
export declare const bgRed: (s: string) => string;
|
|
25
|
+
export declare const bgYellow: (s: string) => string;
|
|
26
|
+
export declare const bgCyan: (s: string) => string;
|
|
27
|
+
export declare const symbols: {
|
|
28
|
+
readonly check: "v" | "✓";
|
|
29
|
+
readonly cross: "✗" | "x";
|
|
30
|
+
readonly bullet: "*" | "•";
|
|
31
|
+
readonly arrow: "→" | "->";
|
|
32
|
+
readonly arrowRight: ">" | "▸";
|
|
33
|
+
readonly info: "i" | "ℹ";
|
|
34
|
+
readonly warning: "⚠" | "!";
|
|
35
|
+
readonly dot: "." | "∙";
|
|
36
|
+
readonly line: "-" | "─";
|
|
37
|
+
readonly vertLine: "|" | "│";
|
|
38
|
+
readonly topLeft: "+" | "╭";
|
|
39
|
+
readonly topRight: "+" | "╮";
|
|
40
|
+
readonly bottomLeft: "+" | "╰";
|
|
41
|
+
readonly bottomRight: "+" | "╯";
|
|
42
|
+
readonly tee: "|" | "├";
|
|
43
|
+
readonly teeEnd: "\\" | "└";
|
|
44
|
+
};
|
|
45
|
+
export declare function box(content: string, options?: {
|
|
46
|
+
title?: string;
|
|
47
|
+
padding?: number;
|
|
48
|
+
}): string;
|
|
49
|
+
export declare function table(headers: string[], rows: string[][]): string;
|
|
50
|
+
export declare class Spinner {
|
|
51
|
+
private frames;
|
|
52
|
+
private frameIndex;
|
|
53
|
+
private interval;
|
|
54
|
+
private message;
|
|
55
|
+
constructor(message: string);
|
|
56
|
+
start(): this;
|
|
57
|
+
succeed(msg?: string): void;
|
|
58
|
+
fail(msg?: string): void;
|
|
59
|
+
info(msg?: string): void;
|
|
60
|
+
stop(): void;
|
|
61
|
+
}
|
|
62
|
+
export declare function header(text: string): void;
|
|
63
|
+
export declare function success(msg: string): void;
|
|
64
|
+
export declare function error(msg: string): void;
|
|
65
|
+
export declare function warn(msg: string): void;
|
|
66
|
+
export declare function info(msg: string): void;
|
|
67
|
+
export declare function label(key: string, value: string): void;
|
|
68
|
+
export declare function newline(): void;
|
|
69
|
+
export declare function divider(): void;
|
|
70
|
+
export declare function banner(): void;
|
|
71
|
+
export declare function elapsed(startMs: number): string;
|
|
72
|
+
export declare function stripAnsi(s: string): string;
|
|
73
|
+
export declare function redactUrl(url: string): string;
|
package/dist/cjs/cli/ui.js
CHANGED
|
@@ -229,16 +229,58 @@ function stripAnsi(s) {
|
|
|
229
229
|
// Redact password from connection URL
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
function redactUrl(url) {
|
|
232
|
-
return (url
|
|
233
|
-
// Userinfo credentials. Anchored on `<scheme>://<user>:` and consuming up
|
|
234
|
-
// to the LAST `@` before the next `/` (or end of authority), because a
|
|
235
|
-
// password may legally contain `:`, `/`, and even `@` in percent-decoded
|
|
236
|
-
// form. The previous `:([^@/:]+)@` could not span any of those, so
|
|
237
|
-
// `postgres://u:pa/ss@host/db` came through completely unredacted and
|
|
238
|
-
// `postgres://u:a@b@host/db` leaked the tail. Global: one string may
|
|
239
|
-
// carry several URLs (a primary + replica pair).
|
|
240
|
-
.replace(/(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))/g, '$1***@')
|
|
232
|
+
return (redactUserinfo(url)
|
|
241
233
|
// Query-string password params: `password=`, `sslpassword=`, and similar,
|
|
242
234
|
// case-insensitive. Value runs up to the next `&`, `#`, or end of string.
|
|
243
235
|
.replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
|
|
244
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Replace `<scheme>://<user>:<password>@` with `<scheme>://<user>:***@`, for
|
|
239
|
+
* every occurrence in the string (one message may carry a primary + replica
|
|
240
|
+
* pair). A password may legally contain `:`, `/`, and even `@` in
|
|
241
|
+
* percent-decoded form, so the credential runs to the LAST `@` of the authority.
|
|
242
|
+
*
|
|
243
|
+
* Deliberately a single forward scan rather than one regex: the previous
|
|
244
|
+
* `(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))` nested two lazy
|
|
245
|
+
* quantifiers under a lookahead and backtracked quadratically on input with no
|
|
246
|
+
* `@` at all (a 60 KB error message took ~16s). `redactUrl` runs on error text
|
|
247
|
+
* echoed back from Postgres, so that stalled the CLI. This version visits every
|
|
248
|
+
* character at most once.
|
|
249
|
+
*/
|
|
250
|
+
function redactUserinfo(input) {
|
|
251
|
+
const scheme = /\w+:\/\//g;
|
|
252
|
+
let out = '';
|
|
253
|
+
let copied = 0;
|
|
254
|
+
for (let m = scheme.exec(input); m; m = scheme.exec(input)) {
|
|
255
|
+
const start = m.index + m[0].length;
|
|
256
|
+
// Scan the run of non-whitespace characters that could hold userinfo,
|
|
257
|
+
// remembering the last `@`. A `/`, `?`, or `#` only ends the run once an `@`
|
|
258
|
+
// has been seen, so a password containing one is still covered; a URL with
|
|
259
|
+
// no credentials simply reaches the end of the run with no `@` recorded.
|
|
260
|
+
let i = start;
|
|
261
|
+
let lastAt = -1;
|
|
262
|
+
for (; i < input.length; i++) {
|
|
263
|
+
const ch = input.charAt(i);
|
|
264
|
+
if (/\s/.test(ch))
|
|
265
|
+
break;
|
|
266
|
+
if (ch === '@') {
|
|
267
|
+
lastAt = i;
|
|
268
|
+
}
|
|
269
|
+
else if (lastAt !== -1 && (ch === '/' || ch === '?' || ch === '#')) {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (lastAt !== -1) {
|
|
274
|
+
const colon = input.slice(start, lastAt).indexOf(':');
|
|
275
|
+
if (colon !== -1) {
|
|
276
|
+
out += `${input.slice(copied, start + colon + 1)}***@`;
|
|
277
|
+
copied = lastAt + 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Resume after the scanned run. Every character in it has been accounted
|
|
281
|
+
// for, and a credential cannot span whitespace, so nothing is missed and the
|
|
282
|
+
// total work stays linear however many `scheme://` prefixes the input holds.
|
|
283
|
+
scheme.lastIndex = Math.max(i, start);
|
|
284
|
+
}
|
|
285
|
+
return copied === 0 ? input : out + input.slice(copied);
|
|
286
|
+
}
|