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.
@@ -34,7 +34,6 @@ exports.apiBuilder = apiBuilder;
34
34
  exports.apiListSavedQueries = apiListSavedQueries;
35
35
  exports.apiCreateSavedQuery = apiCreateSavedQuery;
36
36
  exports.apiDeleteSavedQuery = apiDeleteSavedQuery;
37
- exports.isReadOnlyStatement = isReadOnlyStatement;
38
37
  const node_child_process_1 = require("node:child_process");
39
38
  const node_crypto_1 = require("node:crypto");
40
39
  const node_fs_1 = require("node:fs");
@@ -550,35 +549,6 @@ function clampInt(value, fallback, min, max) {
550
549
  return fallback;
551
550
  return Math.min(Math.max(n, min), max);
552
551
  }
553
- /**
554
- * Accept only SELECT or WITH (CTE) statements. Reject any statement that
555
- * contains a semicolon followed by non-whitespace (prevents statement
556
- * stacking), and require the first non-comment keyword to be SELECT or WITH.
557
- *
558
- * This is a first-line filter — the transaction's READ ONLY mode is the
559
- * second line of defense. Both must fail before a destructive statement
560
- * could run.
561
- */
562
- function isReadOnlyStatement(sql) {
563
- const stripped = stripSqlComments(sql).trim();
564
- if (!stripped)
565
- return false;
566
- // Disallow statement stacking. A single trailing `;` is fine.
567
- const withoutTrailingSemi = stripped.replace(/;+\s*$/, '');
568
- if (withoutTrailingSemi.includes(';'))
569
- return false;
570
- const firstWord = withoutTrailingSemi.slice(0, 6).toUpperCase();
571
- if (firstWord.startsWith('SELECT'))
572
- return true;
573
- if (firstWord.startsWith('WITH'))
574
- return true;
575
- return false;
576
- }
577
- function stripSqlComments(sql) {
578
- // Strip -- line comments and /* block comments */. Not a full SQL parser,
579
- // but sufficient to catch the common bypass attempts.
580
- return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
581
- }
582
552
  function serializeRow(row) {
583
553
  const out = {};
584
554
  for (const [k, v] of Object.entries(row)) {
@@ -331,12 +331,14 @@ class TurbineClient {
331
331
  // Middleware — intercept all queries
332
332
  // -------------------------------------------------------------------------
333
333
  /**
334
- * Register a middleware function that runs before/after every query.
334
+ * Register a middleware function that runs around every query.
335
335
  *
336
- * Middleware can inspect and log query parameters, modify results after execution,
337
- * and measure timing. Note: query SQL is generated before middleware runs, so
338
- * modifying params.args in middleware will NOT affect the executed SQL.
339
- * To intercept queries before SQL generation, use the raw() method instead.
336
+ * Middleware can inspect and log query parameters, measure timing, and
337
+ * transform the result returned by `next()`. Note: query SQL is generated
338
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
339
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
340
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
341
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
340
342
  *
341
343
  * @example
342
344
  * ```ts
@@ -348,16 +350,13 @@ class TurbineClient {
348
350
  * return result;
349
351
  * });
350
352
  *
351
- * // Soft-delete middleware
353
+ * // Result transformation middleware — redact a field on the way out
352
354
  * db.$use(async (params, next) => {
353
- * if (params.action === 'findMany' || params.action === 'findUnique') {
354
- * params.args.where = { ...params.args.where, deletedAt: null };
355
- * }
356
- * if (params.action === 'delete') {
357
- * params.action = 'update';
358
- * params.args = { where: params.args.where, data: { deletedAt: new Date() } };
355
+ * const result = await next(params);
356
+ * if (params.model === 'users' && Array.isArray(result)) {
357
+ * for (const row of result as { email?: string }[]) row.email = '[redacted]';
359
358
  * }
360
- * return next(params);
359
+ * return result;
361
360
  * });
362
361
  * ```
363
362
  */
@@ -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 ||
@@ -392,10 +441,12 @@ class QueryInterface {
392
441
  * Execute a query through the middleware chain.
393
442
  * If no middlewares are registered, executes directly.
394
443
  *
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.
444
+ * Middleware can inspect and log query parameters, measure timing, and
445
+ * transform the result returned by `next()`. Note: query SQL is generated
446
+ * BEFORE middleware runs — `params.args` is a read-only snapshot, and
447
+ * mutating it does NOT change the executed SQL. Cross-cutting filters
448
+ * (e.g. soft deletes) belong in the query itself: pass an explicit
449
+ * `where: { deletedAt: null }` or wrap the table accessor in a small helper.
399
450
  */
400
451
  async executeWithMiddleware(action, args, executor) {
401
452
  this.currentAction = action;
@@ -434,8 +485,12 @@ class QueryInterface {
434
485
  const withFp = args.with ? this.withFingerprint(args.with) : '';
435
486
  const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
436
487
  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);
488
+ // Check if all where values are simple (plain equality, no operators/null/OR).
489
+ // Keys are sorted to match fingerprintWhere — insertion order here would let
490
+ // permuted where literals share a cache entry with misaligned params.
491
+ const whereKeys = Object.keys(whereObj)
492
+ .filter((k) => whereObj[k] !== undefined)
493
+ .sort();
439
494
  const isSimpleWhere = !whereObj.OR &&
440
495
  !whereObj.AND &&
441
496
  !whereObj.NOT &&
@@ -639,7 +694,8 @@ class QueryInterface {
639
694
  }
640
695
  let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
641
696
  if (args?.cursor) {
642
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
697
+ // Sorted (canonical) order MUST match cursorFp and the cache-hit collect below.
698
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
643
699
  if (cursorEntries.length > 0) {
644
700
  const cursorConditions = cursorEntries.map(([k, v]) => {
645
701
  const col = this.toSqlColumn(k);
@@ -680,9 +736,9 @@ class QueryInterface {
680
736
  if (args?.with) {
681
737
  this.collectWithParams(args.with, params);
682
738
  }
683
- // 3. Cursor params
739
+ // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
684
740
  if (args?.cursor) {
685
- const cursorEntries = Object.entries(args.cursor).filter(([, v]) => v !== undefined);
741
+ const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
686
742
  for (const [, v] of cursorEntries) {
687
743
  params.push(v);
688
744
  }
@@ -1922,12 +1978,7 @@ class QueryInterface {
1922
1978
  }
1923
1979
  // Operator objects
1924
1980
  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})`);
1981
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
1931
1982
  continue;
1932
1983
  }
1933
1984
  // Vector distance filter — metric (operator) and present comparators
@@ -2000,12 +2051,7 @@ class QueryInterface {
2000
2051
  parts.push(`${key}:null`);
2001
2052
  }
2002
2053
  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})`);
2054
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2009
2055
  }
2010
2056
  else if (isUnmatchedPlainObject(value)) {
2011
2057
  parts.push(`${key}:obj(${Object.keys(value)
@@ -2026,7 +2072,8 @@ class QueryInterface {
2026
2072
  * @internal Exposed as package-private for testing.
2027
2073
  */
2028
2074
  collectWhereParams(where, params) {
2029
- const keys = Object.keys(where);
2075
+ // Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
2076
+ const keys = sortedKeys(where);
2030
2077
  for (const key of keys) {
2031
2078
  const value = where[key];
2032
2079
  if (value === undefined)
@@ -2111,7 +2158,7 @@ class QueryInterface {
2111
2158
  }
2112
2159
  // Operator objects
2113
2160
  if (isWhereOperator(value)) {
2114
- this.collectOperatorParams(value, params);
2161
+ this.collectOperatorParams(rawColumn, value, params);
2115
2162
  continue;
2116
2163
  }
2117
2164
  // Plain equality — same strict validation as the build path, so a
@@ -2125,22 +2172,28 @@ class QueryInterface {
2125
2172
  const meta = this.schema.tables[targetTable];
2126
2173
  if (!meta)
2127
2174
  return;
2128
- for (const [field, value] of Object.entries(subWhere)) {
2175
+ // Sorted (canonical) order MUST match fingerprintRelFilter and buildSubWhereForRelation.
2176
+ for (const field of sortedKeys(subWhere)) {
2177
+ const value = subWhere[field];
2129
2178
  if (value === undefined)
2130
2179
  continue;
2131
2180
  if (value === null)
2132
2181
  continue;
2182
+ const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2133
2183
  if (isWhereOperator(value)) {
2134
- this.collectOperatorParams(value, params);
2184
+ this.collectOperatorParams(col, value, params);
2135
2185
  continue;
2136
2186
  }
2137
- const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2138
2187
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2139
2188
  params.push(value);
2140
2189
  }
2141
2190
  }
2142
2191
  /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2143
- collectOperatorParams(op, params) {
2192
+ collectOperatorParams(column, op, params) {
2193
+ if (op.equals !== undefined && op.equals !== null) {
2194
+ assertBindableEqualsOperand(op.equals, `"${column}"`);
2195
+ params.push(op.equals);
2196
+ }
2144
2197
  if (op.gt !== undefined)
2145
2198
  params.push(op.gt);
2146
2199
  if (op.gte !== undefined)
@@ -2296,7 +2349,7 @@ class QueryInterface {
2296
2349
  const meta = this.schema.tables[table ?? this.table];
2297
2350
  if (!meta)
2298
2351
  return;
2299
- for (const [relName, relSpec] of Object.entries(withClause)) {
2352
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
2300
2353
  const relDef = meta.relations[relName];
2301
2354
  if (!relDef)
2302
2355
  continue;
@@ -2323,7 +2376,7 @@ class QueryInterface {
2323
2376
  params.push(Number(spec.limit));
2324
2377
  }
2325
2378
  if (spec.with) {
2326
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2379
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2327
2380
  const nestedRelDef = targetMeta.relations[nestedRelName];
2328
2381
  if (!nestedRelDef)
2329
2382
  continue;
@@ -2337,7 +2390,7 @@ class QueryInterface {
2337
2390
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2338
2391
  // Non-wrapped path: nested relations BEFORE where/limit
2339
2392
  if (!willWrap && spec.with) {
2340
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2393
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2341
2394
  const nestedRelDef = targetMeta.relations[nestedRelName];
2342
2395
  if (!nestedRelDef)
2343
2396
  continue;
@@ -2357,7 +2410,7 @@ class QueryInterface {
2357
2410
  }
2358
2411
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
2359
2412
  if (willWrap && spec.with) {
2360
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2413
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2361
2414
  const nestedRelDef = targetMeta.relations[nestedRelName];
2362
2415
  if (!nestedRelDef)
2363
2416
  continue;
@@ -2439,7 +2492,8 @@ class QueryInterface {
2439
2492
  * Supports: equality, operators, NULL, OR, AND, NOT, relation filters (some/every/none).
2440
2493
  */
2441
2494
  buildWhereClause(where, params) {
2442
- const keys = Object.keys(where);
2495
+ // Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
2496
+ const keys = sortedKeys(where);
2443
2497
  if (keys.length === 0)
2444
2498
  return null;
2445
2499
  const andClauses = [];
@@ -2646,7 +2700,9 @@ class QueryInterface {
2646
2700
  return null;
2647
2701
  const qt = this.q(targetTable);
2648
2702
  const conditions = [];
2649
- for (const [field, value] of Object.entries(subWhere)) {
2703
+ // Sorted (canonical) order MUST match fingerprintRelFilter and collectRelFilterParams.
2704
+ for (const field of sortedKeys(subWhere)) {
2705
+ const value = subWhere[field];
2650
2706
  if (value === undefined)
2651
2707
  continue;
2652
2708
  const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
@@ -2711,7 +2767,9 @@ class QueryInterface {
2711
2767
  */
2712
2768
  buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2713
2769
  const clauses = [];
2714
- for (const [key, value] of Object.entries(where)) {
2770
+ // Sorted (canonical) order MUST match fingerprintAliasWhere and collectAliasWhereParams.
2771
+ for (const key of sortedKeys(where)) {
2772
+ const value = where[key];
2715
2773
  if (value === undefined)
2716
2774
  continue;
2717
2775
  if (key === 'OR' || key === 'AND') {
@@ -2753,7 +2811,9 @@ class QueryInterface {
2753
2811
  }
2754
2812
  /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2755
2813
  collectAliasWhereParams(targetTable, targetMeta, where, params) {
2756
- for (const [key, value] of Object.entries(where)) {
2814
+ // Sorted (canonical) order MUST match fingerprintAliasWhere and buildAliasWhere.
2815
+ for (const key of sortedKeys(where)) {
2816
+ const value = where[key];
2757
2817
  if (value === undefined)
2758
2818
  continue;
2759
2819
  if (key === 'OR' || key === 'AND') {
@@ -2771,11 +2831,11 @@ class QueryInterface {
2771
2831
  }
2772
2832
  if (value === null)
2773
2833
  continue;
2834
+ const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2774
2835
  if (isWhereOperator(value)) {
2775
- this.collectOperatorParams(value, params);
2836
+ this.collectOperatorParams(col, value, params);
2776
2837
  continue;
2777
2838
  }
2778
- const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2779
2839
  this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2780
2840
  params.push(value);
2781
2841
  }
@@ -2809,11 +2869,7 @@ class QueryInterface {
2809
2869
  continue;
2810
2870
  }
2811
2871
  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' : ''})`);
2872
+ parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2817
2873
  continue;
2818
2874
  }
2819
2875
  if (isUnmatchedPlainObject(value)) {
@@ -2832,6 +2888,16 @@ class QueryInterface {
2832
2888
  */
2833
2889
  buildOperatorClauses(column, op, params) {
2834
2890
  const clauses = [];
2891
+ if (op.equals !== undefined) {
2892
+ if (op.equals === null) {
2893
+ clauses.push(`${column} IS NULL`);
2894
+ }
2895
+ else {
2896
+ assertBindableEqualsOperand(op.equals, column);
2897
+ params.push(op.equals);
2898
+ clauses.push(`${column} = ${this.p(params.length)}`);
2899
+ }
2900
+ }
2835
2901
  if (op.gt !== undefined) {
2836
2902
  params.push(op.gt);
2837
2903
  clauses.push(`${column} > ${this.p(params.length)}`);
@@ -3130,7 +3196,7 @@ class QueryInterface {
3130
3196
  const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
3131
3197
  const relationSelects = [];
3132
3198
  const aliasCounter = { n: 0 };
3133
- for (const [relName, relSpec] of Object.entries(withClause)) {
3199
+ for (const [relName, relSpec] of sortedEntries(withClause)) {
3134
3200
  const relDef = meta.relations[relName];
3135
3201
  if (!relDef) {
3136
3202
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3285,7 +3351,7 @@ class QueryInterface {
3285
3351
  }
3286
3352
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
3287
3353
  if (!willWrap && spec !== true && spec.with) {
3288
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3354
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3289
3355
  const nestedRelDef = targetMeta.relations[nestedRelName];
3290
3356
  if (!nestedRelDef) {
3291
3357
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3361,7 +3427,7 @@ class QueryInterface {
3361
3427
  ]);
3362
3428
  // Build nested relation subqueries referencing innerAlias
3363
3429
  if (spec !== true && spec.with) {
3364
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3430
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3365
3431
  const nestedRelDef = targetMeta.relations[nestedRelName];
3366
3432
  if (!nestedRelDef) {
3367
3433
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3479,7 +3545,7 @@ class QueryInterface {
3479
3545
  ]);
3480
3546
  // Nested relations reference the inner alias.
3481
3547
  if (spec !== true && spec.with) {
3482
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3548
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3483
3549
  const nestedRelDef = targetMeta.relations[nestedRelName];
3484
3550
  if (!nestedRelDef) {
3485
3551
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -3502,7 +3568,7 @@ class QueryInterface {
3502
3568
  `${talias}.${this.q(col)}`,
3503
3569
  ]);
3504
3570
  if (spec !== true && spec.with) {
3505
- for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3571
+ for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
3506
3572
  const nestedRelDef = targetMeta.relations[nestedRelName];
3507
3573
  if (!nestedRelDef) {
3508
3574
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -109,6 +109,7 @@ function sqlToPreparedName(sql) {
109
109
  }
110
110
  /** Known operator keys — used to detect operator objects vs plain values */
111
111
  exports.OPERATOR_KEYS = new Set([
112
+ 'equals',
112
113
  'gt',
113
114
  'gte',
114
115
  'lt',
package/dist/cli/index.js CHANGED
@@ -1129,13 +1129,15 @@ function showMigrateHelp() {
1129
1129
  newline();
1130
1130
  console.log(` ${bold('Options:')}`);
1131
1131
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
1132
+ console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create only)')}`);
1132
1133
  console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
1133
- console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1134
- console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
1135
- console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1134
+ console.log(` ${cyan('--dry-run')} Show SQL without executing`);
1135
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation ${dim('(migrate up only — advanced)')}`);
1136
+ console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1136
1137
  newline();
1137
1138
  console.log(` ${bold('Examples:')}`);
1138
1139
  console.log(` ${dim('$')} npx turbine migrate create add_users_table`);
1140
+ console.log(` ${dim('$')} npx turbine migrate create add_email_index --auto`);
1139
1141
  console.log(` ${dim('$')} npx turbine migrate up`);
1140
1142
  console.log(` ${dim('$')} npx turbine migrate down --step 2`);
1141
1143
  console.log(` ${dim('$')} npx turbine migrate status`);
@@ -1180,16 +1182,17 @@ function showHelp() {
1180
1182
  newline();
1181
1183
  console.log(` ${bold('Commands:')}`);
1182
1184
  console.log(` ${cyan('init')} Initialize a Turbine project`);
1183
- console.log(` ${cyan('generate')} ${dim('| pull')} Introspect database ${symbols.arrow} generate types`);
1185
+ console.log(` ${cyan('generate')} ${dim('| pull')} Introspect database ${symbols.arrow} generate types`);
1184
1186
  console.log(` ${cyan('push')} Apply schema definitions to database`);
1185
- console.log(` ${cyan('migrate')} ${dim('<sub>')} SQL migration management`);
1186
- console.log(` ${dim('create <name>')} Create a new migration file`);
1187
+ console.log(` ${cyan('migrate')} ${dim('<sub>')} SQL migration management`);
1188
+ console.log(` ${dim('create <name>')} Create a new migration file`);
1187
1189
  console.log(` ${dim('up')} Apply pending migrations`);
1188
1190
  console.log(` ${dim('down')} Rollback last migration`);
1189
1191
  console.log(` ${dim('status')} Show applied/pending migrations`);
1190
1192
  console.log(` ${cyan('seed')} Run seed file`);
1191
- console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1193
+ console.log(` ${cyan('status')} ${dim('| info')} Show schema summary`);
1192
1194
  console.log(` ${cyan('studio')} Launch local read-only web UI`);
1195
+ console.log(` ${cyan('observe')} Launch metrics dashboard ${dim('(requires TURBINE_OBSERVE_URL)')}`);
1193
1196
  newline();
1194
1197
  console.log(` ${bold('Options:')}`);
1195
1198
  console.log(` ${cyan('--url, -u')} ${dim('<url>')} Postgres connection string`);
@@ -1201,8 +1204,13 @@ function showHelp() {
1201
1204
  console.log(` ${cyan('--verbose, -v')} Show detailed output`);
1202
1205
  console.log(` ${cyan('--force, -f')} Overwrite existing files`);
1203
1206
  newline();
1204
- console.log(` ${bold('Studio options:')}`);
1205
- console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983)')}`);
1207
+ console.log(` ${bold('Migrate options:')}`);
1208
+ console.log(` ${cyan('--auto')} Auto-generate UP/DOWN SQL from schema diff ${dim('(create)')}`);
1209
+ console.log(` ${cyan('--step, -n')} ${dim('<N>')} Number of migrations to apply/rollback`);
1210
+ console.log(` ${cyan('--allow-drift')} Bypass checksum validation on ${cyan('migrate up')} ${dim('(advanced)')}`);
1211
+ newline();
1212
+ console.log(` ${bold('Studio / observe options:')}`);
1213
+ console.log(` ${cyan('--port')} ${dim('<n>')} HTTP port ${dim('(default: 4983 studio, 4984 observe)')}`);
1206
1214
  console.log(` ${cyan('--host')} ${dim('<addr>')} Bind address ${dim('(default: 127.0.0.1)')}`);
1207
1215
  console.log(` ${cyan('--no-open')} Don't auto-open the browser`);
1208
1216
  newline();
@@ -113,8 +113,8 @@ export declare function deriveLockId(databaseName: string): number;
113
113
  */
114
114
  export declare function migrateUp(connectionString: string, migrationsDir: string, options?: {
115
115
  step?: number;
116
- allowDrift?: boolean /** @deprecated use allowDrift */;
117
- force?: boolean;
116
+ allowDrift?: boolean;
117
+ force?: boolean /** @deprecated use allowDrift */;
118
118
  adapter?: DatabaseAdapter;
119
119
  dialect?: Dialect;
120
120
  }): Promise<{