turbine-orm 0.16.0 → 0.19.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 (43) hide show
  1. package/README.md +180 -12
  2. package/dist/adapters/cockroachdb.js +4 -2
  3. package/dist/adapters/index.js +4 -1
  4. package/dist/adapters/yugabytedb.js +4 -2
  5. package/dist/cjs/adapters/cockroachdb.js +4 -2
  6. package/dist/cjs/adapters/index.js +4 -1
  7. package/dist/cjs/adapters/yugabytedb.js +4 -2
  8. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  9. package/dist/cjs/cli/studio.js +35 -73
  10. package/dist/cjs/client.js +164 -0
  11. package/dist/cjs/errors.js +35 -5
  12. package/dist/cjs/generate.js +14 -3
  13. package/dist/cjs/index.js +10 -2
  14. package/dist/cjs/introspect.js +81 -0
  15. package/dist/cjs/nested-write.js +70 -6
  16. package/dist/cjs/query/builder.js +581 -17
  17. package/dist/cjs/realtime.js +147 -0
  18. package/dist/cjs/schema-builder.js +86 -0
  19. package/dist/cjs/schema.js +10 -0
  20. package/dist/cjs/typed-sql.js +149 -0
  21. package/dist/cli/studio-ui.generated.js +1 -1
  22. package/dist/cli/studio.js +35 -73
  23. package/dist/client.d.ts +120 -0
  24. package/dist/client.js +165 -1
  25. package/dist/errors.js +35 -5
  26. package/dist/generate.js +14 -3
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.js +5 -1
  29. package/dist/introspect.js +81 -0
  30. package/dist/nested-write.js +70 -6
  31. package/dist/query/builder.d.ts +104 -1
  32. package/dist/query/builder.js +582 -18
  33. package/dist/query/index.d.ts +1 -1
  34. package/dist/query/types.d.ts +126 -2
  35. package/dist/realtime.d.ts +71 -0
  36. package/dist/realtime.js +144 -0
  37. package/dist/schema-builder.d.ts +68 -1
  38. package/dist/schema-builder.js +85 -0
  39. package/dist/schema.d.ts +18 -1
  40. package/dist/schema.js +10 -0
  41. package/dist/typed-sql.d.ts +101 -0
  42. package/dist/typed-sql.js +145 -0
  43. package/package.json +17 -15
@@ -13,7 +13,7 @@
13
13
  import { postgresDialect } from '../dialect.js';
14
14
  import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, ValidationError, wrapPgError, } from '../errors.js';
15
15
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
16
- import { camelToSnake, snakeToCamel } from '../schema.js';
16
+ import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
17
17
  import { escapeLike, LRUCache, OPERATOR_KEYS, sqlToPreparedName } from './utils.js';
18
18
  // ---------------------------------------------------------------------------
19
19
  // Internal detection helpers — used by QueryInterface
@@ -120,6 +120,43 @@ function isTextSearchFilter(value) {
120
120
  function validateTextSearchConfig(config) {
121
121
  return /^[a-zA-Z0-9_]+$/.test(config);
122
122
  }
123
+ /**
124
+ * pgvector distance metric → operator allow-list. This is the ONLY mapping
125
+ * from a user-supplied metric token to a SQL operator; any token not present
126
+ * here is rejected, so a user value can never become an arbitrary operator.
127
+ *
128
+ * - `l2` → `<->` (Euclidean / L2 distance)
129
+ * - `cosine` → `<=>` (cosine distance)
130
+ * - `ip` → `<#>` (negative inner product)
131
+ */
132
+ const VECTOR_METRIC_OPERATORS = {
133
+ l2: '<->',
134
+ cosine: '<=>',
135
+ ip: '<#>',
136
+ };
137
+ /** Comparison keys allowed on a {@link VectorDistanceFilter}. */
138
+ const VECTOR_DISTANCE_COMPARATORS = {
139
+ lt: '<',
140
+ lte: '<=',
141
+ gt: '>',
142
+ gte: '>=',
143
+ };
144
+ /** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
145
+ function isVectorFilter(value) {
146
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
147
+ return false;
148
+ }
149
+ const dist = value.distance;
150
+ return (typeof dist === 'object' &&
151
+ dist !== null &&
152
+ !Array.isArray(dist) &&
153
+ 'to' in dist &&
154
+ 'metric' in dist);
155
+ }
156
+ /** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
157
+ function isVectorOrderBy(value) {
158
+ return isVectorFilter(value);
159
+ }
123
160
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
124
161
  export class QueryInterface {
125
162
  pool;
@@ -151,6 +188,14 @@ export class QueryInterface {
151
188
  columnArrayTypeMap;
152
189
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
153
190
  deepWithWarned = new Set();
191
+ /**
192
+ * Per-table memo of date columns keyed by their camelCase FIELD name.
193
+ * `meta.dateColumns` is keyed by raw snake_case column name, which matches
194
+ * top-level rows from pg. Nested relation rows arrive from json_build_object
195
+ * with camelCase keys, so they need this camelCase-keyed set to be coerced
196
+ * to Date as well (otherwise nested dates leak through as strings).
197
+ */
198
+ camelDateFieldCache = new Map();
154
199
  /** True when this QI runs inside an active transaction (set via _txScoped option). */
155
200
  txScoped;
156
201
  /** Original options reference — forwarded to child QIs in nested writes. */
@@ -493,7 +538,16 @@ export class QueryInterface {
493
538
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
494
539
  const orderFp = args?.orderBy
495
540
  ? Object.entries(args.orderBy)
496
- .map(([k, d]) => `${k}:${d}`)
541
+ .map(([k, d]) => {
542
+ // Vector KNN ordering changes the emitted SQL operator by metric and
543
+ // adds a `::vector` param, so the metric + direction must be part of
544
+ // the cache key — otherwise two KNN queries differing only in metric
545
+ // would collide on a single cached SQL string.
546
+ if (isVectorOrderBy(d)) {
547
+ return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
548
+ }
549
+ return `${k}:${d}`;
550
+ })
497
551
  .join(',')
498
552
  : '';
499
553
  const cursorFp = args?.cursor
@@ -553,7 +607,9 @@ export class QueryInterface {
553
607
  }
554
608
  }
555
609
  if (args?.orderBy) {
556
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
610
+ // Pass freshParams so vector KNN ordering binds its `$n::vector` query
611
+ // vector at the correct position (after cursor params, before LIMIT).
612
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
557
613
  }
558
614
  if (effectiveLimit !== undefined) {
559
615
  freshParams.push(Number(effectiveLimit));
@@ -581,11 +637,16 @@ export class QueryInterface {
581
637
  params.push(v);
582
638
  }
583
639
  }
584
- // 4. LIMIT param
640
+ // 4. ORDER BY params (vector KNN ordering binds a `$n::vector` query vector).
641
+ // Mirrors buildOrderBy's push order — between cursor and LIMIT.
642
+ if (args?.orderBy) {
643
+ this.collectOrderByParams(args.orderBy, params);
644
+ }
645
+ // 5. LIMIT param
585
646
  if (effectiveLimit !== undefined) {
586
647
  params.push(Number(effectiveLimit));
587
648
  }
588
- // 5. OFFSET param
649
+ // 6. OFFSET param
589
650
  if (args?.offset !== undefined) {
590
651
  params.push(Number(args.offset));
591
652
  }
@@ -1276,6 +1337,15 @@ export class QueryInterface {
1276
1337
  }
1277
1338
  }
1278
1339
  let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1340
+ // HAVING — filter whole groups by their aggregate values.
1341
+ // Appends to the same `params` array, so placeholders continue from the
1342
+ // WHERE clause's parameter positions (this.p(params.length) below).
1343
+ if (args.having) {
1344
+ const havingClauses = this.buildHavingClauses(args.having, params);
1345
+ if (havingClauses.length > 0) {
1346
+ sql += ` HAVING ${havingClauses.join(' AND ')}`;
1347
+ }
1348
+ }
1279
1349
  // ORDER BY
1280
1350
  if (args.orderBy) {
1281
1351
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
@@ -1343,6 +1413,117 @@ export class QueryInterface {
1343
1413
  tag: `${this.table}.groupBy`,
1344
1414
  };
1345
1415
  }
1416
+ /**
1417
+ * Build the SQL fragments for a {@link HavingClause}.
1418
+ *
1419
+ * Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
1420
+ * from a **schema-validated, quoted** column identifier — `this.toColumn()`
1421
+ * throws {@link ValidationError} for unknown fields and `this.q()` quotes via
1422
+ * the dialect, so no unvalidated identifier ever reaches the SQL string. Every
1423
+ * comparison value is pushed onto the shared `params` array and referenced by
1424
+ * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
1425
+ * interpolation of user values.
1426
+ */
1427
+ buildHavingClauses(having, params) {
1428
+ const clauses = [];
1429
+ // Maps the per-field aggregate key to its SQL function name. The set of
1430
+ // allowed keys is fixed here — any other key on a field's filter object is
1431
+ // rejected by ValidationError below (never interpolated).
1432
+ const aggFnByKey = {
1433
+ _sum: 'SUM',
1434
+ _avg: 'AVG',
1435
+ _min: 'MIN',
1436
+ _max: 'MAX',
1437
+ _count: 'COUNT',
1438
+ };
1439
+ for (const [key, value] of Object.entries(having)) {
1440
+ if (value === undefined)
1441
+ continue;
1442
+ // Top-level `_count` (no field) → COUNT(*) for the whole group.
1443
+ if (key === '_count') {
1444
+ clauses.push(...this.buildHavingNumericClauses('COUNT(*)', value, params));
1445
+ continue;
1446
+ }
1447
+ // Otherwise `key` is a field name mapping to a per-aggregate filter object.
1448
+ if (typeof value !== 'object' || value === null) {
1449
+ throw new ValidationError(`[turbine] Invalid having filter for field "${key}" on table "${this.table}": ` +
1450
+ `expected an aggregate object like { _sum: { gt: 100 } }.`);
1451
+ }
1452
+ // toColumn validates the field against schema metadata (throws
1453
+ // ValidationError on unknown columns) and q() quotes the identifier — no
1454
+ // unvalidated identifier ever reaches the SQL string.
1455
+ const quotedCol = this.q(this.toColumn(key));
1456
+ for (const [aggKey, filter] of Object.entries(value)) {
1457
+ if (filter === undefined)
1458
+ continue;
1459
+ const fn = aggFnByKey[aggKey];
1460
+ if (!fn) {
1461
+ throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
1462
+ `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
1463
+ }
1464
+ const expr = `${fn}(${quotedCol})`;
1465
+ clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
1466
+ }
1467
+ }
1468
+ return clauses;
1469
+ }
1470
+ /**
1471
+ * Convert a single having filter into one or more parameterized SQL
1472
+ * comparisons against the given aggregate expression. A bare number is
1473
+ * shorthand for equality. Unknown operator keys throw {@link ValidationError}.
1474
+ */
1475
+ buildHavingNumericClauses(expr, filter, params) {
1476
+ // Bare number → equality.
1477
+ if (typeof filter === 'number') {
1478
+ params.push(filter);
1479
+ return [`${expr} = ${this.p(params.length)}`];
1480
+ }
1481
+ if (typeof filter !== 'object' || filter === null) {
1482
+ throw new ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${this.table}": expected a number or operator object.`);
1483
+ }
1484
+ const op = filter;
1485
+ const allowedKeys = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn']);
1486
+ for (const k of Object.keys(op)) {
1487
+ if (!allowedKeys.has(k)) {
1488
+ throw new ValidationError(`[turbine] Unknown having operator "${k}" on "${expr}" for table "${this.table}". ` +
1489
+ `Supported: ${[...allowedKeys].join(', ')}.`);
1490
+ }
1491
+ }
1492
+ const clauses = [];
1493
+ if (op.equals !== undefined) {
1494
+ params.push(op.equals);
1495
+ clauses.push(`${expr} = ${this.p(params.length)}`);
1496
+ }
1497
+ if (op.not !== undefined) {
1498
+ params.push(op.not);
1499
+ clauses.push(`${expr} != ${this.p(params.length)}`);
1500
+ }
1501
+ if (op.gt !== undefined) {
1502
+ params.push(op.gt);
1503
+ clauses.push(`${expr} > ${this.p(params.length)}`);
1504
+ }
1505
+ if (op.gte !== undefined) {
1506
+ params.push(op.gte);
1507
+ clauses.push(`${expr} >= ${this.p(params.length)}`);
1508
+ }
1509
+ if (op.lt !== undefined) {
1510
+ params.push(op.lt);
1511
+ clauses.push(`${expr} < ${this.p(params.length)}`);
1512
+ }
1513
+ if (op.lte !== undefined) {
1514
+ params.push(op.lte);
1515
+ clauses.push(`${expr} <= ${this.p(params.length)}`);
1516
+ }
1517
+ if (op.in !== undefined) {
1518
+ params.push(op.in);
1519
+ clauses.push(`${expr} = ANY(${this.p(params.length)})`);
1520
+ }
1521
+ if (op.notIn !== undefined) {
1522
+ params.push(op.notIn);
1523
+ clauses.push(`${expr} != ALL(${this.p(params.length)})`);
1524
+ }
1525
+ return clauses;
1526
+ }
1346
1527
  // -------------------------------------------------------------------------
1347
1528
  // aggregate — standalone aggregation without groupBy
1348
1529
  // -------------------------------------------------------------------------
@@ -1506,12 +1687,24 @@ export class QueryInterface {
1506
1687
  */
1507
1688
  resolveColumns(select, omit) {
1508
1689
  if (select) {
1690
+ // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
1691
+ // style) instead of the object shape. Object.entries() would iterate the
1692
+ // numeric indices and throw a cryptic `Unknown field "0"` — catch it early
1693
+ // with an actionable message.
1694
+ if (Array.isArray(select)) {
1695
+ throw new ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
1696
+ `(e.g. { id: true, name: true }), not an array.`);
1697
+ }
1509
1698
  // Only include columns where value is true
1510
1699
  return Object.entries(select)
1511
1700
  .filter(([, v]) => v)
1512
1701
  .map(([k]) => this.toColumn(k));
1513
1702
  }
1514
1703
  if (omit) {
1704
+ if (Array.isArray(omit)) {
1705
+ throw new ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
1706
+ `(e.g. { createdAt: true }), not an array.`);
1707
+ }
1515
1708
  // Include all columns except those where value is true
1516
1709
  const omitCols = new Set(Object.entries(omit)
1517
1710
  .filter(([, v]) => v)
@@ -1687,6 +1880,17 @@ export class QueryInterface {
1687
1880
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1688
1881
  continue;
1689
1882
  }
1883
+ // Vector distance filter — metric (operator) and present comparators
1884
+ // change the SQL shape, so both go in the fingerprint.
1885
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
1886
+ const dist = value.distance;
1887
+ const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
1888
+ .filter((c) => dist[c] !== undefined)
1889
+ .sort()
1890
+ .join('|');
1891
+ parts.push(`${key}:vec(${dist.metric},${cmps})`);
1892
+ continue;
1893
+ }
1690
1894
  // JSON filter
1691
1895
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1692
1896
  const jKeys = Object.keys(value).sort();
@@ -1811,6 +2015,14 @@ export class QueryInterface {
1811
2015
  if (value === null)
1812
2016
  continue;
1813
2017
  const rawColumn = this.toColumn(key);
2018
+ // Vector distance filter — mirrors buildVectorFilterClauses push order.
2019
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2020
+ // Validate the same way the build path does so the collect path never
2021
+ // diverges (it would throw before any param was pushed).
2022
+ this.vectorOperator(key, rawColumn, value.distance.metric);
2023
+ this.collectVectorFilterParams(key, rawColumn, value, params);
2024
+ continue;
2025
+ }
1814
2026
  // JSONB filter
1815
2027
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1816
2028
  const colType = this.getColumnPgType(rawColumn);
@@ -1907,6 +2119,37 @@ export class QueryInterface {
1907
2119
  params.push(filter.hasSome);
1908
2120
  // isEmpty has no params (IS NULL / IS NOT NULL)
1909
2121
  }
2122
+ /**
2123
+ * Collect params for an orderBy clause. Only vector KNN ordering pushes a
2124
+ * param (the `$n::vector` query vector); plain direction ordering is
2125
+ * parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
2126
+ * param re-collection stays in lockstep.
2127
+ */
2128
+ collectOrderByParams(orderBy, params) {
2129
+ for (const [key, dir] of Object.entries(orderBy)) {
2130
+ if (isVectorOrderBy(dir)) {
2131
+ const rawColumn = this.toColumn(key);
2132
+ // Re-run the same validation as buildOrderBy so the collect path can
2133
+ // never push a param that the build path rejected (or vice versa).
2134
+ this.vectorOperator(key, rawColumn, dir.distance.metric);
2135
+ this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2136
+ }
2137
+ }
2138
+ }
2139
+ /**
2140
+ * Collect params for a vector distance WHERE filter. Mirrors
2141
+ * {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
2142
+ * the comparison threshold(s).
2143
+ */
2144
+ collectVectorFilterParams(field, rawColumn, filter, params) {
2145
+ const dist = filter.distance;
2146
+ this.pushVectorParam(field, rawColumn, dist.to, params);
2147
+ for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
2148
+ const threshold = dist[cmp];
2149
+ if (threshold !== undefined)
2150
+ params.push(threshold);
2151
+ }
2152
+ }
1910
2153
  /**
1911
2154
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1912
2155
  * buildSelectWithRelations / buildRelationSubquery.
@@ -2003,6 +2246,27 @@ export class QueryInterface {
2003
2246
  const targetMeta = this.schema.tables[targetTable];
2004
2247
  if (!targetMeta)
2005
2248
  return;
2249
+ // manyToMany param order mirrors buildManyToManySubquery:
2250
+ // where params → limit param → nested-with params (always, both paths).
2251
+ if (relDef.type === 'manyToMany') {
2252
+ if (spec.where) {
2253
+ for (const [, v] of Object.entries(spec.where)) {
2254
+ params.push(v);
2255
+ }
2256
+ }
2257
+ if (spec.limit) {
2258
+ params.push(Number(spec.limit));
2259
+ }
2260
+ if (spec.with) {
2261
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2262
+ const nestedRelDef = targetMeta.relations[nestedRelName];
2263
+ if (!nestedRelDef)
2264
+ continue;
2265
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2266
+ }
2267
+ }
2268
+ return;
2269
+ }
2006
2270
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2007
2271
  // Non-wrapped path: nested relations BEFORE where/limit
2008
2272
  if (!willWrap && spec.with) {
@@ -2019,8 +2283,10 @@ export class QueryInterface {
2019
2283
  params.push(v);
2020
2284
  }
2021
2285
  }
2022
- // limit param
2023
- if (spec.limit) {
2286
+ // limit param — only hasMany parameterizes its limit (mirrors
2287
+ // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2288
+ // pushing one here would orphan a param and desync the collect path.
2289
+ if (relDef.type === 'hasMany' && spec.limit) {
2024
2290
  params.push(Number(spec.limit));
2025
2291
  }
2026
2292
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2174,6 +2440,12 @@ export class QueryInterface {
2174
2440
  andClauses.push(`${column} IS NULL`);
2175
2441
  continue;
2176
2442
  }
2443
+ // Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
2444
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2445
+ const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
2446
+ andClauses.push(...vecClauses);
2447
+ continue;
2448
+ }
2177
2449
  // Handle JSONB filter operators (for json/jsonb columns)
2178
2450
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2179
2451
  const colType = this.getColumnPgType(rawColumn);
@@ -2222,6 +2494,25 @@ export class QueryInterface {
2222
2494
  andClauses.push(...opClauses);
2223
2495
  continue;
2224
2496
  }
2497
+ // Strict validation: a plain (non-array, non-Date) object on a non-JSON
2498
+ // column matched no known filter shape — almost always a misspelled
2499
+ // operator (`startWith` for `startsWith`) or a stray nested object.
2500
+ // Silently treating it as `col = $1` returns wrong rows with no error, so
2501
+ // throw with the offending keys and the supported operator list. JSON/JSONB
2502
+ // columns legitimately accept object values for equality, so they fall
2503
+ // through unchanged.
2504
+ if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
2505
+ const colType = this.getColumnPgType(rawColumn);
2506
+ if (colType !== 'json' && colType !== 'jsonb') {
2507
+ const badKeys = Object.keys(value);
2508
+ throw new ValidationError(badKeys.length === 0
2509
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
2510
+ `Provide a value or an operator like { gt: 1 }.`
2511
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2512
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
2513
+ `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
2514
+ }
2515
+ }
2225
2516
  // Plain equality
2226
2517
  params.push(value);
2227
2518
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2380,8 +2671,17 @@ export class QueryInterface {
2380
2671
  }
2381
2672
  return clauses;
2382
2673
  }
2383
- /** Build ORDER BY clause from an object */
2384
- buildOrderBy(orderBy) {
2674
+ /**
2675
+ * Build ORDER BY clause from an object.
2676
+ *
2677
+ * Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
2678
+ * columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
2679
+ * Vector ordering binds the query vector as a `$n::vector` param, so a `params`
2680
+ * array MUST be supplied when a vector ordering may be present (top-level
2681
+ * findMany path). When `params` is omitted (groupBy / relation path) a vector
2682
+ * ordering throws — KNN ordering is only supported at the top level.
2683
+ */
2684
+ buildOrderBy(orderBy, params) {
2385
2685
  // Dev-only: validate that orderBy fields exist in the table schema
2386
2686
  if (process.env.NODE_ENV !== 'production') {
2387
2687
  for (const key of Object.keys(orderBy)) {
@@ -2396,14 +2696,88 @@ export class QueryInterface {
2396
2696
  return Object.entries(orderBy)
2397
2697
  .map(([key, dir]) => {
2398
2698
  if (meta && !(key in meta.columnMap)) {
2399
- throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2699
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
2700
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
2701
+ }
2702
+ // Vector KNN ordering: { distance: { to, metric, direction? } }
2703
+ if (isVectorOrderBy(dir)) {
2704
+ if (!params) {
2705
+ throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
2706
+ }
2707
+ const rawColumn = this.toColumn(key);
2708
+ const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
2709
+ const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2710
+ const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2711
+ return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
2400
2712
  }
2401
2713
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2402
2714
  return `${this.toSqlColumn(key)} ${safeDir}`;
2403
2715
  })
2404
2716
  .join(', ');
2405
2717
  }
2718
+ // -------------------------------------------------------------------------
2719
+ // pgvector helpers (similarity search)
2720
+ // -------------------------------------------------------------------------
2721
+ /**
2722
+ * Resolve a {@link VectorMetric} to its pgvector distance operator from a
2723
+ * fixed allow-list, validating the target column is actually a `vector`
2724
+ * column. Throws {@link ValidationError} for an unknown metric or a
2725
+ * non-vector column — a user-supplied string can never become a SQL operator.
2726
+ */
2727
+ vectorOperator(field, rawColumn, metric) {
2728
+ const colType = this.getColumnPgType(rawColumn);
2729
+ if (colType !== 'vector') {
2730
+ throw new ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
2731
+ `(actual type: ${colType}); cannot apply a vector distance operation.`);
2732
+ }
2733
+ const op = VECTOR_METRIC_OPERATORS[metric];
2734
+ if (!op) {
2735
+ throw new ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
2736
+ `Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
2737
+ }
2738
+ return op;
2739
+ }
2740
+ /**
2741
+ * Validate and bind a query vector as a single `$n::vector` parameter.
2742
+ * Every element must be a finite number (no NaN / Infinity / strings) so a
2743
+ * malformed array can never produce a broken `::vector` literal, and the array
2744
+ * is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
2745
+ * placeholder string.
2746
+ */
2747
+ pushVectorParam(field, _rawColumn, to, params) {
2748
+ if (!Array.isArray(to) || to.length === 0) {
2749
+ throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
2750
+ }
2751
+ for (const el of to) {
2752
+ if (typeof el !== 'number' || !Number.isFinite(el)) {
2753
+ throw new ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
2754
+ `got ${JSON.stringify(el)}.`);
2755
+ }
2756
+ }
2757
+ // Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
2758
+ // as finite numbers, so the joined string is safe; it is still passed as a
2759
+ // bound param (never interpolated) and cast with ::vector.
2760
+ params.push(`[${to.join(',')}]`);
2761
+ return `${this.p(params.length)}::vector`;
2762
+ }
2406
2763
  /** Parse a flat row: convert snake_case to camelCase + Date coercion */
2764
+ /**
2765
+ * Returns the set of camelCase field names for a table's date columns,
2766
+ * derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
2767
+ * memoized per table. Used so nested relation rows (camelCase keys) coerce
2768
+ * dates the same way top-level rows do.
2769
+ */
2770
+ getCamelDateFields(table, meta) {
2771
+ let camel = this.camelDateFieldCache.get(table);
2772
+ if (!camel) {
2773
+ camel = new Set();
2774
+ for (const col of meta.dateColumns) {
2775
+ camel.add(meta.reverseColumnMap[col] ?? col);
2776
+ }
2777
+ this.camelDateFieldCache.set(table, camel);
2778
+ }
2779
+ return camel;
2780
+ }
2407
2781
  parseRow(row, table) {
2408
2782
  const parsed = {};
2409
2783
  const meta = this.schema.tables[table];
@@ -2411,12 +2785,16 @@ export class QueryInterface {
2411
2785
  // Fast path: use pre-computed maps (avoids regex per column per row)
2412
2786
  const reverseMap = meta.reverseColumnMap;
2413
2787
  const dateCols = meta.dateColumns;
2788
+ // camelCase-keyed date fields, so nested json_build_object rows (whose
2789
+ // keys are already camelCase) get the same Date coercion as top-level rows.
2790
+ const camelDateFields = this.getCamelDateFields(table, meta);
2414
2791
  const keys = Object.keys(row);
2415
2792
  for (let i = 0; i < keys.length; i++) {
2416
2793
  const col = keys[i];
2417
2794
  const value = row[col];
2418
2795
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
2419
- if (dateCols.has(col) && value !== null && !(value instanceof Date)) {
2796
+ // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
2797
+ if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
2420
2798
  parsed[field] = new Date(value);
2421
2799
  }
2422
2800
  else {
@@ -2460,14 +2838,15 @@ export class QueryInterface {
2460
2838
  if (typeof rawValue === 'string') {
2461
2839
  try {
2462
2840
  const jsonVal = JSON.parse(rawValue);
2463
- // After parsing, apply parseRow to each item for snake→camel + date coercion
2841
+ // After parsing, recurse via parseNestedRow so each item gets date
2842
+ // coercion AND its own sub-relations parsed at arbitrary depth.
2464
2843
  if (Array.isArray(jsonVal)) {
2465
2844
  parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2466
- ? this.parseRow(item, relDef.to)
2845
+ ? this.parseNestedRow(item, relDef.to)
2467
2846
  : item);
2468
2847
  }
2469
2848
  else if (typeof jsonVal === 'object' && jsonVal !== null) {
2470
- parsed[relName] = this.parseRow(jsonVal, relDef.to);
2849
+ parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
2471
2850
  }
2472
2851
  else {
2473
2852
  parsed[relName] = jsonVal;
@@ -2479,10 +2858,12 @@ export class QueryInterface {
2479
2858
  }
2480
2859
  }
2481
2860
  else if (Array.isArray(rawValue)) {
2482
- parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null ? this.parseRow(item, relDef.to) : item);
2861
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
2862
+ ? this.parseNestedRow(item, relDef.to)
2863
+ : item);
2483
2864
  }
2484
2865
  else if (typeof rawValue === 'object' && rawValue !== null) {
2485
- parsed[relName] = this.parseRow(rawValue, relDef.to);
2866
+ parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
2486
2867
  }
2487
2868
  else {
2488
2869
  parsed[relName] = rawValue;
@@ -2683,6 +3064,12 @@ export class QueryInterface {
2683
3064
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
2684
3065
  // so we must NOT build them here (they would push orphaned params).
2685
3066
  const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3067
+ // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3068
+ // where, orderBy, and select/omit are handled there (the target alias is the
3069
+ // row source, exactly like hasMany), so short-circuit before the hasMany logic.
3070
+ if (relDef.type === 'manyToMany') {
3071
+ return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
3072
+ }
2686
3073
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
2687
3074
  if (!willWrap && spec !== true && spec.with) {
2688
3075
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2739,9 +3126,13 @@ export class QueryInterface {
2739
3126
  whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
2740
3127
  }
2741
3128
  }
2742
- // LIMIT
3129
+ // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3130
+ // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3131
+ // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3132
+ // which Postgres rejects with "could not determine data type of parameter $N"
3133
+ // (and shifts every later placeholder by one). To-one relations ignore limit.
2743
3134
  let limitClause = '';
2744
- if (spec !== true && spec.limit) {
3135
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
2745
3136
  params.push(Number(spec.limit));
2746
3137
  limitClause = ` LIMIT ${this.p(params.length)}`;
2747
3138
  }
@@ -2779,6 +3170,146 @@ export class QueryInterface {
2779
3170
  // belongsTo / hasOne — return single object
2780
3171
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
2781
3172
  }
3173
+ /**
3174
+ * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
3175
+ * table through a junction (join) table.
3176
+ *
3177
+ * Shape (no LIMIT/ORDER):
3178
+ * ```sql
3179
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
3180
+ * FROM <target> <talias>
3181
+ * JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
3182
+ * WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
3183
+ * ```
3184
+ *
3185
+ * With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
3186
+ * applies BEFORE aggregation (identical strategy to hasMany).
3187
+ *
3188
+ * Cardinality is always 'many' → empty-array fallback, never NULL.
3189
+ *
3190
+ * IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
3191
+ * {@link collectRelationSubqueryParams} or pipeline batching will desync.
3192
+ */
3193
+ buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
3194
+ if (!relDef.through) {
3195
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
3196
+ }
3197
+ const targetTable = relDef.to;
3198
+ const qTarget = this.q(targetTable);
3199
+ const qJunction = this.q(relDef.through.table);
3200
+ const qParent = this.q(parentRef);
3201
+ const jalias = `${talias}j`; // junction alias, distinct from the target alias
3202
+ // JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
3203
+ const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
3204
+ // The target PK is the column(s) the junction's targetKey references. An empty
3205
+ // introspected PK means we cannot know what to JOIN on — fail loudly rather than
3206
+ // silently guessing `id` and generating a wrong JOIN.
3207
+ if (targetMeta.primaryKey.length === 0) {
3208
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
3209
+ `cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
3210
+ }
3211
+ const targetPk = targetMeta.primaryKey;
3212
+ if (targetKeys.length !== targetPk.length) {
3213
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
3214
+ `but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
3215
+ }
3216
+ const joinOn = targetKeys
3217
+ .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
3218
+ .join(' AND ');
3219
+ // Correlation: junction.sourceKey = parent.<referenceKey>.
3220
+ const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
3221
+ const refKeys = normalizeKeyColumns(relDef.referenceKey);
3222
+ if (sourceKeys.length !== refKeys.length) {
3223
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
3224
+ `but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
3225
+ }
3226
+ let whereClause = sourceKeys
3227
+ .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3228
+ .join(' AND ');
3229
+ // ORDER BY on the target rows
3230
+ let orderClause = '';
3231
+ if (spec !== true && spec.orderBy) {
3232
+ const orders = Object.entries(spec.orderBy)
3233
+ .map(([k, dir]) => {
3234
+ const col = camelToSnake(k);
3235
+ if (!targetMeta.allColumns.includes(col)) {
3236
+ throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3237
+ }
3238
+ const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3239
+ return `${talias}.${this.q(col)} ${safeDir}`;
3240
+ })
3241
+ .join(', ');
3242
+ orderClause = ` ORDER BY ${orders}`;
3243
+ }
3244
+ // Additional WHERE filters on the target — properly parameterized.
3245
+ if (spec !== true && spec.where) {
3246
+ for (const [k, v] of Object.entries(spec.where)) {
3247
+ const col = camelToSnake(k);
3248
+ if (!targetMeta.allColumns.includes(col)) {
3249
+ throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3250
+ }
3251
+ params.push(v);
3252
+ whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3253
+ }
3254
+ }
3255
+ // LIMIT
3256
+ let limitClause = '';
3257
+ if (spec !== true && spec.limit) {
3258
+ params.push(Number(spec.limit));
3259
+ limitClause = ` LIMIT ${this.p(params.length)}`;
3260
+ }
3261
+ const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
3262
+ // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
3263
+ // so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
3264
+ if (limitClause || orderClause) {
3265
+ const innerAlias = `${talias}i`;
3266
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
3267
+ `${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
3268
+ const innerJsonPairs = targetColumns.map((col) => [
3269
+ targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
3270
+ `${innerAlias}.${this.q(col)}`,
3271
+ ]);
3272
+ // Nested relations reference the inner alias.
3273
+ if (spec !== true && spec.with) {
3274
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3275
+ const nestedRelDef = targetMeta.relations[nestedRelName];
3276
+ if (!nestedRelDef) {
3277
+ throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
3278
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
3279
+ }
3280
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3281
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3282
+ ? this.dialect.nullJsonLiteral
3283
+ : this.dialect.emptyJsonArrayLiteral;
3284
+ innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3285
+ }
3286
+ }
3287
+ const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
3288
+ return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
3289
+ }
3290
+ // Simple path: build the json object pairs directly off the target alias,
3291
+ // including any nested relations (correlated to the target alias).
3292
+ const jsonPairs = targetColumns.map((col) => [
3293
+ targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
3294
+ `${talias}.${this.q(col)}`,
3295
+ ]);
3296
+ if (spec !== true && spec.with) {
3297
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3298
+ const nestedRelDef = targetMeta.relations[nestedRelName];
3299
+ if (!nestedRelDef) {
3300
+ throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
3301
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
3302
+ }
3303
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3304
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3305
+ ? this.dialect.nullJsonLiteral
3306
+ : this.dialect.emptyJsonArrayLiteral;
3307
+ jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3308
+ }
3309
+ }
3310
+ const jsonObj = this.dialect.buildJsonObject(jsonPairs);
3311
+ return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
3312
+ }
2782
3313
  /**
2783
3314
  * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
2784
3315
  * Used to detect JSONB/array columns for specialized operators.
@@ -2872,6 +3403,39 @@ export class QueryInterface {
2872
3403
  }
2873
3404
  return clauses;
2874
3405
  }
3406
+ /**
3407
+ * Build SQL clauses for a pgvector distance WHERE filter:
3408
+ *
3409
+ * `"embedding" <-> $1::vector < $2`
3410
+ *
3411
+ * The query vector is bound as a `$n::vector` param (never interpolated), the
3412
+ * metric maps to an operator via a fixed allow-list, and each comparison
3413
+ * threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
3414
+ * per supplied comparator (all ANDed). Param push order matches
3415
+ * {@link collectVectorFilterParams}.
3416
+ */
3417
+ buildVectorFilterClauses(field, rawColumn, filter, params) {
3418
+ const dist = filter.distance;
3419
+ const operator = this.vectorOperator(field, rawColumn, dist.metric);
3420
+ const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
3421
+ const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
3422
+ const clauses = [];
3423
+ for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
3424
+ const threshold = dist[cmp];
3425
+ if (threshold === undefined)
3426
+ continue;
3427
+ if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
3428
+ throw new ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
3429
+ `got ${JSON.stringify(threshold)}.`);
3430
+ }
3431
+ params.push(threshold);
3432
+ clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
3433
+ }
3434
+ if (clauses.length === 0) {
3435
+ throw new ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
3436
+ }
3437
+ return clauses;
3438
+ }
2875
3439
  /**
2876
3440
  * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2877
3441
  * The config name is validated to prevent injection (only alphanumeric + underscore).