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/cjs/cli/studio.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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' });
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|