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
@@ -97,6 +97,12 @@ class QueryInterface {
97
97
  /** Pre-computed column type lookups (avoids linear scans per query) */
98
98
  columnPgTypeMap;
99
99
  columnArrayTypeMap;
100
+ /**
101
+ * Columns whose type lives in a DIFFERENT schema than the introspected one
102
+ * (ColumnMetadata.pgTypeSchema is recorded only in that case) — such columns
103
+ * must never receive this schema's `::"enum"` cast (see enumTypeForColumn).
104
+ */
105
+ crossSchemaTypeColumns;
100
106
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
101
107
  deepWithWarned = new Set();
102
108
  /**
@@ -134,9 +140,19 @@ class QueryInterface {
134
140
  this.middlewares = middlewares ?? [];
135
141
  this.defaultLimit = options?.defaultLimit;
136
142
  // Default to ON: surfacing accidental full-table scans is more valuable
137
- // than the (small) risk of noisy logs. Callers explicitly opt out with
138
- // `warnOnUnlimited: false`.
139
- this.warnOnUnlimited = options?.warnOnUnlimited !== false;
143
+ // than the (small) risk of noisy logs. Callers opt out globally with
144
+ // `warnOnUnlimited: false`, per table with `warnOnUnlimited: { users:
145
+ // false }` (unlisted tables keep the default), or per call via
146
+ // `findMany({ warnOnUnlimited: false })`.
147
+ // Per-table maps accept BOTH key forms — the snake_case table name
148
+ // (`user_profiles`) and the camelCase accessor (`userProfiles`) — since
149
+ // users naturally key by the accessor they type everywhere else. The
150
+ // snake_case entry wins when both are present.
151
+ const warnOpt = options?.warnOnUnlimited;
152
+ this.warnOnUnlimited =
153
+ typeof warnOpt === 'object' && warnOpt !== null
154
+ ? (warnOpt[table] ?? warnOpt[(0, schema_js_1.snakeToCamel)(table)]) !== false
155
+ : warnOpt !== false;
140
156
  this.utcTimestamps = options?.utcTimestamps !== false;
141
157
  this.preparedStatementsEnabled = options?.preparedStatements ?? true;
142
158
  this.sqlCacheEnabled = options?.sqlCache !== false;
@@ -152,9 +168,12 @@ class QueryInterface {
152
168
  // Pre-compute column type lookup maps (TASK-26)
153
169
  this.columnPgTypeMap = new Map();
154
170
  this.columnArrayTypeMap = new Map();
171
+ this.crossSchemaTypeColumns = new Set();
155
172
  for (const col of this.tableMeta.columns) {
156
173
  this.columnPgTypeMap.set(col.name, col.dialectType ?? col.pgType);
157
174
  this.columnArrayTypeMap.set(col.name, col.arrayType ?? col.pgArrayType);
175
+ if (col.pgTypeSchema !== undefined)
176
+ this.crossSchemaTypeColumns.add(col.name);
158
177
  }
159
178
  }
160
179
  /** Quote an identifier through the active SQL dialect. */
@@ -680,10 +699,16 @@ class QueryInterface {
680
699
  * loop calling `db.users.findMany()` thousands of times only logs once.
681
700
  * Suppressed when `defaultLimit` is configured (the caller has already
682
701
  * opted in to a bounded query) and when the user passed an explicit
683
- * `limit`, `take`, or `cursor`.
702
+ * `limit`, `take`, or `cursor`. A per-call `warnOnUnlimited` overrides the
703
+ * config-level setting in either direction (`false` silences a call that
704
+ * intentionally reads the full set; `true` forces the warning even when
705
+ * disabled in config).
684
706
  */
685
707
  maybeWarnUnlimited(args) {
686
- if (!this.warnOnUnlimited)
708
+ const perCall = args?.warnOnUnlimited;
709
+ if (perCall === false)
710
+ return;
711
+ if (perCall === undefined && !this.warnOnUnlimited)
687
712
  return;
688
713
  if (this.defaultLimit !== undefined)
689
714
  return;
@@ -694,7 +719,7 @@ class QueryInterface {
694
719
  return;
695
720
  this.warnedTables.add(this.table);
696
721
  console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
697
- 'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
722
+ 'Pass `limit`, or silence with `warnOnUnlimited: false` (per call, per table, or in config).');
698
723
  }
699
724
  /**
700
725
  * Recursively measure the maximum depth of a `with` clause tree.
@@ -1043,7 +1068,8 @@ class QueryInterface {
1043
1068
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
1044
1069
  const columns = entries.map(([k]) => this.toSqlColumn(k));
1045
1070
  const params = entries.map(([, v]) => v);
1046
- const placeholders = entries.map((_, i) => `${this.p(i + 1)}`);
1071
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
1072
+ const placeholders = entries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
1047
1073
  const sql = this.dialect.buildInsertStatement({
1048
1074
  table: this.q(this.table),
1049
1075
  columns,
@@ -1125,7 +1151,13 @@ class QueryInterface {
1125
1151
  return keys.map((key) => record[key]);
1126
1152
  });
1127
1153
  // Use actual Postgres types for array casts in the default PostgreSQL dialect.
1128
- const typeCasts = columns.map((col) => this.getColumnArrayType(col));
1154
+ // Enum columns cast to `"EnumName"[]` — the generic text[] fallback would
1155
+ // type the UNNEST output as text, which Postgres refuses to coerce to the
1156
+ // enum ("column X is of type Y but expression is of type text").
1157
+ const typeCasts = columns.map((col) => {
1158
+ const enumType = this.enumTypeForColumn(col);
1159
+ return enumType ? `${this.q(enumType)}[]` : this.getColumnArrayType(col);
1160
+ });
1129
1161
  const quotedColumns = columns.map((c) => this.q(c));
1130
1162
  const built = this.dialect.buildBulkInsertStatement({
1131
1163
  table: qt,
@@ -1399,7 +1431,8 @@ class QueryInterface {
1399
1431
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
1400
1432
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
1401
1433
  const createParams = createEntries.map(([, v]) => v);
1402
- const placeholders = createEntries.map((_, i) => `${this.p(i + 1)}`);
1434
+ // Enum columns get an explicit `::"EnumName"` cast (see enumTypeForColumn).
1435
+ const placeholders = createEntries.map(([k], i) => `${this.p(i + 1)}${this.enumCastSuffix(this.toColumn(k))}`);
1403
1436
  // The conflict target comes from `where` keys — must be unique/PK columns
1404
1437
  const conflictKeys = Object.keys(args.where).filter((k) => args.where[k] !== undefined);
1405
1438
  const conflictColumns = conflictKeys.map((k) => this.toSqlColumn(k));
@@ -1407,7 +1440,7 @@ class QueryInterface {
1407
1440
  const updateEntries = Object.entries(args.update).filter(([, v]) => v !== undefined);
1408
1441
  let paramIdx = createParams.length + 1;
1409
1442
  const setClauses = updateEntries.map(([k]) => {
1410
- const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}`;
1443
+ const clause = `${this.toSqlColumn(k)} = ${this.p(paramIdx)}${this.enumCastSuffix(this.toColumn(k))}`;
1411
1444
  paramIdx++;
1412
1445
  return clause;
1413
1446
  });
@@ -2087,6 +2120,10 @@ class QueryInterface {
2087
2120
  */
2088
2121
  buildSetClause(key, value, params) {
2089
2122
  const col = this.toSqlColumn(key);
2123
+ // Enum columns get an explicit `::"EnumName"` cast on their value bind
2124
+ // (see enumTypeForColumn); `''` everywhere else. Value-invariant, so the
2125
+ // SQL cache and collectSetParams are unaffected.
2126
+ const cast = this.enumCastSuffix(this.toColumn(key));
2090
2127
  // Detect atomic-operator object: plain object (not null, not array, not
2091
2128
  // Date, not Buffer) with EXACTLY one key matching an operator name.
2092
2129
  if (value !== null &&
@@ -2101,7 +2138,7 @@ class QueryInterface {
2101
2138
  const opValue = v[op];
2102
2139
  if (op === 'set') {
2103
2140
  params.push(opValue);
2104
- return `${col} = ${this.p(params.length)}`;
2141
+ return `${col} = ${this.p(params.length)}${cast}`;
2105
2142
  }
2106
2143
  // Arithmetic operators: must be finite numbers
2107
2144
  if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
@@ -2129,7 +2166,7 @@ class QueryInterface {
2129
2166
  }
2130
2167
  // Plain value (including null, Date, Buffer, arrays, JSON objects)
2131
2168
  params.push(value);
2132
- return `${col} = ${this.p(params.length)}`;
2169
+ return `${col} = ${this.p(params.length)}${cast}`;
2133
2170
  }
2134
2171
  // =========================================================================
2135
2172
  // Fingerprinting — value-invariant shape keys for SQL cache lookup
@@ -2228,10 +2265,10 @@ class QueryInterface {
2228
2265
  parts.push(`${key}:vec(${dist.metric},${cmps})`);
2229
2266
  continue;
2230
2267
  }
2231
- // JSON filter
2268
+ // JSON filter — range ops carry a numeric/string annotation because the
2269
+ // numeric compile emits a cast (different SQL shape).
2232
2270
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2233
- const jKeys = Object.keys(value).sort();
2234
- parts.push(`${key}:json(${jKeys.join(',')})`);
2271
+ parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
2235
2272
  continue;
2236
2273
  }
2237
2274
  // Array filter
@@ -2317,6 +2354,15 @@ class QueryInterface {
2317
2354
  else if ((0, filters_js_1.isWhereOperator)(value)) {
2318
2355
  parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
2319
2356
  }
2357
+ else if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2358
+ // Mirrors fingerprintWhere: JSON filters inside relation sub-wheres
2359
+ // build real JSON clauses (buildSubWhereForRelation), so their shape
2360
+ // must be cache-distinct from plain equality.
2361
+ parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
2362
+ }
2363
+ else if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
2364
+ parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
2365
+ }
2320
2366
  else if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
2321
2367
  parts.push(`${key}:obj(${Object.keys(value)
2322
2368
  .sort()
@@ -2394,7 +2440,7 @@ class QueryInterface {
2394
2440
  if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2395
2441
  const colType = this.getColumnPgType(rawColumn);
2396
2442
  if (colType === 'json' || colType === 'jsonb') {
2397
- this.collectJsonFilterParams(value, params);
2443
+ this.collectJsonFilterParams(value, params, this.q(rawColumn));
2398
2444
  continue;
2399
2445
  }
2400
2446
  }
@@ -2495,6 +2541,24 @@ class QueryInterface {
2495
2541
  }
2496
2542
  }
2497
2543
  const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2544
+ // JSONB filter — mirrors buildSubWhereForRelation (which mirrors the
2545
+ // top-level buildWhereClause): route to the JSON param collector when
2546
+ // the target column is json/jsonb.
2547
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2548
+ const colType = this.pgTypeForColumn(meta, col);
2549
+ if (colType === 'json' || colType === 'jsonb') {
2550
+ this.collectJsonFilterParams(value, params, `${this.q(targetTable)}.${this.q(col)}`);
2551
+ continue;
2552
+ }
2553
+ }
2554
+ // Array filter — mirrors buildSubWhereForRelation.
2555
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
2556
+ const colType = this.pgTypeForColumn(meta, col);
2557
+ if (colType.startsWith('_')) {
2558
+ this.collectArrayFilterParams(value, params);
2559
+ continue;
2560
+ }
2561
+ }
2498
2562
  if ((0, filters_js_1.isWhereOperator)(value)) {
2499
2563
  this.collectOperatorParams(col, value, params);
2500
2564
  continue;
@@ -2530,10 +2594,22 @@ class QueryInterface {
2530
2594
  if (op.endsWith !== undefined)
2531
2595
  params.push(`%${(0, utils_js_1.escapeLike)(op.endsWith)}`);
2532
2596
  }
2533
- /** Collect params from JSON filter. Mirrors buildJsonFilterClauses. */
2534
- collectJsonFilterParams(filter, params) {
2597
+ /**
2598
+ * Collect params from JSON filter. Mirrors buildJsonFilterClauses exactly:
2599
+ * the `path` is bound at most once (its placeholder is shared by every
2600
+ * extraction clause), then equals/contains/hasKey values, then the range
2601
+ * comparison values in {@link JSON_RANGE_OPERATORS} order.
2602
+ */
2603
+ collectJsonFilterParams(filter, params, column) {
2604
+ let pathPushed = false;
2605
+ const pushPathOnce = () => {
2606
+ if (!pathPushed) {
2607
+ params.push(filter.path);
2608
+ pathPushed = true;
2609
+ }
2610
+ };
2535
2611
  if (filter.path !== undefined && filter.equals !== undefined) {
2536
- params.push(filter.path);
2612
+ pushPathOnce();
2537
2613
  params.push(String(filter.equals));
2538
2614
  }
2539
2615
  else if (filter.equals !== undefined) {
@@ -2545,6 +2621,10 @@ class QueryInterface {
2545
2621
  if (filter.hasKey !== undefined) {
2546
2622
  params.push(filter.hasKey);
2547
2623
  }
2624
+ for (const { value } of this.jsonRangeEntries(filter, column)) {
2625
+ pushPathOnce();
2626
+ params.push(value);
2627
+ }
2548
2628
  }
2549
2629
  /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
2550
2630
  collectArrayFilterParams(filter, params) {
@@ -3264,6 +3344,35 @@ class QueryInterface {
3264
3344
  conditions.push(`${qCol} IS NULL`);
3265
3345
  continue;
3266
3346
  }
3347
+ // JSONB filter on a json/jsonb column of the relation target — mirrors
3348
+ // the top-level WHERE path (buildWhereClause). Without this branch the
3349
+ // filter object used to fall through to plain equality and bind as a
3350
+ // jsonb value, silently matching nothing.
3351
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3352
+ const colType = this.pgTypeForColumn(meta, col);
3353
+ if (colType === 'json' || colType === 'jsonb') {
3354
+ conditions.push(...this.buildJsonFilterClauses(qCol, value, params));
3355
+ continue;
3356
+ }
3357
+ const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
3358
+ if (jsonKey) {
3359
+ throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
3360
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
3361
+ }
3362
+ }
3363
+ // Array filter on an array column of the relation target — same mirror.
3364
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
3365
+ const colType = this.pgTypeForColumn(meta, col);
3366
+ if (colType.startsWith('_')) {
3367
+ conditions.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
3368
+ continue;
3369
+ }
3370
+ const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
3371
+ if (arrayKey) {
3372
+ throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
3373
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
3374
+ }
3375
+ }
3267
3376
  if ((0, filters_js_1.isWhereOperator)(value)) {
3268
3377
  const opClauses = this.buildOperatorClauses(qCol, value, params);
3269
3378
  conditions.push(...opClauses);
@@ -3282,6 +3391,49 @@ class QueryInterface {
3282
3391
  pgTypeForColumn(meta, column) {
3283
3392
  return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
3284
3393
  }
3394
+ /**
3395
+ * The Postgres enum type name for a column, when the schema knows one.
3396
+ *
3397
+ * Introspection stores each column's `udt_name` in `pgTypes` and every
3398
+ * database enum in `schema.enums` (typname → labels); a column whose type
3399
+ * matches an enum key needs an explicit `::"EnumName"` cast on its write
3400
+ * binds — bulk-insert forms like `UNNEST($1::text[])` otherwise type the
3401
+ * value as text and Postgres refuses the implicit text→enum coercion
3402
+ * ("column X is of type Y but expression is of type text").
3403
+ *
3404
+ * Postgres-only by construction: gated on the active dialect being
3405
+ * `postgresql` AND on `schema.enums` having entries (only PG introspection
3406
+ * produces them — `defineSchema` and the other engines leave it empty), so
3407
+ * SQLite/MySQL/MSSQL/PowDB output is byte-identical.
3408
+ */
3409
+ enumTypeForColumn(column) {
3410
+ if (this.dialect.name !== 'postgresql')
3411
+ return null;
3412
+ const enums = this.schema.enums;
3413
+ if (!enums)
3414
+ return null;
3415
+ // Cross-schema guard (N-5): introspection records pgTypeSchema ONLY when
3416
+ // the column's type lives OUTSIDE the introspected schema. A same-named
3417
+ // enum in another schema must not get this schema's cast — search_path
3418
+ // would resolve `::"status"` to the wrong type. Skipping the cast restores
3419
+ // the pre-cast behavior for such columns. Columns without pgTypeSchema
3420
+ // (same-schema types, defineSchema/legacy metadata) keep the cast.
3421
+ if (this.crossSchemaTypeColumns.has(column))
3422
+ return null;
3423
+ const pgType = this.columnPgTypeMap.get(column) ?? this.tableMeta.pgTypes?.[column];
3424
+ if (!pgType || pgType.startsWith('_'))
3425
+ return null;
3426
+ return Object.hasOwn(enums, pgType) ? pgType : null;
3427
+ }
3428
+ /**
3429
+ * `::"EnumName"` cast suffix for a write-bind placeholder on an enum
3430
+ * column; `''` for every other column, so non-enum SQL stays byte-identical.
3431
+ * The type name is an introspected identifier and is quoted via the dialect.
3432
+ */
3433
+ enumCastSuffix(column) {
3434
+ const enumType = this.enumTypeForColumn(column);
3435
+ return enumType ? `::${this.q(enumType)}` : '';
3436
+ }
3285
3437
  /**
3286
3438
  * Equality-fallthrough guard shared by every SQL-build path AND every
3287
3439
  * cache-hit param-collect path. A plain object literal that matched no known
@@ -3360,6 +3512,34 @@ class QueryInterface {
3360
3512
  clauses.push(`${qCol} IS NULL`);
3361
3513
  continue;
3362
3514
  }
3515
+ // JSONB filter on a json/jsonb column — mirrors the top-level WHERE path
3516
+ // (buildWhereClause) so a `with.where` JSON filter is never silently
3517
+ // bound as a plain equality value.
3518
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3519
+ const colType = this.pgTypeForColumn(targetMeta, col);
3520
+ if (colType === 'json' || colType === 'jsonb') {
3521
+ clauses.push(...this.buildJsonFilterClauses(qCol, value, params));
3522
+ continue;
3523
+ }
3524
+ const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
3525
+ if (jsonKey) {
3526
+ throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not a JSON column ` +
3527
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
3528
+ }
3529
+ }
3530
+ // Array filter on an array column — same mirror.
3531
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
3532
+ const colType = this.pgTypeForColumn(targetMeta, col);
3533
+ if (colType.startsWith('_')) {
3534
+ clauses.push(...this.buildArrayFilterClauses(qCol, value, params, colType));
3535
+ continue;
3536
+ }
3537
+ const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
3538
+ if (arrayKey) {
3539
+ throw new errors_js_1.ValidationError(`[turbine] Column "${col}" on table "${targetTable}" is not an array column ` +
3540
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
3541
+ }
3542
+ }
3363
3543
  if ((0, filters_js_1.isWhereOperator)(value)) {
3364
3544
  clauses.push(...this.buildOperatorClauses(qCol, value, params));
3365
3545
  continue;
@@ -3403,6 +3583,22 @@ class QueryInterface {
3403
3583
  }
3404
3584
  }
3405
3585
  const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
3586
+ // JSONB filter — mirrors buildAliasWhere.
3587
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3588
+ const colType = this.pgTypeForColumn(targetMeta, col);
3589
+ if (colType === 'json' || colType === 'jsonb') {
3590
+ this.collectJsonFilterParams(value, params, this.q(col));
3591
+ continue;
3592
+ }
3593
+ }
3594
+ // Array filter — mirrors buildAliasWhere.
3595
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
3596
+ const colType = this.pgTypeForColumn(targetMeta, col);
3597
+ if (colType.startsWith('_')) {
3598
+ this.collectArrayFilterParams(value, params);
3599
+ continue;
3600
+ }
3601
+ }
3406
3602
  if ((0, filters_js_1.isWhereOperator)(value)) {
3407
3603
  this.collectOperatorParams(col, value, params);
3408
3604
  continue;
@@ -3461,6 +3657,16 @@ class QueryInterface {
3461
3657
  parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
3462
3658
  continue;
3463
3659
  }
3660
+ // JSON / array filters build real clauses in buildAliasWhere, so their
3661
+ // shape must be cache-distinct from plain equality (mirrors fingerprintWhere).
3662
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
3663
+ parts.push(`${key}:${(0, filters_js_1.fingerprintJsonFilterShape)(value)}`);
3664
+ continue;
3665
+ }
3666
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
3667
+ parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
3668
+ continue;
3669
+ }
3464
3670
  if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
3465
3671
  parts.push(`${key}:obj(${Object.keys(value)
3466
3672
  .sort()
@@ -4685,18 +4891,59 @@ class QueryInterface {
4685
4891
  };
4686
4892
  return typeMap[baseType] ?? 'text';
4687
4893
  }
4894
+ /**
4895
+ * Validate and enumerate the range comparisons (`gt`/`gte`/`lt`/`lte`) on a
4896
+ * JSON filter, in the fixed {@link JSON_RANGE_OPERATORS} order. Shared by
4897
+ * the SQL-build path ({@link buildJsonFilterClauses}) and the cache-hit
4898
+ * param-collect path ({@link collectJsonFilterParams}) so both always agree
4899
+ * on which params are pushed — and both throw identically for invalid
4900
+ * shapes, so a warmed cache can never skip validation.
4901
+ */
4902
+ jsonRangeEntries(filter, column) {
4903
+ const entries = [];
4904
+ for (const [op, sqlOp] of Object.entries(filters_js_1.JSON_RANGE_OPERATORS)) {
4905
+ const value = filter[op];
4906
+ if (value === undefined)
4907
+ continue;
4908
+ if (filter.path === undefined) {
4909
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a \`path\` ` +
4910
+ `(e.g. { path: ['meta', 'score'], ${op}: ${JSON.stringify(value)} }).`);
4911
+ }
4912
+ if (typeof value !== 'number' && typeof value !== 'string') {
4913
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a number or string, ` +
4914
+ `got ${JSON.stringify(value)}.`);
4915
+ }
4916
+ if (typeof value === 'number' && !Number.isFinite(value)) {
4917
+ throw new errors_js_1.ValidationError(`[turbine] JSON range operator '${op}' on ${column} requires a finite number.`);
4918
+ }
4919
+ entries.push({ sqlOp, value });
4920
+ }
4921
+ return entries;
4922
+ }
4688
4923
  /**
4689
4924
  * Build SQL clauses for JSONB filter operators on a column.
4690
- * Supports: path, equals, contains, hasKey.
4925
+ * Supports: path, equals, contains, hasKey, gt, gte, lt, lte.
4926
+ *
4927
+ * The `path` param is bound at most once and its placeholder is shared by
4928
+ * every clause that extracts it (equals + range ops), so the param list
4929
+ * stays byte-identical to {@link collectJsonFilterParams}.
4691
4930
  */
4692
4931
  buildJsonFilterClauses(column, filter, params) {
4693
4932
  const clauses = [];
4933
+ // Lazily bind the path once; reuse the same $N in every extraction clause.
4934
+ let pathParamIdx = null;
4935
+ const pathExtract = () => {
4936
+ if (pathParamIdx === null) {
4937
+ params.push(filter.path);
4938
+ pathParamIdx = params.length;
4939
+ }
4940
+ return this.dialect.buildJsonPathExtract(column, this.p(pathParamIdx));
4941
+ };
4694
4942
  if (filter.path !== undefined && filter.equals !== undefined) {
4695
4943
  // Path access + equals: column #>> $N::text[] = $M
4696
- params.push(filter.path);
4697
- const pathParam = params.length;
4944
+ const extract = pathExtract();
4698
4945
  params.push(String(filter.equals));
4699
- clauses.push(`${this.dialect.buildJsonPathExtract(column, this.p(pathParam))} = ${this.p(params.length)}`);
4946
+ clauses.push(`${extract} = ${this.p(params.length)}`);
4700
4947
  }
4701
4948
  else if (filter.equals !== undefined) {
4702
4949
  // Containment equality: column @> $N::jsonb
@@ -4713,8 +4960,28 @@ class QueryInterface {
4713
4960
  params.push(filter.hasKey);
4714
4961
  clauses.push(`${column} ? ${this.p(params.length)}`);
4715
4962
  }
4963
+ // Range comparisons on the extracted path: numbers compare numerically
4964
+ // (cast through the dialect), strings compare as text.
4965
+ for (const { sqlOp, value } of this.jsonRangeEntries(filter, column)) {
4966
+ const extract = pathExtract();
4967
+ params.push(value);
4968
+ const lhs = typeof value === 'number' ? this.castJsonNumeric(extract) : extract;
4969
+ clauses.push(`${lhs} ${sqlOp} ${this.p(params.length)}`);
4970
+ }
4716
4971
  return clauses;
4717
4972
  }
4973
+ /**
4974
+ * Cast an extracted JSON path text value to a numeric type for range
4975
+ * comparison. PostgreSQL uses `(expr)::numeric` (exact — the right way to
4976
+ * compare JSON numbers, and `::float` would lose precision on big ints);
4977
+ * other dialects route through {@link Dialect.castAggregate} (SQLite/MySQL/
4978
+ * SQL Server have no `::` operator) as a float cast.
4979
+ */
4980
+ castJsonNumeric(extract) {
4981
+ if (this.dialect.name === 'postgresql')
4982
+ return `(${extract})::numeric`;
4983
+ return this.dialect.castAggregate ? this.dialect.castAggregate(`(${extract})`, 'float') : `(${extract})::numeric`;
4984
+ }
4718
4985
  /**
4719
4986
  * Build SQL clauses for Array filter operators on a column.
4720
4987
  * Supports: has, hasEvery, hasSome, isEmpty.
@@ -7,13 +7,14 @@
7
7
  * and execution rather than filter-shape bookkeeping.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = void 0;
10
+ exports.VECTOR_DISTANCE_COMPARATORS = exports.VECTOR_METRIC_OPERATORS = exports.TEXT_SEARCH_KEYS = exports.ARRAY_UNIQUE_KEYS = exports.ARRAY_OPERATOR_KEYS = exports.JSONB_UNIQUE_KEYS = exports.JSON_RANGE_OPERATORS = exports.JSONB_OPERATOR_KEYS = exports.UPDATE_OPERATOR_KEYS = void 0;
11
11
  exports.isWhereOperator = isWhereOperator;
12
12
  exports.isUnmatchedPlainObject = isUnmatchedPlainObject;
13
13
  exports.fingerprintOperatorShape = fingerprintOperatorShape;
14
14
  exports.assertBindableEqualsOperand = assertBindableEqualsOperand;
15
15
  exports.sortedKeys = sortedKeys;
16
16
  exports.sortedEntries = sortedEntries;
17
+ exports.fingerprintJsonFilterShape = fingerprintJsonFilterShape;
17
18
  exports.isJsonFilter = isJsonFilter;
18
19
  exports.findJsonUniqueKey = findJsonUniqueKey;
19
20
  exports.isArrayFilter = isArrayFilter;
@@ -109,6 +110,36 @@ function sortedEntries(obj) {
109
110
  exports.UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
110
111
  /** Known JSONB operator keys */
111
112
  exports.JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
113
+ /**
114
+ * JSON range comparison operators → SQL comparison tokens, in the FIXED order
115
+ * the build and collect paths iterate them. These keys are deliberately NOT in
116
+ * {@link JSONB_OPERATOR_KEYS}: `gt`/`gte`/`lt`/`lte` overlap with
117
+ * `WhereOperator`, so a bare `{ gt: 5 }` must keep its column-comparison
118
+ * meaning. They only compile as JSON range ops when the object is already a
119
+ * {@link JsonFilter} (detected via `path` / `equals` / `contains` / `hasKey`),
120
+ * and they always require `path`.
121
+ */
122
+ exports.JSON_RANGE_OPERATORS = {
123
+ gt: '>',
124
+ gte: '>=',
125
+ lt: '<',
126
+ lte: '<=',
127
+ };
128
+ /**
129
+ * Value-invariant shape fingerprint for a {@link JsonFilter}. Range operators
130
+ * are annotated with the comparison value's kind (`#n` numeric / `#s` string)
131
+ * because a numeric comparison compiles to a `::numeric` cast — a different
132
+ * SQL text than the text comparison — so the two must never share a cached
133
+ * SQL entry.
134
+ */
135
+ function fingerprintJsonFilterShape(filter) {
136
+ const obj = filter;
137
+ const parts = Object.keys(obj)
138
+ .filter((k) => obj[k] !== undefined)
139
+ .sort()
140
+ .map((k) => (k in exports.JSON_RANGE_OPERATORS ? `${k}#${typeof obj[k] === 'number' ? 'n' : 's'}` : k));
141
+ return `json(${parts.join(',')})`;
142
+ }
112
143
  /**
113
144
  * JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
114
145
  * appear in any other where-filter shape, so the presence of one of these is