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.
@@ -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 running SELECT queries. Pure Node (built-in `http` module), no
7
- * runtime dependencies beyond `pg`, bound to 127.0.0.1 only.
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
- * • SELECT/WITH-only guard on the query endpoint
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
- counts.set(row.relname, Number(row.reltuples));
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: `unknown table: ${rawTableName}` });
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: `unknown table: ${tableName}` });
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
- return { version: 1, queries: parsed.queries };
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: `unknown table: ${table}` });
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
- if (!kind) {
526
- sendJson(res, 400, { error: 'kind must be "sql" or "builder"' });
527
- return;
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
- sql,
546
- args,
523
+ kind: 'builder',
524
+ args: body?.args,
547
525
  createdAt: new Date().toISOString(),
548
526
  };
549
527
  data.queries.push(entry);
@@ -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).