turbine-orm 0.19.0 → 0.19.1

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.
@@ -66,6 +66,20 @@ function isWhereOperator(value) {
66
66
  const keys = Object.keys(value);
67
67
  return keys.length > 0 && keys.every((k) => utils_js_1.OPERATOR_KEYS.has(k));
68
68
  }
69
+ /**
70
+ * True for a *plain object literal* that reached an equality fallthrough
71
+ * without matching any known filter shape — the misspelled-operator case.
72
+ * Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
73
+ * bind values and return false, as do arrays and Dates.
74
+ */
75
+ function isUnmatchedPlainObject(value) {
76
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
77
+ return false;
78
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
79
+ return false;
80
+ const proto = Object.getPrototypeOf(value);
81
+ return proto === Object.prototype || proto === null;
82
+ }
69
83
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
70
84
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
71
85
  /** Known JSONB operator keys */
@@ -1944,6 +1958,16 @@ class QueryInterface {
1944
1958
  parts.push(`${key}:fts(${cfg})`);
1945
1959
  continue;
1946
1960
  }
1961
+ // Plain object literal that matched no filter shape — give it a
1962
+ // fingerprint distinct from real equality. The build path throws for
1963
+ // these on non-JSON columns; sharing `key:eq` would let a cache entry
1964
+ // warmed by genuine equality serve the bad filter silently.
1965
+ if (isUnmatchedPlainObject(value)) {
1966
+ parts.push(`${key}:obj(${Object.keys(value)
1967
+ .sort()
1968
+ .join(',')})`);
1969
+ continue;
1970
+ }
1947
1971
  // Plain equality
1948
1972
  parts.push(`${key}:eq`);
1949
1973
  }
@@ -1983,6 +2007,11 @@ class QueryInterface {
1983
2007
  const modeStr = mode === 'insensitive' ? ':i' : '';
1984
2008
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1985
2009
  }
2010
+ else if (isUnmatchedPlainObject(value)) {
2011
+ parts.push(`${key}:obj(${Object.keys(value)
2012
+ .sort()
2013
+ .join(',')})`);
2014
+ }
1986
2015
  else {
1987
2016
  parts.push(`${key}:eq`);
1988
2017
  }
@@ -2085,7 +2114,9 @@ class QueryInterface {
2085
2114
  this.collectOperatorParams(value, params);
2086
2115
  continue;
2087
2116
  }
2088
- // Plain equality
2117
+ // Plain equality — same strict validation as the build path, so a
2118
+ // cache hit can never silently bind a misspelled-operator object.
2119
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2089
2120
  params.push(value);
2090
2121
  }
2091
2122
  }
@@ -2094,7 +2125,7 @@ class QueryInterface {
2094
2125
  const meta = this.schema.tables[targetTable];
2095
2126
  if (!meta)
2096
2127
  return;
2097
- for (const [_field, value] of Object.entries(subWhere)) {
2128
+ for (const [field, value] of Object.entries(subWhere)) {
2098
2129
  if (value === undefined)
2099
2130
  continue;
2100
2131
  if (value === null)
@@ -2103,6 +2134,8 @@ class QueryInterface {
2103
2134
  this.collectOperatorParams(value, params);
2104
2135
  continue;
2105
2136
  }
2137
+ const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2138
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2106
2139
  params.push(value);
2107
2140
  }
2108
2141
  }
@@ -2230,13 +2263,11 @@ class QueryInterface {
2230
2263
  .sort();
2231
2264
  subParts.push(`om=${omKeys.join(',')}`);
2232
2265
  }
2233
- // where shape (value-invariant)
2266
+ // where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
2267
+ // `{title: {contains: 'x'}}` emit different SQL so they must not share
2268
+ // a fingerprint)
2234
2269
  if (opts.where) {
2235
- // Use a target-table QI if possible, or a simplified fingerprint
2236
- const wKeys = Object.keys(opts.where)
2237
- .filter((k) => opts.where[k] !== undefined)
2238
- .sort();
2239
- subParts.push(`w=${wKeys.join(',')}`);
2270
+ subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
2240
2271
  }
2241
2272
  // orderBy shape
2242
2273
  if (opts.orderBy) {
@@ -2286,11 +2317,9 @@ class QueryInterface {
2286
2317
  // where params → limit param → nested-with params (always, both paths).
2287
2318
  if (relDef.type === 'manyToMany') {
2288
2319
  if (spec.where) {
2289
- for (const [, v] of Object.entries(spec.where)) {
2290
- params.push(v);
2291
- }
2320
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2292
2321
  }
2293
- if (spec.limit) {
2322
+ if (spec.limit !== undefined) {
2294
2323
  params.push(Number(spec.limit));
2295
2324
  }
2296
2325
  if (spec.with) {
@@ -2303,7 +2332,9 @@ class QueryInterface {
2303
2332
  }
2304
2333
  return;
2305
2334
  }
2306
- const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2335
+ // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2336
+ const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2337
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2307
2338
  // Non-wrapped path: nested relations BEFORE where/limit
2308
2339
  if (!willWrap && spec.with) {
2309
2340
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2313,16 +2344,15 @@ class QueryInterface {
2313
2344
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2314
2345
  }
2315
2346
  }
2316
- // where params
2347
+ // where params — mirrors buildAliasWhere push order
2317
2348
  if (spec.where) {
2318
- for (const [, v] of Object.entries(spec.where)) {
2319
- params.push(v);
2320
- }
2349
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2321
2350
  }
2322
2351
  // limit param — only hasMany parameterizes its limit (mirrors
2323
2352
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2324
2353
  // pushing one here would orphan a param and desync the collect path.
2325
- if (relDef.type === 'hasMany' && spec.limit) {
2354
+ // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2355
+ if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2326
2356
  params.push(Number(spec.limit));
2327
2357
  }
2328
2358
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2530,25 +2560,11 @@ class QueryInterface {
2530
2560
  andClauses.push(...opClauses);
2531
2561
  continue;
2532
2562
  }
2533
- // Strict validation: a plain (non-array, non-Date) object on a non-JSON
2534
- // column matched no known filter shape almost always a misspelled
2535
- // operator (`startWith` for `startsWith`) or a stray nested object.
2536
- // Silently treating it as `col = $1` returns wrong rows with no error, so
2537
- // throw with the offending keys and the supported operator list. JSON/JSONB
2538
- // columns legitimately accept object values for equality, so they fall
2539
- // through unchanged.
2540
- if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
2541
- const colType = this.getColumnPgType(rawColumn);
2542
- if (colType !== 'json' && colType !== 'jsonb') {
2543
- const badKeys = Object.keys(value);
2544
- throw new errors_js_1.ValidationError(badKeys.length === 0
2545
- ? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
2546
- `Provide a value or an operator like { gt: 1 }.`
2547
- : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2548
- `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
2549
- `Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
2550
- }
2551
- }
2563
+ // Strict validation: a plain object literal that matched no known filter
2564
+ // shape is almost always a misspelled operator (`startWith` for
2565
+ // `startsWith`). The guard also runs on the cache-hit param-collect path
2566
+ // (collectWhereParams) so a warmed SQL cache can never skip it.
2567
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2552
2568
  // Plain equality
2553
2569
  params.push(value);
2554
2570
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2648,11 +2664,168 @@ class QueryInterface {
2648
2664
  conditions.push(...opClauses);
2649
2665
  continue;
2650
2666
  }
2667
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2651
2668
  params.push(value);
2652
2669
  conditions.push(`${qCol} = ${this.p(params.length)}`);
2653
2670
  }
2654
2671
  return conditions.length > 0 ? conditions.join(' AND ') : null;
2655
2672
  }
2673
+ /**
2674
+ * Resolve a column's Postgres type from an arbitrary table's metadata
2675
+ * (relation targets, not just `this.table`).
2676
+ */
2677
+ pgTypeForColumn(meta, column) {
2678
+ return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
2679
+ }
2680
+ /**
2681
+ * Equality-fallthrough guard shared by every SQL-build path AND every
2682
+ * cache-hit param-collect path. A plain object literal that matched no known
2683
+ * filter shape on a non-JSON column is almost always a misspelled operator
2684
+ * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
2685
+ * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
2686
+ * legitimate bind values and pass through, as do objects on json/jsonb
2687
+ * columns (object equality).
2688
+ */
2689
+ assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
2690
+ if (!isUnmatchedPlainObject(value))
2691
+ return;
2692
+ if (columnPgType === 'json' || columnPgType === 'jsonb')
2693
+ return;
2694
+ const badKeys = Object.keys(value);
2695
+ throw new errors_js_1.ValidationError(badKeys.length === 0
2696
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
2697
+ `Provide a value or an operator like { gt: 1 }.`
2698
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2699
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
2700
+ `Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
2701
+ }
2702
+ /**
2703
+ * Build the user-supplied `where` filter of a relation `with` clause against
2704
+ * the relation's table alias. Supports the same scalar surface as the
2705
+ * top-level WHERE builder — equality, IS NULL, operator objects (incl.
2706
+ * `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
2707
+ * objects throw via {@link assertBindableEqualityValue}.
2708
+ *
2709
+ * Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
2710
+ * cache hits and pipeline batching will desync.
2711
+ */
2712
+ buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2713
+ const clauses = [];
2714
+ for (const [key, value] of Object.entries(where)) {
2715
+ if (value === undefined)
2716
+ continue;
2717
+ if (key === 'OR' || key === 'AND') {
2718
+ const arr = value;
2719
+ if (!Array.isArray(arr) || arr.length === 0)
2720
+ continue;
2721
+ const subs = arr
2722
+ .map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
2723
+ .filter((s) => s !== null)
2724
+ .map((s) => `(${s})`);
2725
+ if (subs.length > 0)
2726
+ clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
2727
+ continue;
2728
+ }
2729
+ if (key === 'NOT') {
2730
+ const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
2731
+ if (sub)
2732
+ clauses.push(`NOT (${sub})`);
2733
+ continue;
2734
+ }
2735
+ const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2736
+ if (!targetMeta.allColumns.includes(col)) {
2737
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
2738
+ }
2739
+ const qCol = `${alias}.${this.q(col)}`;
2740
+ if (value === null) {
2741
+ clauses.push(`${qCol} IS NULL`);
2742
+ continue;
2743
+ }
2744
+ if (isWhereOperator(value)) {
2745
+ clauses.push(...this.buildOperatorClauses(qCol, value, params));
2746
+ continue;
2747
+ }
2748
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2749
+ params.push(value);
2750
+ clauses.push(`${qCol} = ${this.p(params.length)}`);
2751
+ }
2752
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
2753
+ }
2754
+ /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2755
+ collectAliasWhereParams(targetTable, targetMeta, where, params) {
2756
+ for (const [key, value] of Object.entries(where)) {
2757
+ if (value === undefined)
2758
+ continue;
2759
+ if (key === 'OR' || key === 'AND') {
2760
+ const arr = value;
2761
+ if (!Array.isArray(arr) || arr.length === 0)
2762
+ continue;
2763
+ for (const cond of arr) {
2764
+ this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
2765
+ }
2766
+ continue;
2767
+ }
2768
+ if (key === 'NOT') {
2769
+ this.collectAliasWhereParams(targetTable, targetMeta, value, params);
2770
+ continue;
2771
+ }
2772
+ if (value === null)
2773
+ continue;
2774
+ if (isWhereOperator(value)) {
2775
+ this.collectOperatorParams(value, params);
2776
+ continue;
2777
+ }
2778
+ const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2779
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2780
+ params.push(value);
2781
+ }
2782
+ }
2783
+ /**
2784
+ * Value-invariant, shape-aware fingerprint for a relation `with` clause's
2785
+ * `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
2786
+ * can emit — equality vs null vs operator sets vs combinators — or two
2787
+ * differently-shaped wheres would share one cached SQL string.
2788
+ */
2789
+ fingerprintAliasWhere(where) {
2790
+ const keys = Object.keys(where)
2791
+ .filter((k) => where[k] !== undefined)
2792
+ .sort();
2793
+ const parts = [];
2794
+ for (const key of keys) {
2795
+ const value = where[key];
2796
+ if (key === 'OR' || key === 'AND') {
2797
+ const arr = value;
2798
+ if (!Array.isArray(arr) || arr.length === 0)
2799
+ continue;
2800
+ parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
2801
+ continue;
2802
+ }
2803
+ if (key === 'NOT') {
2804
+ parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
2805
+ continue;
2806
+ }
2807
+ if (value === null) {
2808
+ parts.push(`${key}:null`);
2809
+ continue;
2810
+ }
2811
+ 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' : ''})`);
2817
+ continue;
2818
+ }
2819
+ if (isUnmatchedPlainObject(value)) {
2820
+ parts.push(`${key}:obj(${Object.keys(value)
2821
+ .sort()
2822
+ .join(',')})`);
2823
+ continue;
2824
+ }
2825
+ parts.push(`${key}:eq`);
2826
+ }
2827
+ return parts.join('&');
2828
+ }
2656
2829
  /**
2657
2830
  * Build SQL clauses for a single operator object on a column.
2658
2831
  * Each operator key becomes its own clause, all ANDed together.
@@ -3099,7 +3272,11 @@ class QueryInterface {
3099
3272
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
3100
3273
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
3101
3274
  // so we must NOT build them here (they would push orphaned params).
3102
- const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3275
+ // An orderBy with no defined entries (`orderBy: {}`) is treated as absent
3276
+ // it must neither trigger the wrap (dropping nested relations) nor render a
3277
+ // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
3278
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3279
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
3103
3280
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3104
3281
  // where, orderBy, and select/omit are handled there (the target alias is the
3105
3282
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -3127,14 +3304,14 @@ class QueryInterface {
3127
3304
  const qTarget = this.q(targetTable);
3128
3305
  // Build ORDER BY for json_agg
3129
3306
  let orderClause = '';
3130
- if (spec !== true && spec.orderBy) {
3131
- const orders = Object.entries(spec.orderBy)
3307
+ if (relOrderEntries.length > 0) {
3308
+ const orders = relOrderEntries
3132
3309
  .map(([k, dir]) => {
3133
3310
  const col = (0, schema_js_1.camelToSnake)(k);
3134
3311
  if (!targetMeta.allColumns.includes(col)) {
3135
3312
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3136
3313
  }
3137
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3314
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3138
3315
  return `${alias}.${this.q(col)} ${safeDir}`;
3139
3316
  })
3140
3317
  .join(', ');
@@ -3151,24 +3328,21 @@ class QueryInterface {
3151
3328
  else {
3152
3329
  whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3153
3330
  }
3154
- // Additional filters — properly parameterized
3331
+ // Additional filters — full scalar where surface (equality, null, operator
3332
+ // objects, OR/AND/NOT), properly parameterized against this alias.
3155
3333
  if (spec !== true && spec.where) {
3156
- for (const [k, v] of Object.entries(spec.where)) {
3157
- const col = (0, schema_js_1.camelToSnake)(k);
3158
- if (!targetMeta.allColumns.includes(col)) {
3159
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3160
- }
3161
- params.push(v);
3162
- whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
3163
- }
3334
+ const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
3335
+ if (extra)
3336
+ whereClause += ` AND ${extra}`;
3164
3337
  }
3165
3338
  // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3166
3339
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3167
3340
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3168
3341
  // which Postgres rejects with "could not determine data type of parameter $N"
3169
3342
  // (and shifts every later placeholder by one). To-one relations ignore limit.
3343
+ // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3170
3344
  let limitClause = '';
3171
- if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
3345
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3172
3346
  params.push(Number(spec.limit));
3173
3347
  limitClause = ` LIMIT ${this.p(params.length)}`;
3174
3348
  }
@@ -3262,35 +3436,33 @@ class QueryInterface {
3262
3436
  let whereClause = sourceKeys
3263
3437
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3264
3438
  .join(' AND ');
3265
- // ORDER BY on the target rows
3439
+ // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
3440
+ // treated as absent — it must not render a dangling `ORDER BY `.
3441
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3266
3442
  let orderClause = '';
3267
- if (spec !== true && spec.orderBy) {
3268
- const orders = Object.entries(spec.orderBy)
3443
+ if (relOrderEntries.length > 0) {
3444
+ const orders = relOrderEntries
3269
3445
  .map(([k, dir]) => {
3270
3446
  const col = (0, schema_js_1.camelToSnake)(k);
3271
3447
  if (!targetMeta.allColumns.includes(col)) {
3272
3448
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3273
3449
  }
3274
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3450
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3275
3451
  return `${talias}.${this.q(col)} ${safeDir}`;
3276
3452
  })
3277
3453
  .join(', ');
3278
3454
  orderClause = ` ORDER BY ${orders}`;
3279
3455
  }
3280
- // Additional WHERE filters on the target — properly parameterized.
3456
+ // Additional WHERE filters on the target — full scalar where surface,
3457
+ // properly parameterized against the target alias.
3281
3458
  if (spec !== true && spec.where) {
3282
- for (const [k, v] of Object.entries(spec.where)) {
3283
- const col = (0, schema_js_1.camelToSnake)(k);
3284
- if (!targetMeta.allColumns.includes(col)) {
3285
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3286
- }
3287
- params.push(v);
3288
- whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3289
- }
3459
+ const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
3460
+ if (extra)
3461
+ whereClause += ` AND ${extra}`;
3290
3462
  }
3291
- // LIMIT
3463
+ // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3292
3464
  let limitClause = '';
3293
- if (spec !== true && spec.limit) {
3465
+ if (spec !== true && spec.limit !== undefined) {
3294
3466
  params.push(Number(spec.limit));
3295
3467
  limitClause = ` LIMIT ${this.p(params.length)}`;
3296
3468
  }
package/dist/cli/index.js CHANGED
@@ -20,14 +20,14 @@
20
20
  * npx turbine init --url postgres://...
21
21
  * npx turbine migrate create add_users_table
22
22
  */
23
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
24
24
  import { dirname, relative, resolve } from 'node:path';
25
25
  import { pathToFileURL } from 'node:url';
26
26
  import { generate } from '../generate.js';
27
27
  import { introspect } from '../introspect.js';
28
28
  import { schemaDiff, schemaPush } from '../schema-sql.js';
29
29
  import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
30
- import { needsTsLoader, registerTsLoader } from './loader.js';
30
+ import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
31
31
  import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
32
32
  import { startObserve } from './observe.js';
33
33
  import { startStudio } from './studio.js';
@@ -132,6 +132,15 @@ function failMissingTsLoader(filePath, reason) {
132
132
  console.log(` ${dim('Your Node.js version does not support')} ${cyan('module.register()')}.`);
133
133
  console.log(` ${dim('Upgrade to Node.js')} ${cyan('20.6+')} ${dim('or use a')} ${cyan('.js')} ${dim('/')} ${cyan('.mjs')} ${dim('config file.')}`);
134
134
  }
135
+ else if (reason === 'failed') {
136
+ // tsx IS installed but registering its loader threw. Report the real
137
+ // cause — telling the user to install tsx here would be a misdiagnosis.
138
+ console.log(` ${dim('tsx is installed, but registering its TypeScript loader failed:')}`);
139
+ newline();
140
+ console.log(` ${getTsLoaderError() ?? '(unknown error)'}`);
141
+ newline();
142
+ console.log(` ${dim('Try upgrading tsx:')} ${cyan('npm install --save-dev tsx@latest')}${dim(', or rename your file to')} ${cyan('.mjs')}.`);
143
+ }
135
144
  else {
136
145
  console.log(` ${dim('Loading .ts config / schema files requires')} ${cyan('tsx')} ${dim('to be installed.')}`);
137
146
  newline();
@@ -175,7 +184,7 @@ async function loadSchemaFile(schemaFile) {
175
184
  // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
176
185
  if (needsTsLoader(absPath)) {
177
186
  const status = await registerTsLoader();
178
- if (status === 'missing' || status === 'unsupported') {
187
+ if (status === 'missing' || status === 'unsupported' || status === 'failed') {
179
188
  failMissingTsLoader(schemaFile, status);
180
189
  }
181
190
  }
@@ -311,7 +320,7 @@ export default defineSchema({
311
320
  // id: { type: 'serial', primaryKey: true },
312
321
  // email: { type: 'text', notNull: true, unique: true },
313
322
  // name: { type: 'text', notNull: true },
314
- // created_at: { type: 'timestamptz', default: 'NOW()' },
323
+ // created_at: { type: 'timestamp', default: 'NOW()' },
315
324
  // },
316
325
  });
317
326
  `, 'utf-8');
@@ -374,6 +383,9 @@ export default defineSchema({
374
383
  console.log(` ${dim('or create a')} ${cyan('.env')} ${dim('file with')} ${cyan('DATABASE_URL=postgres://...')}`);
375
384
  }
376
385
  console.log(` ${dim('2.')} Run ${cyan('npx turbine generate')} to introspect your DB`);
386
+ if (!canResolveTsx()) {
387
+ console.log(` ${dim('Note: the TypeScript config requires')} ${cyan('tsx')} ${dim('—')} ${cyan('npm install --save-dev tsx')}`);
388
+ }
377
389
  }
378
390
  else {
379
391
  console.log(` ${dim('1.')} Import the generated client:`);
@@ -1214,7 +1226,17 @@ function showVersion() {
1214
1226
  // Using process.argv[1] instead of import.meta.url so the same code compiles
1215
1227
  // cleanly for both the ESM and CJS builds.
1216
1228
  try {
1217
- let dir = dirname(process.argv[1] ?? '');
1229
+ // Resolve symlinks first: `npx turbine` runs via node_modules/.bin/turbine,
1230
+ // a symlink whose dirname would walk the CONSUMER's tree and never find
1231
+ // turbine-orm's package.json (printing no version number at all).
1232
+ let entry = process.argv[1] ?? '';
1233
+ try {
1234
+ entry = realpathSync(entry);
1235
+ }
1236
+ catch {
1237
+ // keep the raw path if realpath fails (e.g. deleted cwd)
1238
+ }
1239
+ let dir = dirname(entry);
1218
1240
  for (let i = 0; i < 6; i++) {
1219
1241
  const candidate = resolve(dir, 'package.json');
1220
1242
  if (existsSync(candidate)) {
@@ -1262,7 +1284,7 @@ async function main() {
1262
1284
  const configPath = findConfigFile();
1263
1285
  if (needsTsLoader(configPath)) {
1264
1286
  const status = await registerTsLoader();
1265
- if (status === 'missing' || status === 'unsupported') {
1287
+ if (status === 'missing' || status === 'unsupported' || status === 'failed') {
1266
1288
  failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
1267
1289
  }
1268
1290
  }
@@ -8,9 +8,18 @@
8
8
  *
9
9
  * Strategy:
10
10
  * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
11
- * probe whether `tsx/esm` is resolvable from the user's CWD.
12
- * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
13
- * 3. If no, surface an actionable error telling the user to install `tsx`.
11
+ * probe whether `tsx` is resolvable from the user's CWD.
12
+ * 2. Prefer tsx's supported programmatic API, `tsx/esm/api`'s `register()`.
13
+ * Calling Node's `module.register('tsx/esm', ...)` directly throws
14
+ * "tsx must be loaded with --import instead of --loader" on every Node
15
+ * version that has `module.register()` (>= 20.6) — tsx's hook file
16
+ * guards against being loaded that way. The `tsx/esm/api` entry point
17
+ * is the documented path and works everywhere `module.register()` does.
18
+ * 3. Fall back to `module.register('tsx/esm', ...)` only for very old tsx
19
+ * versions (< 4.0) that predate `tsx/esm/api`.
20
+ * 4. If tsx isn't installed, or registration genuinely fails, surface an
21
+ * actionable error — including the REAL underlying error message, never
22
+ * a misdiagnosed "tsx is not installed".
14
23
  *
15
24
  * `tsx` is intentionally NOT a runtime dependency — many projects already
16
25
  * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
@@ -28,7 +37,12 @@ export declare function needsTsLoader(filePath: string | null | undefined): bool
28
37
  * Accepts an injected `resolver` so unit tests don't need a real filesystem.
29
38
  */
30
39
  export declare function canResolveTsx(resolver?: (id: string) => string): boolean;
31
- export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing';
40
+ export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing' | 'failed';
41
+ /**
42
+ * The underlying error message from the last failed registration attempt,
43
+ * or null. Lets the CLI report the REAL cause instead of guessing.
44
+ */
45
+ export declare function getTsLoaderError(): string | null;
32
46
  /**
33
47
  * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
34
48
  * work. Safe to call multiple times — internal flag prevents double registration.
@@ -36,8 +50,11 @@ export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing
36
50
  * Returns:
37
51
  * - 'registered' loader was successfully registered this call
38
52
  * - 'already' a loader was previously registered (idempotent)
39
- * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
53
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6) and tsx has
54
+ * no programmatic API to fall back to
40
55
  * - 'missing' `tsx` is not installed in the user's project
56
+ * - 'failed' tsx IS installed but registration threw — see
57
+ * {@link getTsLoaderError} for the underlying message
41
58
  */
42
59
  export declare function registerTsLoader(): Promise<TsLoaderStatus>;
43
60
  /** Reset the loader state — used by unit tests only. */