turbine-orm 0.18.0 → 0.19.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.
@@ -155,6 +155,13 @@ async function handleRequest(req, res, ctx) {
155
155
  sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML);
156
156
  return;
157
157
  }
158
+ // Favicon — answered before the auth gate so the browser's automatic request
159
+ // doesn't 401/404 on every load. No icon body needed (204).
160
+ if (pathname === '/favicon.ico') {
161
+ res.writeHead(204, { 'Content-Length': '0' });
162
+ res.end();
163
+ return;
164
+ }
158
165
  // API routes — all require auth.
159
166
  if (!isAuthorized(req, ctx.authToken)) {
160
167
  sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
@@ -175,9 +182,6 @@ async function handleRequest(req, res, ctx) {
175
182
  const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
176
183
  return apiTableRows(res, ctx, rawName, url.searchParams);
177
184
  }
178
- if (pathname === '/api/query' && req.method === 'POST') {
179
- return apiQuery(req, res, ctx);
180
- }
181
185
  if (pathname === '/api/builder' && req.method === 'POST') {
182
186
  return apiBuilder(req, res, ctx);
183
187
  }
@@ -237,6 +241,15 @@ function constantTimeEqual(a, b) {
237
241
  }
238
242
  return result === 0;
239
243
  }
244
+ /**
245
+ * Build a helpful "unknown table" error that lists the available tables so the
246
+ * caller can spot a typo or schema mismatch immediately.
247
+ */
248
+ function unknownTableMessage(name, ctx) {
249
+ const available = Object.keys(ctx.metadata.tables);
250
+ const list = available.length ? available.join(', ') : '(none)';
251
+ return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
252
+ }
240
253
  // ---------------------------------------------------------------------------
241
254
  // API: /api/schema
242
255
  // ---------------------------------------------------------------------------
@@ -269,7 +282,9 @@ async function apiSchema(res, ctx) {
269
282
  WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
270
283
  const counts = new Map();
271
284
  for (const row of countsResult.rows) {
272
- counts.set(row.relname, Number(row.reltuples));
285
+ // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
286
+ // sidebar never shows a negative estimate.
287
+ counts.set(row.relname, Math.max(0, Number(row.reltuples)));
273
288
  }
274
289
  sendJson(res, 200, {
275
290
  schema: ctx.options.schema,
@@ -283,7 +298,7 @@ async function apiSchema(res, ctx) {
283
298
  async function apiTableRows(res, ctx, rawTableName, params) {
284
299
  const table = ctx.metadata.tables[rawTableName];
285
300
  if (!table) {
286
- sendJson(res, 404, { error: `unknown table: ${rawTableName}` });
301
+ sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
287
302
  return;
288
303
  }
289
304
  const limit = clampInt(params.get('limit'), 50, 1, 500);
@@ -378,54 +393,6 @@ function escapeLikePattern(s) {
378
393
  return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
379
394
  }
380
395
  // ---------------------------------------------------------------------------
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
396
  // API: /api/builder — Turbine ORM findMany spec runner
430
397
  // ---------------------------------------------------------------------------
431
398
  async function apiBuilder(req, res, ctx) {
@@ -433,7 +400,7 @@ async function apiBuilder(req, res, ctx) {
433
400
  const tableName = typeof body?.table === 'string' ? body.table : '';
434
401
  const args = (body?.args ?? {});
435
402
  if (!tableName || !ctx.metadata.tables[tableName]) {
436
- sendJson(res, 400, { error: `unknown table: ${tableName}` });
403
+ sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
437
404
  return;
438
405
  }
439
406
  let deferred;
@@ -490,7 +457,9 @@ function loadSavedQueries(ctx) {
490
457
  const parsed = JSON.parse(raw);
491
458
  if (!parsed.queries || !Array.isArray(parsed.queries))
492
459
  return { version: 1, queries: [] };
493
- return { version: 1, queries: parsed.queries };
460
+ // Drop any legacy raw-SQL entries — Studio is builder-only now.
461
+ const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
462
+ return { version: 1, queries };
494
463
  }
495
464
  catch {
496
465
  return { version: 1, queries: [] };
@@ -513,27 +482,17 @@ async function apiCreateSavedQuery(req, res, ctx) {
513
482
  const body = await readJsonBody(req);
514
483
  const table = typeof body?.table === 'string' ? body.table : '';
515
484
  const name = typeof body?.name === 'string' ? body.name.trim() : '';
516
- const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
517
485
  if (!table || !ctx.metadata.tables[table]) {
518
- sendJson(res, 400, { error: `unknown table: ${table}` });
486
+ sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
519
487
  return;
520
488
  }
521
489
  if (!name) {
522
490
  sendJson(res, 400, { error: 'name is required' });
523
491
  return;
524
492
  }
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' });
493
+ // Studio only persists visual-builder queries (no raw SQL surface).
494
+ if (body?.kind !== 'builder') {
495
+ sendJson(res, 400, { error: 'kind must be "builder"' });
537
496
  return;
538
497
  }
539
498
  const data = loadSavedQueries(ctx);
@@ -541,9 +500,8 @@ async function apiCreateSavedQuery(req, res, ctx) {
541
500
  id: (0, node_crypto_1.randomUUID)(),
542
501
  table,
543
502
  name,
544
- kind,
545
- sql,
546
- args,
503
+ kind: 'builder',
504
+ args: body?.args,
547
505
  createdAt: new Date().toISOString(),
548
506
  };
549
507
  data.queries.push(entry);
@@ -1723,12 +1723,24 @@ class QueryInterface {
1723
1723
  */
1724
1724
  resolveColumns(select, omit) {
1725
1725
  if (select) {
1726
+ // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
1727
+ // style) instead of the object shape. Object.entries() would iterate the
1728
+ // numeric indices and throw a cryptic `Unknown field "0"` — catch it early
1729
+ // with an actionable message.
1730
+ if (Array.isArray(select)) {
1731
+ throw new errors_js_1.ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
1732
+ `(e.g. { id: true, name: true }), not an array.`);
1733
+ }
1726
1734
  // Only include columns where value is true
1727
1735
  return Object.entries(select)
1728
1736
  .filter(([, v]) => v)
1729
1737
  .map(([k]) => this.toColumn(k));
1730
1738
  }
1731
1739
  if (omit) {
1740
+ if (Array.isArray(omit)) {
1741
+ throw new errors_js_1.ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
1742
+ `(e.g. { createdAt: true }), not an array.`);
1743
+ }
1732
1744
  // Include all columns except those where value is true
1733
1745
  const omitCols = new Set(Object.entries(omit)
1734
1746
  .filter(([, v]) => v)
@@ -2307,8 +2319,10 @@ class QueryInterface {
2307
2319
  params.push(v);
2308
2320
  }
2309
2321
  }
2310
- // limit param
2311
- if (spec.limit) {
2322
+ // limit param — only hasMany parameterizes its limit (mirrors
2323
+ // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2324
+ // pushing one here would orphan a param and desync the collect path.
2325
+ if (relDef.type === 'hasMany' && spec.limit) {
2312
2326
  params.push(Number(spec.limit));
2313
2327
  }
2314
2328
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2516,6 +2530,25 @@ class QueryInterface {
2516
2530
  andClauses.push(...opClauses);
2517
2531
  continue;
2518
2532
  }
2533
+ // Strict validation: a plain (non-array, non-Date) object on a non-JSON
2534
+ // column matched no known filter shape — almost always a misspelled
2535
+ // operator (`startWith` for `startsWith`) or a stray nested object.
2536
+ // Silently treating it as `col = $1` returns wrong rows with no error, so
2537
+ // throw with the offending keys and the supported operator list. JSON/JSONB
2538
+ // columns legitimately accept object values for equality, so they fall
2539
+ // through unchanged.
2540
+ if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
2541
+ const colType = this.getColumnPgType(rawColumn);
2542
+ if (colType !== 'json' && colType !== 'jsonb') {
2543
+ const badKeys = Object.keys(value);
2544
+ throw new errors_js_1.ValidationError(badKeys.length === 0
2545
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
2546
+ `Provide a value or an operator like { gt: 1 }.`
2547
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2548
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
2549
+ `Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
2550
+ }
2551
+ }
2519
2552
  // Plain equality
2520
2553
  params.push(value);
2521
2554
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2699,7 +2732,8 @@ class QueryInterface {
2699
2732
  return Object.entries(orderBy)
2700
2733
  .map(([key, dir]) => {
2701
2734
  if (meta && !(key in meta.columnMap)) {
2702
- throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2735
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
2736
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
2703
2737
  }
2704
2738
  // Vector KNN ordering: { distance: { to, metric, direction? } }
2705
2739
  if (isVectorOrderBy(dir)) {
@@ -3128,9 +3162,13 @@ class QueryInterface {
3128
3162
  whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
3129
3163
  }
3130
3164
  }
3131
- // LIMIT
3165
+ // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3166
+ // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3167
+ // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3168
+ // which Postgres rejects with "could not determine data type of parameter $N"
3169
+ // (and shifts every later placeholder by one). To-one relations ignore limit.
3132
3170
  let limitClause = '';
3133
- if (spec !== true && spec.limit) {
3171
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
3134
3172
  params.push(Number(spec.limit));
3135
3173
  limitClause = ` LIMIT ${this.p(params.length)}`;
3136
3174
  }