turbine-orm 0.36.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -49,6 +49,7 @@
49
49
  import { CircularRelationError, RelationError, UnsupportedFeatureError, ValidationError } from '../errors.js';
50
50
  import { normalizeKeyColumns } from '../schema.js';
51
51
  import { isRelationPickOrderBy } from './filters.js';
52
+ import { ownLookup } from './utils.js';
52
53
  /**
53
54
  * Max parent keys per follow-up query. On Postgres the whole key set travels as
54
55
  * ONE array parameter (`= ANY($1)`), so this is not a bind-parameter limit — it
@@ -123,7 +124,7 @@ export function neededParentKeyFields(parentMeta, withClause) {
123
124
  }
124
125
  continue;
125
126
  }
126
- const rel = parentMeta.relations[relName];
127
+ const rel = ownLookup(parentMeta.relations, relName);
127
128
  if (!rel)
128
129
  continue; // unknown relation — the join path throws; let the loader surface it
129
130
  for (const col of localKeyColumns(rel)) {
@@ -161,7 +162,7 @@ export function resolveCountRelations(parentMeta, countSpec) {
161
162
  for (const [relName, enabled] of Object.entries(countSpec)) {
162
163
  if (!enabled)
163
164
  continue;
164
- const rel = parentMeta.relations[relName];
165
+ const rel = ownLookup(parentMeta.relations, relName);
165
166
  if (!rel) {
166
167
  throw new RelationError(`[turbine] Unknown relation "${relName}" in _count on table "${parentMeta.name}". ` +
167
168
  `Available: ${Object.keys(parentMeta.relations).join(', ')}`);
@@ -232,7 +233,7 @@ export async function loadRelationsBatched(ctx, parents, withClause, timeout, de
232
233
  loads.push(loadCounts(ctx, parents, spec));
233
234
  continue;
234
235
  }
235
- const rel = ctx.parentMeta.relations[relName];
236
+ const rel = ownLookup(ctx.parentMeta.relations, relName);
236
237
  if (!rel) {
237
238
  throw new ValidationError(`[turbine] Unknown relation "${relName}" on table "${ctx.parentMeta.name}". ` +
238
239
  `Available: ${Object.keys(ctx.parentMeta.relations).join(', ')}`);
@@ -18,7 +18,7 @@ import * as aggMod from './aggregates.js';
18
18
  import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, stripFields, } from './batched-loader.js';
19
19
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, sortedEntries, } from './filters.js';
20
20
  import * as relationsMod from './relations.js';
21
- import { LRUCache, parseDbDate, sqlToPreparedName } from './utils.js';
21
+ import { LRUCache, ownLookup, parseDbDate, sqlToPreparedName } from './utils.js';
22
22
  import * as whereMod from './where.js';
23
23
  import * as writesMod from './writes.js';
24
24
  /**
@@ -856,7 +856,7 @@ export class QueryInterface {
856
856
  !whereObj.NOT &&
857
857
  whereKeys.every((k) => {
858
858
  const v = whereObj[k];
859
- return v !== null && !isWhereOperator(v) && !this.tableMeta.relations[k];
859
+ return v !== null && !isWhereOperator(v) && !ownLookup(this.tableMeta.relations, k);
860
860
  });
861
861
  // Simple path: plain equality, no operators/null/OR
862
862
  if (!args.with && isSimpleWhere) {
@@ -1095,7 +1095,7 @@ export class QueryInterface {
1095
1095
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
1096
1096
  const orderFp = args?.orderBy
1097
1097
  ? Object.entries(args.orderBy)
1098
- .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, this.tableMeta.relations[k]?.to)}`)
1098
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d, ownLookup(this.tableMeta.relations, k)?.to)}`)
1099
1099
  .join(',')
1100
1100
  : '';
1101
1101
  const cursorFp = args?.cursor
@@ -1681,7 +1681,11 @@ export class QueryInterface {
1681
1681
  }
1682
1682
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
1683
1683
  toColumn(field) {
1684
- const mapped = this.tableMeta.columnMap[field];
1684
+ // Prototype-safe lookup: a plain-object `columnMap` would otherwise return
1685
+ // an inherited member (e.g. Object.prototype.constructor) for a field named
1686
+ // "constructor" / "toString" / "__proto__", bypassing the unknown-field
1687
+ // check below and returning a non-string as the column name.
1688
+ const mapped = ownLookup(this.tableMeta.columnMap, field);
1685
1689
  if (mapped)
1686
1690
  return mapped;
1687
1691
  // Fall back to camelToSnake ONLY if that snake_cased name also exists as a
@@ -1691,7 +1695,7 @@ export class QueryInterface {
1691
1695
  // SQL injection and catching typos like `where: { emial: 'x' }` with a
1692
1696
  // clear error instead of a cryptic Postgres "column does not exist".
1693
1697
  const snake = camelToSnake(field);
1694
- if (this.tableMeta.reverseColumnMap?.[snake]) {
1698
+ if (this.tableMeta.reverseColumnMap && ownLookup(this.tableMeta.reverseColumnMap, snake)) {
1695
1699
  return snake;
1696
1700
  }
1697
1701
  if (this.tableMeta.allColumns?.includes(snake)) {
@@ -1786,7 +1790,7 @@ export class QueryInterface {
1786
1790
  // pick.where / pick.orderBy paths). To-one relation orderBy carries the
1787
1791
  // target's global filter once per ordered column.
1788
1792
  if (this.isRelationOrderByValue(dir)) {
1789
- const relDef = this.tableMeta.relations[key];
1793
+ const relDef = ownLookup(this.tableMeta.relations, key);
1790
1794
  if (relDef && isRelationPickOrderBy(dir)) {
1791
1795
  this.collectRelationPickOrderParams(key, relDef, dir, params);
1792
1796
  }
@@ -17,6 +17,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { resolveCountRelations } from './batched-loader.js';
19
19
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, normalizeOrderBy, sortedEntries, } from './filters.js';
20
+ import { ownLookup } from './utils.js';
20
21
  import * as whereMod from './where.js';
21
22
  import * as writesMod from './writes.js';
22
23
  /** Relations already warned about missing FK indexes (once per process, dev only). */
@@ -317,10 +318,10 @@ export function buildOrderBy(qi, orderBy, params, lateralSink) {
317
318
  // are validated in the relation branch below, so skip them here.
318
319
  if (process.env.NODE_ENV !== 'production') {
319
320
  for (const [key, value] of Object.entries(orderBy)) {
320
- if (isRelationOrderByValue(qi, value) && qi.tableMeta.relations[key])
321
+ if (isRelationOrderByValue(qi, value) && ownLookup(qi.tableMeta.relations, key))
321
322
  continue;
322
323
  const snakeKey = camelToSnake(key);
323
- if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in qi.tableMeta.columnMap)) {
324
+ if (!qi.tableMeta.columns.some((c) => c.name === snakeKey) && !Object.hasOwn(qi.tableMeta.columnMap, key)) {
324
325
  console.warn(`[turbine] Unknown orderBy field "${key}" for table "${qi.tableMeta.name}". ` +
325
326
  'This will cause a runtime error.');
326
327
  }
@@ -402,7 +403,7 @@ export function nullsSuffix(qi, nulls) {
402
403
  * camelCase-named DB columns like "sortOrder").
403
404
  */
404
405
  export function resolveOrderByColumn(_qi, table, meta, key) {
405
- const col = meta.columnMap[key] ?? camelToSnake(key);
406
+ const col = ownLookup(meta.columnMap, key) ?? camelToSnake(key);
406
407
  if (!meta.allColumns.includes(col)) {
407
408
  throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${table}". ` +
408
409
  `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
@@ -520,7 +521,7 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
520
521
  .map(([col, dirValue]) => {
521
522
  // columnMap-first resolution (camelToSnake fallback): mirrors the
522
523
  // scalar orderBy path so camelCase-named DB columns resolve here too.
523
- const snakeCol = targetMeta.columnMap[col] ?? camelToSnake(col);
524
+ const snakeCol = ownLookup(targetMeta.columnMap, col) ?? camelToSnake(col);
524
525
  if (!targetMeta.allColumns.includes(snakeCol)) {
525
526
  throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
526
527
  }
@@ -988,7 +989,7 @@ export function resolveTargetColumns(qi, spec, targetMeta, includePii) {
988
989
  // opt-in and comes back regardless of the query's `includePii`.
989
990
  const selectedFields = Object.entries(spec.select)
990
991
  .filter(([, v]) => v)
991
- .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k));
992
+ .map(([k]) => ownLookup(targetMeta.columnMap, k) ?? camelToSnake(k));
992
993
  return selectedFields.filter((col) => targetMeta.allColumns.includes(col));
993
994
  }
994
995
  // Default / omit-only relation projection: PII columns are excluded unless
@@ -998,7 +999,7 @@ export function resolveTargetColumns(qi, spec, targetMeta, includePii) {
998
999
  if (spec !== true && spec.omit) {
999
1000
  const omittedFields = new Set(Object.entries(spec.omit)
1000
1001
  .filter(([, v]) => v)
1001
- .map(([k]) => targetMeta.columnMap[k] ?? camelToSnake(k)));
1002
+ .map(([k]) => ownLookup(targetMeta.columnMap, k) ?? camelToSnake(k)));
1002
1003
  return targetMeta.allColumns.filter((col) => !omittedFields.has(col) && !(hasPii && piiCols.has(col)));
1003
1004
  }
1004
1005
  if (hasPii) {
@@ -13,6 +13,16 @@
13
13
  * quoteIdent('user name') → '"user name"'
14
14
  */
15
15
  export declare function quoteIdent(name: string): string;
16
+ /**
17
+ * Prototype-safe own-property read for the plain metadata maps (columnMap,
18
+ * relations, reverseColumnMap). These are constructed as plain objects, so a
19
+ * bare `map[key]` for a user-supplied field name like "constructor",
20
+ * "toString", or "__proto__" returns an inherited member from
21
+ * `Object.prototype` — a truthy value that slips past validation and produces a
22
+ * cryptic `TypeError` instead of a clean `ValidationError`. Returns `undefined`
23
+ * unless `key` is an OWN enumerable/non-enumerable property.
24
+ */
25
+ export declare function ownLookup<T>(map: Record<string, T>, key: string): T | undefined;
16
26
  /**
17
27
  * Escape single quotes for use as string keys in json_build_object().
18
28
  * Doubles single quotes per SQL quoting rules.
@@ -18,6 +18,18 @@
18
18
  export function quoteIdent(name) {
19
19
  return `"${name.replace(/"/g, '""')}"`;
20
20
  }
21
+ /**
22
+ * Prototype-safe own-property read for the plain metadata maps (columnMap,
23
+ * relations, reverseColumnMap). These are constructed as plain objects, so a
24
+ * bare `map[key]` for a user-supplied field name like "constructor",
25
+ * "toString", or "__proto__" returns an inherited member from
26
+ * `Object.prototype` — a truthy value that slips past validation and produces a
27
+ * cryptic `TypeError` instead of a clean `ValidationError`. Returns `undefined`
28
+ * unless `key` is an OWN enumerable/non-enumerable property.
29
+ */
30
+ export function ownLookup(map, key) {
31
+ return Object.hasOwn(map, key) ? map[key] : undefined;
32
+ }
21
33
  /**
22
34
  * Escape single quotes for use as string keys in json_build_object().
23
35
  * Doubles single quotes per SQL quoting rules.
@@ -34,6 +34,7 @@
34
34
  * step.
35
35
  */
36
36
  import { findArrayUniqueKey, findJsonUniqueKey, fingerprintArrayFilterShape, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isWhereOperator, sortedKeys, VECTOR_DISTANCE_COMPARATORS, } from './filters.js';
37
+ import { ownLookup } from './utils.js';
37
38
  /** True when a normalized relation filter carries at least one cardinality key. */
38
39
  function isRelationFilterObj(filterObj) {
39
40
  return ('some' in filterObj || 'every' in filterObj || 'none' in filterObj || 'is' in filterObj || 'isNot' in filterObj);
@@ -73,7 +74,7 @@ export function walkWhere(host, where) {
73
74
  events.push({ kind: 'not', condition: value });
74
75
  continue;
75
76
  }
76
- const relDef = host.tableMeta.relations[key];
77
+ const relDef = ownLookup(host.tableMeta.relations, key);
77
78
  if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
78
79
  const filterObj = host.normalizeRelationFilter(relDef, value);
79
80
  if (isRelationFilterObj(filterObj)) {
@@ -13,7 +13,7 @@
13
13
  import { UnsupportedFeatureError, ValidationError } from '../errors.js';
14
14
  import { camelToSnake } from '../schema.js';
15
15
  import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, isArrayFilter, isColumnRef, isJsonFilter, isUnmatchedPlainObject, isWhereOperator, JSON_RANGE_OPERATORS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
16
- import { escapeLike, OPERATOR_KEYS } from './utils.js';
16
+ import { escapeLike, OPERATOR_KEYS, ownLookup } from './utils.js';
17
17
  import { classifyScalarForSql, fingerprintScalarToken, walkWhere, } from './where-compile.js';
18
18
  /**
19
19
  * Produce a value-invariant fingerprint of a where clause.
@@ -663,7 +663,7 @@ export function buildScopedWhere(qi, scope, where, params) {
663
663
  */
664
664
  export function buildScopedScalarClause(qi, scope, field, value, params, clauses) {
665
665
  const meta = scope.meta;
666
- const col = meta.columnMap[field] ?? camelToSnake(field);
666
+ const col = ownLookup(meta.columnMap, field) ?? camelToSnake(field);
667
667
  if (!meta.allColumns.includes(col))
668
668
  throw scope.unknownColumn(field);
669
669
  const qCol = `${scope.qualifier}${qi.q(col)}`;
@@ -740,7 +740,7 @@ export function collectScopedScalarParams(qi, scope, field, value, params) {
740
740
  if (value === null)
741
741
  return;
742
742
  const meta = scope.meta;
743
- const col = meta.columnMap[field] ?? camelToSnake(field);
743
+ const col = ownLookup(meta.columnMap, field) ?? camelToSnake(field);
744
744
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
745
745
  const colType = pgTypeForColumn(qi, meta, col);
746
746
  if (isJsonColumnType(qi, colType)) {
@@ -1011,7 +1011,7 @@ export function resolveColumnRef(_qi, ref, ctx, mode) {
1011
1011
  `Case-insensitive column-to-column comparison is not supported: use client.sql\`...\` ` +
1012
1012
  `for lower(a) = lower(b).`);
1013
1013
  }
1014
- const col = ctx.meta.columnMap[ref.col] ?? camelToSnake(ref.col);
1014
+ const col = ownLookup(ctx.meta.columnMap, ref.col) ?? camelToSnake(ref.col);
1015
1015
  if (!ctx.meta.allColumns.includes(col)) {
1016
1016
  throw new ValidationError(`[turbine] Unknown field "${ref.col}" referenced by { col } in where on table "${ctx.table}". ` +
1017
1017
  `Known fields: ${Object.keys(ctx.meta.columnMap).join(', ') || '(none)'}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.36.1",
3
+ "version": "0.38.0",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {