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.
package/dist/cli/studio.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
531
|
-
args,
|
|
488
|
+
kind: 'builder',
|
|
489
|
+
args: body?.args,
|
|
532
490
|
createdAt: new Date().toISOString(),
|
|
533
491
|
};
|
|
534
492
|
data.queries.push(entry);
|
package/dist/query/builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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": {
|