turbine-orm 0.36.1 → 0.38.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/README.md +6 -4
- package/dist/cjs/cli/index.js +53 -25
- package/dist/cjs/cli/studio-demo.js +310 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +502 -127
- package/dist/cjs/errors.js +27 -0
- package/dist/cjs/nested-write.js +7 -7
- package/dist/cjs/query/batched-loader.js +4 -3
- package/dist/cjs/query/builder.js +9 -5
- package/dist/cjs/query/relations.js +7 -6
- package/dist/cjs/query/utils.js +13 -0
- package/dist/cjs/query/where-compile.js +2 -1
- package/dist/cjs/query/where.js +3 -3
- package/dist/cli/index.d.ts +3 -1
- package/dist/cli/index.js +53 -25
- package/dist/cli/studio-demo.d.ts +43 -0
- package/dist/cli/studio-demo.js +306 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +58 -8
- package/dist/cli/studio.js +501 -127
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +26 -0
- package/dist/nested-write.js +8 -8
- package/dist/query/batched-loader.js +4 -3
- package/dist/query/builder.js +10 -6
- package/dist/query/relations.js +7 -6
- package/dist/query/utils.d.ts +10 -0
- package/dist/query/utils.js +12 -0
- package/dist/query/where-compile.js +2 -1
- package/dist/query/where.js +4 -4
- package/package.json +1 -1
package/dist/cli/studio.js
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
* $N params compiled through the query builders
|
|
21
21
|
* • Read routes run in a READ ONLY transaction (belt-and-suspenders)
|
|
22
22
|
* • Write routes (only when `--write` is set) run in a plain BEGIN/COMMIT
|
|
23
|
-
* transaction, require a matching Origin header (CSRF), and
|
|
24
|
-
*
|
|
23
|
+
* transaction, require a matching Origin header (CSRF), and address every
|
|
24
|
+
* row by its full primary key (single row, or a capped `rows` array of
|
|
25
|
+
* PK-addressed statements run atomically)
|
|
25
26
|
* • 30s statement timeout via parameterized set_config()
|
|
26
27
|
* • Per-session rate limiting, cross-origin refusal, security headers, and a
|
|
27
28
|
* per-request CSP nonce for the inline script (no `unsafe-inline`)
|
|
@@ -30,9 +31,10 @@
|
|
|
30
31
|
* every row-bearing response (the literal `•• redacted ••`) unless the server
|
|
31
32
|
* was started with `--show-pii`.
|
|
32
33
|
*
|
|
33
|
-
* Write model (opt-in): update
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Write model (opt-in): update a single row; insert/delete one row or a capped
|
|
35
|
+
* list of PK-addressed rows in one all-or-nothing transaction. DDL and
|
|
36
|
+
* predicate-based (unconditional) writes are deliberately unsupported. Use the
|
|
37
|
+
* CLI or migrate for schema changes and true bulk operations.
|
|
36
38
|
*/
|
|
37
39
|
import { spawn } from 'node:child_process';
|
|
38
40
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
@@ -43,6 +45,7 @@ import { dirname, resolve as pathResolve } from 'node:path';
|
|
|
43
45
|
import pg from 'pg';
|
|
44
46
|
import { introspect } from '../introspect.js';
|
|
45
47
|
import { QueryInterface, quoteIdent } from '../query/index.js';
|
|
48
|
+
import { createDemoContext } from './studio-demo.js';
|
|
46
49
|
import { STUDIO_HTML } from './studio-ui.generated.js';
|
|
47
50
|
// ---------------------------------------------------------------------------
|
|
48
51
|
// Main entry point
|
|
@@ -57,35 +60,55 @@ import { STUDIO_HTML } from './studio-ui.generated.js';
|
|
|
57
60
|
* process.on('SIGINT', () => studio.dispose().then(() => process.exit(0)));
|
|
58
61
|
*/
|
|
59
62
|
export async function startStudio(options) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
const demo = options.demo === true;
|
|
64
|
+
let pool;
|
|
65
|
+
let metadata;
|
|
66
|
+
let dialect;
|
|
67
|
+
let statementTimeout;
|
|
68
|
+
if (demo) {
|
|
69
|
+
// Seeded in-memory SQLite store: no DATABASE_URL, no network. Each launch
|
|
70
|
+
// starts pristine and nothing is ever persisted.
|
|
71
|
+
const demoCtx = createDemoContext();
|
|
72
|
+
pool = demoCtx.pool;
|
|
73
|
+
metadata = demoCtx.metadata;
|
|
74
|
+
dialect = demoCtx.dialect;
|
|
75
|
+
// SQLite has no set_config / statement_timeout GUC; a harmless no-op keeps
|
|
76
|
+
// the shared execution path (which issues this before each query) uniform.
|
|
77
|
+
statementTimeout = { sql: 'SELECT 1', params: [] };
|
|
69
78
|
}
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
else {
|
|
80
|
+
// pg.Pool satisfies the PgCompatPool contract (same as the external-pool
|
|
81
|
+
// seam in client.ts); the cast keeps one typed pool field for both modes.
|
|
82
|
+
pool = new pg.Pool({
|
|
83
|
+
connectionString: options.url,
|
|
84
|
+
max: 4, // small pool — single-user tool
|
|
85
|
+
idleTimeoutMillis: 10_000,
|
|
86
|
+
});
|
|
87
|
+
// Verify connectivity before starting the server — fail fast.
|
|
88
|
+
const probe = await pool.connect();
|
|
89
|
+
try {
|
|
90
|
+
await probe.query('SELECT 1');
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
probe.release();
|
|
94
|
+
}
|
|
95
|
+
metadata = await introspect({
|
|
96
|
+
connectionString: options.url,
|
|
97
|
+
schema: options.schema,
|
|
98
|
+
include: options.include,
|
|
99
|
+
exclude: options.exclude,
|
|
100
|
+
});
|
|
101
|
+
statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
|
|
102
|
+
// Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
|
|
103
|
+
// syntax error). `set_config(name, value, is_local=true)` is the
|
|
104
|
+
// parameterizable, transaction-local equivalent and works on every
|
|
105
|
+
// Postgres-compatible engine.
|
|
106
|
+
sql: `SELECT set_config('statement_timeout', $1, true)`,
|
|
107
|
+
params: ['30s'],
|
|
108
|
+
};
|
|
72
109
|
}
|
|
73
|
-
const metadata = await introspect({
|
|
74
|
-
connectionString: options.url,
|
|
75
|
-
schema: options.schema,
|
|
76
|
-
include: options.include,
|
|
77
|
-
exclude: options.exclude,
|
|
78
|
-
});
|
|
79
110
|
const authToken = randomBytes(24).toString('hex');
|
|
80
111
|
const stateDir = pathResolve(options.stateDir ?? '.turbine');
|
|
81
|
-
const statementTimeout = options.adapter?.statementTimeout?.(30) ?? {
|
|
82
|
-
// Postgres rejects parameters in `SET LOCAL` (`SET LOCAL ... = $1` is a
|
|
83
|
-
// syntax error). `set_config(name, value, is_local=true)` is the
|
|
84
|
-
// parameterizable, transaction-local equivalent and works on every
|
|
85
|
-
// Postgres-compatible engine.
|
|
86
|
-
sql: `SELECT set_config('statement_timeout', $1, true)`,
|
|
87
|
-
params: ['30s'],
|
|
88
|
-
};
|
|
89
112
|
const rateLimiter = new Map();
|
|
90
113
|
const ctx = {
|
|
91
114
|
pool,
|
|
@@ -95,8 +118,15 @@ export async function startStudio(options) {
|
|
|
95
118
|
stateDir,
|
|
96
119
|
statementTimeout,
|
|
97
120
|
rateLimiter,
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
// Demo always boots read-only + PII redacted; the in-UI switcher flips these
|
|
122
|
+
// live. Non-demo honors the CLI flags.
|
|
123
|
+
writable: demo ? false : options.write === true,
|
|
124
|
+
showPii: demo ? false : options.showPii === true,
|
|
125
|
+
demo,
|
|
126
|
+
dialect,
|
|
127
|
+
// Demo never touches disk: saved queries live (and die) with the process,
|
|
128
|
+
// and the user's real .turbine/studio-queries.json is never read.
|
|
129
|
+
memorySavedQueries: demo ? { version: 1, queries: [] } : undefined,
|
|
100
130
|
};
|
|
101
131
|
const server = createServer((req, res) => {
|
|
102
132
|
handleRequest(req, res, ctx).catch((err) => {
|
|
@@ -229,9 +259,40 @@ export async function handleRequest(req, res, ctx) {
|
|
|
229
259
|
if (op === 'delete')
|
|
230
260
|
return apiRowWrite(req, res, ctx, 'delete');
|
|
231
261
|
}
|
|
262
|
+
// Demo mode switcher: ONLY exists in demo mode (404 otherwise). Flips the live
|
|
263
|
+
// read-only / PII / write toggles on the in-memory store. State-changing, so it
|
|
264
|
+
// requires a matching Origin like the write routes.
|
|
265
|
+
if (ctx.demo && pathname === '/api/demo/mode' && req.method === 'POST') {
|
|
266
|
+
if (origin !== expectedOrigin) {
|
|
267
|
+
sendJson(res, 403, { error: 'a matching Origin header is required for mode changes' });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
return apiDemoMode(req, res, ctx);
|
|
271
|
+
}
|
|
232
272
|
sendJson(res, 404, { error: 'not found' });
|
|
233
273
|
}
|
|
234
274
|
// ---------------------------------------------------------------------------
|
|
275
|
+
// API: /api/demo/mode: live mode switcher (demo mode only)
|
|
276
|
+
//
|
|
277
|
+
// Mutates the shared StudioContext so the change applies to every subsequent
|
|
278
|
+
// request: `writable` gates the (already-registered) `/api/row/*` routes and the
|
|
279
|
+
// UI's write affordances; `showPii` toggles server-side PII redaction. The two
|
|
280
|
+
// are independent toggles. The UI re-fetches `/api/schema` afterwards to re-read
|
|
281
|
+
// the effective state.
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
export async function apiDemoMode(req, res, ctx) {
|
|
284
|
+
const body = await readJsonBody(req);
|
|
285
|
+
if (typeof body.writable === 'boolean')
|
|
286
|
+
ctx.writable = body.writable;
|
|
287
|
+
if (typeof body.showPii === 'boolean')
|
|
288
|
+
ctx.showPii = body.showPii;
|
|
289
|
+
sendJson(res, 200, {
|
|
290
|
+
demo: true,
|
|
291
|
+
writable: ctx.writable === true,
|
|
292
|
+
showPii: ctx.showPii === true,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
235
296
|
// Auth
|
|
236
297
|
// ---------------------------------------------------------------------------
|
|
237
298
|
function isAuthorized(req, expectedToken) {
|
|
@@ -308,17 +369,28 @@ async function apiSchema(res, ctx) {
|
|
|
308
369
|
referenceKey: rel.referenceKey,
|
|
309
370
|
})),
|
|
310
371
|
}));
|
|
311
|
-
// Row counts
|
|
312
|
-
// a fast estimate so we don't hammer big tables with SELECT COUNT(*).
|
|
313
|
-
const countsResult = await ctx.pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
|
|
314
|
-
FROM pg_class c
|
|
315
|
-
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
316
|
-
WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
|
|
372
|
+
// Row counts (cheap enough to fetch inline).
|
|
317
373
|
const counts = new Map();
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
374
|
+
if (ctx.demo) {
|
|
375
|
+
// The demo dataset is tiny and in-memory: an exact per-table COUNT(*) is
|
|
376
|
+
// instant, and SQLite has no pg_class estimate to read.
|
|
377
|
+
for (const t of tables) {
|
|
378
|
+
const r = await ctx.pool.query(`SELECT COUNT(*) AS count FROM ${quoteIdent(t.name)}`);
|
|
379
|
+
counts.set(t.name, Number(r.rows[0]?.count ?? 0));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
// Use pg_class reltuples as a fast estimate so we don't hammer big tables
|
|
384
|
+
// with SELECT COUNT(*).
|
|
385
|
+
const countsResult = await ctx.pool.query(`SELECT c.relname, c.reltuples::bigint::text AS reltuples
|
|
386
|
+
FROM pg_class c
|
|
387
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
388
|
+
WHERE n.nspname = $1 AND c.relkind = 'r'`, [ctx.options.schema]);
|
|
389
|
+
for (const row of countsResult.rows) {
|
|
390
|
+
// pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
|
|
391
|
+
// sidebar never shows a negative estimate.
|
|
392
|
+
counts.set(row.relname, Math.max(0, Number(row.reltuples)));
|
|
393
|
+
}
|
|
322
394
|
}
|
|
323
395
|
sendJson(res, 200, {
|
|
324
396
|
schema: ctx.options.schema,
|
|
@@ -328,6 +400,8 @@ async function apiSchema(res, ctx) {
|
|
|
328
400
|
// Read-only Studio reports `writable: false` so the UI renders no write UI.
|
|
329
401
|
writable: ctx.writable === true,
|
|
330
402
|
showPii: ctx.showPii === true,
|
|
403
|
+
// Demo flag drives the in-UI mode switcher + persistent demo banner.
|
|
404
|
+
demo: ctx.demo === true,
|
|
331
405
|
});
|
|
332
406
|
}
|
|
333
407
|
// ---------------------------------------------------------------------------
|
|
@@ -367,35 +441,93 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
367
441
|
.map((c) => c.name);
|
|
368
442
|
const hasSearch = search.length > 0 && textColumns.length > 0;
|
|
369
443
|
const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
const
|
|
444
|
+
// Per-column filters: `filters` is a JSON array of { column, op, value }
|
|
445
|
+
// composed by the Data tab's filter bar. Every column is validated against
|
|
446
|
+
// the metadata, every op against a fixed whitelist, and every value is a
|
|
447
|
+
// parameter — same discipline as the builder route.
|
|
448
|
+
let filters;
|
|
449
|
+
try {
|
|
450
|
+
filters = parseTableFilters(params.get('filters'), table, redactedPii);
|
|
451
|
+
}
|
|
452
|
+
catch (err) {
|
|
453
|
+
sendJson(res, 400, { error: `[turbine] ${err instanceof Error ? err.message : String(err)}` });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
// Parameter placeholder + case-insensitive LIKE condition differ by engine.
|
|
457
|
+
// Postgres: numbered `$N` + `ILIKE`. Demo (SQLite): named `:pN` (bound by
|
|
458
|
+
// name from the positional value array, matching Turbine's own SQLite path)
|
|
459
|
+
// + `LOWER(col) LIKE LOWER(:pN)` (explicit case-fold, ASCII). The escape char
|
|
460
|
+
// (`\`) is identical. When demo is off these produce byte-identical SQL.
|
|
461
|
+
const ph = (n) => (ctx.demo ? `:p${n}` : `$${n}`);
|
|
462
|
+
const likeCond = (col, n) => ctx.demo
|
|
463
|
+
? `LOWER(${quoteIdent(col)}) LIKE LOWER(${ph(n)}) ESCAPE '\\'`
|
|
464
|
+
: `${quoteIdent(col)} ILIKE ${ph(n)} ESCAPE '\\'`;
|
|
465
|
+
// Build the WHERE conditions (search OR-set + per-column filters) once per
|
|
466
|
+
// query, numbering parameters from `startIndex` so the main query (params
|
|
467
|
+
// begin after limit/offset) and the count query (params begin at 1) each get
|
|
468
|
+
// indices matching their own value arrays.
|
|
469
|
+
const buildWhere = (startIndex) => {
|
|
470
|
+
let n = startIndex;
|
|
471
|
+
const conds = [];
|
|
472
|
+
const values = [];
|
|
473
|
+
if (hasSearch && pattern !== null) {
|
|
474
|
+
values.push(pattern);
|
|
475
|
+
const idx = n++;
|
|
476
|
+
conds.push(`(${textColumns.map((c) => likeCond(c, idx)).join(' OR ')})`);
|
|
477
|
+
}
|
|
478
|
+
for (const f of filters) {
|
|
479
|
+
if (f.op === 'isNull') {
|
|
480
|
+
conds.push(`${quoteIdent(f.column)} IS NULL`);
|
|
481
|
+
}
|
|
482
|
+
else if (f.op === 'notNull') {
|
|
483
|
+
conds.push(`${quoteIdent(f.column)} IS NOT NULL`);
|
|
484
|
+
}
|
|
485
|
+
else if (f.op === 'contains') {
|
|
486
|
+
values.push(`%${escapeLikePattern(String(f.value))}%`);
|
|
487
|
+
conds.push(likeCond(f.column, n++));
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
const sqlOp = FILTER_OPS[f.op];
|
|
491
|
+
values.push(f.value);
|
|
492
|
+
conds.push(`${quoteIdent(f.column)} ${sqlOp} ${ph(n++)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', values };
|
|
496
|
+
};
|
|
497
|
+
// Main query: $1 = limit, $2 = offset, then search/filter params.
|
|
498
|
+
const mainW = buildWhere(3);
|
|
499
|
+
const mainValues = [limit, offset, ...mainW.values];
|
|
500
|
+
const mainWhere = mainW.where;
|
|
501
|
+
// Count query: params start at $1.
|
|
502
|
+
const countW = buildWhere(1);
|
|
503
|
+
const countValues = countW.values;
|
|
504
|
+
const countWhere = countW.where;
|
|
505
|
+
// Demo runs against an unqualified in-memory SQLite table (no schemas);
|
|
506
|
+
// Postgres qualifies with the configured `--schema`.
|
|
507
|
+
const qualifiedTable = ctx.demo
|
|
508
|
+
? quoteIdent(table.name)
|
|
509
|
+
: `${quoteIdent(ctx.options.schema)}.${quoteIdent(table.name)}`;
|
|
510
|
+
const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT ${ph(1)} OFFSET ${ph(2)}`;
|
|
511
|
+
// Postgres casts the bigint COUNT to text to avoid int8 precision loss on the
|
|
512
|
+
// wire; SQLite returns a safe integer directly, so no cast.
|
|
513
|
+
const countSql = `SELECT COUNT(*)${ctx.demo ? '' : '::text'} AS count FROM ${qualifiedTable} ${countWhere}`;
|
|
389
514
|
const client = await ctx.pool.connect();
|
|
390
515
|
try {
|
|
391
|
-
|
|
392
|
-
|
|
516
|
+
// Demo: the in-memory SQLite handle is a single synchronous connection with
|
|
517
|
+
// no READ ONLY txn mode or statement_timeout GUC, so we skip the read
|
|
518
|
+
// transaction wrapper entirely. Postgres keeps its belt-and-suspenders
|
|
519
|
+
// READ ONLY transaction + timeout.
|
|
520
|
+
if (!ctx.demo) {
|
|
521
|
+
await client.query('BEGIN READ ONLY');
|
|
522
|
+
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
523
|
+
}
|
|
393
524
|
const result = await client.query(sql, mainValues);
|
|
394
525
|
const countResult = await client.query(countSql, countValues);
|
|
395
|
-
|
|
526
|
+
if (!ctx.demo)
|
|
527
|
+
await client.query('COMMIT');
|
|
396
528
|
sendJson(res, 200, {
|
|
397
529
|
table: table.name,
|
|
398
|
-
columns: result
|
|
530
|
+
columns: resultColumns(result, result.rows),
|
|
399
531
|
rows: result.rows.map((r) => serializeRow(redactFlatRow(r, redactedPii))),
|
|
400
532
|
total: Number(countResult.rows[0]?.count ?? 0),
|
|
401
533
|
limit,
|
|
@@ -404,11 +536,13 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
|
|
|
404
536
|
});
|
|
405
537
|
}
|
|
406
538
|
catch (err) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
539
|
+
if (!ctx.demo) {
|
|
540
|
+
try {
|
|
541
|
+
await client.query('ROLLBACK');
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
/* ignore */
|
|
545
|
+
}
|
|
412
546
|
}
|
|
413
547
|
throw err;
|
|
414
548
|
}
|
|
@@ -437,6 +571,78 @@ export function escapeLikePattern(s) {
|
|
|
437
571
|
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
|
438
572
|
}
|
|
439
573
|
// ---------------------------------------------------------------------------
|
|
574
|
+
// Data-tab per-column filters
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
/** Scalar comparison ops → SQL operator. `contains`/`isNull`/`notNull` compile separately. */
|
|
577
|
+
const FILTER_OPS = {
|
|
578
|
+
equals: '=',
|
|
579
|
+
not: '<>',
|
|
580
|
+
gt: '>',
|
|
581
|
+
gte: '>=',
|
|
582
|
+
lt: '<',
|
|
583
|
+
lte: '<=',
|
|
584
|
+
};
|
|
585
|
+
const FILTER_OP_NAMES = new Set([...Object.keys(FILTER_OPS), 'contains', 'isNull', 'notNull']);
|
|
586
|
+
/** Hard cap on filter clauses per request — the UI never composes more. */
|
|
587
|
+
const MAX_TABLE_FILTERS = 10;
|
|
588
|
+
/**
|
|
589
|
+
* Parse + validate the Data tab's `filters` query param. Throws with a clear
|
|
590
|
+
* message on any invalid shape; the caller turns that into a 400. Filters on
|
|
591
|
+
* redacted PII columns are refused outright (a filter is a value-probing
|
|
592
|
+
* oracle, same reason redacted columns are excluded from search and orderBy).
|
|
593
|
+
*/
|
|
594
|
+
function parseTableFilters(raw, table, redactedPii) {
|
|
595
|
+
if (!raw)
|
|
596
|
+
return [];
|
|
597
|
+
let parsed;
|
|
598
|
+
try {
|
|
599
|
+
parsed = JSON.parse(raw);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
throw new Error('`filters` must be a JSON array');
|
|
603
|
+
}
|
|
604
|
+
if (!Array.isArray(parsed))
|
|
605
|
+
throw new Error('`filters` must be a JSON array');
|
|
606
|
+
if (parsed.length > MAX_TABLE_FILTERS) {
|
|
607
|
+
throw new Error(`too many filters (max ${MAX_TABLE_FILTERS})`);
|
|
608
|
+
}
|
|
609
|
+
const out = [];
|
|
610
|
+
for (const item of parsed) {
|
|
611
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
612
|
+
throw new Error('each filter must be an object { column, op, value }');
|
|
613
|
+
}
|
|
614
|
+
const f = item;
|
|
615
|
+
const col = typeof f.column === 'string' ? resolveColumnName(table, f.column) : null;
|
|
616
|
+
if (!col) {
|
|
617
|
+
throw new Error(`unknown filter column "${String(f.column)}" on table "${table.name}"`);
|
|
618
|
+
}
|
|
619
|
+
if (redactedPii.has(col)) {
|
|
620
|
+
throw new Error(`column "${col}" is PII-redacted; filtering on it is disabled (run with --show-pii to enable)`);
|
|
621
|
+
}
|
|
622
|
+
const op = typeof f.op === 'string' ? f.op : '';
|
|
623
|
+
if (!FILTER_OP_NAMES.has(op)) {
|
|
624
|
+
throw new Error(`unknown filter op "${op}" (expected one of: ${[...FILTER_OP_NAMES].join(', ')})`);
|
|
625
|
+
}
|
|
626
|
+
if (op === 'isNull' || op === 'notNull') {
|
|
627
|
+
out.push({ column: col, op });
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const value = f.value;
|
|
631
|
+
const t = typeof value;
|
|
632
|
+
if (value === null || value === undefined || (t !== 'string' && t !== 'number' && t !== 'boolean')) {
|
|
633
|
+
throw new Error(`filter on "${col}" needs a scalar value (use isNull/notNull for null checks)`);
|
|
634
|
+
}
|
|
635
|
+
if (op === 'contains') {
|
|
636
|
+
const colMeta = table.columns.find((c) => c.name === col);
|
|
637
|
+
if (!colMeta || !isTextishType(colMeta.pgType)) {
|
|
638
|
+
throw new Error(`contains only applies to text columns ("${col}" is ${colMeta?.pgType ?? 'unknown'})`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
out.push({ column: col, op, value });
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
440
646
|
// API: /api/builder — Turbine ORM findMany spec runner
|
|
441
647
|
// ---------------------------------------------------------------------------
|
|
442
648
|
export async function apiBuilder(req, res, ctx) {
|
|
@@ -453,6 +659,9 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
453
659
|
warnOnUnlimited: false,
|
|
454
660
|
sqlCache: false,
|
|
455
661
|
preparedStatements: false,
|
|
662
|
+
// Demo compiles SQLite SQL (`:pN`, json_group_array, …); Postgres default
|
|
663
|
+
// when unset.
|
|
664
|
+
dialect: ctx.dialect,
|
|
456
665
|
});
|
|
457
666
|
deferred = qi.buildFindMany(args);
|
|
458
667
|
}
|
|
@@ -462,33 +671,44 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
462
671
|
}
|
|
463
672
|
const client = await ctx.pool.connect();
|
|
464
673
|
try {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
674
|
+
if (!ctx.demo) {
|
|
675
|
+
await client.query('BEGIN READ ONLY');
|
|
676
|
+
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
677
|
+
// QueryInterface emits unqualified table identifiers, which resolve via
|
|
678
|
+
// the connection's search_path. Pin it to the configured --schema so the
|
|
679
|
+
// Query tab reads the same schema as the Data tab (set_config is
|
|
680
|
+
// transaction-local and fully parameterized). Demo has no schemas.
|
|
681
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
682
|
+
}
|
|
472
683
|
const started = Date.now();
|
|
473
684
|
const result = await client.query(deferred.sql, deferred.params);
|
|
474
685
|
const elapsedMs = Date.now() - started;
|
|
475
|
-
|
|
476
|
-
|
|
686
|
+
if (!ctx.demo)
|
|
687
|
+
await client.query('COMMIT');
|
|
688
|
+
// Postgres auto-parses json/jsonb relation columns into JS values via its
|
|
689
|
+
// type parsers; the SQLite demo driver returns them as raw JSON strings. So
|
|
690
|
+
// in demo mode, parse relation columns back into arrays/objects (walking the
|
|
691
|
+
// `with` tree) to match the Postgres shape before redaction + serialization.
|
|
692
|
+
const rawRows = ctx.demo
|
|
693
|
+
? parseDemoRelationRows(result.rows, tableName, args.with, ctx.metadata)
|
|
694
|
+
: result.rows;
|
|
477
695
|
const redactedRows = ctx.showPii ? rawRows : redactBuilderRows(rawRows, tableName, args.with, ctx.metadata);
|
|
478
696
|
sendJson(res, 200, {
|
|
479
697
|
sql: deferred.sql,
|
|
480
|
-
columns: result
|
|
698
|
+
columns: resultColumns(result, result.rows),
|
|
481
699
|
rows: redactedRows.map((r) => serializeRow(r)),
|
|
482
700
|
rowCount: result.rowCount ?? result.rows.length,
|
|
483
701
|
elapsedMs,
|
|
484
702
|
});
|
|
485
703
|
}
|
|
486
704
|
catch (err) {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
705
|
+
if (!ctx.demo) {
|
|
706
|
+
try {
|
|
707
|
+
await client.query('ROLLBACK');
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
/* ignore */
|
|
711
|
+
}
|
|
492
712
|
}
|
|
493
713
|
sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) });
|
|
494
714
|
}
|
|
@@ -497,15 +717,24 @@ export async function apiBuilder(req, res, ctx) {
|
|
|
497
717
|
}
|
|
498
718
|
}
|
|
499
719
|
// ---------------------------------------------------------------------------
|
|
500
|
-
// API: /api/row/update | /api/row/insert | /api/row/delete (
|
|
720
|
+
// API: /api/row/update | /api/row/insert | /api/row/delete (PK-addressed writes)
|
|
501
721
|
//
|
|
502
722
|
// Write mode only (the routes do not exist otherwise). Every column identifier
|
|
503
723
|
// is validated against the introspected metadata and the statement is compiled
|
|
504
724
|
// through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
|
|
505
725
|
// values are $N params; there is no raw SQL. update/delete require the caller
|
|
506
726
|
// to supply the FULL primary key in `where`; the effective predicate is rebuilt
|
|
507
|
-
// from those PK values alone, so a
|
|
727
|
+
// from those PK values alone, so a statement can only ever touch one row.
|
|
728
|
+
//
|
|
729
|
+
// Bulk form (insert/delete only): pass `rows: [...]` instead of `data`/`where`
|
|
730
|
+
// — an array of data objects (insert) or PK-where objects (delete). Each entry
|
|
731
|
+
// goes through the exact same per-row validation and compiles to its own
|
|
732
|
+
// single-row statement; all statements run in ONE transaction (all-or-nothing,
|
|
733
|
+
// capped at MAX_BULK_ROWS). Predicate-based bulk writes stay deliberately
|
|
734
|
+
// unsupported — every row is still addressed by its full primary key.
|
|
508
735
|
// ---------------------------------------------------------------------------
|
|
736
|
+
/** Hard cap on rows per bulk insert/delete request (matches the max page size). */
|
|
737
|
+
const MAX_BULK_ROWS = 500;
|
|
509
738
|
export async function apiRowWrite(req, res, ctx, op) {
|
|
510
739
|
const body = await readJsonBody(req);
|
|
511
740
|
const tableName = typeof body?.table === 'string' ? body.table : '';
|
|
@@ -518,23 +747,51 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
518
747
|
sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
|
|
519
748
|
return;
|
|
520
749
|
}
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const badKey = firstUnknownColumn(table, data);
|
|
527
|
-
if (badKey) {
|
|
528
|
-
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
750
|
+
// Bulk form: `rows` replaces `data` (insert) / `where` (delete).
|
|
751
|
+
const bulkRows = Array.isArray(body?.rows) ? body.rows : null;
|
|
752
|
+
if (bulkRows) {
|
|
753
|
+
if (op === 'update') {
|
|
754
|
+
sendJson(res, 400, { error: '[turbine] bulk update is not supported; update rows one at a time' });
|
|
529
755
|
return;
|
|
530
756
|
}
|
|
531
|
-
if (
|
|
532
|
-
sendJson(res, 400, { error: '[turbine] `
|
|
757
|
+
if (bulkRows.length === 0) {
|
|
758
|
+
sendJson(res, 400, { error: '[turbine] `rows` must include at least one entry' });
|
|
533
759
|
return;
|
|
534
760
|
}
|
|
761
|
+
if (bulkRows.length > MAX_BULK_ROWS) {
|
|
762
|
+
sendJson(res, 400, { error: `[turbine] too many rows (max ${MAX_BULK_ROWS} per request)` });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const data = (body?.data && typeof body.data === 'object' ? body.data : {});
|
|
767
|
+
const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
|
|
768
|
+
// Per-statement inputs, validated up front for a clean typed 400 (the
|
|
769
|
+
// builders would also reject unknowns, but explicit checks keep messages
|
|
770
|
+
// clear and stop before any statement has run).
|
|
771
|
+
const inserts = [];
|
|
772
|
+
const wheres = [];
|
|
773
|
+
if (op === 'insert') {
|
|
774
|
+
const candidates = bulkRows ?? [data];
|
|
775
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
776
|
+
const rowLabel = bulkRows ? ` (rows[${i}])` : '';
|
|
777
|
+
const rowData = candidates[i];
|
|
778
|
+
if (!rowData || typeof rowData !== 'object' || Array.isArray(rowData)) {
|
|
779
|
+
sendJson(res, 400, { error: `[turbine] each insert row must be an object${rowLabel}` });
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const rec = rowData;
|
|
783
|
+
const badKey = firstUnknownColumn(table, rec);
|
|
784
|
+
if (badKey) {
|
|
785
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"${rowLabel}` });
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (!Object.keys(rec).some((k) => rec[k] !== undefined)) {
|
|
789
|
+
sendJson(res, 400, { error: `[turbine] \`data\` must include at least one column${rowLabel}` });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
inserts.push(rec);
|
|
793
|
+
}
|
|
535
794
|
}
|
|
536
|
-
// update/delete: require the table to have a PK and the caller to cover it.
|
|
537
|
-
let effectiveWhere = rawWhere;
|
|
538
795
|
if (op === 'update' || op === 'delete') {
|
|
539
796
|
if (table.primaryKey.length === 0) {
|
|
540
797
|
sendJson(res, 400, {
|
|
@@ -542,33 +799,55 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
542
799
|
});
|
|
543
800
|
return;
|
|
544
801
|
}
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
802
|
+
const candidates = op === 'delete' && bulkRows ? bulkRows : [rawWhere];
|
|
803
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
804
|
+
const rowLabel = bulkRows ? ` (rows[${i}])` : '';
|
|
805
|
+
const rowWhere = candidates[i];
|
|
806
|
+
if (!rowWhere || typeof rowWhere !== 'object' || Array.isArray(rowWhere)) {
|
|
807
|
+
sendJson(res, 400, { error: `[turbine] each delete target must be a where object${rowLabel}` });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
const pk = extractPkWhere(table, rowWhere);
|
|
811
|
+
if ('error' in pk) {
|
|
812
|
+
sendJson(res, 400, { error: `[turbine] ${pk.error}${rowLabel}` });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
// Empty-where can never happen by construction (PK covered above); assert.
|
|
816
|
+
if (Object.keys(pk.where).length === 0) {
|
|
817
|
+
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
wheres.push(pk.where);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (op === 'update') {
|
|
824
|
+
const badKey = firstUnknownColumn(table, data);
|
|
825
|
+
if (badKey) {
|
|
826
|
+
sendJson(res, 400, { error: `[turbine] unknown column "${badKey}" on table "${tableName}"` });
|
|
548
827
|
return;
|
|
549
828
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
sendJson(res, 400, { error: '[turbine] refusing a write with an empty predicate' });
|
|
829
|
+
if (!Object.keys(data).some((k) => data[k] !== undefined)) {
|
|
830
|
+
sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
|
|
553
831
|
return;
|
|
554
832
|
}
|
|
555
|
-
effectiveWhere = pk.where;
|
|
556
833
|
}
|
|
557
|
-
let
|
|
834
|
+
let deferreds;
|
|
558
835
|
try {
|
|
559
836
|
const qi = new QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
|
|
560
837
|
warnOnUnlimited: false,
|
|
561
838
|
sqlCache: false,
|
|
562
839
|
preparedStatements: false,
|
|
840
|
+
dialect: ctx.dialect,
|
|
563
841
|
});
|
|
564
842
|
if (op === 'insert') {
|
|
565
|
-
|
|
843
|
+
deferreds = inserts.map((rec) => qi.buildCreate({ data: rec }));
|
|
566
844
|
}
|
|
567
845
|
else if (op === 'update') {
|
|
568
|
-
|
|
846
|
+
const where = wheres[0];
|
|
847
|
+
deferreds = [qi.buildUpdate({ where, data })];
|
|
569
848
|
}
|
|
570
849
|
else {
|
|
571
|
-
|
|
850
|
+
deferreds = wheres.map((where) => qi.buildDelete({ where }));
|
|
572
851
|
}
|
|
573
852
|
}
|
|
574
853
|
catch (err) {
|
|
@@ -577,27 +856,51 @@ export async function apiRowWrite(req, res, ctx, op) {
|
|
|
577
856
|
}
|
|
578
857
|
const client = await ctx.pool.connect();
|
|
579
858
|
try {
|
|
580
|
-
// A real write transaction, NOT `READ ONLY`.
|
|
581
|
-
// statement-timeout + search_path
|
|
859
|
+
// A real write transaction, NOT `READ ONLY`. Postgres also pins the
|
|
860
|
+
// parameterized statement-timeout + search_path; demo (SQLite) has neither
|
|
861
|
+
// GUC, so those are skipped, but the BEGIN/COMMIT is kept (SqlitePool
|
|
862
|
+
// supports it) so an in-memory write still applies atomically. Bulk
|
|
863
|
+
// requests are all-or-nothing: any per-row failure rolls back every row.
|
|
582
864
|
await client.query('BEGIN');
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
await client.query('COMMIT');
|
|
587
|
-
const row = result.rows[0];
|
|
588
|
-
if (!row) {
|
|
589
|
-
const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
|
|
590
|
-
sendJson(res, 404, { error: `[turbine] ${msg}` });
|
|
591
|
-
return;
|
|
865
|
+
if (!ctx.demo) {
|
|
866
|
+
await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
|
|
867
|
+
await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
|
|
592
868
|
}
|
|
593
|
-
|
|
869
|
+
const returnedRows = [];
|
|
870
|
+
let rowCount = 0;
|
|
871
|
+
for (const deferred of deferreds) {
|
|
872
|
+
const result = await client.query(deferred.sql, deferred.params);
|
|
873
|
+
const row = result.rows[0];
|
|
874
|
+
if (!row) {
|
|
875
|
+
// A statement that touched nothing (stale PK, vanished row) aborts the
|
|
876
|
+
// whole request so a bulk delete can never half-apply.
|
|
877
|
+
await client.query('ROLLBACK');
|
|
878
|
+
const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
|
|
879
|
+
sendJson(res, 404, { error: `[turbine] ${msg}` });
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
returnedRows.push(row);
|
|
883
|
+
rowCount += result.rowCount ?? 1;
|
|
884
|
+
}
|
|
885
|
+
await client.query('COMMIT');
|
|
886
|
+
// The echoed rows are redacted the same way as any read (unless --show-pii),
|
|
594
887
|
// even though a write to a pii column is allowed.
|
|
595
888
|
const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
889
|
+
if (bulkRows) {
|
|
890
|
+
sendJson(res, 200, {
|
|
891
|
+
operation: op,
|
|
892
|
+
rows: returnedRows.map((row) => serializeRow(redactFlatRow(row, piiKeys))),
|
|
893
|
+
rowCount,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
const first = returnedRows[0];
|
|
898
|
+
sendJson(res, 200, {
|
|
899
|
+
operation: op,
|
|
900
|
+
row: serializeRow(redactFlatRow(first, piiKeys)),
|
|
901
|
+
rowCount,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
601
904
|
}
|
|
602
905
|
catch (err) {
|
|
603
906
|
try {
|
|
@@ -659,6 +962,12 @@ function savedQueriesPath(ctx) {
|
|
|
659
962
|
/** One-shot flag so the legacy saved-query notice isn't logged on every request. */
|
|
660
963
|
let legacyDropNoticeShown = false;
|
|
661
964
|
function loadSavedQueries(ctx) {
|
|
965
|
+
// Demo mode: in-memory only — never read the user's real saved-query file.
|
|
966
|
+
if (ctx.demo) {
|
|
967
|
+
if (!ctx.memorySavedQueries)
|
|
968
|
+
ctx.memorySavedQueries = { version: 1, queries: [] };
|
|
969
|
+
return ctx.memorySavedQueries;
|
|
970
|
+
}
|
|
662
971
|
const file = savedQueriesPath(ctx);
|
|
663
972
|
if (!existsSync(file))
|
|
664
973
|
return { version: 1, queries: [] };
|
|
@@ -684,6 +993,11 @@ function loadSavedQueries(ctx) {
|
|
|
684
993
|
}
|
|
685
994
|
}
|
|
686
995
|
function writeSavedQueries(ctx, data) {
|
|
996
|
+
// Demo mode: in-memory only — nothing is ever written to disk.
|
|
997
|
+
if (ctx.demo) {
|
|
998
|
+
ctx.memorySavedQueries = data;
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
687
1001
|
const file = savedQueriesPath(ctx);
|
|
688
1002
|
const dir = dirname(file);
|
|
689
1003
|
if (!existsSync(dir))
|
|
@@ -820,6 +1134,52 @@ function redactBuilderRows(rows, tableName, withClause, metadata) {
|
|
|
820
1134
|
return out;
|
|
821
1135
|
});
|
|
822
1136
|
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Demo-only: parse relation columns that arrive as raw JSON strings from the
|
|
1139
|
+
* SQLite driver back into arrays/objects, walking the `with` tree so nested
|
|
1140
|
+
* relations are parsed at every level. This mirrors what Postgres' json/jsonb
|
|
1141
|
+
* type parsers do automatically, so the builder response shape (and downstream
|
|
1142
|
+
* redaction) is identical across engines. Rows without the named relation, or
|
|
1143
|
+
* whose value is already a parsed object/array, pass through unchanged.
|
|
1144
|
+
*/
|
|
1145
|
+
function parseDemoRelationRows(rows, tableName, withClause, metadata) {
|
|
1146
|
+
const table = metadata.tables[tableName];
|
|
1147
|
+
if (!table)
|
|
1148
|
+
return rows;
|
|
1149
|
+
const relEntries = withClause && typeof withClause === 'object'
|
|
1150
|
+
? Object.entries(withClause).filter(([, v]) => v)
|
|
1151
|
+
: [];
|
|
1152
|
+
if (relEntries.length === 0)
|
|
1153
|
+
return rows;
|
|
1154
|
+
return rows.map((row) => {
|
|
1155
|
+
const out = { ...row };
|
|
1156
|
+
for (const [relName, relVal] of relEntries) {
|
|
1157
|
+
const rel = table.relations[relName];
|
|
1158
|
+
if (!rel)
|
|
1159
|
+
continue;
|
|
1160
|
+
let child = out[relName];
|
|
1161
|
+
if (typeof child === 'string') {
|
|
1162
|
+
try {
|
|
1163
|
+
child = JSON.parse(child);
|
|
1164
|
+
}
|
|
1165
|
+
catch {
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const nestedWith = relVal && typeof relVal === 'object' ? relVal.with : undefined;
|
|
1170
|
+
if (Array.isArray(child)) {
|
|
1171
|
+
out[relName] = parseDemoRelationRows(child, rel.to, nestedWith, metadata);
|
|
1172
|
+
}
|
|
1173
|
+
else if (child && typeof child === 'object') {
|
|
1174
|
+
out[relName] = parseDemoRelationRows([child], rel.to, nestedWith, metadata)[0];
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
out[relName] = child;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return out;
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
823
1183
|
/**
|
|
824
1184
|
* A fresh CSP nonce for one HTML response. Base64 of 16 random bytes; the value
|
|
825
1185
|
* is stamped into both the `Content-Security-Policy` header and the inline
|
|
@@ -836,6 +1196,20 @@ function clampInt(value, fallback, min, max) {
|
|
|
836
1196
|
return fallback;
|
|
837
1197
|
return Math.min(Math.max(n, min), max);
|
|
838
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Column descriptors for a result payload. Postgres results carry a `fields`
|
|
1201
|
+
* array (name + OID); the SQLite demo driver does not, so we fall back to the
|
|
1202
|
+
* keys of the first returned row (dataTypeID 0 = "unknown", which the UI treats
|
|
1203
|
+
* generically). When `fields` is present this is byte-identical to the previous
|
|
1204
|
+
* inline `result.fields.map(...)`.
|
|
1205
|
+
*/
|
|
1206
|
+
function resultColumns(result, rows) {
|
|
1207
|
+
if (result.fields) {
|
|
1208
|
+
return result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID }));
|
|
1209
|
+
}
|
|
1210
|
+
const first = rows[0];
|
|
1211
|
+
return first ? Object.keys(first).map((name) => ({ name, dataTypeID: 0 })) : [];
|
|
1212
|
+
}
|
|
839
1213
|
function serializeRow(row) {
|
|
840
1214
|
const out = {};
|
|
841
1215
|
for (const [k, v] of Object.entries(row)) {
|