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
@@ -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,10 +188,20 @@ 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. */
157
202
  options;
203
+ /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
204
+ currentAction = 'raw';
158
205
  constructor(pool, table, schema, middlewares, options) {
159
206
  this.pool = pool;
160
207
  this.table = table;
@@ -236,12 +283,25 @@ export class QueryInterface {
236
283
  resetUnlimitedWarnings() {
237
284
  this.warnedTables.clear();
238
285
  }
286
+ emitQueryEvent(sql, params, duration, action, rows, error) {
287
+ const onQuery = this.options?._onQuery;
288
+ if (!onQuery)
289
+ return;
290
+ try {
291
+ onQuery({ sql, params, duration, model: this.table, action, rows, timestamp: new Date(), error });
292
+ }
293
+ catch {
294
+ // Listener errors must never crash a query
295
+ }
296
+ }
239
297
  /**
240
298
  * Execute a pool.query with an optional timeout.
241
299
  * If timeout is set, races the query against a timer and rejects on expiry.
242
300
  * pg driver errors are translated to typed Turbine errors via wrapPgError.
243
301
  */
244
302
  async queryWithTimeout(sql, params, timeout, preparedName) {
303
+ const start = performance.now();
304
+ const action = this.currentAction;
245
305
  // Build the query argument — use object form with `name` for prepared
246
306
  // statements, or the plain (text, values) form otherwise.
247
307
  const usePrepared = preparedName && this.preparedStatementsEnabled;
@@ -250,10 +310,14 @@ export class QueryInterface {
250
310
  : this.pool.query(sql, params);
251
311
  if (!timeout) {
252
312
  try {
253
- return await exec;
313
+ const result = await exec;
314
+ this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
315
+ return result;
254
316
  }
255
317
  catch (err) {
256
- throw wrapPgError(err);
318
+ const wrapped = wrapPgError(err);
319
+ this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
320
+ throw wrapped;
257
321
  }
258
322
  }
259
323
  let timer;
@@ -261,10 +325,14 @@ export class QueryInterface {
261
325
  timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
262
326
  });
263
327
  try {
264
- return await Promise.race([exec, timeoutPromise]);
328
+ const result = await Promise.race([exec, timeoutPromise]);
329
+ this.emitQueryEvent(sql, params, performance.now() - start, action, result.rowCount ?? 0);
330
+ return result;
265
331
  }
266
332
  catch (err) {
267
- throw wrapPgError(err);
333
+ const wrapped = wrapPgError(err);
334
+ this.emitQueryEvent(sql, params, performance.now() - start, action, 0, wrapped instanceof Error ? wrapped : undefined);
335
+ throw wrapped;
268
336
  }
269
337
  finally {
270
338
  clearTimeout(timer);
@@ -280,6 +348,7 @@ export class QueryInterface {
280
348
  * To intercept queries before SQL generation, use the raw() method instead.
281
349
  */
282
350
  async executeWithMiddleware(action, args, executor) {
351
+ this.currentAction = action;
283
352
  if (this.middlewares.length === 0) {
284
353
  return executor();
285
354
  }
@@ -299,7 +368,6 @@ export class QueryInterface {
299
368
  // -------------------------------------------------------------------------
300
369
  // findUnique
301
370
  // -------------------------------------------------------------------------
302
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
303
371
  async findUnique(args) {
304
372
  return this.executeWithMiddleware('findUnique', args, async () => {
305
373
  const deferred = this.buildFindUnique(args);
@@ -403,7 +471,6 @@ export class QueryInterface {
403
471
  // -------------------------------------------------------------------------
404
472
  // findMany
405
473
  // -------------------------------------------------------------------------
406
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
407
474
  async findMany(args) {
408
475
  this.maybeWarnUnlimited(args);
409
476
  // Dev-only: warn on deeply nested with clauses
@@ -471,7 +538,16 @@ export class QueryInterface {
471
538
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
472
539
  const orderFp = args?.orderBy
473
540
  ? Object.entries(args.orderBy)
474
- .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
+ })
475
551
  .join(',')
476
552
  : '';
477
553
  const cursorFp = args?.cursor
@@ -531,7 +607,9 @@ export class QueryInterface {
531
607
  }
532
608
  }
533
609
  if (args?.orderBy) {
534
- 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)}`;
535
613
  }
536
614
  if (effectiveLimit !== undefined) {
537
615
  freshParams.push(Number(effectiveLimit));
@@ -559,11 +637,16 @@ export class QueryInterface {
559
637
  params.push(v);
560
638
  }
561
639
  }
562
- // 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
563
646
  if (effectiveLimit !== undefined) {
564
647
  params.push(Number(effectiveLimit));
565
648
  }
566
- // 5. OFFSET param
649
+ // 6. OFFSET param
567
650
  if (args?.offset !== undefined) {
568
651
  params.push(Number(args.offset));
569
652
  }
@@ -605,7 +688,6 @@ export class QueryInterface {
605
688
  * }
606
689
  * ```
607
690
  */
608
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
609
691
  async *findManyStream(args) {
610
692
  const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 1000)));
611
693
  const hasRelations = !!args?.with;
@@ -614,6 +696,7 @@ export class QueryInterface {
614
696
  ...args,
615
697
  limit: batchSize + 1,
616
698
  });
699
+ this.currentAction = 'findManyStream';
617
700
  const speculativeResult = await this.queryWithTimeout(speculativeDeferred.sql, speculativeDeferred.params, args?.timeout);
618
701
  if (speculativeResult.rows.length <= batchSize) {
619
702
  // Small drain — yield all rows and return, no cursor needed
@@ -662,7 +745,6 @@ export class QueryInterface {
662
745
  // -------------------------------------------------------------------------
663
746
  // findFirst — like findMany but returns a single row or null
664
747
  // -------------------------------------------------------------------------
665
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
666
748
  async findFirst(args) {
667
749
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
668
750
  const deferred = this.buildFindFirst(args);
@@ -688,7 +770,6 @@ export class QueryInterface {
688
770
  // -------------------------------------------------------------------------
689
771
  // findFirstOrThrow — like findFirst but throws if no record found
690
772
  // -------------------------------------------------------------------------
691
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
692
773
  async findFirstOrThrow(args) {
693
774
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
694
775
  const deferred = this.buildFindFirstOrThrow(args);
@@ -719,7 +800,6 @@ export class QueryInterface {
719
800
  // -------------------------------------------------------------------------
720
801
  // findUniqueOrThrow — like findUnique but throws if no record found
721
802
  // -------------------------------------------------------------------------
722
- // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
723
803
  async findUniqueOrThrow(args) {
724
804
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
725
805
  const deferred = this.buildFindUniqueOrThrow(args);
@@ -1257,6 +1337,15 @@ export class QueryInterface {
1257
1337
  }
1258
1338
  }
1259
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
+ }
1260
1349
  // ORDER BY
1261
1350
  if (args.orderBy) {
1262
1351
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
@@ -1324,6 +1413,117 @@ export class QueryInterface {
1324
1413
  tag: `${this.table}.groupBy`,
1325
1414
  };
1326
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
+ }
1327
1527
  // -------------------------------------------------------------------------
1328
1528
  // aggregate — standalone aggregation without groupBy
1329
1529
  // -------------------------------------------------------------------------
@@ -1633,7 +1833,11 @@ export class QueryInterface {
1633
1833
  const relDef = this.tableMeta.relations[key];
1634
1834
  if (relDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1635
1835
  const filterObj = value;
1636
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1836
+ if ('some' in filterObj ||
1837
+ 'every' in filterObj ||
1838
+ 'none' in filterObj ||
1839
+ 'is' in filterObj ||
1840
+ 'isNot' in filterObj) {
1637
1841
  const relParts = [];
1638
1842
  if (filterObj.some !== undefined)
1639
1843
  relParts.push(`some(${this.fingerprintRelFilter(relDef.to, filterObj.some)})`);
@@ -1641,6 +1845,10 @@ export class QueryInterface {
1641
1845
  relParts.push(`every(${this.fingerprintRelFilter(relDef.to, filterObj.every)})`);
1642
1846
  if (filterObj.none !== undefined)
1643
1847
  relParts.push(`none(${this.fingerprintRelFilter(relDef.to, filterObj.none)})`);
1848
+ if (filterObj.is !== undefined)
1849
+ relParts.push(`is(${this.fingerprintRelFilter(relDef.to, filterObj.is)})`);
1850
+ if (filterObj.isNot !== undefined)
1851
+ relParts.push(`isNot(${this.fingerprintRelFilter(relDef.to, filterObj.isNot)})`);
1644
1852
  parts.push(`${key}:{${relParts.join(',')}}`);
1645
1853
  continue;
1646
1854
  }
@@ -1660,6 +1868,17 @@ export class QueryInterface {
1660
1868
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1661
1869
  continue;
1662
1870
  }
1871
+ // Vector distance filter — metric (operator) and present comparators
1872
+ // change the SQL shape, so both go in the fingerprint.
1873
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
1874
+ const dist = value.distance;
1875
+ const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
1876
+ .filter((c) => dist[c] !== undefined)
1877
+ .sort()
1878
+ .join('|');
1879
+ parts.push(`${key}:vec(${dist.metric},${cmps})`);
1880
+ continue;
1881
+ }
1663
1882
  // JSON filter
1664
1883
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1665
1884
  const jKeys = Object.keys(value).sort();
@@ -1762,13 +1981,21 @@ export class QueryInterface {
1762
1981
  const relationDef = this.tableMeta.relations[key];
1763
1982
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
1764
1983
  const filterObj = value;
1765
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
1984
+ if ('some' in filterObj ||
1985
+ 'every' in filterObj ||
1986
+ 'none' in filterObj ||
1987
+ 'is' in filterObj ||
1988
+ 'isNot' in filterObj) {
1766
1989
  if (filterObj.some !== undefined)
1767
1990
  this.collectRelFilterParams(relationDef.to, filterObj.some, params);
1768
1991
  if (filterObj.none !== undefined)
1769
1992
  this.collectRelFilterParams(relationDef.to, filterObj.none, params);
1770
1993
  if (filterObj.every !== undefined)
1771
1994
  this.collectRelFilterParams(relationDef.to, filterObj.every, params);
1995
+ if (filterObj.is !== undefined)
1996
+ this.collectRelFilterParams(relationDef.to, filterObj.is, params);
1997
+ if (filterObj.isNot !== undefined)
1998
+ this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
1772
1999
  continue;
1773
2000
  }
1774
2001
  }
@@ -1776,6 +2003,14 @@ export class QueryInterface {
1776
2003
  if (value === null)
1777
2004
  continue;
1778
2005
  const rawColumn = this.toColumn(key);
2006
+ // Vector distance filter — mirrors buildVectorFilterClauses push order.
2007
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2008
+ // Validate the same way the build path does so the collect path never
2009
+ // diverges (it would throw before any param was pushed).
2010
+ this.vectorOperator(key, rawColumn, value.distance.metric);
2011
+ this.collectVectorFilterParams(key, rawColumn, value, params);
2012
+ continue;
2013
+ }
1779
2014
  // JSONB filter
1780
2015
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1781
2016
  const colType = this.getColumnPgType(rawColumn);
@@ -1872,6 +2107,37 @@ export class QueryInterface {
1872
2107
  params.push(filter.hasSome);
1873
2108
  // isEmpty has no params (IS NULL / IS NOT NULL)
1874
2109
  }
2110
+ /**
2111
+ * Collect params for an orderBy clause. Only vector KNN ordering pushes a
2112
+ * param (the `$n::vector` query vector); plain direction ordering is
2113
+ * parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
2114
+ * param re-collection stays in lockstep.
2115
+ */
2116
+ collectOrderByParams(orderBy, params) {
2117
+ for (const [key, dir] of Object.entries(orderBy)) {
2118
+ if (isVectorOrderBy(dir)) {
2119
+ const rawColumn = this.toColumn(key);
2120
+ // Re-run the same validation as buildOrderBy so the collect path can
2121
+ // never push a param that the build path rejected (or vice versa).
2122
+ this.vectorOperator(key, rawColumn, dir.distance.metric);
2123
+ this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2124
+ }
2125
+ }
2126
+ }
2127
+ /**
2128
+ * Collect params for a vector distance WHERE filter. Mirrors
2129
+ * {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
2130
+ * the comparison threshold(s).
2131
+ */
2132
+ collectVectorFilterParams(field, rawColumn, filter, params) {
2133
+ const dist = filter.distance;
2134
+ this.pushVectorParam(field, rawColumn, dist.to, params);
2135
+ for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
2136
+ const threshold = dist[cmp];
2137
+ if (threshold !== undefined)
2138
+ params.push(threshold);
2139
+ }
2140
+ }
1875
2141
  /**
1876
2142
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1877
2143
  * buildSelectWithRelations / buildRelationSubquery.
@@ -1968,6 +2234,27 @@ export class QueryInterface {
1968
2234
  const targetMeta = this.schema.tables[targetTable];
1969
2235
  if (!targetMeta)
1970
2236
  return;
2237
+ // manyToMany param order mirrors buildManyToManySubquery:
2238
+ // where params → limit param → nested-with params (always, both paths).
2239
+ if (relDef.type === 'manyToMany') {
2240
+ if (spec.where) {
2241
+ for (const [, v] of Object.entries(spec.where)) {
2242
+ params.push(v);
2243
+ }
2244
+ }
2245
+ if (spec.limit) {
2246
+ params.push(Number(spec.limit));
2247
+ }
2248
+ if (spec.with) {
2249
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2250
+ const nestedRelDef = targetMeta.relations[nestedRelName];
2251
+ if (!nestedRelDef)
2252
+ continue;
2253
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2254
+ }
2255
+ }
2256
+ return;
2257
+ }
1971
2258
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
1972
2259
  // Non-wrapped path: nested relations BEFORE where/limit
1973
2260
  if (!willWrap && spec.with) {
@@ -2121,7 +2408,11 @@ export class QueryInterface {
2121
2408
  if (relationDef && typeof value === 'object' && value !== null && !Array.isArray(value)) {
2122
2409
  const filterObj = value;
2123
2410
  // Check if this is a relation filter (has some/every/none keys)
2124
- if ('some' in filterObj || 'every' in filterObj || 'none' in filterObj) {
2411
+ if ('some' in filterObj ||
2412
+ 'every' in filterObj ||
2413
+ 'none' in filterObj ||
2414
+ 'is' in filterObj ||
2415
+ 'isNot' in filterObj) {
2125
2416
  const relClause = this.buildRelationFilter(key, relationDef, filterObj, params);
2126
2417
  if (relClause)
2127
2418
  andClauses.push(relClause);
@@ -2135,6 +2426,12 @@ export class QueryInterface {
2135
2426
  andClauses.push(`${column} IS NULL`);
2136
2427
  continue;
2137
2428
  }
2429
+ // Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
2430
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2431
+ const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
2432
+ andClauses.push(...vecClauses);
2433
+ continue;
2434
+ }
2138
2435
  // Handle JSONB filter operators (for json/jsonb columns)
2139
2436
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2140
2437
  const colType = this.getColumnPgType(rawColumn);
@@ -2238,6 +2535,20 @@ export class QueryInterface {
2238
2535
  // "every" with empty filter = true (all match trivially)
2239
2536
  }
2240
2537
  }
2538
+ // "is": EXISTS — for to-one relations (same SQL as "some")
2539
+ if (filterObj.is !== undefined) {
2540
+ const subWhere = filterObj.is;
2541
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
2542
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
2543
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
2544
+ }
2545
+ // "isNot": NOT EXISTS — for to-one relations (same SQL as "none")
2546
+ if (filterObj.isNot !== undefined) {
2547
+ const subWhere = filterObj.isNot;
2548
+ const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
2549
+ const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
2550
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
2551
+ }
2241
2552
  return clauses.length > 0 ? clauses.join(' AND ') : null;
2242
2553
  }
2243
2554
  /**
@@ -2327,8 +2638,17 @@ export class QueryInterface {
2327
2638
  }
2328
2639
  return clauses;
2329
2640
  }
2330
- /** Build ORDER BY clause from an object */
2331
- buildOrderBy(orderBy) {
2641
+ /**
2642
+ * Build ORDER BY clause from an object.
2643
+ *
2644
+ * Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
2645
+ * columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
2646
+ * Vector ordering binds the query vector as a `$n::vector` param, so a `params`
2647
+ * array MUST be supplied when a vector ordering may be present (top-level
2648
+ * findMany path). When `params` is omitted (groupBy / relation path) a vector
2649
+ * ordering throws — KNN ordering is only supported at the top level.
2650
+ */
2651
+ buildOrderBy(orderBy, params) {
2332
2652
  // Dev-only: validate that orderBy fields exist in the table schema
2333
2653
  if (process.env.NODE_ENV !== 'production') {
2334
2654
  for (const key of Object.keys(orderBy)) {
@@ -2345,12 +2665,85 @@ export class QueryInterface {
2345
2665
  if (meta && !(key in meta.columnMap)) {
2346
2666
  throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2347
2667
  }
2668
+ // Vector KNN ordering: { distance: { to, metric, direction? } }
2669
+ if (isVectorOrderBy(dir)) {
2670
+ if (!params) {
2671
+ throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
2672
+ }
2673
+ const rawColumn = this.toColumn(key);
2674
+ const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
2675
+ const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2676
+ const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2677
+ return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
2678
+ }
2348
2679
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2349
2680
  return `${this.toSqlColumn(key)} ${safeDir}`;
2350
2681
  })
2351
2682
  .join(', ');
2352
2683
  }
2684
+ // -------------------------------------------------------------------------
2685
+ // pgvector helpers (similarity search)
2686
+ // -------------------------------------------------------------------------
2687
+ /**
2688
+ * Resolve a {@link VectorMetric} to its pgvector distance operator from a
2689
+ * fixed allow-list, validating the target column is actually a `vector`
2690
+ * column. Throws {@link ValidationError} for an unknown metric or a
2691
+ * non-vector column — a user-supplied string can never become a SQL operator.
2692
+ */
2693
+ vectorOperator(field, rawColumn, metric) {
2694
+ const colType = this.getColumnPgType(rawColumn);
2695
+ if (colType !== 'vector') {
2696
+ throw new ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
2697
+ `(actual type: ${colType}); cannot apply a vector distance operation.`);
2698
+ }
2699
+ const op = VECTOR_METRIC_OPERATORS[metric];
2700
+ if (!op) {
2701
+ throw new ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
2702
+ `Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
2703
+ }
2704
+ return op;
2705
+ }
2706
+ /**
2707
+ * Validate and bind a query vector as a single `$n::vector` parameter.
2708
+ * Every element must be a finite number (no NaN / Infinity / strings) so a
2709
+ * malformed array can never produce a broken `::vector` literal, and the array
2710
+ * is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
2711
+ * placeholder string.
2712
+ */
2713
+ pushVectorParam(field, _rawColumn, to, params) {
2714
+ if (!Array.isArray(to) || to.length === 0) {
2715
+ throw new ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
2716
+ }
2717
+ for (const el of to) {
2718
+ if (typeof el !== 'number' || !Number.isFinite(el)) {
2719
+ throw new ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
2720
+ `got ${JSON.stringify(el)}.`);
2721
+ }
2722
+ }
2723
+ // Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
2724
+ // as finite numbers, so the joined string is safe; it is still passed as a
2725
+ // bound param (never interpolated) and cast with ::vector.
2726
+ params.push(`[${to.join(',')}]`);
2727
+ return `${this.p(params.length)}::vector`;
2728
+ }
2353
2729
  /** Parse a flat row: convert snake_case to camelCase + Date coercion */
2730
+ /**
2731
+ * Returns the set of camelCase field names for a table's date columns,
2732
+ * derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
2733
+ * memoized per table. Used so nested relation rows (camelCase keys) coerce
2734
+ * dates the same way top-level rows do.
2735
+ */
2736
+ getCamelDateFields(table, meta) {
2737
+ let camel = this.camelDateFieldCache.get(table);
2738
+ if (!camel) {
2739
+ camel = new Set();
2740
+ for (const col of meta.dateColumns) {
2741
+ camel.add(meta.reverseColumnMap[col] ?? col);
2742
+ }
2743
+ this.camelDateFieldCache.set(table, camel);
2744
+ }
2745
+ return camel;
2746
+ }
2354
2747
  parseRow(row, table) {
2355
2748
  const parsed = {};
2356
2749
  const meta = this.schema.tables[table];
@@ -2358,12 +2751,16 @@ export class QueryInterface {
2358
2751
  // Fast path: use pre-computed maps (avoids regex per column per row)
2359
2752
  const reverseMap = meta.reverseColumnMap;
2360
2753
  const dateCols = meta.dateColumns;
2754
+ // camelCase-keyed date fields, so nested json_build_object rows (whose
2755
+ // keys are already camelCase) get the same Date coercion as top-level rows.
2756
+ const camelDateFields = this.getCamelDateFields(table, meta);
2361
2757
  const keys = Object.keys(row);
2362
2758
  for (let i = 0; i < keys.length; i++) {
2363
2759
  const col = keys[i];
2364
2760
  const value = row[col];
2365
2761
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
2366
- if (dateCols.has(col) && value !== null && !(value instanceof Date)) {
2762
+ // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
2763
+ if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
2367
2764
  parsed[field] = new Date(value);
2368
2765
  }
2369
2766
  else {
@@ -2407,14 +2804,15 @@ export class QueryInterface {
2407
2804
  if (typeof rawValue === 'string') {
2408
2805
  try {
2409
2806
  const jsonVal = JSON.parse(rawValue);
2410
- // After parsing, apply parseRow to each item for snake→camel + date coercion
2807
+ // After parsing, recurse via parseNestedRow so each item gets date
2808
+ // coercion AND its own sub-relations parsed at arbitrary depth.
2411
2809
  if (Array.isArray(jsonVal)) {
2412
2810
  parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2413
- ? this.parseRow(item, relDef.to)
2811
+ ? this.parseNestedRow(item, relDef.to)
2414
2812
  : item);
2415
2813
  }
2416
2814
  else if (typeof jsonVal === 'object' && jsonVal !== null) {
2417
- parsed[relName] = this.parseRow(jsonVal, relDef.to);
2815
+ parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
2418
2816
  }
2419
2817
  else {
2420
2818
  parsed[relName] = jsonVal;
@@ -2426,10 +2824,12 @@ export class QueryInterface {
2426
2824
  }
2427
2825
  }
2428
2826
  else if (Array.isArray(rawValue)) {
2429
- parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null ? this.parseRow(item, relDef.to) : item);
2827
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
2828
+ ? this.parseNestedRow(item, relDef.to)
2829
+ : item);
2430
2830
  }
2431
2831
  else if (typeof rawValue === 'object' && rawValue !== null) {
2432
- parsed[relName] = this.parseRow(rawValue, relDef.to);
2832
+ parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
2433
2833
  }
2434
2834
  else {
2435
2835
  parsed[relName] = rawValue;
@@ -2630,6 +3030,12 @@ export class QueryInterface {
2630
3030
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
2631
3031
  // so we must NOT build them here (they would push orphaned params).
2632
3032
  const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3033
+ // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3034
+ // where, orderBy, and select/omit are handled there (the target alias is the
3035
+ // row source, exactly like hasMany), so short-circuit before the hasMany logic.
3036
+ if (relDef.type === 'manyToMany') {
3037
+ return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
3038
+ }
2633
3039
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
2634
3040
  if (!willWrap && spec !== true && spec.with) {
2635
3041
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2726,6 +3132,146 @@ export class QueryInterface {
2726
3132
  // belongsTo / hasOne — return single object
2727
3133
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
2728
3134
  }
3135
+ /**
3136
+ * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
3137
+ * table through a junction (join) table.
3138
+ *
3139
+ * Shape (no LIMIT/ORDER):
3140
+ * ```sql
3141
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
3142
+ * FROM <target> <talias>
3143
+ * JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
3144
+ * WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
3145
+ * ```
3146
+ *
3147
+ * With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
3148
+ * applies BEFORE aggregation (identical strategy to hasMany).
3149
+ *
3150
+ * Cardinality is always 'many' → empty-array fallback, never NULL.
3151
+ *
3152
+ * IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
3153
+ * {@link collectRelationSubqueryParams} or pipeline batching will desync.
3154
+ */
3155
+ buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
3156
+ if (!relDef.through) {
3157
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
3158
+ }
3159
+ const targetTable = relDef.to;
3160
+ const qTarget = this.q(targetTable);
3161
+ const qJunction = this.q(relDef.through.table);
3162
+ const qParent = this.q(parentRef);
3163
+ const jalias = `${talias}j`; // junction alias, distinct from the target alias
3164
+ // JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
3165
+ const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
3166
+ // The target PK is the column(s) the junction's targetKey references. An empty
3167
+ // introspected PK means we cannot know what to JOIN on — fail loudly rather than
3168
+ // silently guessing `id` and generating a wrong JOIN.
3169
+ if (targetMeta.primaryKey.length === 0) {
3170
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
3171
+ `cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
3172
+ }
3173
+ const targetPk = targetMeta.primaryKey;
3174
+ if (targetKeys.length !== targetPk.length) {
3175
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
3176
+ `but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
3177
+ }
3178
+ const joinOn = targetKeys
3179
+ .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
3180
+ .join(' AND ');
3181
+ // Correlation: junction.sourceKey = parent.<referenceKey>.
3182
+ const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
3183
+ const refKeys = normalizeKeyColumns(relDef.referenceKey);
3184
+ if (sourceKeys.length !== refKeys.length) {
3185
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
3186
+ `but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
3187
+ }
3188
+ let whereClause = sourceKeys
3189
+ .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3190
+ .join(' AND ');
3191
+ // ORDER BY on the target rows
3192
+ let orderClause = '';
3193
+ if (spec !== true && spec.orderBy) {
3194
+ const orders = Object.entries(spec.orderBy)
3195
+ .map(([k, dir]) => {
3196
+ const col = camelToSnake(k);
3197
+ if (!targetMeta.allColumns.includes(col)) {
3198
+ throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3199
+ }
3200
+ const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3201
+ return `${talias}.${this.q(col)} ${safeDir}`;
3202
+ })
3203
+ .join(', ');
3204
+ orderClause = ` ORDER BY ${orders}`;
3205
+ }
3206
+ // Additional WHERE filters on the target — properly parameterized.
3207
+ if (spec !== true && spec.where) {
3208
+ for (const [k, v] of Object.entries(spec.where)) {
3209
+ const col = camelToSnake(k);
3210
+ if (!targetMeta.allColumns.includes(col)) {
3211
+ throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3212
+ }
3213
+ params.push(v);
3214
+ whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3215
+ }
3216
+ }
3217
+ // LIMIT
3218
+ let limitClause = '';
3219
+ if (spec !== true && spec.limit) {
3220
+ params.push(Number(spec.limit));
3221
+ limitClause = ` LIMIT ${this.p(params.length)}`;
3222
+ }
3223
+ const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
3224
+ // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
3225
+ // so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
3226
+ if (limitClause || orderClause) {
3227
+ const innerAlias = `${talias}i`;
3228
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
3229
+ `${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
3230
+ const innerJsonPairs = targetColumns.map((col) => [
3231
+ targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
3232
+ `${innerAlias}.${this.q(col)}`,
3233
+ ]);
3234
+ // Nested relations reference the inner alias.
3235
+ if (spec !== true && spec.with) {
3236
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3237
+ const nestedRelDef = targetMeta.relations[nestedRelName];
3238
+ if (!nestedRelDef) {
3239
+ throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
3240
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
3241
+ }
3242
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3243
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3244
+ ? this.dialect.nullJsonLiteral
3245
+ : this.dialect.emptyJsonArrayLiteral;
3246
+ innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3247
+ }
3248
+ }
3249
+ const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
3250
+ return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
3251
+ }
3252
+ // Simple path: build the json object pairs directly off the target alias,
3253
+ // including any nested relations (correlated to the target alias).
3254
+ const jsonPairs = targetColumns.map((col) => [
3255
+ targetMeta.reverseColumnMap[col] ?? snakeToCamel(col),
3256
+ `${talias}.${this.q(col)}`,
3257
+ ]);
3258
+ if (spec !== true && spec.with) {
3259
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3260
+ const nestedRelDef = targetMeta.relations[nestedRelName];
3261
+ if (!nestedRelDef) {
3262
+ throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
3263
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
3264
+ }
3265
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3266
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3267
+ ? this.dialect.nullJsonLiteral
3268
+ : this.dialect.emptyJsonArrayLiteral;
3269
+ jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3270
+ }
3271
+ }
3272
+ const jsonObj = this.dialect.buildJsonObject(jsonPairs);
3273
+ return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
3274
+ }
2729
3275
  /**
2730
3276
  * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
2731
3277
  * Used to detect JSONB/array columns for specialized operators.
@@ -2819,6 +3365,39 @@ export class QueryInterface {
2819
3365
  }
2820
3366
  return clauses;
2821
3367
  }
3368
+ /**
3369
+ * Build SQL clauses for a pgvector distance WHERE filter:
3370
+ *
3371
+ * `"embedding" <-> $1::vector < $2`
3372
+ *
3373
+ * The query vector is bound as a `$n::vector` param (never interpolated), the
3374
+ * metric maps to an operator via a fixed allow-list, and each comparison
3375
+ * threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
3376
+ * per supplied comparator (all ANDed). Param push order matches
3377
+ * {@link collectVectorFilterParams}.
3378
+ */
3379
+ buildVectorFilterClauses(field, rawColumn, filter, params) {
3380
+ const dist = filter.distance;
3381
+ const operator = this.vectorOperator(field, rawColumn, dist.metric);
3382
+ const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
3383
+ const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
3384
+ const clauses = [];
3385
+ for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
3386
+ const threshold = dist[cmp];
3387
+ if (threshold === undefined)
3388
+ continue;
3389
+ if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
3390
+ throw new ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
3391
+ `got ${JSON.stringify(threshold)}.`);
3392
+ }
3393
+ params.push(threshold);
3394
+ clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
3395
+ }
3396
+ if (clauses.length === 0) {
3397
+ throw new ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
3398
+ }
3399
+ return clauses;
3400
+ }
2822
3401
  /**
2823
3402
  * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2824
3403
  * The config name is validated to prevent injection (only alphanumeric + underscore).