turbine-orm 0.28.3 → 0.30.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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/cli/index.js +5 -0
  3. package/dist/cjs/cli/mcp.js +22 -92
  4. package/dist/cjs/client.js +69 -5
  5. package/dist/cjs/generate.js +71 -25
  6. package/dist/cjs/index.js +4 -1
  7. package/dist/cjs/introspect.js +350 -120
  8. package/dist/cjs/mssql.js +18 -133
  9. package/dist/cjs/mysql.js +16 -129
  10. package/dist/cjs/optional-peer-import.cjs +122 -0
  11. package/dist/cjs/powdb.js +440 -81
  12. package/dist/cjs/powql.js +49 -25
  13. package/dist/cjs/query/builder.js +290 -23
  14. package/dist/cjs/query/filters.js +32 -1
  15. package/dist/cjs/schema-metadata.js +316 -0
  16. package/dist/cjs/sqlite.js +8 -89
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +5 -0
  19. package/dist/cli/mcp.d.ts +18 -0
  20. package/dist/cli/mcp.js +22 -93
  21. package/dist/client.d.ts +44 -6
  22. package/dist/client.js +69 -5
  23. package/dist/generate.d.ts +16 -4
  24. package/dist/generate.js +71 -25
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +2 -0
  27. package/dist/introspect.d.ts +94 -1
  28. package/dist/introspect.js +345 -120
  29. package/dist/mssql.js +16 -101
  30. package/dist/mysql.js +14 -97
  31. package/dist/optional-peer-import.cjs +89 -0
  32. package/dist/optional-peer-import.d.cts +53 -0
  33. package/dist/powdb.d.ts +94 -26
  34. package/dist/powdb.js +435 -80
  35. package/dist/powql.d.ts +6 -0
  36. package/dist/powql.js +51 -27
  37. package/dist/query/builder.d.ts +60 -3
  38. package/dist/query/builder.js +291 -24
  39. package/dist/query/deferred.d.ts +7 -2
  40. package/dist/query/filters.d.ts +18 -0
  41. package/dist/query/filters.js +30 -0
  42. package/dist/query/types.d.ts +19 -0
  43. package/dist/schema-metadata.d.ts +77 -0
  44. package/dist/schema-metadata.js +313 -0
  45. package/dist/schema.d.ts +10 -0
  46. package/dist/sqlite.js +9 -90
  47. package/package.json +3 -3
@@ -16,7 +16,7 @@ import { missingIndexForRelation } from '../index-advisor.js';
16
16
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, resolveCountRelations, stripFields, } from './batched-loader.js';
19
- import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
19
+ import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintJsonFilterShape, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, JSON_RANGE_OPERATORS, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
20
20
  import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
21
21
  /** Relations already warned about missing FK indexes (once per process, dev only). */
22
22
  const unindexedRelationWarned = new Set();
@@ -61,6 +61,12 @@ export class QueryInterface {
61
61
  /** Pre-computed column type lookups (avoids linear scans per query) */
62
62
  columnPgTypeMap;
63
63
  columnArrayTypeMap;
64
+ /**
65
+ * Columns whose type lives in a DIFFERENT schema than the introspected one
66
+ * (ColumnMetadata.pgTypeSchema is recorded only in that case) — such columns
67
+ * must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
68
+ */
69
+ crossSchemaTypeColumns;
64
70
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
65
71
  deepWithWarned = new Set();
66
72
  /**
@@ -98,9 +104,19 @@ export class QueryInterface {
98
104
  this.middlewares = middlewares ?? [];
99
105
  this.defaultLimit = options?.defaultLimit;
100
106
  // Default to ON: surfacing accidental full-table scans is more valuable
101
- // than the (small) risk of noisy logs. Callers explicitly opt out with
102
- // `warnOnUnlimited: false`.
103
- this.warnOnUnlimited = options?.warnOnUnlimited !== false;
107
+ // than the (small) risk of noisy logs. Callers opt out globally with
108
+ // `warnOnUnlimited: false`, per table with `warnOnUnlimited: { users:
109
+ // false }` (unlisted tables keep the default), or per call via
110
+ // `findMany({ warnOnUnlimited: false })`.
111
+ // Per-table maps accept BOTH key forms — the snake_case table name
112
+ // (`user_profiles`) and the camelCase accessor (`userProfiles`) — since
113
+ // users naturally key by the accessor they type everywhere else. The
114
+ // snake_case entry wins when both are present.
115
+ const warnOpt = options?.warnOnUnlimited;
116
+ this.warnOnUnlimited =
117
+ typeof warnOpt === 'object' && warnOpt !== null
118
+ ? (warnOpt[table] ?? warnOpt[snakeToCamel(table)]) !== false
119
+ : warnOpt !== false;
104
120
  this.utcTimestamps = options?.utcTimestamps !== false;
105
121
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
106
122
  this.sqlCacheEnabled = options?.sqlCache !== false;
@@ -116,9 +132,12 @@ export class QueryInterface {
116
132
  // Pre-compute column type lookup maps (TASK-26)
117
133
  this.columnPgTypeMap = new Map();
118
134
  this.columnArrayTypeMap = new Map();
135
+ this.crossSchemaTypeColumns = new Set();
119
136
  for (const col of this.tableMeta.columns) {
120
137
  this.columnPgTypeMap.set(col.name, col.dialectType ?? col.pgType);
121
138
  this.columnArrayTypeMap.set(col.name, col.arrayType ?? col.pgArrayType);
139
+ if (col.pgTypeSchema !== undefined)
140
+ this.crossSchemaTypeColumns.add(col.name);
122
141
  }
123
142
  }
124
143
  /** Quote an identifier through the active SQL dialect. */
@@ -644,10 +663,16 @@ export class QueryInterface {
644
663
  * loop calling `db.users.findMany()` thousands of times only logs once.
645
664
  * Suppressed when `defaultLimit` is configured (the caller has already
646
665
  * opted in to a bounded query) and when the user passed an explicit
647
- * `limit`, `take`, or `cursor`.
666
+ * `limit`, `take`, or `cursor`. A per-call `warnOnUnlimited` overrides the
667
+ * config-level setting in either direction (`false` silences a call that
668
+ * intentionally reads the full set; `true` forces the warning even when
669
+ * disabled in config).
648
670
  */
649
671
  maybeWarnUnlimited(args) {
650
- if (!this.warnOnUnlimited)
672
+ const perCall = args?.warnOnUnlimited;
673
+ if (perCall === false)
674
+ return;
675
+ if (perCall === undefined && !this.warnOnUnlimited)
651
676
  return;
652
677
  if (this.defaultLimit !== undefined)
653
678
  return;
@@ -658,7 +683,7 @@ export class QueryInterface {
658
683
  return;
659
684
  this.warnedTables.add(this.table);
660
685
  console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
661
- 'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
686
+ 'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
662
687
  }
663
688
  /**
664
689
  * Recursively measure the maximum depth of a `with` clause tree.
@@ -1007,7 +1032,8 @@ export class QueryInterface {
1007
1032
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
1008
1033
  const columns = entries.map(([k]) => this.toSqlColumn(k));
1009
1034
  const params = entries.map(([, v]) => v);
1010
- const placeholders = entries.map((_, i) => `${this.p(i + 1)}`);
1035
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
1036
+ const placeholders = entries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
1011
1037
  const sql = this.dialect.buildInsertStatement({
1012
1038
  table: this.q(this.table),
1013
1039
  columns,
@@ -1089,7 +1115,13 @@ export class QueryInterface {
1089
1115
  return keys.map((key) => record[key]);
1090
1116
  });
1091
1117
  // Use actual Postgres types for array casts in the default PostgreSQL dialect.
1092
- const typeCasts = columns.map((col) => this.getColumnArrayType(col));
1118
+ // Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
1119
+ // type the UNNEST output as text, which Postgres refuses to coerce to the
1120
+ // enum ("column X is of type Y but expression is of type text").
1121
+ const typeCasts = columns.map((col) => {
1122
+ const enumType = this.enumTypeForColumn(col);
1123
+ return enumType ? `${this.q(enumType)}[]` : this.getColumnArrayType(col);
1124
+ });
1093
1125
  const quotedColumns = columns.map((c) => this.q(c));
1094
1126
  const built = this.dialect.buildBulkInsertStatement({
1095
1127
  table: qt,
@@ -1363,7 +1395,8 @@ export class QueryInterface {
1363
1395
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
1364
1396
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
1365
1397
  const createParams = createEntries.map(([, v]) => v);
1366
- const placeholders = createEntries.map((_, i) => `${this.p(i + 1)}`);
1398
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
1399
+ const placeholders = createEntries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
1367
1400
  // The conflict target comes from `where` keys — must be unique/PK columns
1368
1401
  const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
1369
1402
  const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
@@ -1371,7 +1404,7 @@ export class QueryInterface {
1371
1404
  const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
1372
1405
  let paramIdx = createParams.length + 1;
1373
1406
  const setClauses = updateEntries.map(([k]) => {
1374
- const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}`;
1407
+ const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}${this.enumCastSuffix(this.toColumn(k))}`;
1375
1408
  paramIdx++;
1376
1409
  return clause;
1377
1410
  });
@@ -2051,6 +2084,10 @@ export class QueryInterface {
2051
2084
  */
2052
2085
  buildSetClause(key, value, params) {
2053
2086
  const col = this.toSqlColumn(key);
2087
+ // Enum columns get an explicit `::"EnumName"` cast on their value bind
2088
+ // (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
2089
+ // SQL cache and collectSetParams are unaffected.
2090
+ const cast = this.enumCastSuffix(this.toColumn(key));
2054
2091
  // Detect atomic-operator object: plain object (not null, not array, not
2055
2092
  // Date, not Buffer) with EXACTLY one key matching an operator name.
2056
2093
  if (value !== null &&
@@ -2065,7 +2102,7 @@ export class QueryInterface {
2065
2102
  const opValue = v[op];
2066
2103
  if (op === 'set') {
2067
2104
  params.push(opValue);
2068
- return `${col} = ${this.p(params.length)}`;
2105
+ return `${col} = ${this.p(params.length)}${cast}`;
2069
2106
  }
2070
2107
  // Arithmetic operators: must be finite numbers
2071
2108
  if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
@@ -2093,7 +2130,7 @@ export class QueryInterface {
2093
2130
  }
2094
2131
  // Plain value (including null, Date, Buffer, arrays, JSON objects)
2095
2132
  params.push(value);
2096
- return `${col} = ${this.p(params.length)}`;
2133
+ return `${col} = ${this.p(params.length)}${cast}`;
2097
2134
  }
2098
2135
  // =========================================================================
2099
2136
  // Fingerprinting — value-invariant shape keys for SQL cache lookup
@@ -2192,10 +2229,10 @@ export class QueryInterface {
2192
2229
  parts.push(`${key}:vec(${dist.metric},${cmps})`);
2193
2230
  continue;
2194
2231
  }
2195
- // JSON filter
2232
+ // JSON filter — range ops carry a numeric/string annotation because the
2233
+ // numeric compile emits a cast (different SQL shape).
2196
2234
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2197
- const jKeys = Object.keys(value).sort();
2198
- parts.push(`${key}:json(${jKeys.join(',')})`);
2235
+ parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
2199
2236
  continue;
2200
2237
  }
2201
2238
  // Array filter
@@ -2281,6 +2318,15 @@ export class QueryInterface {
2281
2318
  else if (isWhereOperator(value)) {
2282
2319
  parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2283
2320
  }
2321
+ else if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2322
+ // Mirrors fingerprintWhere: JSON filters inside relation sub-wheres
2323
+ // build real JSON clauses (buildSubWhereForRelation), so their shape
2324
+ // must be cache-distinct from plain equality.
2325
+ parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
2326
+ }
2327
+ else if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
2328
+ parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
2329
+ }
2284
2330
  else if (isUnmatchedPlainObject(value)) {
2285
2331
  parts.push(`${key}:obj(${Object.keys(value)
2286
2332
  .sort()
@@ -2358,7 +2404,7 @@ export class QueryInterface {
2358
2404
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2359
2405
  const colType = this.getColumnPgType(rawColumn);
2360
2406
  if (colType === 'json' || colType === 'jsonb') {
2361
- this.collectJsonFilterParams(value, params);
2407
+ this.collectJsonFilterParams(value, params, this.q(rawColumn));
2362
2408
  continue;
2363
2409
  }
2364
2410
  }
@@ -2459,6 +2505,24 @@ export class QueryInterface {
2459
2505
  }
2460
2506
  }
2461
2507
  const col = meta.columnMap[field] ?? camelToSnake(field);
2508
+ // JSONB filter — mirrors buildSubWhereForRelation (which mirrors the
2509
+ // top-level buildWhereClause): route to the JSON param collector when
2510
+ // the target column is json/jsonb.
2511
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2512
+ const colType = this.pgTypeForColumn(meta, col);
2513
+ if (colType === 'json' || colType === 'jsonb') {
2514
+ this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2515
+ continue;
2516
+ }
2517
+ }
2518
+ // Array filter — mirrors buildSubWhereForRelation.
2519
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
2520
+ const colType = this.pgTypeForColumn(meta, col);
2521
+ if (colType.startsWith('_')) {
2522
+ this.collectArrayFilterParams(value, params);
2523
+ continue;
2524
+ }
2525
+ }
2462
2526
  if (isWhereOperator(value)) {
2463
2527
  this.collectOperatorParams(col, value, params);
2464
2528
  continue;
@@ -2494,10 +2558,22 @@ export class QueryInterface {
2494
2558
  if (op.endsWith !== undefined)
2495
2559
  params.push(`%${escapeLike(op.endsWith)}`);
2496
2560
  }
2497
- /** Collect params from JSON filter. Mirrors buildJsonFilterClauses. */
2498
- collectJsonFilterParams(filter, params) {
2561
+ /**
2562
+ * Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
2563
+ * the `path` is bound at most once (its placeholder is shared by every
2564
+ * extraction clause), then equals/contains/hasKey values, then the range
2565
+ * comparison values in {@link JSON_RANGE_OPERATORS} order.
2566
+ */
2567
+ collectJsonFilterParams(filter, params, column) {
2568
+ let pathPushed = false;
2569
+ const pushPathOnce = () => {
2570
+ if (!pathPushed) {
2571
+ params.push(filter.path);
2572
+ pathPushed = true;
2573
+ }
2574
+ };
2499
2575
  if (filter.path !== undefined && filter.equals !== undefined) {
2500
- params.push(filter.path);
2576
+ pushPathOnce();
2501
2577
  params.push(String(filter.equals));
2502
2578
  }
2503
2579
  else if (filter.equals !== undefined) {
@@ -2509,6 +2585,10 @@ export class QueryInterface {
2509
2585
  if (filter.hasKey !== undefined) {
2510
2586
  params.push(filter.hasKey);
2511
2587
  }
2588
+ for (const { value } of this.jsonRangeEntries(filter, column)) {
2589
+ pushPathOnce();
2590
+ params.push(value);
2591
+ }
2512
2592
  }
2513
2593
  /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
2514
2594
  collectArrayFilterParams(filter, params) {
@@ -3228,6 +3308,35 @@ export class QueryInterface {
3228
3308
  conditions.push(`${qCol} IS NULL`);
3229
3309
  continue;
3230
3310
  }
3311
+ // JSONB filter on a json/jsonb column of the relation target — mirrors
3312
+ // the top-level WHERE path (buildWhereClause). Without this branch the
3313
+ // filter object used to fall through to plain equality and bind as a
3314
+ // jsonb value, silently matching nothing.
3315
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3316
+ const colType = this.pgTypeForColumn(meta, col);
3317
+ if (colType === 'json' || colType === 'jsonb') {
3318
+ conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3319
+ continue;
3320
+ }
3321
+ const jsonKey = findJsonUniqueKey(value);
3322
+ if (jsonKey) {
3323
+ throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
3324
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
3325
+ }
3326
+ }
3327
+ // Array filter on an array column of the relation target — same mirror.
3328
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3329
+ const colType = this.pgTypeForColumn(meta, col);
3330
+ if (colType.startsWith('_')) {
3331
+ conditions.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
3332
+ continue;
3333
+ }
3334
+ const arrayKey = findArrayUniqueKey(value);
3335
+ if (arrayKey) {
3336
+ throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
3337
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
3338
+ }
3339
+ }
3231
3340
  if (isWhereOperator(value)) {
3232
3341
  const opClauses = this.buildOperatorClauses(qCol, value, params);
3233
3342
  conditions.push(...opClauses);
@@ -3246,6 +3355,49 @@ export class QueryInterface {
3246
3355
  pgTypeForColumn(meta, column) {
3247
3356
  return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
3248
3357
  }
3358
+ /**
3359
+ * The Postgres enum type name for a column, when the schema knows one.
3360
+ *
3361
+ * Introspection stores each column's `udt_name` in `pgTypes` and every
3362
+ * database enum in `schema.enums` (typname → labels); a column whose type
3363
+ * matches an enum key needs an explicit `::"EnumName"` cast on its write
3364
+ * binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
3365
+ * value as text and Postgres refuses the implicit text→enum coercion
3366
+ * ("column X is of type Y but expression is of type text").
3367
+ *
3368
+ * Postgres-only by construction: gated on the active dialect being
3369
+ * `postgresql` AND on `schema.enums` having entries (only PG introspection
3370
+ * produces them — `defineSchema` and the other engines leave it empty), so
3371
+ * SQLite/MySQL/MSSQL/PowDB output is byte-identical.
3372
+ */
3373
+ enumTypeForColumn(column) {
3374
+ if (this.dialect.name !== 'postgresql')
3375
+ return null;
3376
+ const enums = this.schema.enums;
3377
+ if (!enums)
3378
+ return null;
3379
+ // Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
3380
+ // the column's type lives OUTSIDE the introspected schema. A same-named
3381
+ // enum in another schema must not get this schema's cast — search_path
3382
+ // would resolve `::"status"` to the wrong type. Skipping the cast restores
3383
+ // the pre-cast behavior for such columns. Columns without pgTypeSchema
3384
+ // (same-schema types, defineSchema/legacy metadata) keep the cast.
3385
+ if (this.crossSchemaTypeColumns.has(column))
3386
+ return null;
3387
+ const pgType = this.columnPgTypeMap.get(column) ?? this.tableMeta.pgTypes?.[column];
3388
+ if (!pgType || pgType.startsWith('_'))
3389
+ return null;
3390
+ return Object.hasOwn(enums, pgType) ? pgType : null;
3391
+ }
3392
+ /**
3393
+ * `::"EnumName"` cast suffix for a write-bind placeholder on an enum
3394
+ * column; `''` for every other column, so non-enum SQL stays byte-identical.
3395
+ * The type name is an introspected identifier and is quoted via the dialect.
3396
+ */
3397
+ enumCastSuffix(column) {
3398
+ const enumType = this.enumTypeForColumn(column);
3399
+ return enumType ? `::${this.q(enumType)}` : '';
3400
+ }
3249
3401
  /**
3250
3402
  * Equality-fallthrough guard shared by every SQL-build path AND every
3251
3403
  * cache-hit param-collect path. A plain object literal that matched no known
@@ -3324,6 +3476,34 @@ export class QueryInterface {
3324
3476
  clauses.push(`${qCol} IS NULL`);
3325
3477
  continue;
3326
3478
  }
3479
+ // JSONB filter on a json/jsonb column — mirrors the top-level WHERE path
3480
+ // (buildWhereClause) so a `with.where` JSON filter is never silently
3481
+ // bound as a plain equality value.
3482
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3483
+ const colType = this.pgTypeForColumn(targetMeta, col);
3484
+ if (colType === 'json' || colType === 'jsonb') {
3485
+ clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3486
+ continue;
3487
+ }
3488
+ const jsonKey = findJsonUniqueKey(value);
3489
+ if (jsonKey) {
3490
+ throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
3491
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
3492
+ }
3493
+ }
3494
+ // Array filter on an array column — same mirror.
3495
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3496
+ const colType = this.pgTypeForColumn(targetMeta, col);
3497
+ if (colType.startsWith('_')) {
3498
+ clauses.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
3499
+ continue;
3500
+ }
3501
+ const arrayKey = findArrayUniqueKey(value);
3502
+ if (arrayKey) {
3503
+ throw new ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
3504
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
3505
+ }
3506
+ }
3327
3507
  if (isWhereOperator(value)) {
3328
3508
  clauses.push(...this.buildOperatorClauses(qCol, value, params));
3329
3509
  continue;
@@ -3367,6 +3547,22 @@ export class QueryInterface {
3367
3547
  }
3368
3548
  }
3369
3549
  const col = targetMeta.columnMap[key] ?? camelToSnake(key);
3550
+ // JSONB filter — mirrors buildAliasWhere.
3551
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3552
+ const colType = this.pgTypeForColumn(targetMeta, col);
3553
+ if (colType === 'json' || colType === 'jsonb') {
3554
+ this.collectJsonFilterParams(value, params, this.q(col));
3555
+ continue;
3556
+ }
3557
+ }
3558
+ // Array filter — mirrors buildAliasWhere.
3559
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3560
+ const colType = this.pgTypeForColumn(targetMeta, col);
3561
+ if (colType.startsWith('_')) {
3562
+ this.collectArrayFilterParams(value, params);
3563
+ continue;
3564
+ }
3565
+ }
3370
3566
  if (isWhereOperator(value)) {
3371
3567
  this.collectOperatorParams(col, value, params);
3372
3568
  continue;
@@ -3425,6 +3621,16 @@ export class QueryInterface {
3425
3621
  parts.push(`${key}:${fingerprintOperatorShape(value)}`);
3426
3622
  continue;
3427
3623
  }
3624
+ // JSON / array filters build real clauses in buildAliasWhere, so their
3625
+ // shape must be cache-distinct from plain equality (mirrors fingerprintWhere).
3626
+ if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3627
+ parts.push(`${key}:${fingerprintJsonFilterShape(value)}`);
3628
+ continue;
3629
+ }
3630
+ if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3631
+ parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
3632
+ continue;
3633
+ }
3428
3634
  if (isUnmatchedPlainObject(value)) {
3429
3635
  parts.push(`${key}:obj(${Object.keys(value)
3430
3636
  .sort()
@@ -4649,18 +4855,59 @@ export class QueryInterface {
4649
4855
  };
4650
4856
  return typeMap[baseType] ?? 'text';
4651
4857
  }
4858
+ /**
4859
+ * Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
4860
+ * JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
4861
+ * the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
4862
+ * param-collect path ({@link collectJsonFilterParams}) so both always agree
4863
+ * on which params are pushed — and both throw identically for invalid
4864
+ * shapes, so a warmed cache can never skip validation.
4865
+ */
4866
+ jsonRangeEntries(filter, column) {
4867
+ const entries = [];
4868
+ for (const [op, sqlOp] of Object.entries(JSON_RANGE_OPERATORS)) {
4869
+ const value = filter[op];
4870
+ if (value === undefined)
4871
+ continue;
4872
+ if (filter.path === undefined) {
4873
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
4874
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
4875
+ }
4876
+ if (typeof value !== 'number' && typeof value !== 'string') {
4877
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
4878
+ `got ${JSON.stringify(value)}.`);
4879
+ }
4880
+ if (typeof value === 'number' && !Number.isFinite(value)) {
4881
+ throw new ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
4882
+ }
4883
+ entries.push({ sqlOp, value });
4884
+ }
4885
+ return entries;
4886
+ }
4652
4887
  /**
4653
4888
  * Build SQL clauses for JSONB filter operators on a column.
4654
- * Supports: path, equals, contains, hasKey.
4889
+ * Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
4890
+ *
4891
+ * The `path` param is bound at most once and its placeholder is shared by
4892
+ * every clause that extracts it (equals + range ops), so the param list
4893
+ * stays byte-identical to {@link collectJsonFilterParams}.
4655
4894
  */
4656
4895
  buildJsonFilterClauses(column, filter, params) {
4657
4896
  const clauses = [];
4897
+ // Lazily bind the path once; reuse the same $N in every extraction clause.
4898
+ let pathParamIdx = null;
4899
+ const pathExtract = () => {
4900
+ if (pathParamIdx === null) {
4901
+ params.push(filter.path);
4902
+ pathParamIdx = params.length;
4903
+ }
4904
+ return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
4905
+ };
4658
4906
  if (filter.path !== undefined && filter.equals !== undefined) {
4659
4907
  // Path access + equals: column #>> $N::text[] = $M
4660
- params.push(filter.path);
4661
- const pathParam = params.length;
4908
+ const extract = pathExtract();
4662
4909
  params.push(String(filter.equals));
4663
- clauses.push(`${this.dialect.buildJsonPathExtract(column, this.p(pathParam))} = ${this.p(params.length)}`);
4910
+ clauses.push(`${extract} = ${this.p(params.length)}`);
4664
4911
  }
4665
4912
  else if (filter.equals !== undefined) {
4666
4913
  // Containment equality: column @> $N::jsonb
@@ -4677,8 +4924,28 @@ export class QueryInterface {
4677
4924
  params.push(filter.hasKey);
4678
4925
  clauses.push(`${column} ? ${this.p(params.length)}`);
4679
4926
  }
4927
+ // Range comparisons on the extracted path: numbers compare numerically
4928
+ // (cast through the dialect), strings compare as text.
4929
+ for (const { sqlOp, value } of this.jsonRangeEntries(filter, column)) {
4930
+ const extract = pathExtract();
4931
+ params.push(value);
4932
+ const lhs = typeof value === 'number' ? this.castJsonNumeric(extract) : extract;
4933
+ clauses.push(`${lhs} ${sqlOp} ${this.p(params.length)}`);
4934
+ }
4680
4935
  return clauses;
4681
4936
  }
4937
+ /**
4938
+ * Cast an extracted JSON path text value to a numeric type for range
4939
+ * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
4940
+ * compare JSON numbers, and `::float` would lose precision on big ints);
4941
+ * other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
4942
+ * SQL Server have no `::` operator) as a float cast.
4943
+ */
4944
+ castJsonNumeric(extract) {
4945
+ if (this.dialect.name === 'postgresql')
4946
+ return `(${extract})::numeric`;
4947
+ return this.dialect.castAggregate ? this.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
4948
+ }
4682
4949
  /**
4683
4950
  * Build SQL clauses for Array filter operators on a column.
4684
4951
  * Supports: has, hasEvery, hasSome, isEmpty.
@@ -66,9 +66,14 @@ export interface QueryInterfaceOptions {
66
66
  * without a `limit`. Defaults to `true` so that accidental unbounded
67
67
  * queries are surfaced loudly during development. Pass `false` to silence
68
68
  * the warning entirely (e.g. for CLI tooling that intentionally streams
69
- * full tables).
69
+ * full tables), or a per-table map (`{ userProfiles: false }`) to silence
70
+ * only the tables that intentionally read full sets — unlisted tables keep
71
+ * the default. Map keys accept BOTH the camelCase accessor name
72
+ * (`userProfiles`) and the snake_case table name (`user_profiles`); the
73
+ * snake_case entry wins if both are present. Individual calls can also
74
+ * override via `findMany({ warnOnUnlimited: false })`.
70
75
  */
71
- warnOnUnlimited?: boolean;
76
+ warnOnUnlimited?: boolean | Record<string, boolean>;
72
77
  /**
73
78
  * Enable prepared statements. When true, queries are submitted with a
74
79
  * `{ name, text, values }` object to the pg driver, which caches the
@@ -48,6 +48,24 @@ export declare function sortedEntries<V>(obj: Record<string, V>): [string, V][];
48
48
  export declare const UPDATE_OPERATOR_KEYS: Set<string>;
49
49
  /** Known JSONB operator keys */
50
50
  export declare const JSONB_OPERATOR_KEYS: Set<string>;
51
+ /**
52
+ * JSON range comparison operators → SQL comparison tokens, in the FIXED order
53
+ * the build and collect paths iterate them. These keys are deliberately NOT in
54
+ * {@link JSONB_OPERATOR_KEYS}: `gt`/`gte`/`lt`/`lte` overlap with
55
+ * `WhereOperator`, so a bare `{ gt: 5 }` must keep its column-comparison
56
+ * meaning. They only compile as JSON range ops when the object is already a
57
+ * {@link JsonFilter} (detected via `path` / `equals` / `contains` / `hasKey`),
58
+ * and they always require `path`.
59
+ */
60
+ export declare const JSON_RANGE_OPERATORS: Record<'gt' | 'gte' | 'lt' | 'lte', string>;
61
+ /**
62
+ * Value-invariant shape fingerprint for a {@link JsonFilter}. Range operators
63
+ * are annotated with the comparison value's kind (`#n` numeric / `#s` string)
64
+ * because a numeric comparison compiles to a `::numeric` cast — a different
65
+ * SQL text than the text comparison — so the two must never share a cached
66
+ * SQL entry.
67
+ */
68
+ export declare function fingerprintJsonFilterShape(filter: JsonFilter): string;
51
69
  /**
52
70
  * JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
53
71
  * appear in any other where-filter shape, so the presence of one of these is
@@ -90,6 +90,36 @@ export function sortedEntries(obj) {
90
90
  export const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
91
91
  /** Known JSONB operator keys */
92
92
  export const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
93
+ /**
94
+ * JSON range comparison operators → SQL comparison tokens, in the FIXED order
95
+ * the build and collect paths iterate them. These keys are deliberately NOT in
96
+ * {@link JSONB_OPERATOR_KEYS}: `gt`/`gte`/`lt`/`lte` overlap with
97
+ * `WhereOperator`, so a bare `{ gt: 5 }` must keep its column-comparison
98
+ * meaning. They only compile as JSON range ops when the object is already a
99
+ * {@link JsonFilter} (detected via `path` / `equals` / `contains` / `hasKey`),
100
+ * and they always require `path`.
101
+ */
102
+ export const JSON_RANGE_OPERATORS = {
103
+ gt: '>',
104
+ gte: '>=',
105
+ lt: '<',
106
+ lte: '<=',
107
+ };
108
+ /**
109
+ * Value-invariant shape fingerprint for a {@link JsonFilter}. Range operators
110
+ * are annotated with the comparison value's kind (`#n` numeric / `#s` string)
111
+ * because a numeric comparison compiles to a `::numeric` cast — a different
112
+ * SQL text than the text comparison — so the two must never share a cached
113
+ * SQL entry.
114
+ */
115
+ export function fingerprintJsonFilterShape(filter) {
116
+ const obj = filter;
117
+ const parts = Object.keys(obj)
118
+ .filter((k) => obj[k] !== undefined)
119
+ .sort()
120
+ .map((k) => (k in JSON_RANGE_OPERATORS ? `${k}#${typeof obj[k] === 'number' ? 'n' : 's'}` : k));
121
+ return `json(${parts.join(',')})`;
122
+ }
93
123
  /**
94
124
  * JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
95
125
  * appear in any other where-filter shape, so the presence of one of these is
@@ -310,6 +310,13 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
310
310
  relationLoadStrategy?: RelationLoadStrategy;
311
311
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
312
312
  skipGlobalFilters?: SkipGlobalFilters;
313
+ /**
314
+ * Per-call override of the unbounded-findMany warning. Pass `false` when
315
+ * this call intentionally reads the full table (the config-level
316
+ * `warnOnUnlimited` stays in effect for every other call); pass `true` to
317
+ * force the warning even when it is disabled in config.
318
+ */
319
+ warnOnUnlimited?: boolean;
313
320
  }
314
321
  export interface FindManyStreamArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined> extends FindManyArgs<T, R, W, S, O> {
315
322
  /**
@@ -628,6 +635,18 @@ export interface JsonFilter {
628
635
  contains?: unknown;
629
636
  /** Key existence check: column ? key */
630
637
  hasKey?: string;
638
+ /**
639
+ * Greater-than comparison of the value at `path` (required). Numbers cast
640
+ * the extracted text to numeric — `(col #>> path)::numeric > $n` — while
641
+ * strings compare as text.
642
+ */
643
+ gt?: number | string;
644
+ /** Greater-than-or-equal comparison of the value at `path` (required). See {@link JsonFilter.gt}. */
645
+ gte?: number | string;
646
+ /** Less-than comparison of the value at `path` (required). See {@link JsonFilter.gt}. */
647
+ lt?: number | string;
648
+ /** Less-than-or-equal comparison of the value at `path` (required). See {@link JsonFilter.gt}. */
649
+ lte?: number | string;
631
650
  }
632
651
  /** Array query operators for where clauses */
633
652
  export interface ArrayFilter {