turbine-orm 0.15.0 → 0.18.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 (54) 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/index.js +64 -0
  9. package/dist/cjs/cli/observe-ui.js +182 -0
  10. package/dist/cjs/cli/observe.js +242 -0
  11. package/dist/cjs/cli/studio.js +5 -1
  12. package/dist/cjs/client.js +218 -0
  13. package/dist/cjs/errors.js +35 -5
  14. package/dist/cjs/generate.js +14 -3
  15. package/dist/cjs/index.js +10 -2
  16. package/dist/cjs/introspect.js +81 -0
  17. package/dist/cjs/nested-write.js +164 -10
  18. package/dist/cjs/observe.js +145 -0
  19. package/dist/cjs/query/builder.js +604 -25
  20. package/dist/cjs/realtime.js +147 -0
  21. package/dist/cjs/schema-builder.js +86 -0
  22. package/dist/cjs/schema.js +10 -0
  23. package/dist/cjs/typed-sql.js +149 -0
  24. package/dist/cli/index.d.ts +1 -0
  25. package/dist/cli/index.js +64 -0
  26. package/dist/cli/observe-ui.d.ts +2 -0
  27. package/dist/cli/observe-ui.js +180 -0
  28. package/dist/cli/observe.d.ts +20 -0
  29. package/dist/cli/observe.js +237 -0
  30. package/dist/cli/studio.js +5 -1
  31. package/dist/client.d.ts +129 -2
  32. package/dist/client.js +220 -2
  33. package/dist/errors.js +35 -5
  34. package/dist/generate.js +14 -3
  35. package/dist/index.d.ts +5 -2
  36. package/dist/index.js +5 -1
  37. package/dist/introspect.js +81 -0
  38. package/dist/nested-write.d.ts +2 -2
  39. package/dist/nested-write.js +164 -10
  40. package/dist/observe.d.ts +36 -0
  41. package/dist/observe.js +141 -0
  42. package/dist/query/builder.d.ts +121 -1
  43. package/dist/query/builder.js +605 -26
  44. package/dist/query/index.d.ts +2 -2
  45. package/dist/query/types.d.ts +126 -2
  46. package/dist/realtime.d.ts +71 -0
  47. package/dist/realtime.js +144 -0
  48. package/dist/schema-builder.d.ts +68 -1
  49. package/dist/schema-builder.js +85 -0
  50. package/dist/schema.d.ts +18 -1
  51. package/dist/schema.js +10 -0
  52. package/dist/typed-sql.d.ts +101 -0
  53. package/dist/typed-sql.js +145 -0
  54. package/package.json +18 -16
@@ -156,6 +156,43 @@ function isTextSearchFilter(value) {
156
156
  function validateTextSearchConfig(config) {
157
157
  return /^[a-zA-Z0-9_]+$/.test(config);
158
158
  }
159
+ /**
160
+ * pgvector distance metric → operator allow-list. This is the ONLY mapping
161
+ * from a user-supplied metric token to a SQL operator; any token not present
162
+ * here is rejected, so a user value can never become an arbitrary operator.
163
+ *
164
+ * - `l2` → `<->` (Euclidean / L2 distance)
165
+ * - `cosine` → `<=>` (cosine distance)
166
+ * - `ip` → `<#>` (negative inner product)
167
+ */
168
+ const VECTOR_METRIC_OPERATORS = {
169
+ l2: '<->',
170
+ cosine: '<=>',
171
+ ip: '<#>',
172
+ };
173
+ /** Comparison keys allowed on a {@link VectorDistanceFilter}. */
174
+ const VECTOR_DISTANCE_COMPARATORS = {
175
+ lt: '<',
176
+ lte: '<=',
177
+ gt: '>',
178
+ gte: '>=',
179
+ };
180
+ /** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
181
+ function isVectorFilter(value) {
182
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
183
+ return false;
184
+ }
185
+ const dist = value.distance;
186
+ return (typeof dist === 'object' &&
187
+ dist !== null &&
188
+ !Array.isArray(dist) &&
189
+ 'to' in dist &&
190
+ 'metric' in dist);
191
+ }
192
+ /** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
193
+ function isVectorOrderBy(value) {
194
+ return isVectorFilter(value);
195
+ }
159
196
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
160
197
  class QueryInterface {
161
198
  pool;
@@ -187,10 +224,20 @@ class QueryInterface {
187
224
  columnArrayTypeMap;
188
225
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
189
226
  deepWithWarned = new Set();
227
+ /**
228
+ * Per-table memo of date columns keyed by their camelCase FIELD name.
229
+ * `meta.dateColumns` is keyed by raw snake_case column name, which matches
230
+ * top-level rows from pg. Nested relation rows arrive from json_build_object
231
+ * with camelCase keys, so they need this camelCase-keyed set to be coerced
232
+ * to Date as well (otherwise nested dates leak through as strings).
233
+ */
234
+ camelDateFieldCache = new Map();
190
235
  /** True when this QI runs inside an active transaction (set via _txScoped option). */
191
236
  txScoped;
192
237
  /** Original options reference — forwarded to child QIs in nested writes. */
193
238
  options;
239
+ /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
240
+ currentAction = 'raw';
194
241
  constructor(pool, table, schema, middlewares, options) {
195
242
  this.pool = pool;
196
243
  this.table = table;
@@ -272,12 +319,25 @@ class QueryInterface {
272
319
  resetUnlimitedWarnings() {
273
320
  this.warnedTables.clear();
274
321
  }
322
+ emitQueryEvent(sql, params, duration, action, rows, error) {
323
+ const onQuery = this.options?._onQuery;
324
+ if (!onQuery)
325
+ return;
326
+ try {
327
+ onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
328
+ }
329
+ catch {
330
+ // Listener errors must never crash a query
331
+ }
332
+ }
275
333
  /**
276
334
  * Execute a pool.query with an optional timeout.
277
335
  * If timeout is set, races the query against a timer and rejects on expiry.
278
336
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
279
337
  */
280
338
  async queryWithTimeout(sql, params, timeout, preparedName) {
339
+ const start = performance.now();
340
+ const action = this.currentAction;
281
341
  // Build the query argument — use object form with `name` for prepared
282
342
  // statements, or the plain (text, values) form otherwise.
283
343
  const usePrepared = preparedName && this.preparedStatementsEnabled;
@@ -286,10 +346,14 @@ class QueryInterface {
286
346
  : this.pool.query(sql, params);
287
347
  if (!timeout) {
288
348
  try {
289
- return await exec;
349
+ const result = await exec;
350
+ this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
351
+ return result;
290
352
  }
291
353
  catch (err) {
292
- throw (0, errors_js_1.wrapPgError)(err);
354
+ const wrapped = (0, errors_js_1.wrapPgError)(err);
355
+ this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
356
+ throw wrapped;
293
357
  }
294
358
  }
295
359
  let timer;
@@ -297,10 +361,14 @@ class QueryInterface {
297
361
  timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
298
362
  });
299
363
  try {
300
- return await Promise.race([exec, timeoutPromise]);
364
+ const result = await Promise.race([exec, timeoutPromise]);
365
+ this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
366
+ return result;
301
367
  }
302
368
  catch (err) {
303
- throw (0, errors_js_1.wrapPgError)(err);
369
+ const wrapped = (0, errors_js_1.wrapPgError)(err);
370
+ this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
371
+ throw wrapped;
304
372
  }
305
373
  finally {
306
374
  clearTimeout(timer);
@@ -316,6 +384,7 @@ class QueryInterface {
316
384
  * To intercept queries before SQL generation, use the raw() method instead.
317
385
  */
318
386
  async executeWithMiddleware(action, args, executor) {
387
+ this.currentAction = action;
319
388
  if (this.middlewares.length === 0) {
320
389
  return executor();
321
390
  }
@@ -335,7 +404,6 @@ class QueryInterface {
335
404
  // -------------------------------------------------------------------------
336
405
  // findUnique
337
406
  // -------------------------------------------------------------------------
338
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
339
407
  async findUnique(args) {
340
408
  return this.executeWithMiddleware('findUnique', args, async () => {
341
409
  const deferred = this.buildFindUnique(args);
@@ -439,7 +507,6 @@ class QueryInterface {
439
507
  // -------------------------------------------------------------------------
440
508
  // findMany
441
509
  // -------------------------------------------------------------------------
442
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
443
510
  async findMany(args) {
444
511
  this.maybeWarnUnlimited(args);
445
512
  // Dev-only: warn on deeply nested with clauses
@@ -507,7 +574,16 @@ class QueryInterface {
507
574
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
508
575
  const orderFp = args?.orderBy
509
576
  ? Object.entries(args.orderBy)
510
- .map(([k, d]) => `${k}:${d}`)
577
+ .map(([k, d]) => {
578
+ // Vector KNN ordering changes the emitted SQL operator by metric and
579
+ // adds a `::vector` param, so the metric + direction must be part of
580
+ // the cache key — otherwise two KNN queries differing only in metric
581
+ // would collide on a single cached SQL string.
582
+ if (isVectorOrderBy(d)) {
583
+ return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
584
+ }
585
+ return `${k}:${d}`;
586
+ })
511
587
  .join(',')
512
588
  : '';
513
589
  const cursorFp = args?.cursor
@@ -567,7 +643,9 @@ class QueryInterface {
567
643
  }
568
644
  }
569
645
  if (args?.orderBy) {
570
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
646
+ // Pass freshParams so vector KNN ordering binds its `$n::vector` query
647
+ // vector at the correct position (after cursor params, before LIMIT).
648
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
571
649
  }
572
650
  if (effectiveLimit !== undefined) {
573
651
  freshParams.push(Number(effectiveLimit));
@@ -595,11 +673,16 @@ class QueryInterface {
595
673
  params.push(v);
596
674
  }
597
675
  }
598
- // 4. LIMIT param
676
+ // 4. ORDER BY params (vector KNN ordering binds a `$n::vector` query vector).
677
+ // Mirrors buildOrderBy's push order — between cursor and LIMIT.
678
+ if (args?.orderBy) {
679
+ this.collectOrderByParams(args.orderBy, params);
680
+ }
681
+ // 5. LIMIT param
599
682
  if (effectiveLimit !== undefined) {
600
683
  params.push(Number(effectiveLimit));
601
684
  }
602
- // 5. OFFSET param
685
+ // 6. OFFSET param
603
686
  if (args?.offset !== undefined) {
604
687
  params.push(Number(args.offset));
605
688
  }
@@ -641,7 +724,6 @@ class QueryInterface {
641
724
  * }
642
725
  * ```
643
726
  */
644
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
645
727
  async *findManyStream(args) {
646
728
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
647
729
  const hasRelations = !!args?.with;
@@ -650,6 +732,7 @@ class QueryInterface {
650
732
  ...args,
651
733
  limit: batchSize + 1,
652
734
  });
735
+ this.currentAction = 'findManyStream';
653
736
  const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
654
737
  if (speculativeResult.rows.length <= batchSize) {
655
738
  // Small drain — yield all rows and return, no cursor needed
@@ -698,7 +781,6 @@ class QueryInterface {
698
781
  // -------------------------------------------------------------------------
699
782
  // findFirst — like findMany but returns a single row or null
700
783
  // -------------------------------------------------------------------------
701
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
702
784
  async findFirst(args) {
703
785
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
704
786
  const deferred = this.buildFindFirst(args);
@@ -724,7 +806,6 @@ class QueryInterface {
724
806
  // -------------------------------------------------------------------------
725
807
  // findFirstOrThrow — like findFirst but throws if no record found
726
808
  // -------------------------------------------------------------------------
727
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
728
809
  async findFirstOrThrow(args) {
729
810
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
730
811
  const deferred = this.buildFindFirstOrThrow(args);
@@ -755,7 +836,6 @@ class QueryInterface {
755
836
  // -------------------------------------------------------------------------
756
837
  // findUniqueOrThrow — like findUnique but throws if no record found
757
838
  // -------------------------------------------------------------------------
758
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
759
839
  async findUniqueOrThrow(args) {
760
840
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
761
841
  const deferred = this.buildFindUniqueOrThrow(args);
@@ -1293,6 +1373,15 @@ class QueryInterface {
1293
1373
  }
1294
1374
  }
1295
1375
  let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1376
+ // HAVING — filter whole groups by their aggregate values.
1377
+ // Appends to the same `params` array, so placeholders continue from the
1378
+ // WHERE clause's parameter positions (this.p(params.length) below).
1379
+ if (args.having) {
1380
+ const havingClauses = this.buildHavingClauses(args.having, params);
1381
+ if (havingClauses.length > 0) {
1382
+ sql += ` HAVING ${havingClauses.join(' AND ')}`;
1383
+ }
1384
+ }
1296
1385
  // ORDER BY
1297
1386
  if (args.orderBy) {
1298
1387
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
@@ -1360,6 +1449,117 @@ class QueryInterface {
1360
1449
  tag: `${this.table}.groupBy`,
1361
1450
  };
1362
1451
  }
1452
+ /**
1453
+ * Build the SQL fragments for a {@link HavingClause}.
1454
+ *
1455
+ * Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
1456
+ * from a **schema-validated, quoted** column identifier — `this.toColumn()`
1457
+ * throws {@link ValidationError} for unknown fields and `this.q()` quotes via
1458
+ * the dialect, so no unvalidated identifier ever reaches the SQL string. Every
1459
+ * comparison value is pushed onto the shared `params` array and referenced by
1460
+ * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
1461
+ * interpolation of user values.
1462
+ */
1463
+ buildHavingClauses(having, params) {
1464
+ const clauses = [];
1465
+ // Maps the per-field aggregate key to its SQL function name. The set of
1466
+ // allowed keys is fixed here — any other key on a field's filter object is
1467
+ // rejected by ValidationError below (never interpolated).
1468
+ const aggFnByKey = {
1469
+ _sum: 'SUM',
1470
+ _avg: 'AVG',
1471
+ _min: 'MIN',
1472
+ _max: 'MAX',
1473
+ _count: 'COUNT',
1474
+ };
1475
+ for (const [key, value] of Object.entries(having)) {
1476
+ if (value === undefined)
1477
+ continue;
1478
+ // Top-level `_count` (no field) → COUNT(*) for the whole group.
1479
+ if (key === '_count') {
1480
+ clauses.push(...this.buildHavingNumericClauses('COUNT(*)', value, params));
1481
+ continue;
1482
+ }
1483
+ // Otherwise `key` is a field name mapping to a per-aggregate filter object.
1484
+ if (typeof value !== 'object' || value === null) {
1485
+ throw new errors_js_1.ValidationError(`[turbine] Invalid having filter for field "${key}" on table "${this.table}": ` +
1486
+ `expected an aggregate object like { _sum: { gt: 100 } }.`);
1487
+ }
1488
+ // toColumn validates the field against schema metadata (throws
1489
+ // ValidationError on unknown columns) and q() quotes the identifier — no
1490
+ // unvalidated identifier ever reaches the SQL string.
1491
+ const quotedCol = this.q(this.toColumn(key));
1492
+ for (const [aggKey, filter] of Object.entries(value)) {
1493
+ if (filter === undefined)
1494
+ continue;
1495
+ const fn = aggFnByKey[aggKey];
1496
+ if (!fn) {
1497
+ throw new errors_js_1.ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
1498
+ `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
1499
+ }
1500
+ const expr = `${fn}(${quotedCol})`;
1501
+ clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
1502
+ }
1503
+ }
1504
+ return clauses;
1505
+ }
1506
+ /**
1507
+ * Convert a single having filter into one or more parameterized SQL
1508
+ * comparisons against the given aggregate expression. A bare number is
1509
+ * shorthand for equality. Unknown operator keys throw {@link ValidationError}.
1510
+ */
1511
+ buildHavingNumericClauses(expr, filter, params) {
1512
+ // Bare number → equality.
1513
+ if (typeof filter === 'number') {
1514
+ params.push(filter);
1515
+ return [`${expr} = ${this.p(params.length)}`];
1516
+ }
1517
+ if (typeof filter !== 'object' || filter === null) {
1518
+ throw new errors_js_1.ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${this.table}": expected a number or operator object.`);
1519
+ }
1520
+ const op = filter;
1521
+ const allowedKeys = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn']);
1522
+ for (const k of Object.keys(op)) {
1523
+ if (!allowedKeys.has(k)) {
1524
+ throw new errors_js_1.ValidationError(`[turbine] Unknown having operator "${k}" on "${expr}" for table "${this.table}". ` +
1525
+ `Supported: ${[...allowedKeys].join(', ')}.`);
1526
+ }
1527
+ }
1528
+ const clauses = [];
1529
+ if (op.equals !== undefined) {
1530
+ params.push(op.equals);
1531
+ clauses.push(`${expr} = ${this.p(params.length)}`);
1532
+ }
1533
+ if (op.not !== undefined) {
1534
+ params.push(op.not);
1535
+ clauses.push(`${expr} != ${this.p(params.length)}`);
1536
+ }
1537
+ if (op.gt !== undefined) {
1538
+ params.push(op.gt);
1539
+ clauses.push(`${expr} > ${this.p(params.length)}`);
1540
+ }
1541
+ if (op.gte !== undefined) {
1542
+ params.push(op.gte);
1543
+ clauses.push(`${expr} >= ${this.p(params.length)}`);
1544
+ }
1545
+ if (op.lt !== undefined) {
1546
+ params.push(op.lt);
1547
+ clauses.push(`${expr} < ${this.p(params.length)}`);
1548
+ }
1549
+ if (op.lte !== undefined) {
1550
+ params.push(op.lte);
1551
+ clauses.push(`${expr} <= ${this.p(params.length)}`);
1552
+ }
1553
+ if (op.in !== undefined) {
1554
+ params.push(op.in);
1555
+ clauses.push(`${expr} = ANY(${this.p(params.length)})`);
1556
+ }
1557
+ if (op.notIn !== undefined) {
1558
+ params.push(op.notIn);
1559
+ clauses.push(`${expr} != ALL(${this.p(params.length)})`);
1560
+ }
1561
+ return clauses;
1562
+ }
1363
1563
  // -------------------------------------------------------------------------
1364
1564
  // aggregate — standalone aggregation without groupBy
1365
1565
  // -------------------------------------------------------------------------
@@ -1669,7 +1869,11 @@ class QueryInterface {
1669
1869
  const relDef = this.tableMeta.relations[key];
1670
1870
  if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1671
1871
  const filterObj = value;
1672
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1872
+ if ('some' in filterObj ||
1873
+ 'every' in filterObj ||
1874
+ 'none' in filterObj ||
1875
+ 'is' in filterObj ||
1876
+ 'isNot' in filterObj) {
1673
1877
  const relParts = [];
1674
1878
  if (filterObj.some !== undefined)
1675
1879
  relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
@@ -1677,6 +1881,10 @@ class QueryInterface {
1677
1881
  relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
1678
1882
  if (filterObj.none !== undefined)
1679
1883
  relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
1884
+ if (filterObj.is !== undefined)
1885
+ relParts.push(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
1886
+ if (filterObj.isNot !== undefined)
1887
+ relParts.push(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
1680
1888
  parts.push(`${key}:{${relParts.join(',')}}`);
1681
1889
  continue;
1682
1890
  }
@@ -1696,6 +1904,17 @@ class QueryInterface {
1696
1904
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1697
1905
  continue;
1698
1906
  }
1907
+ // Vector distance filter — metric (operator) and present comparators
1908
+ // change the SQL shape, so both go in the fingerprint.
1909
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
1910
+ const dist = value.distance;
1911
+ const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
1912
+ .filter((c) => dist[c] !== undefined)
1913
+ .sort()
1914
+ .join('|');
1915
+ parts.push(`${key}:vec(${dist.metric},${cmps})`);
1916
+ continue;
1917
+ }
1699
1918
  // JSON filter
1700
1919
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1701
1920
  const jKeys = Object.keys(value).sort();
@@ -1798,13 +2017,21 @@ class QueryInterface {
1798
2017
  const relationDef = this.tableMeta.relations[key];
1799
2018
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1800
2019
  const filterObj = value;
1801
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
2020
+ if ('some' in filterObj ||
2021
+ 'every' in filterObj ||
2022
+ 'none' in filterObj ||
2023
+ 'is' in filterObj ||
2024
+ 'isNot' in filterObj) {
1802
2025
  if (filterObj.some !== undefined)
1803
2026
  this.collectRelFilterParams(relationDef.to, filterObj.some, params);
1804
2027
  if (filterObj.none !== undefined)
1805
2028
  this.collectRelFilterParams(relationDef.to, filterObj.none, params);
1806
2029
  if (filterObj.every !== undefined)
1807
2030
  this.collectRelFilterParams(relationDef.to, filterObj.every, params);
2031
+ if (filterObj.is !== undefined)
2032
+ this.collectRelFilterParams(relationDef.to, filterObj.is, params);
2033
+ if (filterObj.isNot !== undefined)
2034
+ this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
1808
2035
  continue;
1809
2036
  }
1810
2037
  }
@@ -1812,6 +2039,14 @@ class QueryInterface {
1812
2039
  if (value === null)
1813
2040
  continue;
1814
2041
  const rawColumn = this.toColumn(key);
2042
+ // Vector distance filter — mirrors buildVectorFilterClauses push order.
2043
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2044
+ // Validate the same way the build path does so the collect path never
2045
+ // diverges (it would throw before any param was pushed).
2046
+ this.vectorOperator(key, rawColumn, value.distance.metric);
2047
+ this.collectVectorFilterParams(key, rawColumn, value, params);
2048
+ continue;
2049
+ }
1815
2050
  // JSONB filter
1816
2051
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1817
2052
  const colType = this.getColumnPgType(rawColumn);
@@ -1908,6 +2143,37 @@ class QueryInterface {
1908
2143
  params.push(filter.hasSome);
1909
2144
  // isEmpty has no params (IS NULL / IS NOT NULL)
1910
2145
  }
2146
+ /**
2147
+ * Collect params for an orderBy clause. Only vector KNN ordering pushes a
2148
+ * param (the `$n::vector` query vector); plain direction ordering is
2149
+ * parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
2150
+ * param re-collection stays in lockstep.
2151
+ */
2152
+ collectOrderByParams(orderBy, params) {
2153
+ for (const [key, dir] of Object.entries(orderBy)) {
2154
+ if (isVectorOrderBy(dir)) {
2155
+ const rawColumn = this.toColumn(key);
2156
+ // Re-run the same validation as buildOrderBy so the collect path can
2157
+ // never push a param that the build path rejected (or vice versa).
2158
+ this.vectorOperator(key, rawColumn, dir.distance.metric);
2159
+ this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2160
+ }
2161
+ }
2162
+ }
2163
+ /**
2164
+ * Collect params for a vector distance WHERE filter. Mirrors
2165
+ * {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
2166
+ * the comparison threshold(s).
2167
+ */
2168
+ collectVectorFilterParams(field, rawColumn, filter, params) {
2169
+ const dist = filter.distance;
2170
+ this.pushVectorParam(field, rawColumn, dist.to, params);
2171
+ for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
2172
+ const threshold = dist[cmp];
2173
+ if (threshold !== undefined)
2174
+ params.push(threshold);
2175
+ }
2176
+ }
1911
2177
  /**
1912
2178
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1913
2179
  * buildSelectWithRelations / buildRelationSubquery.
@@ -2004,6 +2270,27 @@ class QueryInterface {
2004
2270
  const targetMeta = this.schema.tables[targetTable];
2005
2271
  if (!targetMeta)
2006
2272
  return;
2273
+ // manyToMany param order mirrors buildManyToManySubquery:
2274
+ // where params → limit param → nested-with params (always, both paths).
2275
+ if (relDef.type === 'manyToMany') {
2276
+ if (spec.where) {
2277
+ for (const [, v] of Object.entries(spec.where)) {
2278
+ params.push(v);
2279
+ }
2280
+ }
2281
+ if (spec.limit) {
2282
+ params.push(Number(spec.limit));
2283
+ }
2284
+ if (spec.with) {
2285
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2286
+ const nestedRelDef = targetMeta.relations[nestedRelName];
2287
+ if (!nestedRelDef)
2288
+ continue;
2289
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2290
+ }
2291
+ }
2292
+ return;
2293
+ }
2007
2294
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2008
2295
  // Non-wrapped path: nested relations BEFORE where/limit
2009
2296
  if (!willWrap && spec.with) {
@@ -2157,7 +2444,11 @@ class QueryInterface {
2157
2444
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
2158
2445
  const filterObj = value;
2159
2446
  // Check if this is a relation filter (has some/every/none keys)
2160
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
2447
+ if ('some' in filterObj ||
2448
+ 'every' in filterObj ||
2449
+ 'none' in filterObj ||
2450
+ 'is' in filterObj ||
2451
+ 'isNot' in filterObj) {
2161
2452
  const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
2162
2453
  if (relClause)
2163
2454
  andClauses.push(relClause);
@@ -2171,6 +2462,12 @@ class QueryInterface {
2171
2462
  andClauses.push(`${column} IS NULL`);
2172
2463
  continue;
2173
2464
  }
2465
+ // Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
2466
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2467
+ const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
2468
+ andClauses.push(...vecClauses);
2469
+ continue;
2470
+ }
2174
2471
  // Handle JSONB filter operators (for json/jsonb columns)
2175
2472
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2176
2473
  const colType = this.getColumnPgType(rawColumn);
@@ -2274,6 +2571,20 @@ class QueryInterface {
2274
2571
  // "every" with empty filter = true (all match trivially)
2275
2572
  }
2276
2573
  }
2574
+ // "is": EXISTS — for to-one relations (same SQL as "some")
2575
+ if (filterObj.is !== undefined) {
2576
+ const subWhere = filterObj.is;
2577
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
2578
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
2579
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
2580
+ }
2581
+ // "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
2582
+ if (filterObj.isNot !== undefined) {
2583
+ const subWhere = filterObj.isNot;
2584
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
2585
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
2586
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
2587
+ }
2277
2588
  return clauses.length > 0 ? clauses.join(' AND ') : null;
2278
2589
  }
2279
2590
  /**
@@ -2363,8 +2674,17 @@ class QueryInterface {
2363
2674
  }
2364
2675
  return clauses;
2365
2676
  }
2366
- /** Build ORDER BY clause from an object */
2367
- buildOrderBy(orderBy) {
2677
+ /**
2678
+ * Build ORDER BY clause from an object.
2679
+ *
2680
+ * Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
2681
+ * columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
2682
+ * Vector ordering binds the query vector as a `$n::vector` param, so a `params`
2683
+ * array MUST be supplied when a vector ordering may be present (top-level
2684
+ * findMany path). When `params` is omitted (groupBy / relation path) a vector
2685
+ * ordering throws — KNN ordering is only supported at the top level.
2686
+ */
2687
+ buildOrderBy(orderBy, params) {
2368
2688
  // Dev-only: validate that orderBy fields exist in the table schema
2369
2689
  if (process.env.NODE_ENV !== 'production') {
2370
2690
  for (const key of Object.keys(orderBy)) {
@@ -2381,12 +2701,85 @@ class QueryInterface {
2381
2701
  if (meta && !(key in meta.columnMap)) {
2382
2702
  throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2383
2703
  }
2704
+ // Vector KNN ordering: { distance: { to, metric, direction? } }
2705
+ if (isVectorOrderBy(dir)) {
2706
+ if (!params) {
2707
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
2708
+ }
2709
+ const rawColumn = this.toColumn(key);
2710
+ const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
2711
+ const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2712
+ const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2713
+ return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
2714
+ }
2384
2715
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2385
2716
  return `${this.toSqlColumn(key)} ${safeDir}`;
2386
2717
  })
2387
2718
  .join(', ');
2388
2719
  }
2720
+ // -------------------------------------------------------------------------
2721
+ // pgvector helpers (similarity search)
2722
+ // -------------------------------------------------------------------------
2723
+ /**
2724
+ * Resolve a {@link VectorMetric} to its pgvector distance operator from a
2725
+ * fixed allow-list, validating the target column is actually a `vector`
2726
+ * column. Throws {@link ValidationError} for an unknown metric or a
2727
+ * non-vector column — a user-supplied string can never become a SQL operator.
2728
+ */
2729
+ vectorOperator(field, rawColumn, metric) {
2730
+ const colType = this.getColumnPgType(rawColumn);
2731
+ if (colType !== 'vector') {
2732
+ throw new errors_js_1.ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
2733
+ `(actual type: ${colType}); cannot apply a vector distance operation.`);
2734
+ }
2735
+ const op = VECTOR_METRIC_OPERATORS[metric];
2736
+ if (!op) {
2737
+ throw new errors_js_1.ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
2738
+ `Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
2739
+ }
2740
+ return op;
2741
+ }
2742
+ /**
2743
+ * Validate and bind a query vector as a single `$n::vector` parameter.
2744
+ * Every element must be a finite number (no NaN / Infinity / strings) so a
2745
+ * malformed array can never produce a broken `::vector` literal, and the array
2746
+ * is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
2747
+ * placeholder string.
2748
+ */
2749
+ pushVectorParam(field, _rawColumn, to, params) {
2750
+ if (!Array.isArray(to) || to.length === 0) {
2751
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
2752
+ }
2753
+ for (const el of to) {
2754
+ if (typeof el !== 'number' || !Number.isFinite(el)) {
2755
+ throw new errors_js_1.ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
2756
+ `got ${JSON.stringify(el)}.`);
2757
+ }
2758
+ }
2759
+ // Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
2760
+ // as finite numbers, so the joined string is safe; it is still passed as a
2761
+ // bound param (never interpolated) and cast with ::vector.
2762
+ params.push(`[${to.join(',')}]`);
2763
+ return `${this.p(params.length)}::vector`;
2764
+ }
2389
2765
  /** Parse a flat row: convert snake_case to camelCase + Date coercion */
2766
+ /**
2767
+ * Returns the set of camelCase field names for a table's date columns,
2768
+ * derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
2769
+ * memoized per table. Used so nested relation rows (camelCase keys) coerce
2770
+ * dates the same way top-level rows do.
2771
+ */
2772
+ getCamelDateFields(table, meta) {
2773
+ let camel = this.camelDateFieldCache.get(table);
2774
+ if (!camel) {
2775
+ camel = new Set();
2776
+ for (const col of meta.dateColumns) {
2777
+ camel.add(meta.reverseColumnMap[col] ?? col);
2778
+ }
2779
+ this.camelDateFieldCache.set(table, camel);
2780
+ }
2781
+ return camel;
2782
+ }
2390
2783
  parseRow(row, table) {
2391
2784
  const parsed = {};
2392
2785
  const meta = this.schema.tables[table];
@@ -2394,12 +2787,16 @@ class QueryInterface {
2394
2787
  // Fast path: use pre-computed maps (avoids regex per column per row)
2395
2788
  const reverseMap = meta.reverseColumnMap;
2396
2789
  const dateCols = meta.dateColumns;
2790
+ // camelCase-keyed date fields, so nested json_build_object rows (whose
2791
+ // keys are already camelCase) get the same Date coercion as top-level rows.
2792
+ const camelDateFields = this.getCamelDateFields(table, meta);
2397
2793
  const keys = Object.keys(row);
2398
2794
  for (let i = 0; i < keys.length; i++) {
2399
2795
  const col = keys[i];
2400
2796
  const value = row[col];
2401
2797
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
2402
- if (dateCols.has(col) && value !== null && !(value instanceof Date)) {
2798
+ // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
2799
+ if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
2403
2800
  parsed[field] = new Date(value);
2404
2801
  }
2405
2802
  else {
@@ -2443,14 +2840,15 @@ class QueryInterface {
2443
2840
  if (typeof rawValue === 'string') {
2444
2841
  try {
2445
2842
  const jsonVal = JSON.parse(rawValue);
2446
- // After parsing, apply parseRow to each item for snake→camel + date coercion
2843
+ // After parsing, recurse via parseNestedRow so each item gets date
2844
+ // coercion AND its own sub-relations parsed at arbitrary depth.
2447
2845
  if (Array.isArray(jsonVal)) {
2448
2846
  parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2449
- ? this.parseRow(item, relDef.to)
2847
+ ? this.parseNestedRow(item, relDef.to)
2450
2848
  : item);
2451
2849
  }
2452
2850
  else if (typeof jsonVal === 'object' && jsonVal !== null) {
2453
- parsed[relName] = this.parseRow(jsonVal, relDef.to);
2851
+ parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
2454
2852
  }
2455
2853
  else {
2456
2854
  parsed[relName] = jsonVal;
@@ -2462,10 +2860,12 @@ class QueryInterface {
2462
2860
  }
2463
2861
  }
2464
2862
  else if (Array.isArray(rawValue)) {
2465
- parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null ? this.parseRow(item, relDef.to) : item);
2863
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
2864
+ ? this.parseNestedRow(item, relDef.to)
2865
+ : item);
2466
2866
  }
2467
2867
  else if (typeof rawValue === 'object' && rawValue !== null) {
2468
- parsed[relName] = this.parseRow(rawValue, relDef.to);
2868
+ parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
2469
2869
  }
2470
2870
  else {
2471
2871
  parsed[relName] = rawValue;
@@ -2666,6 +3066,12 @@ class QueryInterface {
2666
3066
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
2667
3067
  // so we must NOT build them here (they would push orphaned params).
2668
3068
  const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3069
+ // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3070
+ // where, orderBy, and select/omit are handled there (the target alias is the
3071
+ // row source, exactly like hasMany), so short-circuit before the hasMany logic.
3072
+ if (relDef.type === 'manyToMany') {
3073
+ return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
3074
+ }
2669
3075
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
2670
3076
  if (!willWrap && spec !== true && spec.with) {
2671
3077
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2762,6 +3168,146 @@ class QueryInterface {
2762
3168
  // belongsTo / hasOne — return single object
2763
3169
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
2764
3170
  }
3171
+ /**
3172
+ * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
3173
+ * table through a junction (join) table.
3174
+ *
3175
+ * Shape (no LIMIT/ORDER):
3176
+ * ```sql
3177
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
3178
+ * FROM <target> <talias>
3179
+ * JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
3180
+ * WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
3181
+ * ```
3182
+ *
3183
+ * With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
3184
+ * applies BEFORE aggregation (identical strategy to hasMany).
3185
+ *
3186
+ * Cardinality is always 'many' → empty-array fallback, never NULL.
3187
+ *
3188
+ * IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
3189
+ * {@link collectRelationSubqueryParams} or pipeline batching will desync.
3190
+ */
3191
+ buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
3192
+ if (!relDef.through) {
3193
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
3194
+ }
3195
+ const targetTable = relDef.to;
3196
+ const qTarget = this.q(targetTable);
3197
+ const qJunction = this.q(relDef.through.table);
3198
+ const qParent = this.q(parentRef);
3199
+ const jalias = `${talias}j`; // junction alias, distinct from the target alias
3200
+ // JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
3201
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
3202
+ // The target PK is the column(s) the junction's targetKey references. An empty
3203
+ // introspected PK means we cannot know what to JOIN on — fail loudly rather than
3204
+ // silently guessing `id` and generating a wrong JOIN.
3205
+ if (targetMeta.primaryKey.length === 0) {
3206
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
3207
+ `cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
3208
+ }
3209
+ const targetPk = targetMeta.primaryKey;
3210
+ if (targetKeys.length !== targetPk.length) {
3211
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
3212
+ `but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
3213
+ }
3214
+ const joinOn = targetKeys
3215
+ .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
3216
+ .join(' AND ');
3217
+ // Correlation: junction.sourceKey = parent.<referenceKey>.
3218
+ const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
3219
+ const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
3220
+ if (sourceKeys.length !== refKeys.length) {
3221
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
3222
+ `but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
3223
+ }
3224
+ let whereClause = sourceKeys
3225
+ .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3226
+ .join(' AND ');
3227
+ // ORDER BY on the target rows
3228
+ let orderClause = '';
3229
+ if (spec !== true && spec.orderBy) {
3230
+ const orders = Object.entries(spec.orderBy)
3231
+ .map(([k, dir]) => {
3232
+ const col = (0, schema_js_1.camelToSnake)(k);
3233
+ if (!targetMeta.allColumns.includes(col)) {
3234
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3235
+ }
3236
+ const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3237
+ return `${talias}.${this.q(col)} ${safeDir}`;
3238
+ })
3239
+ .join(', ');
3240
+ orderClause = ` ORDER BY ${orders}`;
3241
+ }
3242
+ // Additional WHERE filters on the target — properly parameterized.
3243
+ if (spec !== true && spec.where) {
3244
+ for (const [k, v] of Object.entries(spec.where)) {
3245
+ const col = (0, schema_js_1.camelToSnake)(k);
3246
+ if (!targetMeta.allColumns.includes(col)) {
3247
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3248
+ }
3249
+ params.push(v);
3250
+ whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3251
+ }
3252
+ }
3253
+ // LIMIT
3254
+ let limitClause = '';
3255
+ if (spec !== true && spec.limit) {
3256
+ params.push(Number(spec.limit));
3257
+ limitClause = ` LIMIT ${this.p(params.length)}`;
3258
+ }
3259
+ const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
3260
+ // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
3261
+ // so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
3262
+ if (limitClause || orderClause) {
3263
+ const innerAlias = `${talias}i`;
3264
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
3265
+ `${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
3266
+ const innerJsonPairs = targetColumns.map((col) => [
3267
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
3268
+ `${innerAlias}.${this.q(col)}`,
3269
+ ]);
3270
+ // Nested relations reference the inner alias.
3271
+ if (spec !== true && spec.with) {
3272
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3273
+ const nestedRelDef = targetMeta.relations[nestedRelName];
3274
+ if (!nestedRelDef) {
3275
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
3276
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
3277
+ }
3278
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3279
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3280
+ ? this.dialect.nullJsonLiteral
3281
+ : this.dialect.emptyJsonArrayLiteral;
3282
+ innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3283
+ }
3284
+ }
3285
+ const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
3286
+ return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
3287
+ }
3288
+ // Simple path: build the json object pairs directly off the target alias,
3289
+ // including any nested relations (correlated to the target alias).
3290
+ const jsonPairs = targetColumns.map((col) => [
3291
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
3292
+ `${talias}.${this.q(col)}`,
3293
+ ]);
3294
+ if (spec !== true && spec.with) {
3295
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3296
+ const nestedRelDef = targetMeta.relations[nestedRelName];
3297
+ if (!nestedRelDef) {
3298
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
3299
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
3300
+ }
3301
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3302
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3303
+ ? this.dialect.nullJsonLiteral
3304
+ : this.dialect.emptyJsonArrayLiteral;
3305
+ jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3306
+ }
3307
+ }
3308
+ const jsonObj = this.dialect.buildJsonObject(jsonPairs);
3309
+ return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
3310
+ }
2765
3311
  /**
2766
3312
  * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
2767
3313
  * Used to detect JSONB/array columns for specialized operators.
@@ -2855,6 +3401,39 @@ class QueryInterface {
2855
3401
  }
2856
3402
  return clauses;
2857
3403
  }
3404
+ /**
3405
+ * Build SQL clauses for a pgvector distance WHERE filter:
3406
+ *
3407
+ * `"embedding" <-> $1::vector < $2`
3408
+ *
3409
+ * The query vector is bound as a `$n::vector` param (never interpolated), the
3410
+ * metric maps to an operator via a fixed allow-list, and each comparison
3411
+ * threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
3412
+ * per supplied comparator (all ANDed). Param push order matches
3413
+ * {@link collectVectorFilterParams}.
3414
+ */
3415
+ buildVectorFilterClauses(field, rawColumn, filter, params) {
3416
+ const dist = filter.distance;
3417
+ const operator = this.vectorOperator(field, rawColumn, dist.metric);
3418
+ const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
3419
+ const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
3420
+ const clauses = [];
3421
+ for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
3422
+ const threshold = dist[cmp];
3423
+ if (threshold === undefined)
3424
+ continue;
3425
+ if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
3426
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
3427
+ `got ${JSON.stringify(threshold)}.`);
3428
+ }
3429
+ params.push(threshold);
3430
+ clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
3431
+ }
3432
+ if (clauses.length === 0) {
3433
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
3434
+ }
3435
+ return clauses;
3436
+ }
2858
3437
  /**
2859
3438
  * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2860
3439
  * The config name is validated to prevent injection (only alphanumeric + underscore).