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.
@@ -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 target exactly
24
- * one row by its full primary key
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/insert/delete a single row. DDL and multi-row or
34
- * unconditional writes are deliberately unsupported. Use the CLI or migrate for
35
- * schema changes and bulk operations.
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 pool = new pg.Pool({
61
- connectionString: options.url,
62
- max: 4, // small pool — single-user tool
63
- idleTimeoutMillis: 10_000,
64
- });
65
- // Verify connectivity before starting the server — fail fast.
66
- const probe = await pool.connect();
67
- try {
68
- await probe.query('SELECT 1');
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
- finally {
71
- probe.release();
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
- writable: options.write === true,
99
- showPii: options.showPii === true,
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 — cheap enough to fetch inline. Use pg_class reltuples as
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
- for (const row of countsResult.rows) {
319
- // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
320
- // sidebar never shows a negative estimate.
321
- counts.set(row.relname, Math.max(0, Number(row.reltuples)));
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
- // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
371
- const mainValues = [limit, offset];
372
- let mainWhere = '';
373
- if (hasSearch && pattern !== null) {
374
- mainValues.push(pattern);
375
- const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $3 ESCAPE '\\'`);
376
- mainWhere = `WHERE (${conds.join(' OR ')})`;
377
- }
378
- // Count query: $1 = pattern (if search)
379
- const countValues = [];
380
- let countWhere = '';
381
- if (hasSearch && pattern !== null) {
382
- countValues.push(pattern);
383
- const conds = textColumns.map((c) => `${quoteIdent(c)} ILIKE $1 ESCAPE '\\'`);
384
- countWhere = `WHERE (${conds.join(' OR ')})`;
385
- }
386
- const qualifiedTable = `${quoteIdent(ctx.options.schema)}.${quoteIdent(table.name)}`;
387
- const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT $1 OFFSET $2`;
388
- const countSql = `SELECT COUNT(*)::text AS count FROM ${qualifiedTable} ${countWhere}`;
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
- await client.query('BEGIN READ ONLY');
392
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
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
- await client.query('COMMIT');
526
+ if (!ctx.demo)
527
+ await client.query('COMMIT');
396
528
  sendJson(res, 200, {
397
529
  table: table.name,
398
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
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
- try {
408
- await client.query('ROLLBACK');
409
- }
410
- catch {
411
- /* ignore */
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
- await client.query('BEGIN READ ONLY');
466
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
467
- // QueryInterface emits unqualified table identifiers, which resolve via
468
- // the connection's search_path. Pin it to the configured --schema so the
469
- // Query tab reads the same schema as the Data tab (set_config is
470
- // transaction-local and fully parameterized).
471
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
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
- await client.query('COMMIT');
476
- const rawRows = result.rows;
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.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
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
- try {
488
- await client.query('ROLLBACK');
489
- }
490
- catch {
491
- /* ignore */
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 (single-row writes)
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 write can only ever touch one row.
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
- const data = (body?.data && typeof body.data === 'object' ? body.data : {});
522
- const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
523
- // Validate every column name up front for a clean typed 400 (the builders
524
- // would also reject unknowns, but an explicit check keeps the message clear).
525
- if (op === 'insert' || op === 'update') {
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 (!Object.keys(data).some((k) => data[k] !== undefined)) {
532
- sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
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 pk = extractPkWhere(table, rawWhere);
546
- if ('error' in pk) {
547
- sendJson(res, 400, { error: `[turbine] ${pk.error}` });
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
- // Empty-where can never happen by construction (PK covered above); assert.
551
- if (Object.keys(pk.where).length === 0) {
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 deferred;
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
- deferred = qi.buildCreate({ data });
843
+ deferreds = inserts.map((rec) => qi.buildCreate({ data: rec }));
566
844
  }
567
845
  else if (op === 'update') {
568
- deferred = qi.buildUpdate({ where: effectiveWhere, data });
846
+ const where = wheres[0];
847
+ deferreds = [qi.buildUpdate({ where, data })];
569
848
  }
570
849
  else {
571
- deferred = qi.buildDelete({ where: effectiveWhere });
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`. Same parameterized
581
- // statement-timeout + search_path pin as the read paths.
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
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
584
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
585
- const result = await client.query(deferred.sql, deferred.params);
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
- // The echoed row is redacted the same way as any read (unless --show-pii),
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
- sendJson(res, 200, {
597
- operation: op,
598
- row: serializeRow(redactFlatRow(row, piiKeys)),
599
- rowCount: result.rowCount ?? 1,
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)) {