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.
@@ -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 target exactly
25
- * one row by its full primary key
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/insert/delete a single row. DDL and multi-row or
35
- * unconditional writes are deliberately unsupported. Use the CLI or migrate for
36
- * schema changes and bulk operations.
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 pool = new pg_1.default.Pool({
78
- connectionString: options.url,
79
- max: 4, // small pool — single-user tool
80
- idleTimeoutMillis: 10_000,
81
- });
82
- // Verify connectivity before starting the server — fail fast.
83
- const probe = await pool.connect();
84
- try {
85
- await probe.query('SELECT 1');
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
- finally {
88
- probe.release();
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
- writable: options.write === true,
116
- showPii: options.showPii === true,
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 cheap enough to fetch inline. Use pg_class reltuples as
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
- for (const row of countsResult.rows) {
336
- // pg_class.reltuples is -1 on PG14+ until a table is ANALYZEd; clamp so the
337
- // sidebar never shows a negative estimate.
338
- counts.set(row.relname, Math.max(0, Number(row.reltuples)));
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
- // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
388
- const mainValues = [limit, offset];
389
- let mainWhere = '';
390
- if (hasSearch && pattern !== null) {
391
- mainValues.push(pattern);
392
- const conds = textColumns.map((c) => `${(0, index_js_1.quoteIdent)(c)} ILIKE $3 ESCAPE '\\'`);
393
- mainWhere = `WHERE (${conds.join(' OR ')})`;
394
- }
395
- // Count query: $1 = pattern (if search)
396
- const countValues = [];
397
- let countWhere = '';
398
- if (hasSearch && pattern !== null) {
399
- countValues.push(pattern);
400
- const conds = textColumns.map((c) => `${(0, index_js_1.quoteIdent)(c)} ILIKE $1 ESCAPE '\\'`);
401
- countWhere = `WHERE (${conds.join(' OR ')})`;
402
- }
403
- const qualifiedTable = `${(0, index_js_1.quoteIdent)(ctx.options.schema)}.${(0, index_js_1.quoteIdent)(table.name)}`;
404
- const sql = `SELECT * FROM ${qualifiedTable} ${mainWhere} ${orderByClause} LIMIT $1 OFFSET $2`;
405
- const countSql = `SELECT COUNT(*)::text AS count FROM ${qualifiedTable} ${countWhere}`;
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
- await client.query('BEGIN READ ONLY');
409
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
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
- await client.query('COMMIT');
544
+ if (!ctx.demo)
545
+ await client.query('COMMIT');
413
546
  sendJson(res, 200, {
414
547
  table: table.name,
415
- columns: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
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
- try {
425
- await client.query('ROLLBACK');
426
- }
427
- catch {
428
- /* ignore */
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
- await client.query('BEGIN READ ONLY');
483
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
484
- // QueryInterface emits unqualified table identifiers, which resolve via
485
- // the connection's search_path. Pin it to the configured --schema so the
486
- // Query tab reads the same schema as the Data tab (set_config is
487
- // transaction-local and fully parameterized).
488
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
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
- await client.query('COMMIT');
493
- const rawRows = result.rows;
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.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
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
- try {
505
- await client.query('ROLLBACK');
506
- }
507
- catch {
508
- /* ignore */
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 (single-row writes)
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 write can only ever touch one row.
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
- const data = (body?.data && typeof body.data === 'object' ? body.data : {});
539
- const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
540
- // Validate every column name up front for a clean typed 400 (the builders
541
- // would also reject unknowns, but an explicit check keeps the message clear).
542
- if (op === 'insert' || op === 'update') {
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 (!Object.keys(data).some((k) => data[k] !== undefined)) {
549
- sendJson(res, 400, { error: '[turbine] `data` must include at least one column' });
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 pk = extractPkWhere(table, rawWhere);
563
- if ('error' in pk) {
564
- sendJson(res, 400, { error: `[turbine] ${pk.error}` });
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
- // Empty-where can never happen by construction (PK covered above); assert.
568
- if (Object.keys(pk.where).length === 0) {
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 deferred;
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
- deferred = qi.buildCreate({ data });
861
+ deferreds = inserts.map((rec) => qi.buildCreate({ data: rec }));
583
862
  }
584
863
  else if (op === 'update') {
585
- deferred = qi.buildUpdate({ where: effectiveWhere, data });
864
+ const where = wheres[0];
865
+ deferreds = [qi.buildUpdate({ where, data })];
586
866
  }
587
867
  else {
588
- deferred = qi.buildDelete({ where: effectiveWhere });
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`. Same parameterized
598
- // statement-timeout + search_path pin as the read paths.
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
- await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
601
- await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
602
- const result = await client.query(deferred.sql, deferred.params);
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
- // The echoed row is redacted the same way as any read (unless --show-pii),
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
- sendJson(res, 200, {
614
- operation: op,
615
- row: serializeRow(redactFlatRow(row, piiKeys)),
616
- rowCount: result.rowCount ?? 1,
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)) {