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.
@@ -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 };
@@ -140,6 +142,9 @@ async function startStudio(options) {
140
142
  showPii: demo ? false : options.showPii === true,
141
143
  demo,
142
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,
143
148
  };
144
149
  const server = (0, node_http_1.createServer)((req, res) => {
145
150
  handleRequest(req, res, ctx).catch((err) => {
@@ -454,6 +459,18 @@ async function apiTableRows(res, ctx, rawTableName, params) {
454
459
  .map((c) => c.name);
455
460
  const hasSearch = search.length > 0 && textColumns.length > 0;
456
461
  const pattern = hasSearch ? `%${escapeLikePattern(search)}%` : null;
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
+ }
457
474
  // Parameter placeholder + case-insensitive LIKE condition differ by engine.
458
475
  // Postgres: numbered `$N` + `ILIKE`. Demo (SQLite): named `:pN` (bound by
459
476
  // name from the positional value array, matching Turbine's own SQLite path)
@@ -463,22 +480,46 @@ async function apiTableRows(res, ctx, rawTableName, params) {
463
480
  const likeCond = (col, n) => ctx.demo
464
481
  ? `LOWER(${(0, index_js_1.quoteIdent)(col)}) LIKE LOWER(${ph(n)}) ESCAPE '\\'`
465
482
  : `${(0, index_js_1.quoteIdent)(col)} ILIKE ${ph(n)} ESCAPE '\\'`;
466
- // Main query: $1 = limit, $2 = offset, $3 = pattern (if search)
467
- const mainValues = [limit, offset];
468
- let mainWhere = '';
469
- if (hasSearch && pattern !== null) {
470
- mainValues.push(pattern);
471
- const conds = textColumns.map((c) => likeCond(c, 3));
472
- mainWhere = `WHERE (${conds.join(' OR ')})`;
473
- }
474
- // Count query: $1 = pattern (if search)
475
- const countValues = [];
476
- let countWhere = '';
477
- if (hasSearch && pattern !== null) {
478
- countValues.push(pattern);
479
- const conds = textColumns.map((c) => likeCond(c, 1));
480
- countWhere = `WHERE (${conds.join(' OR ')})`;
481
- }
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;
482
523
  // Demo runs against an unqualified in-memory SQLite table (no schemas);
483
524
  // Postgres qualifies with the configured `--schema`.
484
525
  const qualifiedTable = ctx.demo
@@ -548,6 +589,78 @@ function escapeLikePattern(s) {
548
589
  return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
549
590
  }
550
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
+ // ---------------------------------------------------------------------------
551
664
  // API: /api/builder — Turbine ORM findMany spec runner
552
665
  // ---------------------------------------------------------------------------
553
666
  async function apiBuilder(req, res, ctx) {
@@ -622,15 +735,24 @@ async function apiBuilder(req, res, ctx) {
622
735
  }
623
736
  }
624
737
  // ---------------------------------------------------------------------------
625
- // 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)
626
739
  //
627
740
  // Write mode only (the routes do not exist otherwise). Every column identifier
628
741
  // is validated against the introspected metadata and the statement is compiled
629
742
  // through the query builders (`buildUpdate`/`buildCreate`/`buildDelete`) so all
630
743
  // values are $N params; there is no raw SQL. update/delete require the caller
631
744
  // to supply the FULL primary key in `where`; the effective predicate is rebuilt
632
- // 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.
633
753
  // ---------------------------------------------------------------------------
754
+ /** Hard cap on rows per bulk insert/delete request (matches the max page size). */
755
+ const MAX_BULK_ROWS = 500;
634
756
  async function apiRowWrite(req, res, ctx, op) {
635
757
  const body = await readJsonBody(req);
636
758
  const tableName = typeof body?.table === 'string' ? body.table : '';
@@ -643,23 +765,51 @@ async function apiRowWrite(req, res, ctx, op) {
643
765
  sendJson(res, 400, { error: `[turbine] "${tableName}" is a view; Studio cannot write to it.` });
644
766
  return;
645
767
  }
646
- const data = (body?.data && typeof body.data === 'object' ? body.data : {});
647
- const rawWhere = (body?.where && typeof body.where === 'object' ? body.where : {});
648
- // Validate every column name up front for a clean typed 400 (the builders
649
- // would also reject unknowns, but an explicit check keeps the message clear).
650
- if (op === 'insert' || op === 'update') {
651
- const badKey = firstUnknownColumn(table, data);
652
- if (badKey) {
653
- 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' });
654
773
  return;
655
774
  }
656
- if (!Object.keys(data).some((k) => data[k] !== undefined)) {
657
- 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' });
777
+ return;
778
+ }
779
+ if (bulkRows.length > MAX_BULK_ROWS) {
780
+ sendJson(res, 400, { error: `[turbine] too many rows (max ${MAX_BULK_ROWS} per request)` });
658
781
  return;
659
782
  }
660
783
  }
661
- // update/delete: require the table to have a PK and the caller to cover it.
662
- let effectiveWhere = rawWhere;
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
+ }
812
+ }
663
813
  if (op === 'update' || op === 'delete') {
664
814
  if (table.primaryKey.length === 0) {
665
815
  sendJson(res, 400, {
@@ -667,19 +817,39 @@ async function apiRowWrite(req, res, ctx, op) {
667
817
  });
668
818
  return;
669
819
  }
670
- const pk = extractPkWhere(table, rawWhere);
671
- if ('error' in pk) {
672
- 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}"` });
673
845
  return;
674
846
  }
675
- // Empty-where can never happen by construction (PK covered above); assert.
676
- if (Object.keys(pk.where).length === 0) {
677
- 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' });
678
849
  return;
679
850
  }
680
- effectiveWhere = pk.where;
681
851
  }
682
- let deferred;
852
+ let deferreds;
683
853
  try {
684
854
  const qi = new index_js_1.QueryInterface(ctx.pool, tableName, ctx.metadata, [], {
685
855
  warnOnUnlimited: false,
@@ -688,13 +858,14 @@ async function apiRowWrite(req, res, ctx, op) {
688
858
  dialect: ctx.dialect,
689
859
  });
690
860
  if (op === 'insert') {
691
- deferred = qi.buildCreate({ data });
861
+ deferreds = inserts.map((rec) => qi.buildCreate({ data: rec }));
692
862
  }
693
863
  else if (op === 'update') {
694
- deferred = qi.buildUpdate({ where: effectiveWhere, data });
864
+ const where = wheres[0];
865
+ deferreds = [qi.buildUpdate({ where, data })];
695
866
  }
696
867
  else {
697
- deferred = qi.buildDelete({ where: effectiveWhere });
868
+ deferreds = wheres.map((where) => qi.buildDelete({ where }));
698
869
  }
699
870
  }
700
871
  catch (err) {
@@ -706,28 +877,48 @@ async function apiRowWrite(req, res, ctx, op) {
706
877
  // A real write transaction, NOT `READ ONLY`. Postgres also pins the
707
878
  // parameterized statement-timeout + search_path; demo (SQLite) has neither
708
879
  // GUC, so those are skipped, but the BEGIN/COMMIT is kept (SqlitePool
709
- // supports it) so an in-memory write still applies atomically.
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.
710
882
  await client.query('BEGIN');
711
883
  if (!ctx.demo) {
712
884
  await client.query(ctx.statementTimeout.sql, ctx.statementTimeout.params);
713
885
  await client.query(`SELECT set_config('search_path', $1, true)`, [ctx.options.schema]);
714
886
  }
715
- const result = await client.query(deferred.sql, deferred.params);
716
- await client.query('COMMIT');
717
- const row = result.rows[0];
718
- if (!row) {
719
- const msg = op === 'insert' ? 'insert returned no row' : 'no row matched the primary key';
720
- sendJson(res, 404, { error: `[turbine] ${msg}` });
721
- return;
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;
722
902
  }
723
- // The echoed row is redacted the same way as any read (unless --show-pii),
903
+ await client.query('COMMIT');
904
+ // The echoed rows are redacted the same way as any read (unless --show-pii),
724
905
  // even though a write to a pii column is allowed.
725
906
  const piiKeys = ctx.showPii ? NO_PII_KEYS : piiKeysForTable(table);
726
- sendJson(res, 200, {
727
- operation: op,
728
- row: serializeRow(redactFlatRow(row, piiKeys)),
729
- rowCount: result.rowCount ?? 1,
730
- });
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
+ }
731
922
  }
732
923
  catch (err) {
733
924
  try {
@@ -789,6 +980,12 @@ function savedQueriesPath(ctx) {
789
980
  /** One-shot flag so the legacy saved-query notice isn't logged on every request. */
790
981
  let legacyDropNoticeShown = false;
791
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
+ }
792
989
  const file = savedQueriesPath(ctx);
793
990
  if (!(0, node_fs_1.existsSync)(file))
794
991
  return { version: 1, queries: [] };
@@ -814,6 +1011,11 @@ function loadSavedQueries(ctx) {
814
1011
  }
815
1012
  }
816
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
+ }
817
1019
  const file = savedQueriesPath(ctx);
818
1020
  const dir = (0, node_path_1.dirname)(file);
819
1021
  if (!(0, node_fs_1.existsSync)(dir))
@@ -9,6 +9,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.ReadOnlyError = exports.UnsupportedFeatureError = exports.OptimisticLockError = exports.PipelineError = exports.ExclusionConstraintError = exports.CheckConstraintError = exports.SerializationFailureError = exports.DeadlockError = exports.NotNullViolationError = exports.ForeignKeyError = exports.UniqueConstraintError = exports.CircularRelationError = exports.MigrationError = exports.RelationError = exports.ConnectionError = exports.ValidationError = exports.TimeoutError = exports.NotFoundError = exports.TurbineError = exports.TurbineErrorCode = void 0;
10
10
  exports.setErrorMessageMode = setErrorMessageMode;
11
11
  exports.getErrorMessageMode = getErrorMessageMode;
12
+ exports.describeTargetForMessage = describeTargetForMessage;
12
13
  exports.wrapPgError = wrapPgError;
13
14
  /** Error codes for all Turbine errors */
14
15
  exports.TurbineErrorCode = {
@@ -72,6 +73,32 @@ function setErrorMessageMode(mode) {
72
73
  function getErrorMessageMode() {
73
74
  return errorMessageMode;
74
75
  }
76
+ /**
77
+ * Render a user-supplied `where` / `connect` target for a "no row found" error
78
+ * message, honoring the global {@link ErrorMessageMode}. In 'safe' mode (the
79
+ * default) only the key names are shown (`keys [email, id]`) so that PII values
80
+ * never leak into logs; in 'verbose' mode the full JSON serialization is used.
81
+ *
82
+ * This mirrors {@link NotFoundError}'s redaction so that every "no row found"
83
+ * message in the library follows one convention, including the nested-write
84
+ * connect/update failures which historically embedded the raw values.
85
+ */
86
+ function describeTargetForMessage(target) {
87
+ if (errorMessageMode === 'verbose') {
88
+ try {
89
+ return JSON.stringify(target);
90
+ }
91
+ catch {
92
+ return '[unserializable]';
93
+ }
94
+ }
95
+ // safe mode: key names only
96
+ if (target === null || target === undefined || typeof target !== 'object') {
97
+ return 'keys []';
98
+ }
99
+ const keys = Object.keys(target);
100
+ return `keys [${keys.join(', ')}]`;
101
+ }
75
102
  /**
76
103
  * Render a `where` clause for error messages. In 'safe' mode (the default),
77
104
  * only the keys are shown; values are stripped to avoid leaking PII into logs.
@@ -39,7 +39,7 @@ function extractRelationFields(data, tableMeta) {
39
39
  const scalars = {};
40
40
  const relations = {};
41
41
  for (const [key, value] of Object.entries(data)) {
42
- if (key in tableMeta.relations &&
42
+ if (Object.hasOwn(tableMeta.relations, key) &&
43
43
  value !== null &&
44
44
  typeof value === 'object' &&
45
45
  !Array.isArray(value) &&
@@ -59,7 +59,7 @@ function extractRelationFields(data, tableMeta) {
59
59
  */
60
60
  function hasRelationFields(data, tableMeta) {
61
61
  for (const key of Object.keys(data)) {
62
- if (key in tableMeta.relations) {
62
+ if (Object.hasOwn(tableMeta.relations, key)) {
63
63
  const val = data[key];
64
64
  if (val !== null && typeof val === 'object' && !Array.isArray(val) && !(val instanceof Date)) {
65
65
  return true;
@@ -214,7 +214,7 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
214
214
  else {
215
215
  parentRow = (await ctx.tx.table(tableName).findUnique({ where }));
216
216
  if (!parentRow) {
217
- throw new errors_js_1.ValidationError(`[turbine] update: no ${tableName} row found matching ${JSON.stringify(where)}.`);
217
+ throw new errors_js_1.ValidationError(`[turbine] update: no ${tableName} row found matching ${(0, errors_js_1.describeTargetForMessage)(where)}.`);
218
218
  }
219
219
  }
220
220
  // Process each relation
@@ -297,7 +297,7 @@ async function processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relNa
297
297
  if (items.length > 0) {
298
298
  // Check if any items have nested relations (need per-row recursion)
299
299
  const childTable = ctx.schema.tables[rel.to];
300
- const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => k in (childTable.relations ?? {})));
300
+ const hasNested = childTable && items.some((item) => Object.keys(item).some((k) => Object.hasOwn(childTable.relations ?? {}, k)));
301
301
  if (hasNested) {
302
302
  // Per-row recursive create for items with nested relations
303
303
  for (const item of items) {
@@ -357,7 +357,7 @@ async function resolveBelongsToForCreate(ctx, rel, ops, parentTable, depth, path
357
357
  const target = items[0];
358
358
  relatedRow = (await ctx.tx.table(rel.to).findUnique({ where: target }));
359
359
  if (!relatedRow) {
360
- throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
360
+ throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
361
361
  }
362
362
  }
363
363
  }
@@ -412,7 +412,7 @@ async function processBelongsToCreate(ctx, rel, ops, parentRow, parentTable, dep
412
412
  const target = items[0];
413
413
  const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
414
414
  if (!existing) {
415
- throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${JSON.stringify(target)}.`);
415
+ throw new errors_js_1.ValidationError(`[turbine] connect on "${relName}": no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
416
416
  }
417
417
  const updateData = {};
418
418
  const relatedTable = ctx.schema.tables[rel.to];
@@ -442,7 +442,7 @@ async function batchConnect(ctx, rel, items, parentRow) {
442
442
  for (const target of items) {
443
443
  const existing = await ctx.tx.table(rel.to).findUnique({ where: target });
444
444
  if (!existing) {
445
- throw new errors_js_1.ValidationError(`[turbine] connect: no ${rel.to} row found matching ${JSON.stringify(target)}.`);
445
+ throw new errors_js_1.ValidationError(`[turbine] connect: no ${rel.to} row found matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
446
446
  }
447
447
  }
448
448
  // Build FK update data to point children at parent
@@ -57,6 +57,7 @@ exports.loadRelationsBatched = loadRelationsBatched;
57
57
  const errors_js_1 = require("../errors.js");
58
58
  const schema_js_1 = require("../schema.js");
59
59
  const filters_js_1 = require("./filters.js");
60
+ const utils_js_1 = require("./utils.js");
60
61
  /**
61
62
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
62
63
  * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
@@ -131,7 +132,7 @@ function neededParentKeyFields(parentMeta, withClause) {
131
132
  }
132
133
  continue;
133
134
  }
134
- const rel = parentMeta.relations[relName];
135
+ const rel = (0, utils_js_1.ownLookup)(parentMeta.relations, relName);
135
136
  if (!rel)
136
137
  continue; // unknown relation — the join path throws; let the loader surface it
137
138
  for (const col of localKeyColumns(rel)) {
@@ -169,7 +170,7 @@ function resolveCountRelations(parentMeta, countSpec) {
169
170
  for (const [relName, enabled] of Object.entries(countSpec)) {
170
171
  if (!enabled)
171
172
  continue;
172
- const rel = parentMeta.relations[relName];
173
+ const rel = (0, utils_js_1.ownLookup)(parentMeta.relations, relName);
173
174
  if (!rel) {
174
175
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in _count on table "${parentMeta.name}". ` +
175
176
  `Available: ${Object.keys(parentMeta.relations).join(', ')}`);
@@ -240,7 +241,7 @@ async function loadRelationsBatched(ctx, parents, withClause, timeout, depth = 0
240
241
  loads.push(loadCounts(ctx, parents, spec));
241
242
  continue;
242
243
  }
243
- const rel = ctx.parentMeta.relations[relName];
244
+ const rel = (0, utils_js_1.ownLookup)(ctx.parentMeta.relations, relName);
244
245
  if (!rel) {
245
246
  throw new errors_js_1.ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
246
247
  `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
@@ -892,7 +892,7 @@ class QueryInterface {
892
892
  !whereObj.NOT &&
893
893
  whereKeys.every((k) => {
894
894
  const v = whereObj[k];
895
- return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !this.tableMeta.relations[k];
895
+ return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !(0, utils_js_1.ownLookup)(this.tableMeta.relations, k);
896
896
  });
897
897
  // Simple path: plain equality, no operators/null/OR
898
898
  if (!args.with && isSimpleWhere) {
@@ -1131,7 +1131,7 @@ class QueryInterface {
1131
1131
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
1132
1132
  const orderFp = args?.orderBy
1133
1133
  ? Object.entries(args.orderBy)
1134
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
1134
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, (0, utils_js_1.ownLookup)(this.tableMeta.relations, k)?.to)}`)
1135
1135
  .join(',')
1136
1136
  : '';
1137
1137
  const cursorFp = args?.cursor
@@ -1717,7 +1717,11 @@ class QueryInterface {
1717
1717
  }
1718
1718
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
1719
1719
  toColumn(field) {
1720
- const mapped = this.tableMeta.columnMap[field];
1720
+ // Prototype-safe lookup: a plain-object `columnMap` would otherwise return
1721
+ // an inherited member (e.g. Object.prototype.constructor) for a field named
1722
+ // "constructor" / "toString" / "__proto__", bypassing the unknown-field
1723
+ // check below and returning a non-string as the column name.
1724
+ const mapped = (0, utils_js_1.ownLookup)(this.tableMeta.columnMap, field);
1721
1725
  if (mapped)
1722
1726
  return mapped;
1723
1727
  // Fall back to camelToSnake ONLY if that snake_cased name also exists as a
@@ -1727,7 +1731,7 @@ class QueryInterface {
1727
1731
  // SQL injection and catching typos like `where: { emial: 'x' }` with a
1728
1732
  // clear error instead of a cryptic Postgres "column does not exist".
1729
1733
  const snake = (0, schema_js_1.camelToSnake)(field);
1730
- if (this.tableMeta.reverseColumnMap?.[snake]) {
1734
+ if (this.tableMeta.reverseColumnMap && (0, utils_js_1.ownLookup)(this.tableMeta.reverseColumnMap, snake)) {
1731
1735
  return snake;
1732
1736
  }
1733
1737
  if (this.tableMeta.allColumns?.includes(snake)) {
@@ -1822,7 +1826,7 @@ class QueryInterface {
1822
1826
  // pick.where / pick.orderBy paths). To-one relation orderBy carries the
1823
1827
  // target's global filter once per ordered column.
1824
1828
  if (this.isRelationOrderByValue(dir)) {
1825
- const relDef = this.tableMeta.relations[key];
1829
+ const relDef = (0, utils_js_1.ownLookup)(this.tableMeta.relations, key);
1826
1830
  if (relDef && (0, filters_js_1.isRelationPickOrderBy)(dir)) {
1827
1831
  this.collectRelationPickOrderParams(key, relDef, dir, params);
1828
1832
  }