turbine-orm 0.37.0 → 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 { type IncomingMessage, type ServerResponse } from 'node:http';
38
40
  import type { PgCompatPool } from '../client.js';
@@ -117,6 +119,12 @@ export interface StudioContext {
117
119
  * (the default). Set to the SQLite dialect in demo mode.
118
120
  */
119
121
  dialect?: Dialect;
122
+ /**
123
+ * Demo mode only: saved queries live here instead of on disk, honoring the
124
+ * "nothing you do here is saved anywhere" promise. Absent in normal mode
125
+ * (saved queries persist to `<stateDir>/studio-queries.json`).
126
+ */
127
+ memorySavedQueries?: SavedQueriesFile;
120
128
  }
121
129
  /**
122
130
  * Start the Studio server. Returns a handle with the session token, a pre-built
@@ -136,8 +144,22 @@ export declare function isTextishType(pgType: string): boolean;
136
144
  export declare function escapeLikePattern(s: string): string;
137
145
  export declare function apiBuilder(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
138
146
  export declare function apiRowWrite(req: IncomingMessage, res: ServerResponse, ctx: StudioContext, op: 'update' | 'insert' | 'delete'): Promise<void>;
147
+ interface SavedQuery {
148
+ id: string;
149
+ table: string;
150
+ name: string;
151
+ /** Studio only saves visual-builder queries — there is no raw-SQL surface. */
152
+ kind: 'builder';
153
+ args?: unknown;
154
+ createdAt: string;
155
+ }
156
+ interface SavedQueriesFile {
157
+ version: 1;
158
+ queries: SavedQuery[];
159
+ }
139
160
  export declare function apiListSavedQueries(res: ServerResponse, ctx: StudioContext, params: URLSearchParams): void;
140
161
  export declare function apiCreateSavedQuery(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
141
162
  export declare function apiDeleteSavedQuery(res: ServerResponse, ctx: StudioContext, id: string): void;
142
163
  /** The literal replacement value for a redacted PII cell. */
143
164
  export declare const PII_REDACTED = "\u2022\u2022 redacted \u2022\u2022";
165
+ export {};
@@ -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';
@@ -122,6 +124,9 @@ export async function startStudio(options) {
122
124
  showPii: demo ? false : options.showPii === true,
123
125
  demo,
124
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,
125
130
  };
126
131
  const server = createServer((req, res) => {
127
132
  handleRequest(req, res, ctx).catch((err) => {
@@ -436,6 +441,18 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
436
441
  .map((c) => c.name);
437
442
  const hasSearch = search.length > 0 && textColumns.length > 0;
438
443
  const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
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
+ }
439
456
  // Parameter placeholder + case-insensitive LIKE condition differ by engine.
440
457
  // Postgres: numbered `$N` + `ILIKE`. Demo (SQLite): named `:pN` (bound by
441
458
  // name from the positional value array, matching Turbine's own SQLite path)
@@ -445,22 +462,46 @@ export async function apiTableRows(res, ctx, rawTableName, params) {
445
462
  const likeCond = (col, n) => ctx.demo
446
463
  ? `LOWER(${quoteIdent(col)}) LIKE LOWER(${ph(n)}) ESCAPE '\\'`
447
464
  : `${quoteIdent(col)} ILIKE ${ph(n)} ESCAPE '\\'`;
448
- // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
449
- const mainValues = [limit, offset];
450
- let mainWhere = '';
451
- if (hasSearch && pattern !== null) {
452
- mainValues.push(pattern);
453
- const conds = textColumns.map((c) => likeCond(c, 3));
454
- mainWhere = `WHERE (${conds.join(' OR ')})`;
455
- }
456
- // Count query: $1 = pattern (if search)
457
- const countValues = [];
458
- let countWhere = '';
459
- if (hasSearch && pattern !== null) {
460
- countValues.push(pattern);
461
- const conds = textColumns.map((c) => likeCond(c, 1));
462
- countWhere = `WHERE (${conds.join(' OR ')})`;
463
- }
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;
464
505
  // Demo runs against an unqualified in-memory SQLite table (no schemas);
465
506
  // Postgres qualifies with the configured `--schema`.
466
507
  const qualifiedTable = ctx.demo
@@ -530,6 +571,78 @@ export function escapeLikePattern(s) {
530
571
  return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
531
572
  }
532
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
+ // ---------------------------------------------------------------------------
533
646
  // API: /api/builder — Turbine ORM findMany spec runner
534
647
  // ---------------------------------------------------------------------------
535
648
  export async function apiBuilder(req, res, ctx) {
@@ -604,15 +717,24 @@ export async function apiBuilder(req, res, ctx) {
604
717
  }
605
718
  }
606
719
  // ---------------------------------------------------------------------------
607
- // 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)
608
721
  //
609
722
  // Write mode only (the routes do not exist otherwise). Every column identifier
610
723
  // is validated against the introspected metadata and the statement is compiled
611
724
  // through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
612
725
  // values are $N params; there is no raw SQL. update/delete require the caller
613
726
  // to supply the FULL primary key in `where`; the effective predicate is rebuilt
614
- // 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.
615
735
  // ---------------------------------------------------------------------------
736
+ /** Hard cap on rows per bulk insert/delete request (matches the max page size). */
737
+ const MAX_BULK_ROWS = 500;
616
738
  export async function apiRowWrite(req, res, ctx, op) {
617
739
  const body = await readJsonBody(req);
618
740
  const tableName = typeof body?.table === 'string' ? body.table : '';
@@ -625,23 +747,51 @@ export async function apiRowWrite(req, res, ctx, op) {
625
747
  sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
626
748
  return;
627
749
  }
628
- const data = (body?.data && typeof body.data === 'object' ? body.data : {});
629
- const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
630
- // Validate every column name up front for a clean typed 400 (the builders
631
- // would also reject unknowns, but an explicit check keeps the message clear).
632
- if (op === 'insert' || op === 'update') {
633
- const badKey = firstUnknownColumn(table, data);
634
- if (badKey) {
635
- 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' });
636
755
  return;
637
756
  }
638
- if (!Object.keys(data).some((k) => data[k] !== undefined)) {
639
- 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' });
759
+ return;
760
+ }
761
+ if (bulkRows.length > MAX_BULK_ROWS) {
762
+ sendJson(res, 400, { error: `[turbine] too many rows (max ${MAX_BULK_ROWS} per request)` });
640
763
  return;
641
764
  }
642
765
  }
643
- // update/delete: require the table to have a PK and the caller to cover it.
644
- let effectiveWhere = rawWhere;
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
+ }
794
+ }
645
795
  if (op === 'update' || op === 'delete') {
646
796
  if (table.primaryKey.length === 0) {
647
797
  sendJson(res, 400, {
@@ -649,19 +799,39 @@ export async function apiRowWrite(req, res, ctx, op) {
649
799
  });
650
800
  return;
651
801
  }
652
- const pk = extractPkWhere(table, rawWhere);
653
- if ('error' in pk) {
654
- 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}"` });
655
827
  return;
656
828
  }
657
- // Empty-where can never happen by construction (PK covered above); assert.
658
- if (Object.keys(pk.where).length === 0) {
659
- 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' });
660
831
  return;
661
832
  }
662
- effectiveWhere = pk.where;
663
833
  }
664
- let deferred;
834
+ let deferreds;
665
835
  try {
666
836
  const qi = new QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
667
837
  warnOnUnlimited: false,
@@ -670,13 +840,14 @@ export async function apiRowWrite(req, res, ctx, op) {
670
840
  dialect: ctx.dialect,
671
841
  });
672
842
  if (op === 'insert') {
673
- deferred = qi.buildCreate({ data });
843
+ deferreds = inserts.map((rec) => qi.buildCreate({ data: rec }));
674
844
  }
675
845
  else if (op === 'update') {
676
- deferred = qi.buildUpdate({ where: effectiveWhere, data });
846
+ const where = wheres[0];
847
+ deferreds = [qi.buildUpdate({ where, data })];
677
848
  }
678
849
  else {
679
- deferred = qi.buildDelete({ where: effectiveWhere });
850
+ deferreds = wheres.map((where) => qi.buildDelete({ where }));
680
851
  }
681
852
  }
682
853
  catch (err) {
@@ -688,28 +859,48 @@ export async function apiRowWrite(req, res, ctx, op) {
688
859
  // A real write transaction, NOT `READ ONLY`. Postgres also pins the
689
860
  // parameterized statement-timeout + search_path; demo (SQLite) has neither
690
861
  // GUC, so those are skipped, but the BEGIN/COMMIT is kept (SqlitePool
691
- // supports it) so an in-memory write still applies atomically.
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.
692
864
  await client.query('BEGIN');
693
865
  if (!ctx.demo) {
694
866
  await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
695
867
  await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
696
868
  }
697
- const result = await client.query(deferred.sql, deferred.params);
698
- await client.query('COMMIT');
699
- const row = result.rows[0];
700
- if (!row) {
701
- const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
702
- sendJson(res, 404, { error: `[turbine] ${msg}` });
703
- return;
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;
704
884
  }
705
- // The echoed row is redacted the same way as any read (unless --show-pii),
885
+ await client.query('COMMIT');
886
+ // The echoed rows are redacted the same way as any read (unless --show-pii),
706
887
  // even though a write to a pii column is allowed.
707
888
  const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
708
- sendJson(res, 200, {
709
- operation: op,
710
- row: serializeRow(redactFlatRow(row, piiKeys)),
711
- rowCount: result.rowCount ?? 1,
712
- });
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
+ }
713
904
  }
714
905
  catch (err) {
715
906
  try {
@@ -771,6 +962,12 @@ function savedQueriesPath(ctx) {
771
962
  /** One-shot flag so the legacy saved-query notice isn't logged on every request. */
772
963
  let legacyDropNoticeShown = false;
773
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
+ }
774
971
  const file = savedQueriesPath(ctx);
775
972
  if (!existsSync(file))
776
973
  return { version: 1, queries: [] };
@@ -796,6 +993,11 @@ function loadSavedQueries(ctx) {
796
993
  }
797
994
  }
798
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
+ }
799
1001
  const file = savedQueriesPath(ctx);
800
1002
  const dir = dirname(file);
801
1003
  if (!existsSync(dir))
package/dist/errors.d.ts CHANGED
@@ -57,6 +57,17 @@ export type ErrorMessageMode = 'safe' | 'verbose';
57
57
  export declare function setErrorMessageMode(mode: ErrorMessageMode): void;
58
58
  /** Returns the current NotFoundError message mode. Exported for tests. */
59
59
  export declare function getErrorMessageMode(): ErrorMessageMode;
60
+ /**
61
+ * Render a user-supplied `where` / `connect` target for a "no row found" error
62
+ * message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
63
+ * default) only the key names are shown (`keys [email, id]`) so that PII values
64
+ * never leak into logs; in 'verbose' mode the full JSON serialization is used.
65
+ *
66
+ * This mirrors {@link NotFoundError}'s redaction so that every "no row found"
67
+ * message in the library follows one convention, including the nested-write
68
+ * connect/update failures which historically embedded the raw values.
69
+ */
70
+ export declare function describeTargetForMessage(target: unknown): string;
60
71
  /**
61
72
  * Thrown when a record is not found (findUniqueOrThrow, findFirstOrThrow,
62
73
  * update/delete against a non-matching row, etc.)
package/dist/errors.js CHANGED
@@ -65,6 +65,32 @@ export function setErrorMessageMode(mode) {
65
65
  export function getErrorMessageMode() {
66
66
  return errorMessageMode;
67
67
  }
68
+ /**
69
+ * Render a user-supplied `where` / `connect` target for a "no row found" error
70
+ * message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
71
+ * default) only the key names are shown (`keys [email, id]`) so that PII values
72
+ * never leak into logs; in 'verbose' mode the full JSON serialization is used.
73
+ *
74
+ * This mirrors {@link NotFoundError}'s redaction so that every "no row found"
75
+ * message in the library follows one convention, including the nested-write
76
+ * connect/update failures which historically embedded the raw values.
77
+ */
78
+ export function describeTargetForMessage(target) {
79
+ if (errorMessageMode === 'verbose') {
80
+ try {
81
+ return JSON.stringify(target);
82
+ }
83
+ catch {
84
+ return '[unserializable]';
85
+ }
86
+ }
87
+ // safe mode: key names only
88
+ if (target === null || target === undefined || typeof target !== 'object') {
89
+ return 'keys []';
90
+ }
91
+ const keys = Object.keys(target);
92
+ return `keys [${keys.join(', ')}]`;
93
+ }
68
94
  /**
69
95
  * Render a `where` clause for error messages. In 'safe' mode (the default),
70
96
  * only the keys are shown; values are stripped to avoid leaking PII into logs.
@@ -11,7 +11,7 @@
11
11
  * `client.ts` directly — the transaction handle is passed in via
12
12
  * `NestedWriteContext`.
13
13
  */
14
- import { CircularRelationError, RelationError, ValidationError } from './errors.js';
14
+ import { CircularRelationError, describeTargetForMessage, RelationError, ValidationError } from './errors.js';
15
15
  import { normalizeKeyColumns } from './schema.js';
16
16
  const MAX_DEPTH = 10;
17
17
  const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
@@ -32,7 +32,7 @@ export function extractRelationFields(data, tableMeta) {
32
32
  const scalars = {};
33
33
  const relations = {};
34
34
  for (const [key, value] of Object.entries(data)) {
35
- if (key in tableMeta.relations &&
35
+ if (Object.hasOwn(tableMeta.relations, key) &&
36
36
  value !== null &&
37
37
  typeof value === 'object' &&
38
38
  !Array.isArray(value) &&
@@ -52,7 +52,7 @@ export function extractRelationFields(data, tableMeta) {
52
52
  */
53
53
  export function hasRelationFields(data, tableMeta) {
54
54
  for (const key of Object.keys(data)) {
55
- if (key in tableMeta.relations) {
55
+ if (Object.hasOwn(tableMeta.relations, key)) {
56
56
  const val = data[key];
57
57
  if (val !== null && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) {
58
58
  return true;
@@ -207,7 +207,7 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
207
207
  else {
208
208
  parentRow = (await ctx.tx.table(tableName).findUnique({ where }));
209
209
  if (!parentRow) {
210
- throw new ValidationError(`[turbine] update: no ${tableName} row found matching ${JSON.stringify(where)}.`);
210
+ throw new ValidationError(`[turbine] update: no ${tableName} row found matching ${describeTargetForMessage(where)}.`);
211
211
  }
212
212
  }
213
213
  // Process each relation
@@ -290,7 +290,7 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
290
290
  if (items.length > 0) {
291
291
  // Check if any items have nested relations (need per-row recursion)
292
292
  const childTable = ctx.schema.tables[rel.to];
293
- const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => k in (childTable.relations ?? {})));
293
+ const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => Object.hasOwn(childTable.relations ?? {}, k)));
294
294
  if (hasNested) {
295
295
  // Per-row recursive create for items with nested relations
296
296
  for (const item of items) {
@@ -350,7 +350,7 @@ async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path
350
350
  const target = items[0];
351
351
  relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
352
352
  if (!relatedRow) {
353
- throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
353
+ throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${describeTargetForMessage(target)}.`);
354
354
  }
355
355
  }
356
356
  }
@@ -405,7 +405,7 @@ async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, dep
405
405
  const target = items[0];
406
406
  const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
407
407
  if (!existing) {
408
- throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
408
+ throw new ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${describeTargetForMessage(target)}.`);
409
409
  }
410
410
  const updateData = {};
411
411
  const relatedTable = ctx.schema.tables[rel.to];
@@ -435,7 +435,7 @@ async function batchConnect(ctx, rel, items, parentRow) {
435
435
  for (const target of items) {
436
436
  const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
437
437
  if (!existing) {
438
- throw new ValidationError(`[turbine] connect: no ${rel.to} row found matching ${JSON.stringify(target)}.`);
438
+ throw new ValidationError(`[turbine] connect: no ${rel.to} row found matching ${describeTargetForMessage(target)}.`);
439
439
  }
440
440
  }
441
441
  // Build FK update data to point children at parent