turbine-orm 0.19.1 → 0.19.2

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.
@@ -79,14 +79,4 @@ export declare function apiBuilder(req: IncomingMessage, res: ServerResponse, ct
79
79
  export declare function apiListSavedQueries(res: ServerResponse, ctx: StudioContext, params: URLSearchParams): void;
80
80
  export declare function apiCreateSavedQuery(req: IncomingMessage, res: ServerResponse, ctx: StudioContext): Promise<void>;
81
81
  export declare function apiDeleteSavedQuery(res: ServerResponse, ctx: StudioContext, id: string): void;
82
- /**
83
- * Accept only SELECT or WITH (CTE) statements. Reject any statement that
84
- * contains a semicolon followed by non-whitespace (prevents statement
85
- * stacking), and require the first non-comment keyword to be SELECT or WITH.
86
- *
87
- * This is a first-line filter — the transaction's READ ONLY mode is the
88
- * second line of defense. Both must fail before a destructive statement
89
- * could run.
90
- */
91
- export declare function isReadOnlyStatement(sql: string): boolean;
92
82
  //# sourceMappingURL=studio.d.ts.map
@@ -535,35 +535,6 @@ function clampInt(value, fallback, min, max) {
535
535
  return fallback;
536
536
  return Math.min(Math.max(n, min), max);
537
537
  }
538
- /**
539
- * Accept only SELECT or WITH (CTE) statements. Reject any statement that
540
- * contains a semicolon followed by non-whitespace (prevents statement
541
- * stacking), and require the first non-comment keyword to be SELECT or WITH.
542
- *
543
- * This is a first-line filter — the transaction's READ ONLY mode is the
544
- * second line of defense. Both must fail before a destructive statement
545
- * could run.
546
- */
547
- export function isReadOnlyStatement(sql) {
548
- const stripped = stripSqlComments(sql).trim();
549
- if (!stripped)
550
- return false;
551
- // Disallow statement stacking. A single trailing `;` is fine.
552
- const withoutTrailingSemi = stripped.replace(/;+\s*$/, '');
553
- if (withoutTrailingSemi.includes(';'))
554
- return false;
555
- const firstWord = withoutTrailingSemi.slice(0, 6).toUpperCase();
556
- if (firstWord.startsWith('SELECT'))
557
- return true;
558
- if (firstWord.startsWith('WITH'))
559
- return true;
560
- return false;
561
- }
562
- function stripSqlComments(sql) {
563
- // Strip -- line comments and /* block comments */. Not a full SQL parser,
564
- // but sufficient to catch the common bypass attempts.
565
- return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
566
- }
567
538
  function serializeRow(row) {
568
539
  const out = {};
569
540
  for (const [k, v] of Object.entries(row)) {
package/dist/client.d.ts CHANGED
@@ -255,12 +255,14 @@ export declare class TurbineClient {
255
255
  private readonly activeSubscriptions;
256
256
  constructor(config: TurbineConfig | undefined, schema: SchemaMetadata);
257
257
  /**
258
- * Register a middleware function that runs before/after every query.
258
+ * Register a middleware function that runs around every query.
259
259
  *
260
- * Middleware can inspect and log query parameters, modify results after execution,
261
- * and measure timing. Note: query SQL is generated before middleware runs, so
262
- * modifying params.args in middleware will NOT affect the executed SQL.
263
- * To intercept queries before SQL generation, use the raw() method instead.
260
+ * Middleware can inspect and log query parameters, measure timing, and
261
+ * transform the result returned by `next()`. Note: query SQL is generated
262
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
263
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
264
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
265
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
264
266
  *
265
267
  * @example
266
268
  * ```ts
@@ -272,16 +274,13 @@ export declare class TurbineClient {
272
274
  * return result;
273
275
  * });
274
276
  *
275
- * // Soft-delete middleware
277
+ * // Result transformation middleware — redact a field on the way out
276
278
  * db.$use(async (params, next) => {
277
- * if (params.action === 'findMany' || params.action === 'findUnique') {
278
- * params.args.where = { ...params.args.where, deletedAt: null };
279
- * }
280
- * if (params.action === 'delete') {
281
- * params.action = 'update';
282
- * params.args = { where: params.args.where, data: { deletedAt: new Date() } };
279
+ * const result = await next(params);
280
+ * if (params.model === 'users' && Array.isArray(result)) {
281
+ * for (const row of result as { email?: string }[]) row.email = '[redacted]';
283
282
  * }
284
- * return next(params);
283
+ * return result;
285
284
  * });
286
285
  * ```
287
286
  */
package/dist/client.js CHANGED
@@ -323,12 +323,14 @@ export class TurbineClient {
323
323
  // Middleware — intercept all queries
324
324
  // -------------------------------------------------------------------------
325
325
  /**
326
- * Register a middleware function that runs before/after every query.
326
+ * Register a middleware function that runs around every query.
327
327
  *
328
- * Middleware can inspect and log query parameters, modify results after execution,
329
- * and measure timing. Note: query SQL is generated before middleware runs, so
330
- * modifying params.args in middleware will NOT affect the executed SQL.
331
- * To intercept queries before SQL generation, use the raw() method instead.
328
+ * Middleware can inspect and log query parameters, measure timing, and
329
+ * transform the result returned by `next()`. Note: query SQL is generated
330
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
331
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
332
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
333
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
332
334
  *
333
335
  * @example
334
336
  * ```ts
@@ -340,16 +342,13 @@ export class TurbineClient {
340
342
  * return result;
341
343
  * });
342
344
  *
343
- * // Soft-delete middleware
345
+ * // Result transformation middleware — redact a field on the way out
344
346
  * db.$use(async (params, next) => {
345
- * if (params.action === 'findMany' || params.action === 'findUnique') {
346
- * params.args.where = { ...params.args.where, deletedAt: null };
347
- * }
348
- * if (params.action === 'delete') {
349
- * params.action = 'update';
350
- * params.args = { where: params.args.where, data: { deletedAt: new Date() } };
347
+ * const result = await next(params);
348
+ * if (params.model === 'users' && Array.isArray(result)) {
349
+ * for (const row of result as { email?: string }[]) row.email = '[redacted]';
351
350
  * }
352
- * return next(params);
351
+ * return result;
353
352
  * });
354
353
  * ```
355
354
  */
package/dist/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export { type IntrospectOptions, introspect } from './introspect.js';
43
43
  export { executeNestedCreate, executeNestedUpdate, hasRelationFields, type NestedWriteContext, } from './nested-write.js';
44
44
  export type { ObserveConfig, ObserveHandle } from './observe.js';
45
45
  export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSupported } from './pipeline.js';
46
- export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type HavingClause, type JsonFilter, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
46
+ export { type AggregateArgs, type AggregateResult, type ArrayFilter, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GroupByArgs, type HavingClause, type JsonFilter, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderDirection, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type SelectResult, type TextSearchFilter, type TypedWithClause, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithResult, } from './query/index.js';
47
47
  export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
48
48
  export type { ColumnMetadata, IndexMetadata, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
@@ -168,10 +168,12 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
168
168
  * Execute a query through the middleware chain.
169
169
  * If no middlewares are registered, executes directly.
170
170
  *
171
- * Middleware can inspect and log query parameters, modify results after execution,
172
- * and measure timing. Note: query SQL is generated before middleware runs, so
173
- * modifying params.args in middleware will NOT affect the executed SQL.
174
- * To intercept queries before SQL generation, use the raw() method instead.
171
+ * Middleware can inspect and log query parameters, measure timing, and
172
+ * transform the result returned by `next()`. Note: query SQL is generated
173
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
174
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
175
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
176
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
175
177
  */
176
178
  private executeWithMiddleware;
177
179
  findUnique<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args: FindUniqueArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O> | null>;
@@ -228,11 +230,11 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
228
230
  buildFindFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T>;
229
231
  findUniqueOrThrow<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args: FindUniqueArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O>>;
230
232
  buildFindUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T>;
231
- create(args: CreateArgs<T>): Promise<T>;
233
+ create(args: CreateArgs<T, R>): Promise<T>;
232
234
  buildCreate(args: CreateArgs<T>): DeferredQuery<T>;
233
235
  createMany(args: CreateManyArgs<T>): Promise<T[]>;
234
236
  buildCreateMany(args: CreateManyArgs<T>): DeferredQuery<T[]>;
235
- update(args: UpdateArgs<T>): Promise<T>;
237
+ update(args: UpdateArgs<T, R>): Promise<T>;
236
238
  buildUpdate(args: UpdateArgs<T>): DeferredQuery<T>;
237
239
  private nestedCreate;
238
240
  private nestedUpdate;
@@ -44,6 +44,53 @@ function isUnmatchedPlainObject(value) {
44
44
  const proto = Object.getPrototypeOf(value);
45
45
  return proto === Object.prototype || proto === null;
46
46
  }
47
+ /**
48
+ * Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
49
+ * `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
50
+ * param pushed), so null-ness is part of the shape — without it a cache entry
51
+ * warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
52
+ */
53
+ function fingerprintOperatorShape(value) {
54
+ const obj = value;
55
+ const opKeys = Object.keys(obj)
56
+ .filter((k) => k !== 'mode')
57
+ .map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
58
+ .sort();
59
+ const modeStr = value.mode === 'insensitive' ? ':i' : '';
60
+ return `op(${opKeys.join(',')}${modeStr})`;
61
+ }
62
+ /**
63
+ * Guard for the value of an `equals` operator reaching the plain-equality
64
+ * operator path. A plain object literal can only legitimately be an equality
65
+ * value on a json/jsonb column — and those route to the JSONB filter branch
66
+ * BEFORE the operator branch, so any plain object that reaches here is a
67
+ * mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
68
+ * SQL-build path and the cache-hit param-collect path so a warmed cache can
69
+ * never skip the check.
70
+ */
71
+ function assertBindableEqualsOperand(value, column) {
72
+ if (!isUnmatchedPlainObject(value))
73
+ return;
74
+ throw new ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
75
+ `objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
76
+ `where 'equals' is the JSONB containment filter.`);
77
+ }
78
+ /**
79
+ * Object keys in sorted order, mirroring the canonical order used by every
80
+ * cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
81
+ * enumerate object keys in this exact order: fingerprints sort keys, so two
82
+ * where clauses with the same fields in different insertion order share one
83
+ * cache entry — if build/collect iterated insertion order, the cached SQL's
84
+ * `$N` placeholders would bind the wrong values (cross-tenant-leak class).
85
+ * Array order (OR/AND members) is positional and is never sorted.
86
+ */
87
+ function sortedKeys(obj) {
88
+ return Object.keys(obj).sort();
89
+ }
90
+ /** {@link sortedKeys}, but yielding `[key, value]` pairs. */
91
+ function sortedEntries(obj) {
92
+ return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
93
+ }
47
94
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
48
95
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
49
96
  /** Known JSONB operator keys */
@@ -53,9 +100,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
53
100
  * appear in any other where-filter shape, so the presence of one of these is
54
101
  * an unambiguous signal that the user meant a JSON filter. Used by the
55
102
  * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
56
- * `WhereOperator` for LIKE) is not misclassified.
103
+ * `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
104
+ * set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
105
+ * so it must fall through instead of throwing.
57
106
  */
58
- const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
107
+ const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
59
108
  /** Check if a value is a JSONB filter object */
60
109
  function isJsonFilter(value) {
61
110
  if (value === null ||
@@ -356,10 +405,12 @@ export class QueryInterface {
356
405
  * Execute a query through the middleware chain.
357
406
  * If no middlewares are registered, executes directly.
358
407
  *
359
- * Middleware can inspect and log query parameters, modify results after execution,
360
- * and measure timing. Note: query SQL is generated before middleware runs, so
361
- * modifying params.args in middleware will NOT affect the executed SQL.
362
- * To intercept queries before SQL generation, use the raw() method instead.
408
+ * Middleware can inspect and log query parameters, measure timing, and
409
+ * transform the result returned by `next()`. Note: query SQL is generated
410
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
411
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
412
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
413
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
363
414
  */
364
415
  async executeWithMiddleware(action, args, executor) {
365
416
  this.currentAction = action;
@@ -398,8 +449,12 @@ export class QueryInterface {
398
449
  const withFp = args.with ? this.withFingerprint(args.with) : '';
399
450
  const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
400
451
  const params = [];
401
- // Check if all where values are simple (plain equality, no operators/null/OR)
402
- const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
452
+ // Check if all where values are simple (plain equality, no operators/null/OR).
453
+ // Keys are sorted to match fingerprintWhere — insertion order here would let
454
+ // permuted where literals share a cache entry with misaligned params.
455
+ const whereKeys = Object.keys(whereObj)
456
+ .filter((k) => whereObj[k] !== undefined)
457
+ .sort();
403
458
  const isSimpleWhere = !whereObj.OR &&
404
459
  !whereObj.AND &&
405
460
  !whereObj.NOT &&
@@ -603,7 +658,8 @@ export class QueryInterface {
603
658
  }
604
659
  let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
605
660
  if (args?.cursor) {
606
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
661
+ // Sorted (canonical) order MUST match cursorFp and the cache-hit collect below.
662
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
607
663
  if (cursorEntries.length > 0) {
608
664
  const cursorConditions = cursorEntries.map(([k, v]) => {
609
665
  const col = this.toSqlColumn(k);
@@ -644,9 +700,9 @@ export class QueryInterface {
644
700
  if (args?.with) {
645
701
  this.collectWithParams(args.with, params);
646
702
  }
647
- // 3. Cursor params
703
+ // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
648
704
  if (args?.cursor) {
649
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
705
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
650
706
  for (const [, v] of cursorEntries) {
651
707
  params.push(v);
652
708
  }
@@ -1886,12 +1942,7 @@ export class QueryInterface {
1886
1942
  }
1887
1943
  // Operator objects
1888
1944
  if (isWhereOperator(value)) {
1889
- const opKeys = Object.keys(value)
1890
- .filter((k) => k !== 'mode')
1891
- .sort();
1892
- const mode = value.mode;
1893
- const modeStr = mode === 'insensitive' ? ':i' : '';
1894
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1945
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
1895
1946
  continue;
1896
1947
  }
1897
1948
  // Vector distance filter — metric (operator) and present comparators
@@ -1964,12 +2015,7 @@ export class QueryInterface {
1964
2015
  parts.push(`${key}:null`);
1965
2016
  }
1966
2017
  else if (isWhereOperator(value)) {
1967
- const opKeys = Object.keys(value)
1968
- .filter((k) => k !== 'mode')
1969
- .sort();
1970
- const mode = value.mode;
1971
- const modeStr = mode === 'insensitive' ? ':i' : '';
1972
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
2018
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
1973
2019
  }
1974
2020
  else if (isUnmatchedPlainObject(value)) {
1975
2021
  parts.push(`${key}:obj(${Object.keys(value)
@@ -1990,7 +2036,8 @@ export class QueryInterface {
1990
2036
  * @internal Exposed as package-private for testing.
1991
2037
  */
1992
2038
  collectWhereParams(where, params) {
1993
- const keys = Object.keys(where);
2039
+ // Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
2040
+ const keys = sortedKeys(where);
1994
2041
  for (const key of keys) {
1995
2042
  const value = where[key];
1996
2043
  if (value === undefined)
@@ -2075,7 +2122,7 @@ export class QueryInterface {
2075
2122
  }
2076
2123
  // Operator objects
2077
2124
  if (isWhereOperator(value)) {
2078
- this.collectOperatorParams(value, params);
2125
+ this.collectOperatorParams(rawColumn, value, params);
2079
2126
  continue;
2080
2127
  }
2081
2128
  // Plain equality — same strict validation as the build path, so a
@@ -2089,22 +2136,28 @@ export class QueryInterface {
2089
2136
  const meta = this.schema.tables[targetTable];
2090
2137
  if (!meta)
2091
2138
  return;
2092
- for (const [field, value] of Object.entries(subWhere)) {
2139
+ // Sorted (canonical) order MUST match fingerprintRelFilter and buildSubWhereForRelation.
2140
+ for (const field of sortedKeys(subWhere)) {
2141
+ const value = subWhere[field];
2093
2142
  if (value === undefined)
2094
2143
  continue;
2095
2144
  if (value === null)
2096
2145
  continue;
2146
+ const col = meta.columnMap[field] ?? camelToSnake(field);
2097
2147
  if (isWhereOperator(value)) {
2098
- this.collectOperatorParams(value, params);
2148
+ this.collectOperatorParams(col, value, params);
2099
2149
  continue;
2100
2150
  }
2101
- const col = meta.columnMap[field] ?? camelToSnake(field);
2102
2151
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2103
2152
  params.push(value);
2104
2153
  }
2105
2154
  }
2106
2155
  /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2107
- collectOperatorParams(op, params) {
2156
+ collectOperatorParams(column, op, params) {
2157
+ if (op.equals !== undefined && op.equals !== null) {
2158
+ assertBindableEqualsOperand(op.equals, `"${column}"`);
2159
+ params.push(op.equals);
2160
+ }
2108
2161
  if (op.gt !== undefined)
2109
2162
  params.push(op.gt);
2110
2163
  if (op.gte !== undefined)
@@ -2260,7 +2313,7 @@ export class QueryInterface {
2260
2313
  const meta = this.schema.tables[table ?? this.table];
2261
2314
  if (!meta)
2262
2315
  return;
2263
- for (const [relName, relSpec] of Object.entries(withClause)) {
2316
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
2264
2317
  const relDef = meta.relations[relName];
2265
2318
  if (!relDef)
2266
2319
  continue;
@@ -2287,7 +2340,7 @@ export class QueryInterface {
2287
2340
  params.push(Number(spec.limit));
2288
2341
  }
2289
2342
  if (spec.with) {
2290
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2343
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2291
2344
  const nestedRelDef = targetMeta.relations[nestedRelName];
2292
2345
  if (!nestedRelDef)
2293
2346
  continue;
@@ -2301,7 +2354,7 @@ export class QueryInterface {
2301
2354
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2302
2355
  // Non-wrapped path: nested relations BEFORE where/limit
2303
2356
  if (!willWrap && spec.with) {
2304
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2357
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2305
2358
  const nestedRelDef = targetMeta.relations[nestedRelName];
2306
2359
  if (!nestedRelDef)
2307
2360
  continue;
@@ -2321,7 +2374,7 @@ export class QueryInterface {
2321
2374
  }
2322
2375
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
2323
2376
  if (willWrap && spec.with) {
2324
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2377
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2325
2378
  const nestedRelDef = targetMeta.relations[nestedRelName];
2326
2379
  if (!nestedRelDef)
2327
2380
  continue;
@@ -2403,7 +2456,8 @@ export class QueryInterface {
2403
2456
  * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
2404
2457
  */
2405
2458
  buildWhereClause(where, params) {
2406
- const keys = Object.keys(where);
2459
+ // Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
2460
+ const keys = sortedKeys(where);
2407
2461
  if (keys.length === 0)
2408
2462
  return null;
2409
2463
  const andClauses = [];
@@ -2610,7 +2664,9 @@ export class QueryInterface {
2610
2664
  return null;
2611
2665
  const qt = this.q(targetTable);
2612
2666
  const conditions = [];
2613
- for (const [field, value] of Object.entries(subWhere)) {
2667
+ // Sorted (canonical) order MUST match fingerprintRelFilter and collectRelFilterParams.
2668
+ for (const field of sortedKeys(subWhere)) {
2669
+ const value = subWhere[field];
2614
2670
  if (value === undefined)
2615
2671
  continue;
2616
2672
  const col = meta.columnMap[field] ?? camelToSnake(field);
@@ -2675,7 +2731,9 @@ export class QueryInterface {
2675
2731
  */
2676
2732
  buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2677
2733
  const clauses = [];
2678
- for (const [key, value] of Object.entries(where)) {
2734
+ // Sorted (canonical) order MUST match fingerprintAliasWhere and collectAliasWhereParams.
2735
+ for (const key of sortedKeys(where)) {
2736
+ const value = where[key];
2679
2737
  if (value === undefined)
2680
2738
  continue;
2681
2739
  if (key === 'OR' || key === 'AND') {
@@ -2717,7 +2775,9 @@ export class QueryInterface {
2717
2775
  }
2718
2776
  /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2719
2777
  collectAliasWhereParams(targetTable, targetMeta, where, params) {
2720
- for (const [key, value] of Object.entries(where)) {
2778
+ // Sorted (canonical) order MUST match fingerprintAliasWhere and buildAliasWhere.
2779
+ for (const key of sortedKeys(where)) {
2780
+ const value = where[key];
2721
2781
  if (value === undefined)
2722
2782
  continue;
2723
2783
  if (key === 'OR' || key === 'AND') {
@@ -2735,11 +2795,11 @@ export class QueryInterface {
2735
2795
  }
2736
2796
  if (value === null)
2737
2797
  continue;
2798
+ const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2738
2799
  if (isWhereOperator(value)) {
2739
- this.collectOperatorParams(value, params);
2800
+ this.collectOperatorParams(col, value, params);
2740
2801
  continue;
2741
2802
  }
2742
- const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2743
2803
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2744
2804
  params.push(value);
2745
2805
  }
@@ -2773,11 +2833,7 @@ export class QueryInterface {
2773
2833
  continue;
2774
2834
  }
2775
2835
  if (isWhereOperator(value)) {
2776
- const opKeys = Object.keys(value)
2777
- .filter((k) => k !== 'mode')
2778
- .sort();
2779
- const mode = value.mode;
2780
- parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
2836
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2781
2837
  continue;
2782
2838
  }
2783
2839
  if (isUnmatchedPlainObject(value)) {
@@ -2796,6 +2852,16 @@ export class QueryInterface {
2796
2852
  */
2797
2853
  buildOperatorClauses(column, op, params) {
2798
2854
  const clauses = [];
2855
+ if (op.equals !== undefined) {
2856
+ if (op.equals === null) {
2857
+ clauses.push(`${column} IS NULL`);
2858
+ }
2859
+ else {
2860
+ assertBindableEqualsOperand(op.equals, column);
2861
+ params.push(op.equals);
2862
+ clauses.push(`${column} = ${this.p(params.length)}`);
2863
+ }
2864
+ }
2799
2865
  if (op.gt !== undefined) {
2800
2866
  params.push(op.gt);
2801
2867
  clauses.push(`${column} > ${this.p(params.length)}`);
@@ -3094,7 +3160,7 @@ export class QueryInterface {
3094
3160
  const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
3095
3161
  const relationSelects = [];
3096
3162
  const aliasCounter = { n: 0 };
3097
- for (const [relName, relSpec] of Object.entries(withClause)) {
3163
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
3098
3164
  const relDef = meta.relations[relName];
3099
3165
  if (!relDef) {
3100
3166
  throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3249,7 +3315,7 @@ export class QueryInterface {
3249
3315
  }
3250
3316
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
3251
3317
  if (!willWrap && spec !== true && spec.with) {
3252
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3318
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3253
3319
  const nestedRelDef = targetMeta.relations[nestedRelName];
3254
3320
  if (!nestedRelDef) {
3255
3321
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3325,7 +3391,7 @@ export class QueryInterface {
3325
3391
  ]);
3326
3392
  // Build nested relation subqueries referencing innerAlias
3327
3393
  if (spec !== true && spec.with) {
3328
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3394
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3329
3395
  const nestedRelDef = targetMeta.relations[nestedRelName];
3330
3396
  if (!nestedRelDef) {
3331
3397
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3443,7 +3509,7 @@ export class QueryInterface {
3443
3509
  ]);
3444
3510
  // Nested relations reference the inner alias.
3445
3511
  if (spec !== true && spec.with) {
3446
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3512
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3447
3513
  const nestedRelDef = targetMeta.relations[nestedRelName];
3448
3514
  if (!nestedRelDef) {
3449
3515
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3466,7 +3532,7 @@ export class QueryInterface {
3466
3532
  `${talias}.${this.q(col)}`,
3467
3533
  ]);
3468
3534
  if (spec !== true && spec.with) {
3469
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3535
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3470
3536
  const nestedRelDef = targetMeta.relations[nestedRelName];
3471
3537
  if (!nestedRelDef) {
3472
3538
  throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -5,7 +5,7 @@
5
5
  * `import { … } from './query/index.js'` is a drop-in replacement for the
6
6
  * former monolithic `import { … } from './query.js'`.
7
7
  */
8
- export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, HavingClause, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateDataInput, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, HavingClause, JsonFilter, NestedCreateOp, NestedUpdateOp, NestedUpdateOpItem, NestedUpsertOpItem, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateDataInput, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
9
9
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { SqlCacheEntry } from './utils.js';