turbine-orm 0.19.1 → 0.21.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 (53) hide show
  1. package/README.md +156 -24
  2. package/dist/adapters/cockroachdb.js +4 -9
  3. package/dist/cjs/adapters/cockroachdb.js +4 -9
  4. package/dist/cjs/cli/index.js +23 -12
  5. package/dist/cjs/cli/observe-ui.js +12 -2
  6. package/dist/cjs/cli/observe.js +6 -7
  7. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  8. package/dist/cjs/cli/studio.js +6 -37
  9. package/dist/cjs/client.js +45 -33
  10. package/dist/cjs/dialect.js +116 -0
  11. package/dist/cjs/errors.js +19 -1
  12. package/dist/cjs/generate.js +4 -0
  13. package/dist/cjs/index.js +3 -2
  14. package/dist/cjs/introspect.js +20 -0
  15. package/dist/cjs/mssql.js +1338 -0
  16. package/dist/cjs/mysql.js +1052 -0
  17. package/dist/cjs/query/builder.js +408 -122
  18. package/dist/cjs/query/utils.js +1 -0
  19. package/dist/cjs/sqlite.js +849 -0
  20. package/dist/cjs/typed-sql.js +9 -7
  21. package/dist/cli/index.js +23 -12
  22. package/dist/cli/migrate.d.ts +2 -2
  23. package/dist/cli/observe-ui.d.ts +1 -1
  24. package/dist/cli/observe-ui.js +12 -2
  25. package/dist/cli/observe.js +7 -8
  26. package/dist/cli/studio-ui.generated.js +1 -1
  27. package/dist/cli/studio.d.ts +0 -10
  28. package/dist/cli/studio.js +7 -37
  29. package/dist/client.d.ts +35 -14
  30. package/dist/client.js +46 -34
  31. package/dist/dialect.d.ts +258 -1
  32. package/dist/dialect.js +83 -0
  33. package/dist/errors.d.ts +12 -0
  34. package/dist/errors.js +17 -0
  35. package/dist/generate.js +4 -0
  36. package/dist/index.d.ts +4 -4
  37. package/dist/index.js +1 -1
  38. package/dist/introspect.d.ts +18 -0
  39. package/dist/introspect.js +19 -0
  40. package/dist/mssql.d.ts +233 -0
  41. package/dist/mssql.js +1298 -0
  42. package/dist/mysql.d.ts +174 -0
  43. package/dist/mysql.js +1012 -0
  44. package/dist/query/builder.d.ts +105 -6
  45. package/dist/query/builder.js +409 -123
  46. package/dist/query/index.d.ts +2 -2
  47. package/dist/query/types.d.ts +62 -12
  48. package/dist/query/utils.js +1 -0
  49. package/dist/sqlite.d.ts +144 -0
  50. package/dist/sqlite.js +842 -0
  51. package/dist/typed-sql.d.ts +7 -5
  52. package/dist/typed-sql.js +9 -7
  53. package/package.json +32 -3
@@ -80,6 +80,53 @@ function isUnmatchedPlainObject(value) {
80
80
  const proto = Object.getPrototypeOf(value);
81
81
  return proto === Object.prototype || proto === null;
82
82
  }
83
+ /**
84
+ * Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
85
+ * `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
86
+ * param pushed), so null-ness is part of the shape — without it a cache entry
87
+ * warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
88
+ */
89
+ function fingerprintOperatorShape(value) {
90
+ const obj = value;
91
+ const opKeys = Object.keys(obj)
92
+ .filter((k) => k !== 'mode')
93
+ .map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
94
+ .sort();
95
+ const modeStr = value.mode === 'insensitive' ? ':i' : '';
96
+ return `op(${opKeys.join(',')}${modeStr})`;
97
+ }
98
+ /**
99
+ * Guard for the value of an `equals` operator reaching the plain-equality
100
+ * operator path. A plain object literal can only legitimately be an equality
101
+ * value on a json/jsonb column — and those route to the JSONB filter branch
102
+ * BEFORE the operator branch, so any plain object that reaches here is a
103
+ * mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
104
+ * SQL-build path and the cache-hit param-collect path so a warmed cache can
105
+ * never skip the check.
106
+ */
107
+ function assertBindableEqualsOperand(value, column) {
108
+ if (!isUnmatchedPlainObject(value))
109
+ return;
110
+ throw new errors_js_1.ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
111
+ `objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
112
+ `where 'equals' is the JSONB containment filter.`);
113
+ }
114
+ /**
115
+ * Object keys in sorted order, mirroring the canonical order used by every
116
+ * cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
117
+ * enumerate object keys in this exact order: fingerprints sort keys, so two
118
+ * where clauses with the same fields in different insertion order share one
119
+ * cache entry — if build/collect iterated insertion order, the cached SQL's
120
+ * `$N` placeholders would bind the wrong values (cross-tenant-leak class).
121
+ * Array order (OR/AND members) is positional and is never sorted.
122
+ */
123
+ function sortedKeys(obj) {
124
+ return Object.keys(obj).sort();
125
+ }
126
+ /** {@link sortedKeys}, but yielding `[key, value]` pairs. */
127
+ function sortedEntries(obj) {
128
+ return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
129
+ }
83
130
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
84
131
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
85
132
  /** Known JSONB operator keys */
@@ -89,9 +136,11 @@ const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
89
136
  * appear in any other where-filter shape, so the presence of one of these is
90
137
  * an unambiguous signal that the user meant a JSON filter. Used by the
91
138
  * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
92
- * `WhereOperator` for LIKE) is not misclassified.
139
+ * `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
140
+ * set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
141
+ * so it must fall through instead of throwing.
93
142
  */
94
- const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
143
+ const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
95
144
  /** Check if a value is a JSONB filter object */
96
145
  function isJsonFilter(value) {
97
146
  if (value === null ||
@@ -288,6 +337,89 @@ class QueryInterface {
288
337
  p(index) {
289
338
  return this.dialect.paramPlaceholder(index);
290
339
  }
340
+ /**
341
+ * Cast an aggregate expression to an integer/float result type through the
342
+ * active dialect. PostgreSQL keeps the historical postfix cast (`expr::int` /
343
+ * `expr::float`); SQLite (no `::` operator) maps to `CAST(expr AS INTEGER/REAL)`.
344
+ * Falls back to the Postgres postfix cast for dialects without the hook.
345
+ */
346
+ castAgg(expr, target) {
347
+ return this.dialect.castAggregate?.(expr, target) ?? `${expr}::${target}`;
348
+ }
349
+ /**
350
+ * Build the trailing pagination clause for an OUTER SELECT. PostgreSQL/MySQL/
351
+ * SQLite use ` LIMIT <ph>` and/or ` OFFSET <ph>`. SQL Server has no `LIMIT`, so
352
+ * its dialect implements {@link Dialect.buildLimitOffset} to emit
353
+ * `[ORDER BY (SELECT NULL)] OFFSET <off> ROWS [FETCH NEXT <lim> ROWS ONLY]`.
354
+ * Param-push order (limit before offset) is owned by the caller and unchanged —
355
+ * this only varies the SQL text, so PG output stays byte-identical.
356
+ */
357
+ buildPagination(limitPh, offsetPh, hasOrderBy) {
358
+ if (this.dialect.buildLimitOffset) {
359
+ return this.dialect.buildLimitOffset({ limitPlaceholder: limitPh, offsetPlaceholder: offsetPh, hasOrderBy });
360
+ }
361
+ let s = '';
362
+ if (limitPh !== undefined)
363
+ s += ` LIMIT ${limitPh}`;
364
+ if (offsetPh !== undefined)
365
+ s += ` OFFSET ${offsetPh}`;
366
+ return s;
367
+ }
368
+ /**
369
+ * The single-row limit appended to findUnique / findFirst-style lookups. ` LIMIT 1`
370
+ * for PG/MySQL/SQLite; SQL Server routes through {@link Dialect.buildLimitOffset}
371
+ * (literal `1`, no params) → ` ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1
372
+ * ROWS ONLY`. No params are pushed, so the collect path is unaffected.
373
+ */
374
+ limitOneClause() {
375
+ if (this.dialect.buildLimitOffset) {
376
+ return this.dialect.buildLimitOffset({ limitPlaceholder: '1', offsetPlaceholder: undefined, hasOrderBy: false });
377
+ }
378
+ return ' LIMIT 1';
379
+ }
380
+ /**
381
+ * Validate a LIMIT/OFFSET value as a non-negative integer and return it as an
382
+ * inline SQL literal. Used only on `dialect.inlineLimitOffset` engines (MySQL).
383
+ * The input is always a Turbine-controlled pagination value, never a raw user
384
+ * string — and this guard guarantees the output is `String` of a validated
385
+ * integer, so inlining cannot inject SQL.
386
+ */
387
+ limitOffsetLiteral(value) {
388
+ const n = Number(value);
389
+ if (!Number.isInteger(n) || n < 0) {
390
+ throw new errors_js_1.ValidationError(`LIMIT/OFFSET must be a non-negative integer, received: ${String(value)}`);
391
+ }
392
+ return String(n);
393
+ }
394
+ /**
395
+ * Resolve a LIMIT/OFFSET value to either an inline literal (no param pushed, on
396
+ * `dialect.inlineLimitOffset` engines) or a bound placeholder (the value is
397
+ * pushed to `params`). Build and collect paths both gate on the same flag so
398
+ * the param order stays mirrored; PG/SQLite/SQL Server keep parameterizing and
399
+ * stay byte-identical.
400
+ */
401
+ paginationRef(value, params) {
402
+ if (this.dialect.inlineLimitOffset) {
403
+ return this.limitOffsetLiteral(value);
404
+ }
405
+ params.push(Number(value));
406
+ return this.p(params.length);
407
+ }
408
+ /**
409
+ * Build an `IN` / `NOT IN` predicate through the active dialect. PostgreSQL
410
+ * keeps the array-param form (`expr = ANY($n)` / `expr != ALL($n)`); other
411
+ * engines (SQLite) use a length-independent single-placeholder form. Paired
412
+ * with {@link inParam}, which supplies the single bound value.
413
+ */
414
+ inClause(expr, paramRef, negated) {
415
+ if (this.dialect.buildInClause)
416
+ return this.dialect.buildInClause(expr, paramRef, negated);
417
+ return negated ? `${expr} != ALL(${paramRef})` : `${expr} = ANY(${paramRef})`;
418
+ }
419
+ /** The single bound parameter for an `IN` list (PG: the array; SQLite: a JSON string). */
420
+ inParam(values) {
421
+ return this.dialect.inClauseParam ? this.dialect.inClauseParam(values) : values;
422
+ }
291
423
  /**
292
424
  * Return cache hit/miss statistics for this QueryInterface instance.
293
425
  * Useful for monitoring and benchmarking.
@@ -388,14 +520,59 @@ class QueryInterface {
388
520
  clearTimeout(timer);
389
521
  }
390
522
  }
523
+ /**
524
+ * Execute a write `DeferredQuery` (create/update/delete/upsert) according to
525
+ * the active dialect's {@link Dialect.resultStrategy}, then apply its
526
+ * transform.
527
+ *
528
+ * - `'returning'` / `'output'`: the statement returns its own affected rows
529
+ * (`RETURNING *` / `OUTPUT INSERTED.*`). Byte-identical to the historical
530
+ * single `queryWithTimeout` + `transform(result)` path — the PostgreSQL
531
+ * route is unchanged.
532
+ * - `'reselect'`: the engine cannot return rows from a write, so the build
533
+ * method attached a {@link DeferredQuery.reselect} plan that runs the
534
+ * write and a follow-up SELECT; the SELECT's rows feed the transform.
535
+ */
536
+ async executeMutation(deferred, timeout) {
537
+ if (this.dialect.resultStrategy === 'reselect' && deferred.reselect) {
538
+ const exec = (sql, params, preparedName) => this.queryWithTimeout(sql, params, timeout, preparedName);
539
+ const result = await deferred.reselect(exec);
540
+ return deferred.transform(result);
541
+ }
542
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, timeout, deferred.preparedName);
543
+ return deferred.transform(result);
544
+ }
545
+ /**
546
+ * Build a `SELECT * ... WHERE <predicate>` that re-fetches the row(s) matched
547
+ * by a write's `where` clause. Used by the `'reselect'` result strategy to
548
+ * return rows from non-RETURNING engines. Reuses the same parameterized WHERE
549
+ * builder as reads, so no user value is interpolated.
550
+ */
551
+ buildReselectByWhere(whereObj) {
552
+ const params = [];
553
+ const clause = this.buildWhereClause(whereObj, params);
554
+ const where = clause ? ` WHERE ${clause}` : '';
555
+ return { sql: `SELECT * FROM ${this.q(this.table)}${where}`, params };
556
+ }
557
+ /**
558
+ * Best-effort extraction of an auto-generated primary key from a write
559
+ * result for `'reselect'` engines (e.g. mysql2's `insertId`). Returns
560
+ * `undefined` when the driver exposes no such field.
561
+ */
562
+ mutationInsertId(result) {
563
+ const r = result;
564
+ return r.insertId ?? r.lastID;
565
+ }
391
566
  /**
392
567
  * Execute a query through the middleware chain.
393
568
  * If no middlewares are registered, executes directly.
394
569
  *
395
- * Middleware can inspect and log query parameters, modify results after execution,
396
- * and measure timing. Note: query SQL is generated before middleware runs, so
397
- * modifying params.args in middleware will NOT affect the executed SQL.
398
- * To intercept queries before SQL generation, use the raw() method instead.
570
+ * Middleware can inspect and log query parameters, measure timing, and
571
+ * transform the result returned by `next()`. Note: query SQL is generated
572
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
573
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
574
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
575
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
399
576
  */
400
577
  async executeWithMiddleware(action, args, executor) {
401
578
  this.currentAction = action;
@@ -434,8 +611,12 @@ class QueryInterface {
434
611
  const withFp = args.with ? this.withFingerprint(args.with) : '';
435
612
  const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
436
613
  const params = [];
437
- // Check if all where values are simple (plain equality, no operators/null/OR)
438
- const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
614
+ // Check if all where values are simple (plain equality, no operators/null/OR).
615
+ // Keys are sorted to match fingerprintWhere — insertion order here would let
616
+ // permuted where literals share a cache entry with misaligned params.
617
+ const whereKeys = Object.keys(whereObj)
618
+ .filter((k) => whereObj[k] !== undefined)
619
+ .sort();
439
620
  const isSimpleWhere = !whereObj.OR &&
440
621
  !whereObj.AND &&
441
622
  !whereObj.NOT &&
@@ -452,7 +633,7 @@ class QueryInterface {
452
633
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
453
634
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
454
635
  void tempParams; // params are positional, SQL is value-invariant
455
- return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
636
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
456
637
  });
457
638
  // Collect params (same order as build)
458
639
  for (const k of whereKeys) {
@@ -477,7 +658,7 @@ class QueryInterface {
477
658
  const whereSql = clause ? ` WHERE ${clause}` : '';
478
659
  const qt = this.q(this.table);
479
660
  const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${this.q(c)}`).join(', ') : `${qt}.*`;
480
- return `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
661
+ return `SELECT ${selectExpr} FROM ${qt}${whereSql}${this.limitOneClause()}`;
481
662
  });
482
663
  // Collect params
483
664
  this.collectWhereParams(whereObj, params);
@@ -502,7 +683,7 @@ class QueryInterface {
502
683
  const clause = this.buildWhereClause(whereObj, freshParams);
503
684
  const whereSql = clause ? ` WHERE ${clause}` : '';
504
685
  const selectClause = this.buildSelectWithRelations(this.table, args.with, freshParams, columnsList);
505
- return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql} LIMIT 1`;
686
+ return `SELECT ${selectClause} FROM ${this.q(this.table)}${whereSql}${this.limitOneClause()}`;
506
687
  });
507
688
  // Collect params in exact build order: where first, then with-clause relations
508
689
  this.collectWhereParams(whereObj, params);
@@ -639,7 +820,8 @@ class QueryInterface {
639
820
  }
640
821
  let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
641
822
  if (args?.cursor) {
642
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
823
+ // Sorted (canonical) order MUST match cursorFp and the cache-hit collect below.
824
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
643
825
  if (cursorEntries.length > 0) {
644
826
  const cursorConditions = cursorEntries.map(([k, v]) => {
645
827
  const col = this.toSqlColumn(k);
@@ -661,14 +843,18 @@ class QueryInterface {
661
843
  // vector at the correct position (after cursor params, before LIMIT).
662
844
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
663
845
  }
846
+ // Pagination — push params in the same order the collect path mirrors
847
+ // (limit before offset); the SQL TEXT shape is dialect-owned via
848
+ // buildPagination (PG: ` LIMIT $n`/` OFFSET $n`; SQL Server: OFFSET/FETCH).
849
+ let limitPh;
850
+ let offsetPh;
664
851
  if (effectiveLimit !== undefined) {
665
- freshParams.push(Number(effectiveLimit));
666
- sql += ` LIMIT ${this.p(freshParams.length)}`;
852
+ limitPh = this.paginationRef(effectiveLimit, freshParams);
667
853
  }
668
854
  if (args?.offset !== undefined) {
669
- freshParams.push(Number(args.offset));
670
- sql += ` OFFSET ${this.p(freshParams.length)}`;
855
+ offsetPh = this.paginationRef(args.offset, freshParams);
671
856
  }
857
+ sql += this.buildPagination(limitPh, offsetPh, !!args?.orderBy);
672
858
  return sql;
673
859
  });
674
860
  // Collect params in exact build order:
@@ -680,9 +866,9 @@ class QueryInterface {
680
866
  if (args?.with) {
681
867
  this.collectWithParams(args.with, params);
682
868
  }
683
- // 3. Cursor params
869
+ // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
684
870
  if (args?.cursor) {
685
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
871
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
686
872
  for (const [, v] of cursorEntries) {
687
873
  params.push(v);
688
874
  }
@@ -692,12 +878,13 @@ class QueryInterface {
692
878
  if (args?.orderBy) {
693
879
  this.collectOrderByParams(args.orderBy, params);
694
880
  }
695
- // 5. LIMIT param
696
- if (effectiveLimit !== undefined) {
881
+ // 5. LIMIT param — skipped when the dialect inlines pagination (build path
882
+ // mirrors via paginationRef → no placeholder, no param).
883
+ if (effectiveLimit !== undefined && !this.dialect.inlineLimitOffset) {
697
884
  params.push(Number(effectiveLimit));
698
885
  }
699
- // 6. OFFSET param
700
- if (args?.offset !== undefined) {
886
+ // 6. OFFSET param — same inline gate as LIMIT above.
887
+ if (args?.offset !== undefined && !this.dialect.inlineLimitOffset) {
701
888
  params.push(Number(args.offset));
702
889
  }
703
890
  return {
@@ -757,34 +944,19 @@ class QueryInterface {
757
944
  }
758
945
  // --- Overflow: fall back to cursor path from scratch ---
759
946
  const deferred = this.buildFindMany(args);
760
- // Acquire a dedicated connection — cursors require a single connection in a transaction
947
+ // Acquire a dedicated connection — cursors require a single connection in a
948
+ // transaction. The dialect owns the streaming SQL (Postgres: BEGIN → DECLARE
949
+ // … NO SCROLL CURSOR FOR → FETCH n → CLOSE → COMMIT, ROLLBACK on error); we
950
+ // just parse + yield the row batches it produces.
761
951
  const client = await this.pool.connect();
762
- const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
763
- const quotedCursor = this.q(cursorName);
764
952
  try {
765
- await client.query('BEGIN');
766
- await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
767
- while (true) {
768
- const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
769
- if (batch.rows.length === 0)
770
- break;
771
- for (const row of batch.rows) {
953
+ for await (const batch of this.dialect.openStream(client, deferred.sql, deferred.params, batchSize)) {
954
+ for (const row of batch) {
772
955
  yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
773
956
  }
774
- if (batch.rows.length < batchSize)
775
- break;
776
957
  }
777
- await client.query(`CLOSE ${quotedCursor}`);
778
- await client.query('COMMIT');
779
958
  }
780
959
  catch (err) {
781
- // Rollback on error (also closes cursor implicitly)
782
- try {
783
- await client.query('ROLLBACK');
784
- }
785
- catch {
786
- // Connection may already be broken — ignore rollback error
787
- }
788
960
  // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
789
961
  throw (0, errors_js_1.wrapPgError)(err);
790
962
  }
@@ -886,8 +1058,7 @@ class QueryInterface {
886
1058
  return this.nestedCreate(args);
887
1059
  }
888
1060
  const deferred = this.buildCreate(args);
889
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
890
- return deferred.transform(result);
1061
+ return this.executeMutation(deferred, args.timeout);
891
1062
  });
892
1063
  }
893
1064
  buildCreate(args) {
@@ -916,6 +1087,33 @@ class QueryInterface {
916
1087
  return this.parseRow(row, this.table);
917
1088
  },
918
1089
  tag: `${this.table}.create`,
1090
+ // Non-RETURNING engines: INSERT, then re-fetch the new row by primary key
1091
+ // (provided value, else the driver's generated insert id).
1092
+ reselect: this.makeCreateReselect(sql, params, args.data),
1093
+ };
1094
+ }
1095
+ /**
1096
+ * Build the `'reselect'` plan for {@link buildCreate}: run the INSERT, then
1097
+ * `SELECT * WHERE pk = ?`. Returns `undefined` (skipped) unless the active
1098
+ * dialect's result strategy is `'reselect'`, so the PostgreSQL/RETURNING path
1099
+ * pays nothing. Not yet wired to a real non-RETURNING engine.
1100
+ */
1101
+ makeCreateReselect(insertSql, insertParams, data) {
1102
+ if (this.dialect.resultStrategy !== 'reselect')
1103
+ return undefined;
1104
+ return async (exec) => {
1105
+ const writeResult = await exec(insertSql, insertParams);
1106
+ const insertId = this.mutationInsertId(writeResult);
1107
+ const conds = [];
1108
+ const selParams = [];
1109
+ let idx = 1;
1110
+ for (const pk of this.tableMeta.primaryKey) {
1111
+ const field = this.tableMeta.reverseColumnMap[pk] ?? (0, schema_js_1.snakeToCamel)(pk);
1112
+ selParams.push(data[field] ?? data[pk] ?? insertId);
1113
+ conds.push(`${this.q(pk)} = ${this.p(idx++)}`);
1114
+ }
1115
+ const where = conds.length > 0 ? ` WHERE ${conds.join(' AND ')}` : '';
1116
+ return exec(`SELECT * FROM ${this.q(this.table)}${where}`, selParams);
919
1117
  };
920
1118
  }
921
1119
  // -------------------------------------------------------------------------
@@ -971,8 +1169,7 @@ class QueryInterface {
971
1169
  return this.nestedUpdate(args);
972
1170
  }
973
1171
  const deferred = this.buildUpdate(args);
974
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
975
- return deferred.transform(result);
1172
+ return this.executeMutation(deferred, args.timeout);
976
1173
  });
977
1174
  }
978
1175
  buildUpdate(args) {
@@ -1000,7 +1197,12 @@ class QueryInterface {
1000
1197
  whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
1001
1198
  }
1002
1199
  this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
1003
- return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
1200
+ // Engines that inject their returning shape MID-statement (SQL Server
1201
+ // `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
1202
+ // absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
1203
+ return this.dialect.buildUpdateStatement
1204
+ ? this.dialect.buildUpdateStatement({ table: this.q(this.table), setClauses, whereSql, returning: '*' })
1205
+ : `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}${this.dialect.buildReturningClause('*')}`;
1004
1206
  };
1005
1207
  let sql;
1006
1208
  let preparedName;
@@ -1044,6 +1246,26 @@ class QueryInterface {
1044
1246
  },
1045
1247
  tag: `${this.table}.update`,
1046
1248
  preparedName,
1249
+ // Non-RETURNING engines: UPDATE, then re-fetch the row by the same where.
1250
+ reselect: this.dialect.resultStrategy === 'reselect'
1251
+ ? async (exec) => {
1252
+ const writeResult = await exec(sql, params, preparedName);
1253
+ // Optimistic-lock conflict: the version-checked UPDATE matched no
1254
+ // row. The re-fetch below uses `where` WITHOUT the version
1255
+ // predicate, so it would return the stale row and silently mask
1256
+ // the conflict — detect it from affected-rows here instead, to
1257
+ // match the OptimisticLockError thrown on RETURNING/OUTPUT engines.
1258
+ if (lock && (writeResult.rowCount ?? 0) === 0) {
1259
+ throw new errors_js_1.OptimisticLockError({
1260
+ table: this.table,
1261
+ versionField: lock.field,
1262
+ expectedVersion: lock.expected,
1263
+ });
1264
+ }
1265
+ const sel = this.buildReselectByWhere(whereObj);
1266
+ return exec(sel.sql, sel.params);
1267
+ }
1268
+ : undefined,
1047
1269
  };
1048
1270
  }
1049
1271
  // -------------------------------------------------------------------------
@@ -1075,19 +1297,19 @@ class QueryInterface {
1075
1297
  async runInImplicitTx(fn) {
1076
1298
  const client = await this.pool.connect();
1077
1299
  try {
1078
- await client.query('BEGIN');
1300
+ await client.query(this.dialect.beginStatement());
1079
1301
  const { TransactionClient } = await Promise.resolve().then(() => __importStar(require('../client.js')));
1080
1302
  // biome-ignore lint/suspicious/noExplicitAny: MiddlewareFn and Middleware are structurally identical
1081
1303
  const tx = new TransactionClient(client, this.schema, this.middlewares, this.options);
1082
1304
  // biome-ignore lint/suspicious/noExplicitAny: TransactionClient satisfies NestedWriteContext['tx'] at runtime
1083
1305
  const ctx = { schema: this.schema, tx: tx };
1084
1306
  const result = await fn(ctx);
1085
- await client.query('COMMIT');
1307
+ await client.query(this.dialect.commitStatement());
1086
1308
  return result;
1087
1309
  }
1088
1310
  catch (err) {
1089
1311
  try {
1090
- await client.query('ROLLBACK');
1312
+ await client.query(this.dialect.rollbackStatement());
1091
1313
  }
1092
1314
  catch {
1093
1315
  // Best-effort rollback — connection may have died.
@@ -1120,8 +1342,7 @@ class QueryInterface {
1120
1342
  async delete(args) {
1121
1343
  return this.executeWithMiddleware('delete', args, async () => {
1122
1344
  const deferred = this.buildDelete(args);
1123
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1124
- return deferred.transform(result);
1345
+ return this.executeMutation(deferred, args.timeout);
1125
1346
  });
1126
1347
  }
1127
1348
  buildDelete(args) {
@@ -1136,7 +1357,11 @@ class QueryInterface {
1136
1357
  const clause = this.buildWhereClause(whereObj, freshParams);
1137
1358
  const whereSql = clause ? ` WHERE ${clause}` : '';
1138
1359
  this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
1139
- return `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1360
+ // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
1361
+ // absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
1362
+ return this.dialect.buildDeleteStatement
1363
+ ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1364
+ : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1140
1365
  });
1141
1366
  // On cache hit, still validate the predicate
1142
1367
  if (whereFp === '') {
@@ -1159,6 +1384,16 @@ class QueryInterface {
1159
1384
  },
1160
1385
  tag: `${this.table}.delete`,
1161
1386
  preparedName: entry.name,
1387
+ // Non-RETURNING engines: the row is gone after DELETE, so pre-SELECT it
1388
+ // by the same where, then run the DELETE, returning the captured row.
1389
+ reselect: this.dialect.resultStrategy === 'reselect'
1390
+ ? async (exec) => {
1391
+ const sel = this.buildReselectByWhere(whereObj);
1392
+ const pre = await exec(sel.sql, sel.params);
1393
+ await exec(entry.sql, params, entry.name);
1394
+ return pre;
1395
+ }
1396
+ : undefined,
1162
1397
  };
1163
1398
  }
1164
1399
  // -------------------------------------------------------------------------
@@ -1167,8 +1402,7 @@ class QueryInterface {
1167
1402
  async upsert(args) {
1168
1403
  return this.executeWithMiddleware('upsert', args, async () => {
1169
1404
  const deferred = this.buildUpsert(args);
1170
- const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
1171
- return deferred.transform(result);
1405
+ return this.executeMutation(deferred, args.timeout);
1172
1406
  });
1173
1407
  }
1174
1408
  buildUpsert(args) {
@@ -1214,6 +1448,14 @@ class QueryInterface {
1214
1448
  return this.parseRow(row, this.table);
1215
1449
  },
1216
1450
  tag: `${this.table}.upsert`,
1451
+ // Non-RETURNING engines: run the upsert, then re-fetch by the where keys.
1452
+ reselect: this.dialect.resultStrategy === 'reselect'
1453
+ ? async (exec) => {
1454
+ await exec(sql, params);
1455
+ const sel = this.buildReselectByWhere(args.where);
1456
+ return exec(sel.sql, sel.params);
1457
+ }
1458
+ : undefined,
1217
1459
  };
1218
1460
  }
1219
1461
  // -------------------------------------------------------------------------
@@ -1308,7 +1550,7 @@ class QueryInterface {
1308
1550
  const freshParams = [];
1309
1551
  const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1310
1552
  const whereSql = clause ? ` WHERE ${clause}` : '';
1311
- return `SELECT COUNT(*)::int AS count FROM ${this.q(this.table)}${whereSql}`;
1553
+ return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1312
1554
  });
1313
1555
  if (args?.where) {
1314
1556
  this.collectWhereParams(whereObj, params);
@@ -1348,7 +1590,7 @@ class QueryInterface {
1348
1590
  // _count
1349
1591
  if (args._count === true || args._count === undefined) {
1350
1592
  // default: always include count
1351
- selectExprs.push('COUNT(*)::int AS _count');
1593
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1352
1594
  }
1353
1595
  // _sum
1354
1596
  if (args._sum) {
@@ -1364,7 +1606,7 @@ class QueryInterface {
1364
1606
  for (const [field, enabled] of Object.entries(args._avg)) {
1365
1607
  if (enabled) {
1366
1608
  const col = this.toColumn(field);
1367
- selectExprs.push(`AVG(${this.q(col)})::float AS ${this.q(`_avg_${col}`)}`);
1609
+ selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
1368
1610
  }
1369
1611
  }
1370
1612
  }
@@ -1565,12 +1807,12 @@ class QueryInterface {
1565
1807
  clauses.push(`${expr} <= ${this.p(params.length)}`);
1566
1808
  }
1567
1809
  if (op.in !== undefined) {
1568
- params.push(op.in);
1569
- clauses.push(`${expr} = ANY(${this.p(params.length)})`);
1810
+ params.push(this.inParam(op.in));
1811
+ clauses.push(this.inClause(expr, this.p(params.length), false));
1570
1812
  }
1571
1813
  if (op.notIn !== undefined) {
1572
- params.push(op.notIn);
1573
- clauses.push(`${expr} != ALL(${this.p(params.length)})`);
1814
+ params.push(this.inParam(op.notIn));
1815
+ clauses.push(this.inClause(expr, this.p(params.length), true));
1574
1816
  }
1575
1817
  return clauses;
1576
1818
  }
@@ -1608,13 +1850,13 @@ class QueryInterface {
1608
1850
  const selectExprs = [];
1609
1851
  // _count
1610
1852
  if (args._count === true) {
1611
- selectExprs.push('COUNT(*)::int AS _count');
1853
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1612
1854
  }
1613
1855
  else if (args._count && typeof args._count === 'object') {
1614
1856
  for (const [field, enabled] of Object.entries(args._count)) {
1615
1857
  if (enabled) {
1616
1858
  const col = this.toColumn(field);
1617
- selectExprs.push(`COUNT(${this.q(col)})::int AS ${this.q(`_count_${col}`)}`);
1859
+ selectExprs.push(`${this.castAgg(`COUNT(${this.q(col)})`, 'int')} AS ${this.q(`_count_${col}`)}`);
1618
1860
  }
1619
1861
  }
1620
1862
  }
@@ -1632,7 +1874,7 @@ class QueryInterface {
1632
1874
  for (const [field, enabled] of Object.entries(args._avg)) {
1633
1875
  if (enabled) {
1634
1876
  const col = this.toColumn(field);
1635
- selectExprs.push(`AVG(${this.q(col)})::float AS ${this.q(`_avg_${col}`)}`);
1877
+ selectExprs.push(`${this.castAgg(`AVG(${this.q(col)})`, 'float')} AS ${this.q(`_avg_${col}`)}`);
1636
1878
  }
1637
1879
  }
1638
1880
  }
@@ -1655,7 +1897,7 @@ class QueryInterface {
1655
1897
  }
1656
1898
  }
1657
1899
  if (selectExprs.length === 0) {
1658
- selectExprs.push('COUNT(*)::int AS _count');
1900
+ selectExprs.push(`${this.castAgg('COUNT(*)', 'int')} AS _count`);
1659
1901
  }
1660
1902
  const sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql}`;
1661
1903
  return {
@@ -1922,12 +2164,7 @@ class QueryInterface {
1922
2164
  }
1923
2165
  // Operator objects
1924
2166
  if (isWhereOperator(value)) {
1925
- const opKeys = Object.keys(value)
1926
- .filter((k) => k !== 'mode')
1927
- .sort();
1928
- const mode = value.mode;
1929
- const modeStr = mode === 'insensitive' ? ':i' : '';
1930
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
2167
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
1931
2168
  continue;
1932
2169
  }
1933
2170
  // Vector distance filter — metric (operator) and present comparators
@@ -2000,12 +2237,7 @@ class QueryInterface {
2000
2237
  parts.push(`${key}:null`);
2001
2238
  }
2002
2239
  else if (isWhereOperator(value)) {
2003
- const opKeys = Object.keys(value)
2004
- .filter((k) => k !== 'mode')
2005
- .sort();
2006
- const mode = value.mode;
2007
- const modeStr = mode === 'insensitive' ? ':i' : '';
2008
- parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
2240
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2009
2241
  }
2010
2242
  else if (isUnmatchedPlainObject(value)) {
2011
2243
  parts.push(`${key}:obj(${Object.keys(value)
@@ -2026,7 +2258,8 @@ class QueryInterface {
2026
2258
  * @internal Exposed as package-private for testing.
2027
2259
  */
2028
2260
  collectWhereParams(where, params) {
2029
- const keys = Object.keys(where);
2261
+ // Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
2262
+ const keys = sortedKeys(where);
2030
2263
  for (const key of keys) {
2031
2264
  const value = where[key];
2032
2265
  if (value === undefined)
@@ -2111,7 +2344,7 @@ class QueryInterface {
2111
2344
  }
2112
2345
  // Operator objects
2113
2346
  if (isWhereOperator(value)) {
2114
- this.collectOperatorParams(value, params);
2347
+ this.collectOperatorParams(rawColumn, value, params);
2115
2348
  continue;
2116
2349
  }
2117
2350
  // Plain equality — same strict validation as the build path, so a
@@ -2125,22 +2358,28 @@ class QueryInterface {
2125
2358
  const meta = this.schema.tables[targetTable];
2126
2359
  if (!meta)
2127
2360
  return;
2128
- for (const [field, value] of Object.entries(subWhere)) {
2361
+ // Sorted (canonical) order MUST match fingerprintRelFilter and buildSubWhereForRelation.
2362
+ for (const field of sortedKeys(subWhere)) {
2363
+ const value = subWhere[field];
2129
2364
  if (value === undefined)
2130
2365
  continue;
2131
2366
  if (value === null)
2132
2367
  continue;
2368
+ const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2133
2369
  if (isWhereOperator(value)) {
2134
- this.collectOperatorParams(value, params);
2370
+ this.collectOperatorParams(col, value, params);
2135
2371
  continue;
2136
2372
  }
2137
- const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2138
2373
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2139
2374
  params.push(value);
2140
2375
  }
2141
2376
  }
2142
2377
  /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2143
- collectOperatorParams(op, params) {
2378
+ collectOperatorParams(column, op, params) {
2379
+ if (op.equals !== undefined && op.equals !== null) {
2380
+ assertBindableEqualsOperand(op.equals, `"${column}"`);
2381
+ params.push(op.equals);
2382
+ }
2144
2383
  if (op.gt !== undefined)
2145
2384
  params.push(op.gt);
2146
2385
  if (op.gte !== undefined)
@@ -2152,9 +2391,9 @@ class QueryInterface {
2152
2391
  if (op.not !== undefined && op.not !== null)
2153
2392
  params.push(op.not);
2154
2393
  if (op.in !== undefined)
2155
- params.push(op.in);
2394
+ params.push(this.inParam(op.in));
2156
2395
  if (op.notIn !== undefined)
2157
- params.push(op.notIn);
2396
+ params.push(this.inParam(op.notIn));
2158
2397
  if (op.contains !== undefined)
2159
2398
  params.push(`%${(0, utils_js_1.escapeLike)(op.contains)}%`);
2160
2399
  if (op.startsWith !== undefined)
@@ -2296,7 +2535,7 @@ class QueryInterface {
2296
2535
  const meta = this.schema.tables[table ?? this.table];
2297
2536
  if (!meta)
2298
2537
  return;
2299
- for (const [relName, relSpec] of Object.entries(withClause)) {
2538
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
2300
2539
  const relDef = meta.relations[relName];
2301
2540
  if (!relDef)
2302
2541
  continue;
@@ -2319,11 +2558,11 @@ class QueryInterface {
2319
2558
  if (spec.where) {
2320
2559
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2321
2560
  }
2322
- if (spec.limit !== undefined) {
2561
+ if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2323
2562
  params.push(Number(spec.limit));
2324
2563
  }
2325
2564
  if (spec.with) {
2326
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2565
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2327
2566
  const nestedRelDef = targetMeta.relations[nestedRelName];
2328
2567
  if (!nestedRelDef)
2329
2568
  continue;
@@ -2337,7 +2576,7 @@ class QueryInterface {
2337
2576
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2338
2577
  // Non-wrapped path: nested relations BEFORE where/limit
2339
2578
  if (!willWrap && spec.with) {
2340
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2579
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2341
2580
  const nestedRelDef = targetMeta.relations[nestedRelName];
2342
2581
  if (!nestedRelDef)
2343
2582
  continue;
@@ -2352,12 +2591,12 @@ class QueryInterface {
2352
2591
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2353
2592
  // pushing one here would orphan a param and desync the collect path.
2354
2593
  // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2355
- if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2594
+ if (relDef.type === 'hasMany' && spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2356
2595
  params.push(Number(spec.limit));
2357
2596
  }
2358
2597
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
2359
2598
  if (willWrap && spec.with) {
2360
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2599
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2361
2600
  const nestedRelDef = targetMeta.relations[nestedRelName];
2362
2601
  if (!nestedRelDef)
2363
2602
  continue;
@@ -2439,7 +2678,8 @@ class QueryInterface {
2439
2678
  * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
2440
2679
  */
2441
2680
  buildWhereClause(where, params) {
2442
- const keys = Object.keys(where);
2681
+ // Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
2682
+ const keys = sortedKeys(where);
2443
2683
  if (keys.length === 0)
2444
2684
  return null;
2445
2685
  const andClauses = [];
@@ -2646,7 +2886,9 @@ class QueryInterface {
2646
2886
  return null;
2647
2887
  const qt = this.q(targetTable);
2648
2888
  const conditions = [];
2649
- for (const [field, value] of Object.entries(subWhere)) {
2889
+ // Sorted (canonical) order MUST match fingerprintRelFilter and collectRelFilterParams.
2890
+ for (const field of sortedKeys(subWhere)) {
2891
+ const value = subWhere[field];
2650
2892
  if (value === undefined)
2651
2893
  continue;
2652
2894
  const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
@@ -2711,7 +2953,9 @@ class QueryInterface {
2711
2953
  */
2712
2954
  buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2713
2955
  const clauses = [];
2714
- for (const [key, value] of Object.entries(where)) {
2956
+ // Sorted (canonical) order MUST match fingerprintAliasWhere and collectAliasWhereParams.
2957
+ for (const key of sortedKeys(where)) {
2958
+ const value = where[key];
2715
2959
  if (value === undefined)
2716
2960
  continue;
2717
2961
  if (key === 'OR' || key === 'AND') {
@@ -2753,7 +2997,9 @@ class QueryInterface {
2753
2997
  }
2754
2998
  /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2755
2999
  collectAliasWhereParams(targetTable, targetMeta, where, params) {
2756
- for (const [key, value] of Object.entries(where)) {
3000
+ // Sorted (canonical) order MUST match fingerprintAliasWhere and buildAliasWhere.
3001
+ for (const key of sortedKeys(where)) {
3002
+ const value = where[key];
2757
3003
  if (value === undefined)
2758
3004
  continue;
2759
3005
  if (key === 'OR' || key === 'AND') {
@@ -2771,11 +3017,11 @@ class QueryInterface {
2771
3017
  }
2772
3018
  if (value === null)
2773
3019
  continue;
3020
+ const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2774
3021
  if (isWhereOperator(value)) {
2775
- this.collectOperatorParams(value, params);
3022
+ this.collectOperatorParams(col, value, params);
2776
3023
  continue;
2777
3024
  }
2778
- const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2779
3025
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2780
3026
  params.push(value);
2781
3027
  }
@@ -2809,11 +3055,7 @@ class QueryInterface {
2809
3055
  continue;
2810
3056
  }
2811
3057
  if (isWhereOperator(value)) {
2812
- const opKeys = Object.keys(value)
2813
- .filter((k) => k !== 'mode')
2814
- .sort();
2815
- const mode = value.mode;
2816
- parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
3058
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2817
3059
  continue;
2818
3060
  }
2819
3061
  if (isUnmatchedPlainObject(value)) {
@@ -2832,6 +3074,16 @@ class QueryInterface {
2832
3074
  */
2833
3075
  buildOperatorClauses(column, op, params) {
2834
3076
  const clauses = [];
3077
+ if (op.equals !== undefined) {
3078
+ if (op.equals === null) {
3079
+ clauses.push(`${column} IS NULL`);
3080
+ }
3081
+ else {
3082
+ assertBindableEqualsOperand(op.equals, column);
3083
+ params.push(op.equals);
3084
+ clauses.push(`${column} = ${this.p(params.length)}`);
3085
+ }
3086
+ }
2835
3087
  if (op.gt !== undefined) {
2836
3088
  params.push(op.gt);
2837
3089
  clauses.push(`${column} > ${this.p(params.length)}`);
@@ -2858,12 +3110,12 @@ class QueryInterface {
2858
3110
  }
2859
3111
  }
2860
3112
  if (op.in !== undefined) {
2861
- params.push(op.in);
2862
- clauses.push(`${column} = ANY(${this.p(params.length)})`);
3113
+ params.push(this.inParam(op.in));
3114
+ clauses.push(this.inClause(column, this.p(params.length), false));
2863
3115
  }
2864
3116
  if (op.notIn !== undefined) {
2865
- params.push(op.notIn);
2866
- clauses.push(`${column} != ALL(${this.p(params.length)})`);
3117
+ params.push(this.inParam(op.notIn));
3118
+ clauses.push(this.inClause(column, this.p(params.length), true));
2867
3119
  }
2868
3120
  const buildLikeClause = (paramRef) => op.mode === 'insensitive' ? this.dialect.buildInsensitiveLike(column, paramRef) : `${column} LIKE ${paramRef}`;
2869
3121
  if (op.contains !== undefined) {
@@ -2934,6 +3186,9 @@ class QueryInterface {
2934
3186
  * non-vector column — a user-supplied string can never become a SQL operator.
2935
3187
  */
2936
3188
  vectorOperator(field, rawColumn, metric) {
3189
+ if (!this.dialect.supportsVector) {
3190
+ throw new errors_js_1.UnsupportedFeatureError('pgvector distance operations', this.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
3191
+ }
2937
3192
  const colType = this.getColumnPgType(rawColumn);
2938
3193
  if (colType !== 'vector') {
2939
3194
  throw new errors_js_1.ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
@@ -2954,6 +3209,9 @@ class QueryInterface {
2954
3209
  * placeholder string.
2955
3210
  */
2956
3211
  pushVectorParam(field, _rawColumn, to, params) {
3212
+ if (!this.dialect.supportsVector) {
3213
+ throw new errors_js_1.UnsupportedFeatureError('pgvector distance operations', this.dialect.name, 'Vector search requires PostgreSQL with the pgvector extension.');
3214
+ }
2957
3215
  if (!Array.isArray(to) || to.length === 0) {
2958
3216
  throw new errors_js_1.ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
2959
3217
  }
@@ -3130,7 +3388,7 @@ class QueryInterface {
3130
3388
  const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
3131
3389
  const relationSelects = [];
3132
3390
  const aliasCounter = { n: 0 };
3133
- for (const [relName, relSpec] of Object.entries(withClause)) {
3391
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
3134
3392
  const relDef = meta.relations[relName];
3135
3393
  if (!relDef) {
3136
3394
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3264,6 +3522,32 @@ class QueryInterface {
3264
3522
  .map(([k]) => targetMeta.columnMap[k] ?? (0, schema_js_1.camelToSnake)(k)));
3265
3523
  targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
3266
3524
  }
3525
+ // Engine override seam (additive): a dialect whose JSON-aggregation shape does
3526
+ // not map onto buildJsonObject/buildJsonArrayAgg (SQL Server FOR JSON PATH) owns
3527
+ // the WHOLE subquery. Absent for PG/MySQL/SQLite → the native path below runs
3528
+ // unchanged (byte-identical output, all their tests stay green). The override
3529
+ // pushes params per the documented RelationSubqueryContext ordering contract,
3530
+ // which mirrors collectRelationSubqueryParams so the SQL cache / pipeline stay
3531
+ // in sync.
3532
+ if (this.dialect.buildRelationSubquery) {
3533
+ return this.dialect.buildRelationSubquery({
3534
+ relDef,
3535
+ spec,
3536
+ params,
3537
+ parentRef,
3538
+ alias,
3539
+ targetTable,
3540
+ targetMeta,
3541
+ targetColumns,
3542
+ depth: currentDepth,
3543
+ path: currentPath,
3544
+ quote: (name) => this.q(name),
3545
+ buildWhere: (whereAlias) => (spec !== true && spec.where
3546
+ ? this.buildAliasWhere(targetTable, targetMeta, whereAlias, spec.where, params)
3547
+ : '') ?? '',
3548
+ recurse: (nRelDef, nSpec, nParent, nDepth, nPath) => this.buildRelationSubquery(nRelDef, nSpec, params, nParent, aliasCounter, nDepth, nPath),
3549
+ });
3550
+ }
3267
3551
  // Build JSON object pairs for resolved columns
3268
3552
  const jsonPairs = targetColumns.map((col) => [
3269
3553
  targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
@@ -3285,7 +3569,7 @@ class QueryInterface {
3285
3569
  }
3286
3570
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
3287
3571
  if (!willWrap && spec !== true && spec.with) {
3288
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3572
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3289
3573
  const nestedRelDef = targetMeta.relations[nestedRelName];
3290
3574
  if (!nestedRelDef) {
3291
3575
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3295,7 +3579,7 @@ class QueryInterface {
3295
3579
  const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3296
3580
  // Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
3297
3581
  const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
3298
- jsonPairs.push([nestedRelName, `COALESCE((${nestedSubquery}), ${fallback})`]);
3582
+ jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSubquery, fallback)]);
3299
3583
  }
3300
3584
  }
3301
3585
  const jsonObj = this.dialect.buildJsonObject(jsonPairs);
@@ -3343,8 +3627,7 @@ class QueryInterface {
3343
3627
  // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3344
3628
  let limitClause = '';
3345
3629
  if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3346
- params.push(Number(spec.limit));
3347
- limitClause = ` LIMIT ${this.p(params.length)}`;
3630
+ limitClause = ` LIMIT ${this.paginationRef(spec.limit, params)}`;
3348
3631
  }
3349
3632
  if (relDef.type === 'hasMany') {
3350
3633
  // When LIMIT or ORDER BY is used, wrap in a subquery so LIMIT applies to rows
@@ -3361,7 +3644,7 @@ class QueryInterface {
3361
3644
  ]);
3362
3645
  // Build nested relation subqueries referencing innerAlias
3363
3646
  if (spec !== true && spec.with) {
3364
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3647
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3365
3648
  const nestedRelDef = targetMeta.relations[nestedRelName];
3366
3649
  if (!nestedRelDef) {
3367
3650
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3369,13 +3652,17 @@ class QueryInterface {
3369
3652
  }
3370
3653
  const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3371
3654
  const fallback = nestedRelDef.type === 'hasMany' ? this.dialect.emptyJsonArrayLiteral : this.dialect.nullJsonLiteral;
3372
- innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3655
+ innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3373
3656
  }
3374
3657
  }
3375
3658
  const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
3376
3659
  return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
3377
3660
  }
3378
- return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, orderClause.trim() || undefined)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
3661
+ // Inline ORDER BY only when the dialect's array-agg supports it (PG). For
3662
+ // hasMany this path is reached only when there is no orderClause, so the
3663
+ // argument is `undefined` either way — keeping PG output byte-identical.
3664
+ const inlineOrder = this.dialect.aggSupportsInlineOrderBy ? orderClause.trim() || undefined : undefined;
3665
+ return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj, inlineOrder)} FROM ${qTarget} ${alias} WHERE ${whereClause}`;
3379
3666
  }
3380
3667
  // belongsTo / hasOne — return single object
3381
3668
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
@@ -3463,8 +3750,7 @@ class QueryInterface {
3463
3750
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3464
3751
  let limitClause = '';
3465
3752
  if (spec !== true && spec.limit !== undefined) {
3466
- params.push(Number(spec.limit));
3467
- limitClause = ` LIMIT ${this.p(params.length)}`;
3753
+ limitClause = ` LIMIT ${this.paginationRef(spec.limit, params)}`;
3468
3754
  }
3469
3755
  const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
3470
3756
  // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
@@ -3479,7 +3765,7 @@ class QueryInterface {
3479
3765
  ]);
3480
3766
  // Nested relations reference the inner alias.
3481
3767
  if (spec !== true && spec.with) {
3482
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3768
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3483
3769
  const nestedRelDef = targetMeta.relations[nestedRelName];
3484
3770
  if (!nestedRelDef) {
3485
3771
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3489,7 +3775,7 @@ class QueryInterface {
3489
3775
  const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3490
3776
  ? this.dialect.nullJsonLiteral
3491
3777
  : this.dialect.emptyJsonArrayLiteral;
3492
- innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3778
+ innerJsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3493
3779
  }
3494
3780
  }
3495
3781
  const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
@@ -3502,7 +3788,7 @@ class QueryInterface {
3502
3788
  `${talias}.${this.q(col)}`,
3503
3789
  ]);
3504
3790
  if (spec !== true && spec.with) {
3505
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3791
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3506
3792
  const nestedRelDef = targetMeta.relations[nestedRelName];
3507
3793
  if (!nestedRelDef) {
3508
3794
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3512,7 +3798,7 @@ class QueryInterface {
3512
3798
  const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3513
3799
  ? this.dialect.nullJsonLiteral
3514
3800
  : this.dialect.emptyJsonArrayLiteral;
3515
- jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3801
+ jsonPairs.push([nestedRelName, this.dialect.wrapJsonSubresult(nestedSub, fallback)]);
3516
3802
  }
3517
3803
  }
3518
3804
  const jsonObj = this.dialect.buildJsonObject(jsonPairs);