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