turbine-orm 0.19.0 → 0.19.2
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 +83 -15
- package/dist/adapters/index.d.ts +3 -2
- package/dist/cjs/cli/index.js +43 -13
- package/dist/cjs/cli/loader.js +62 -7
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +25 -35
- package/dist/cjs/client.js +20 -13
- package/dist/cjs/query/builder.js +342 -104
- package/dist/cjs/query/utils.js +1 -0
- package/dist/cli/index.js +45 -15
- package/dist/cli/loader.d.ts +22 -5
- package/dist/cli/loader.js +61 -7
- package/dist/cli/migrate.d.ts +2 -2
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +9 -14
- package/dist/cli/studio.js +25 -34
- package/dist/client.d.ts +12 -13
- package/dist/client.js +20 -13
- package/dist/index.d.ts +1 -1
- package/dist/query/builder.d.ts +43 -6
- package/dist/query/builder.js +342 -104
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +62 -12
- package/dist/query/utils.js +1 -0
- package/package.json +4 -4
- package/dist/cjs/query.js +0 -2711
- package/dist/query.d.ts +0 -878
- package/dist/query.js +0 -2705
package/dist/cjs/cli/studio.js
CHANGED
|
@@ -3,15 +3,20 @@
|
|
|
3
3
|
* turbine-orm CLI — Studio
|
|
4
4
|
*
|
|
5
5
|
* A local, read-only web UI for browsing databases, exploring relations,
|
|
6
|
-
* and
|
|
7
|
-
*
|
|
6
|
+
* and composing queries visually. ORM-native since v0.19: there is no
|
|
7
|
+
* raw-SQL input surface — the Query tab builds `findMany` args that are
|
|
8
|
+
* validated against introspected metadata and compiled by QueryInterface
|
|
9
|
+
* (`/api/builder`). Pure Node (built-in `http` module), no runtime
|
|
10
|
+
* dependencies beyond `pg`, bound to 127.0.0.1 only.
|
|
8
11
|
*
|
|
9
12
|
* Security model:
|
|
10
13
|
* • Bind 127.0.0.1 only (never 0.0.0.0 — no LAN exposure)
|
|
11
14
|
* • Random auth token generated per process, required in Cookie header
|
|
12
|
-
* •
|
|
15
|
+
* • No SQL input surface at all — every identifier in a builder request is
|
|
16
|
+
* validated against the introspected schema; all values are $N params
|
|
13
17
|
* • Every query runs in a READ ONLY transaction (belt-and-suspenders)
|
|
14
|
-
* • 30s statement timeout
|
|
18
|
+
* • 30s statement timeout via parameterized set_config()
|
|
19
|
+
* • Per-session rate limiting, CSP + security headers, cross-origin refusal
|
|
15
20
|
*
|
|
16
21
|
* Not implemented (deliberately): row editing, DDL, destructive operations.
|
|
17
22
|
* Studio is for inspection. Use the CLI, migrate, or raw SQL for writes.
|
|
@@ -29,7 +34,6 @@ exports.apiBuilder = apiBuilder;
|
|
|
29
34
|
exports.apiListSavedQueries = apiListSavedQueries;
|
|
30
35
|
exports.apiCreateSavedQuery = apiCreateSavedQuery;
|
|
31
36
|
exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
|
|
32
|
-
exports.isReadOnlyStatement = isReadOnlyStatement;
|
|
33
37
|
const node_child_process_1 = require("node:child_process");
|
|
34
38
|
const node_crypto_1 = require("node:crypto");
|
|
35
39
|
const node_fs_1 = require("node:fs");
|
|
@@ -420,6 +424,11 @@ async function apiBuilder(req, res, ctx) {
|
|
|
420
424
|
try {
|
|
421
425
|
await client.query('BEGIN READ ONLY');
|
|
422
426
|
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
427
|
+
// QueryInterface emits unqualified table identifiers, which resolve via
|
|
428
|
+
// the connection's search_path. Pin it to the configured --schema so the
|
|
429
|
+
// Query tab reads the same schema as the Data tab (set_config is
|
|
430
|
+
// transaction-local and fully parameterized).
|
|
431
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
423
432
|
const started = Date.now();
|
|
424
433
|
const result = await client.query(deferred.sql, deferred.params);
|
|
425
434
|
const elapsedMs = Date.now() - started;
|
|
@@ -448,6 +457,8 @@ async function apiBuilder(req, res, ctx) {
|
|
|
448
457
|
function savedQueriesPath(ctx) {
|
|
449
458
|
return (0, node_path_1.resolve)(ctx.stateDir, 'studio-queries.json');
|
|
450
459
|
}
|
|
460
|
+
/** One-shot flag so the legacy saved-query notice isn't logged on every request. */
|
|
461
|
+
let legacyDropNoticeShown = false;
|
|
451
462
|
function loadSavedQueries(ctx) {
|
|
452
463
|
const file = savedQueriesPath(ctx);
|
|
453
464
|
if (!(0, node_fs_1.existsSync)(file))
|
|
@@ -457,8 +468,16 @@ function loadSavedQueries(ctx) {
|
|
|
457
468
|
const parsed = JSON.parse(raw);
|
|
458
469
|
if (!parsed.queries || !Array.isArray(parsed.queries))
|
|
459
470
|
return { version: 1, queries: [] };
|
|
460
|
-
// Drop any legacy raw-SQL entries — Studio is builder-only now.
|
|
471
|
+
// Drop any legacy raw-SQL entries — Studio is builder-only now. Tell the
|
|
472
|
+
// user instead of silently discarding their saved work (the file on disk
|
|
473
|
+
// is only rewritten when a new query is saved, so this is recoverable).
|
|
461
474
|
const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
|
|
475
|
+
const dropped = parsed.queries.length - queries.length;
|
|
476
|
+
if (dropped > 0 && !legacyDropNoticeShown) {
|
|
477
|
+
legacyDropNoticeShown = true;
|
|
478
|
+
console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file} — ` +
|
|
479
|
+
'Studio is builder-only since v0.19. The entries remain in the file until a new query is saved.');
|
|
480
|
+
}
|
|
462
481
|
return { version: 1, queries };
|
|
463
482
|
}
|
|
464
483
|
catch {
|
|
@@ -530,35 +549,6 @@ function clampInt(value, fallback, min, max) {
|
|
|
530
549
|
return fallback;
|
|
531
550
|
return Math.min(Math.max(n, min), max);
|
|
532
551
|
}
|
|
533
|
-
/**
|
|
534
|
-
* Accept only SELECT or WITH (CTE) statements. Reject any statement that
|
|
535
|
-
* contains a semicolon followed by non-whitespace (prevents statement
|
|
536
|
-
* stacking), and require the first non-comment keyword to be SELECT or WITH.
|
|
537
|
-
*
|
|
538
|
-
* This is a first-line filter — the transaction's READ ONLY mode is the
|
|
539
|
-
* second line of defense. Both must fail before a destructive statement
|
|
540
|
-
* could run.
|
|
541
|
-
*/
|
|
542
|
-
function isReadOnlyStatement(sql) {
|
|
543
|
-
const stripped = stripSqlComments(sql).trim();
|
|
544
|
-
if (!stripped)
|
|
545
|
-
return false;
|
|
546
|
-
// Disallow statement stacking. A single trailing `;` is fine.
|
|
547
|
-
const withoutTrailingSemi = stripped.replace(/;+\s*$/, '');
|
|
548
|
-
if (withoutTrailingSemi.includes(';'))
|
|
549
|
-
return false;
|
|
550
|
-
const firstWord = withoutTrailingSemi.slice(0, 6).toUpperCase();
|
|
551
|
-
if (firstWord.startsWith('SELECT'))
|
|
552
|
-
return true;
|
|
553
|
-
if (firstWord.startsWith('WITH'))
|
|
554
|
-
return true;
|
|
555
|
-
return false;
|
|
556
|
-
}
|
|
557
|
-
function stripSqlComments(sql) {
|
|
558
|
-
// Strip -- line comments and /* block comments */. Not a full SQL parser,
|
|
559
|
-
// but sufficient to catch the common bypass attempts.
|
|
560
|
-
return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
561
|
-
}
|
|
562
552
|
function serializeRow(row) {
|
|
563
553
|
const out = {};
|
|
564
554
|
for (const [k, v] of Object.entries(row)) {
|
package/dist/cjs/client.js
CHANGED
|
@@ -210,6 +210,14 @@ class TurbineClient {
|
|
|
210
210
|
/** Active LISTEN subscriptions — torn down on disconnect() so it never hangs */
|
|
211
211
|
activeSubscriptions = new Set();
|
|
212
212
|
constructor(config = {}, schema) {
|
|
213
|
+
// Constructing without schema metadata previously crashed deep in the
|
|
214
|
+
// constructor with an opaque "Cannot read properties of undefined
|
|
215
|
+
// (reading 'tables')". Fail fast with an actionable message instead.
|
|
216
|
+
if (!schema || typeof schema !== 'object' || !schema.tables) {
|
|
217
|
+
throw new errors_js_1.ValidationError('[turbine] TurbineClient requires schema metadata as its second argument. ' +
|
|
218
|
+
'Run `npx turbine generate` and use the generated client (`turbine()` from your output dir), ' +
|
|
219
|
+
'or pass the generated `schemaMetadata` object: new TurbineClient(config, schemaMetadata).');
|
|
220
|
+
}
|
|
213
221
|
/**
|
|
214
222
|
* Parse int8 (bigint, OID 20) as JavaScript number instead of string.
|
|
215
223
|
* Safe for values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
|
|
@@ -323,12 +331,14 @@ class TurbineClient {
|
|
|
323
331
|
// Middleware — intercept all queries
|
|
324
332
|
// -------------------------------------------------------------------------
|
|
325
333
|
/**
|
|
326
|
-
* Register a middleware function that runs
|
|
334
|
+
* Register a middleware function that runs around every query.
|
|
327
335
|
*
|
|
328
|
-
* Middleware can inspect and log query parameters,
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
336
|
+
* Middleware can inspect and log query parameters, measure timing, and
|
|
337
|
+
* transform the result returned by `next()`. Note: query SQL is generated
|
|
338
|
+
* BEFORE middleware runs — `params.args` is a read-only snapshot, and
|
|
339
|
+
* mutating it does NOT change the executed SQL. Cross-cutting filters
|
|
340
|
+
* (e.g. soft deletes) belong in the query itself: pass an explicit
|
|
341
|
+
* `where: { deletedAt: null }` or wrap the table accessor in a small helper.
|
|
332
342
|
*
|
|
333
343
|
* @example
|
|
334
344
|
* ```ts
|
|
@@ -340,16 +350,13 @@ class TurbineClient {
|
|
|
340
350
|
* return result;
|
|
341
351
|
* });
|
|
342
352
|
*
|
|
343
|
-
* //
|
|
353
|
+
* // Result transformation middleware — redact a field on the way out
|
|
344
354
|
* db.$use(async (params, next) => {
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
* if (params.action === 'delete') {
|
|
349
|
-
* params.action = 'update';
|
|
350
|
-
* params.args = { where: params.args.where, data: { deletedAt: new Date() } };
|
|
355
|
+
* const result = await next(params);
|
|
356
|
+
* if (params.model === 'users' && Array.isArray(result)) {
|
|
357
|
+
* for (const row of result as { email?: string }[]) row.email = '[redacted]';
|
|
351
358
|
* }
|
|
352
|
-
* return
|
|
359
|
+
* return result;
|
|
353
360
|
* });
|
|
354
361
|
* ```
|
|
355
362
|
*/
|