turbine-orm 0.18.0 → 0.19.1
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 +8 -8
- package/dist/adapters/index.d.ts +3 -2
- package/dist/cjs/cli/index.js +26 -4
- package/dist/cjs/cli/loader.js +62 -7
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +54 -76
- package/dist/cjs/client.js +8 -0
- package/dist/cjs/query/builder.js +261 -51
- package/dist/cli/index.js +28 -6
- package/dist/cli/loader.d.ts +22 -5
- package/dist/cli/loader.js +61 -7
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +9 -4
- package/dist/cli/studio.js +54 -76
- package/dist/client.js +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/query/builder.d.ts +35 -0
- package/dist/query/builder.js +261 -51
- package/dist/query/index.d.ts +1 -1
- package/package.json +3 -3
- 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.
|
|
@@ -155,6 +160,13 @@ async function handleRequest(req, res, ctx) {
|
|
|
155
160
|
sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML);
|
|
156
161
|
return;
|
|
157
162
|
}
|
|
163
|
+
// Favicon — answered before the auth gate so the browser's automatic request
|
|
164
|
+
// doesn't 401/404 on every load. No icon body needed (204).
|
|
165
|
+
if (pathname === '/favicon.ico') {
|
|
166
|
+
res.writeHead(204, { 'Content-Length': '0' });
|
|
167
|
+
res.end();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
158
170
|
// API routes — all require auth.
|
|
159
171
|
if (!isAuthorized(req, ctx.authToken)) {
|
|
160
172
|
sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
|
|
@@ -175,9 +187,6 @@ async function handleRequest(req, res, ctx) {
|
|
|
175
187
|
const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
|
|
176
188
|
return apiTableRows(res, ctx, rawName, url.searchParams);
|
|
177
189
|
}
|
|
178
|
-
if (pathname === '/api/query' && req.method === 'POST') {
|
|
179
|
-
return apiQuery(req, res, ctx);
|
|
180
|
-
}
|
|
181
190
|
if (pathname === '/api/builder' && req.method === 'POST') {
|
|
182
191
|
return apiBuilder(req, res, ctx);
|
|
183
192
|
}
|
|
@@ -237,6 +246,15 @@ function constantTimeEqual(a, b) {
|
|
|
237
246
|
}
|
|
238
247
|
return result === 0;
|
|
239
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Build a helpful "unknown table" error that lists the available tables so the
|
|
251
|
+
* caller can spot a typo or schema mismatch immediately.
|
|
252
|
+
*/
|
|
253
|
+
function unknownTableMessage(name, ctx) {
|
|
254
|
+
const available = Object.keys(ctx.metadata.tables);
|
|
255
|
+
const list = available.length ? available.join(', ') : '(none)';
|
|
256
|
+
return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
|
|
257
|
+
}
|
|
240
258
|
// ---------------------------------------------------------------------------
|
|
241
259
|
// API: /api/schema
|
|
242
260
|
// ---------------------------------------------------------------------------
|
|
@@ -269,7 +287,9 @@ async function apiSchema(res, ctx) {
|
|
|
269
287
|
WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
|
|
270
288
|
const counts = new Map();
|
|
271
289
|
for (const row of countsResult.rows) {
|
|
272
|
-
|
|
290
|
+
// pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
|
|
291
|
+
// sidebar never shows a negative estimate.
|
|
292
|
+
counts.set(row.relname, Math.max(0, Number(row.reltuples)));
|
|
273
293
|
}
|
|
274
294
|
sendJson(res, 200, {
|
|
275
295
|
schema: ctx.options.schema,
|
|
@@ -283,7 +303,7 @@ async function apiSchema(res, ctx) {
|
|
|
283
303
|
async function apiTableRows(res, ctx, rawTableName, params) {
|
|
284
304
|
const table = ctx.metadata.tables[rawTableName];
|
|
285
305
|
if (!table) {
|
|
286
|
-
sendJson(res, 404, { error:
|
|
306
|
+
sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
|
|
287
307
|
return;
|
|
288
308
|
}
|
|
289
309
|
const limit = clampInt(params.get('limit'), 50, 1, 500);
|
|
@@ -378,54 +398,6 @@ function escapeLikePattern(s) {
|
|
|
378
398
|
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
379
399
|
}
|
|
380
400
|
// ---------------------------------------------------------------------------
|
|
381
|
-
// API: /api/query — read-only SELECT/WITH runner
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
async function apiQuery(req, res, ctx) {
|
|
384
|
-
const body = await readJsonBody(req);
|
|
385
|
-
const rawSql = typeof body?.sql === 'string' ? body.sql.trim() : '';
|
|
386
|
-
if (!rawSql) {
|
|
387
|
-
sendJson(res, 400, { error: 'missing sql' });
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (rawSql.length > 10_000) {
|
|
391
|
-
sendJson(res, 400, { error: 'query too long — maximum 10,000 characters allowed' });
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
if (!isReadOnlyStatement(rawSql)) {
|
|
395
|
-
sendJson(res, 400, {
|
|
396
|
-
error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
|
|
397
|
-
});
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
const client = await ctx.pool.connect();
|
|
401
|
-
try {
|
|
402
|
-
await client.query('BEGIN READ ONLY');
|
|
403
|
-
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
404
|
-
const started = Date.now();
|
|
405
|
-
const result = await client.query(rawSql);
|
|
406
|
-
const elapsedMs = Date.now() - started;
|
|
407
|
-
await client.query('COMMIT');
|
|
408
|
-
sendJson(res, 200, {
|
|
409
|
-
columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
410
|
-
rows: result.rows.map((r) => serializeRow(r)),
|
|
411
|
-
rowCount: result.rowCount ?? result.rows.length,
|
|
412
|
-
elapsedMs,
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
catch (err) {
|
|
416
|
-
try {
|
|
417
|
-
await client.query('ROLLBACK');
|
|
418
|
-
}
|
|
419
|
-
catch {
|
|
420
|
-
/* ignore */
|
|
421
|
-
}
|
|
422
|
-
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
423
|
-
}
|
|
424
|
-
finally {
|
|
425
|
-
client.release();
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
// ---------------------------------------------------------------------------
|
|
429
401
|
// API: /api/builder — Turbine ORM findMany spec runner
|
|
430
402
|
// ---------------------------------------------------------------------------
|
|
431
403
|
async function apiBuilder(req, res, ctx) {
|
|
@@ -433,7 +405,7 @@ async function apiBuilder(req, res, ctx) {
|
|
|
433
405
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
434
406
|
const args = (body?.args ?? {});
|
|
435
407
|
if (!tableName || !ctx.metadata.tables[tableName]) {
|
|
436
|
-
sendJson(res, 400, { error:
|
|
408
|
+
sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
|
|
437
409
|
return;
|
|
438
410
|
}
|
|
439
411
|
let deferred;
|
|
@@ -453,6 +425,11 @@ async function apiBuilder(req, res, ctx) {
|
|
|
453
425
|
try {
|
|
454
426
|
await client.query('BEGIN READ ONLY');
|
|
455
427
|
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
428
|
+
// QueryInterface emits unqualified table identifiers, which resolve via
|
|
429
|
+
// the connection's search_path. Pin it to the configured --schema so the
|
|
430
|
+
// Query tab reads the same schema as the Data tab (set_config is
|
|
431
|
+
// transaction-local and fully parameterized).
|
|
432
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
456
433
|
const started = Date.now();
|
|
457
434
|
const result = await client.query(deferred.sql, deferred.params);
|
|
458
435
|
const elapsedMs = Date.now() - started;
|
|
@@ -481,6 +458,8 @@ async function apiBuilder(req, res, ctx) {
|
|
|
481
458
|
function savedQueriesPath(ctx) {
|
|
482
459
|
return (0, node_path_1.resolve)(ctx.stateDir, 'studio-queries.json');
|
|
483
460
|
}
|
|
461
|
+
/** One-shot flag so the legacy saved-query notice isn't logged on every request. */
|
|
462
|
+
let legacyDropNoticeShown = false;
|
|
484
463
|
function loadSavedQueries(ctx) {
|
|
485
464
|
const file = savedQueriesPath(ctx);
|
|
486
465
|
if (!(0, node_fs_1.existsSync)(file))
|
|
@@ -490,7 +469,17 @@ function loadSavedQueries(ctx) {
|
|
|
490
469
|
const parsed = JSON.parse(raw);
|
|
491
470
|
if (!parsed.queries || !Array.isArray(parsed.queries))
|
|
492
471
|
return { version: 1, queries: [] };
|
|
493
|
-
|
|
472
|
+
// Drop any legacy raw-SQL entries — Studio is builder-only now. Tell the
|
|
473
|
+
// user instead of silently discarding their saved work (the file on disk
|
|
474
|
+
// is only rewritten when a new query is saved, so this is recoverable).
|
|
475
|
+
const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
|
|
476
|
+
const dropped = parsed.queries.length - queries.length;
|
|
477
|
+
if (dropped > 0 && !legacyDropNoticeShown) {
|
|
478
|
+
legacyDropNoticeShown = true;
|
|
479
|
+
console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file} — ` +
|
|
480
|
+
'Studio is builder-only since v0.19. The entries remain in the file until a new query is saved.');
|
|
481
|
+
}
|
|
482
|
+
return { version: 1, queries };
|
|
494
483
|
}
|
|
495
484
|
catch {
|
|
496
485
|
return { version: 1, queries: [] };
|
|
@@ -513,27 +502,17 @@ async function apiCreateSavedQuery(req, res, ctx) {
|
|
|
513
502
|
const body = await readJsonBody(req);
|
|
514
503
|
const table = typeof body?.table === 'string' ? body.table : '';
|
|
515
504
|
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
516
|
-
const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
|
|
517
505
|
if (!table || !ctx.metadata.tables[table]) {
|
|
518
|
-
sendJson(res, 400, { error:
|
|
506
|
+
sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
|
|
519
507
|
return;
|
|
520
508
|
}
|
|
521
509
|
if (!name) {
|
|
522
510
|
sendJson(res, 400, { error: 'name is required' });
|
|
523
511
|
return;
|
|
524
512
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
const sql = kind === 'sql' && typeof body?.sql === 'string' ? body.sql : undefined;
|
|
530
|
-
const args = kind === 'builder' ? body?.args : undefined;
|
|
531
|
-
if (kind === 'sql' && !sql) {
|
|
532
|
-
sendJson(res, 400, { error: 'sql is required for kind=sql' });
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
if (kind === 'sql' && sql && !isReadOnlyStatement(sql)) {
|
|
536
|
-
sendJson(res, 400, { error: 'saved sql must be SELECT/WITH only' });
|
|
513
|
+
// Studio only persists visual-builder queries (no raw SQL surface).
|
|
514
|
+
if (body?.kind !== 'builder') {
|
|
515
|
+
sendJson(res, 400, { error: 'kind must be "builder"' });
|
|
537
516
|
return;
|
|
538
517
|
}
|
|
539
518
|
const data = loadSavedQueries(ctx);
|
|
@@ -541,9 +520,8 @@ async function apiCreateSavedQuery(req, res, ctx) {
|
|
|
541
520
|
id: (0, node_crypto_1.randomUUID)(),
|
|
542
521
|
table,
|
|
543
522
|
name,
|
|
544
|
-
kind,
|
|
545
|
-
|
|
546
|
-
args,
|
|
523
|
+
kind: 'builder',
|
|
524
|
+
args: body?.args,
|
|
547
525
|
createdAt: new Date().toISOString(),
|
|
548
526
|
};
|
|
549
527
|
data.queries.push(entry);
|
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).
|