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.
@@ -140,6 +140,13 @@ async function handleRequest(req, res, ctx) {
140
140
  sendHtml(res, 200, STUDIO_HTML);
141
141
  return;
142
142
  }
143
+ // Favicon — answered before the auth gate so the browser's automatic request
144
+ // doesn't 401/404 on every load. No icon body needed (204).
145
+ if (pathname === '/favicon.ico') {
146
+ res.writeHead(204, { 'Content-Length': '0' });
147
+ res.end();
148
+ return;
149
+ }
143
150
  // API routes — all require auth.
144
151
  if (!isAuthorized(req, ctx.authToken)) {
145
152
  sendJson(res, 401, { error: 'unauthorized — use the URL printed in the terminal' });
@@ -160,9 +167,6 @@ async function handleRequest(req, res, ctx) {
160
167
  const rawName = decodeURIComponent(pathname.slice('/api/tables/'.length));
161
168
  return apiTableRows(res, ctx, rawName, url.searchParams);
162
169
  }
163
- if (pathname === '/api/query' && req.method === 'POST') {
164
- return apiQuery(req, res, ctx);
165
- }
166
170
  if (pathname === '/api/builder' && req.method === 'POST') {
167
171
  return apiBuilder(req, res, ctx);
168
172
  }
@@ -222,6 +226,15 @@ function constantTimeEqual(a, b) {
222
226
  }
223
227
  return result === 0;
224
228
  }
229
+ /**
230
+ * Build a helpful "unknown table" error that lists the available tables so the
231
+ * caller can spot a typo or schema mismatch immediately.
232
+ */
233
+ function unknownTableMessage(name, ctx) {
234
+ const available = Object.keys(ctx.metadata.tables);
235
+ const list = available.length ? available.join(', ') : '(none)';
236
+ return `[turbine] Unknown table "${name}" in schema "${ctx.options.schema}". Available: ${list}`;
237
+ }
225
238
  // ---------------------------------------------------------------------------
226
239
  // API: /api/schema
227
240
  // ---------------------------------------------------------------------------
@@ -254,7 +267,9 @@ async function apiSchema(res, ctx) {
254
267
  WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
255
268
  const counts = new Map();
256
269
  for (const row of countsResult.rows) {
257
- counts.set(row.relname, Number(row.reltuples));
270
+ // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
271
+ // sidebar never shows a negative estimate.
272
+ counts.set(row.relname, Math.max(0, Number(row.reltuples)));
258
273
  }
259
274
  sendJson(res, 200, {
260
275
  schema: ctx.options.schema,
@@ -268,7 +283,7 @@ async function apiSchema(res, ctx) {
268
283
  export async function apiTableRows(res, ctx, rawTableName, params) {
269
284
  const table = ctx.metadata.tables[rawTableName];
270
285
  if (!table) {
271
- sendJson(res, 404, { error: `unknown table: ${rawTableName}` });
286
+ sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
272
287
  return;
273
288
  }
274
289
  const limit = clampInt(params.get('limit'), 50, 1, 500);
@@ -363,54 +378,6 @@ export function escapeLikePattern(s) {
363
378
  return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
364
379
  }
365
380
  // ---------------------------------------------------------------------------
366
- // API: /api/query — read-only SELECT/WITH runner
367
- // ---------------------------------------------------------------------------
368
- async function apiQuery(req, res, ctx) {
369
- const body = await readJsonBody(req);
370
- const rawSql = typeof body?.sql === 'string' ? body.sql.trim() : '';
371
- if (!rawSql) {
372
- sendJson(res, 400, { error: 'missing sql' });
373
- return;
374
- }
375
- if (rawSql.length > 10_000) {
376
- sendJson(res, 400, { error: 'query too long — maximum 10,000 characters allowed' });
377
- return;
378
- }
379
- if (!isReadOnlyStatement(rawSql)) {
380
- sendJson(res, 400, {
381
- error: 'only SELECT / WITH statements are allowed in Studio — use the CLI for writes',
382
- });
383
- return;
384
- }
385
- const client = await ctx.pool.connect();
386
- try {
387
- await client.query('BEGIN READ ONLY');
388
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
389
- const started = Date.now();
390
- const result = await client.query(rawSql);
391
- const elapsedMs = Date.now() - started;
392
- await client.query('COMMIT');
393
- sendJson(res, 200, {
394
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
395
- rows: result.rows.map((r) => serializeRow(r)),
396
- rowCount: result.rowCount ?? result.rows.length,
397
- elapsedMs,
398
- });
399
- }
400
- catch (err) {
401
- try {
402
- await client.query('ROLLBACK');
403
- }
404
- catch {
405
- /* ignore */
406
- }
407
- sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
408
- }
409
- finally {
410
- client.release();
411
- }
412
- }
413
- // ---------------------------------------------------------------------------
414
381
  // API: /api/builder — Turbine ORM findMany spec runner
415
382
  // ---------------------------------------------------------------------------
416
383
  export async function apiBuilder(req, res, ctx) {
@@ -418,7 +385,7 @@ export async function apiBuilder(req, res, ctx) {
418
385
  const tableName = typeof body?.table === 'string' ? body.table : '';
419
386
  const args = (body?.args ?? {});
420
387
  if (!tableName || !ctx.metadata.tables[tableName]) {
421
- sendJson(res, 400, { error: `unknown table: ${tableName}` });
388
+ sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
422
389
  return;
423
390
  }
424
391
  let deferred;
@@ -475,7 +442,9 @@ function loadSavedQueries(ctx) {
475
442
  const parsed = JSON.parse(raw);
476
443
  if (!parsed.queries || !Array.isArray(parsed.queries))
477
444
  return { version: 1, queries: [] };
478
- return { version: 1, queries: parsed.queries };
445
+ // Drop any legacy raw-SQL entries — Studio is builder-only now.
446
+ const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
447
+ return { version: 1, queries };
479
448
  }
480
449
  catch {
481
450
  return { version: 1, queries: [] };
@@ -498,27 +467,17 @@ export async function apiCreateSavedQuery(req, res, ctx) {
498
467
  const body = await readJsonBody(req);
499
468
  const table = typeof body?.table === 'string' ? body.table : '';
500
469
  const name = typeof body?.name === 'string' ? body.name.trim() : '';
501
- const kind = body?.kind === 'builder' ? 'builder' : body?.kind === 'sql' ? 'sql' : null;
502
470
  if (!table || !ctx.metadata.tables[table]) {
503
- sendJson(res, 400, { error: `unknown table: ${table}` });
471
+ sendJson(res, 400, { error: unknownTableMessage(table, ctx) });
504
472
  return;
505
473
  }
506
474
  if (!name) {
507
475
  sendJson(res, 400, { error: 'name is required' });
508
476
  return;
509
477
  }
510
- if (!kind) {
511
- sendJson(res, 400, { error: 'kind must be "sql" or "builder"' });
512
- return;
513
- }
514
- const sql = kind === 'sql' && typeof body?.sql === 'string' ? body.sql : undefined;
515
- const args = kind === 'builder' ? body?.args : undefined;
516
- if (kind === 'sql' && !sql) {
517
- sendJson(res, 400, { error: 'sql is required for kind=sql' });
518
- return;
519
- }
520
- if (kind === 'sql' && sql && !isReadOnlyStatement(sql)) {
521
- sendJson(res, 400, { error: 'saved sql must be SELECT/WITH only' });
478
+ // Studio only persists visual-builder queries (no raw SQL surface).
479
+ if (body?.kind !== 'builder') {
480
+ sendJson(res, 400, { error: 'kind must be "builder"' });
522
481
  return;
523
482
  }
524
483
  const data = loadSavedQueries(ctx);
@@ -526,9 +485,8 @@ export async function apiCreateSavedQuery(req, res, ctx) {
526
485
  id: randomUUID(),
527
486
  table,
528
487
  name,
529
- kind,
530
- sql,
531
- args,
488
+ kind: 'builder',
489
+ args: body?.args,
532
490
  createdAt: new Date().toISOString(),
533
491
  };
534
492
  data.queries.push(entry);
@@ -1687,12 +1687,24 @@ export class QueryInterface {
1687
1687
  */
1688
1688
  resolveColumns(select, omit) {
1689
1689
  if (select) {
1690
+ // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
1691
+ // style) instead of the object shape. Object.entries() would iterate the
1692
+ // numeric indices and throw a cryptic `Unknown field "0"` — catch it early
1693
+ // with an actionable message.
1694
+ if (Array.isArray(select)) {
1695
+ throw new ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
1696
+ `(e.g. { id: true, name: true }), not an array.`);
1697
+ }
1690
1698
  // Only include columns where value is true
1691
1699
  return Object.entries(select)
1692
1700
  .filter(([, v]) => v)
1693
1701
  .map(([k]) => this.toColumn(k));
1694
1702
  }
1695
1703
  if (omit) {
1704
+ if (Array.isArray(omit)) {
1705
+ throw new ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
1706
+ `(e.g. { createdAt: true }), not an array.`);
1707
+ }
1696
1708
  // Include all columns except those where value is true
1697
1709
  const omitCols = new Set(Object.entries(omit)
1698
1710
  .filter(([, v]) => v)
@@ -2271,8 +2283,10 @@ export class QueryInterface {
2271
2283
  params.push(v);
2272
2284
  }
2273
2285
  }
2274
- // limit param
2275
- if (spec.limit) {
2286
+ // limit param — only hasMany parameterizes its limit (mirrors
2287
+ // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2288
+ // pushing one here would orphan a param and desync the collect path.
2289
+ if (relDef.type === 'hasMany' && spec.limit) {
2276
2290
  params.push(Number(spec.limit));
2277
2291
  }
2278
2292
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2480,6 +2494,25 @@ export class QueryInterface {
2480
2494
  andClauses.push(...opClauses);
2481
2495
  continue;
2482
2496
  }
2497
+ // Strict validation: a plain (non-array, non-Date) object on a non-JSON
2498
+ // column matched no known filter shape — almost always a misspelled
2499
+ // operator (`startWith` for `startsWith`) or a stray nested object.
2500
+ // Silently treating it as `col = $1` returns wrong rows with no error, so
2501
+ // throw with the offending keys and the supported operator list. JSON/JSONB
2502
+ // columns legitimately accept object values for equality, so they fall
2503
+ // through unchanged.
2504
+ if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
2505
+ const colType = this.getColumnPgType(rawColumn);
2506
+ if (colType !== 'json' && colType !== 'jsonb') {
2507
+ const badKeys = Object.keys(value);
2508
+ throw new ValidationError(badKeys.length === 0
2509
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
2510
+ `Provide a value or an operator like { gt: 1 }.`
2511
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2512
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
2513
+ `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
2514
+ }
2515
+ }
2483
2516
  // Plain equality
2484
2517
  params.push(value);
2485
2518
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2663,7 +2696,8 @@ export class QueryInterface {
2663
2696
  return Object.entries(orderBy)
2664
2697
  .map(([key, dir]) => {
2665
2698
  if (meta && !(key in meta.columnMap)) {
2666
- throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2699
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
2700
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
2667
2701
  }
2668
2702
  // Vector KNN ordering: { distance: { to, metric, direction? } }
2669
2703
  if (isVectorOrderBy(dir)) {
@@ -3092,9 +3126,13 @@ export class QueryInterface {
3092
3126
  whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
3093
3127
  }
3094
3128
  }
3095
- // LIMIT
3129
+ // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3130
+ // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3131
+ // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3132
+ // which Postgres rejects with "could not determine data type of parameter $N"
3133
+ // (and shifts every later placeholder by one). To-one relations ignore limit.
3096
3134
  let limitClause = '';
3097
- if (spec !== true && spec.limit) {
3135
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
3098
3136
  params.push(Number(spec.limit));
3099
3137
  limitClause = ` LIMIT ${this.p(params.length)}`;
3100
3138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {