turbine-orm 0.16.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 (41) hide show
  1. package/README.md +180 -12
  2. package/dist/adapters/cockroachdb.js +4 -2
  3. package/dist/adapters/index.js +4 -1
  4. package/dist/adapters/yugabytedb.js +4 -2
  5. package/dist/cjs/adapters/cockroachdb.js +4 -2
  6. package/dist/cjs/adapters/index.js +4 -1
  7. package/dist/cjs/adapters/yugabytedb.js +4 -2
  8. package/dist/cjs/cli/studio.js +5 -1
  9. package/dist/cjs/client.js +164 -0
  10. package/dist/cjs/errors.js +35 -5
  11. package/dist/cjs/generate.js +14 -3
  12. package/dist/cjs/index.js +10 -2
  13. package/dist/cjs/introspect.js +81 -0
  14. package/dist/cjs/nested-write.js +70 -6
  15. package/dist/cjs/query/builder.js +538 -12
  16. package/dist/cjs/realtime.js +147 -0
  17. package/dist/cjs/schema-builder.js +86 -0
  18. package/dist/cjs/schema.js +10 -0
  19. package/dist/cjs/typed-sql.js +149 -0
  20. package/dist/cli/studio.js +5 -1
  21. package/dist/client.d.ts +120 -0
  22. package/dist/client.js +165 -1
  23. package/dist/errors.js +35 -5
  24. package/dist/generate.js +14 -3
  25. package/dist/index.d.ts +4 -2
  26. package/dist/index.js +5 -1
  27. package/dist/introspect.js +81 -0
  28. package/dist/nested-write.js +70 -6
  29. package/dist/query/builder.d.ts +104 -1
  30. package/dist/query/builder.js +539 -13
  31. package/dist/query/index.d.ts +1 -1
  32. package/dist/query/types.d.ts +126 -2
  33. package/dist/realtime.d.ts +71 -0
  34. package/dist/realtime.js +144 -0
  35. package/dist/schema-builder.d.ts +68 -1
  36. package/dist/schema-builder.js +85 -0
  37. package/dist/schema.d.ts +18 -1
  38. package/dist/schema.js +10 -0
  39. package/dist/typed-sql.d.ts +101 -0
  40. package/dist/typed-sql.js +145 -0
  41. package/package.json +17 -15
@@ -13,7 +13,7 @@
13
13
  import { postgresDialect } from '../dialect.js';
14
14
  import { CircularRelationError, NotFoundError, OptimisticLockError, RelationError, TimeoutError, ValidationError, wrapPgError, } from '../errors.js';
15
15
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
16
- import { camelToSnake, snakeToCamel } from '../schema.js';
16
+ import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
17
17
  import { escapeLike, LRUCache, OPERATOR_KEYS, sqlToPreparedName } from './utils.js';
18
18
  // ---------------------------------------------------------------------------
19
19
  // Internal detection helpers — used by QueryInterface
@@ -120,6 +120,43 @@ function isTextSearchFilter(value) {
120
120
  function validateTextSearchConfig(config) {
121
121
  return /^[a-zA-Z0-9_]+$/.test(config);
122
122
  }
123
+ /**
124
+ * pgvector distance metric → operator allow-list. This is the ONLY mapping
125
+ * from a user-supplied metric token to a SQL operator; any token not present
126
+ * here is rejected, so a user value can never become an arbitrary operator.
127
+ *
128
+ * - `l2` → `<->` (Euclidean / L2 distance)
129
+ * - `cosine` → `<=>` (cosine distance)
130
+ * - `ip` → `<#>` (negative inner product)
131
+ */
132
+ const VECTOR_METRIC_OPERATORS = {
133
+ l2: '<->',
134
+ cosine: '<=>',
135
+ ip: '<#>',
136
+ };
137
+ /** Comparison keys allowed on a {@link VectorDistanceFilter}. */
138
+ const VECTOR_DISTANCE_COMPARATORS = {
139
+ lt: '<',
140
+ lte: '<=',
141
+ gt: '>',
142
+ gte: '>=',
143
+ };
144
+ /** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
145
+ function isVectorFilter(value) {
146
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
147
+ return false;
148
+ }
149
+ const dist = value.distance;
150
+ return (typeof dist === 'object' &&
151
+ dist !== null &&
152
+ !Array.isArray(dist) &&
153
+ 'to' in dist &&
154
+ 'metric' in dist);
155
+ }
156
+ /** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
157
+ function isVectorOrderBy(value) {
158
+ return isVectorFilter(value);
159
+ }
123
160
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
124
161
  export class QueryInterface {
125
162
  pool;
@@ -151,6 +188,14 @@ export class QueryInterface {
151
188
  columnArrayTypeMap;
152
189
  /** Tracks tables that have already triggered a deep-with warning (one-time) */
153
190
  deepWithWarned = new Set();
191
+ /**
192
+ * Per-table memo of date columns keyed by their camelCase FIELD name.
193
+ * `meta.dateColumns` is keyed by raw snake_case column name, which matches
194
+ * top-level rows from pg. Nested relation rows arrive from json_build_object
195
+ * with camelCase keys, so they need this camelCase-keyed set to be coerced
196
+ * to Date as well (otherwise nested dates leak through as strings).
197
+ */
198
+ camelDateFieldCache = new Map();
154
199
  /** True when this QI runs inside an active transaction (set via _txScoped option). */
155
200
  txScoped;
156
201
  /** Original options reference — forwarded to child QIs in nested writes. */
@@ -493,7 +538,16 @@ export class QueryInterface {
493
538
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
494
539
  const orderFp = args?.orderBy
495
540
  ? Object.entries(args.orderBy)
496
- .map(([k, d]) => `${k}:${d}`)
541
+ .map(([k, d]) => {
542
+ // Vector KNN ordering changes the emitted SQL operator by metric and
543
+ // adds a `::vector` param, so the metric + direction must be part of
544
+ // the cache key — otherwise two KNN queries differing only in metric
545
+ // would collide on a single cached SQL string.
546
+ if (isVectorOrderBy(d)) {
547
+ return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
548
+ }
549
+ return `${k}:${d}`;
550
+ })
497
551
  .join(',')
498
552
  : '';
499
553
  const cursorFp = args?.cursor
@@ -553,7 +607,9 @@ export class QueryInterface {
553
607
  }
554
608
  }
555
609
  if (args?.orderBy) {
556
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
610
+ // Pass freshParams so vector KNN ordering binds its `$n::vector` query
611
+ // vector at the correct position (after cursor params, before LIMIT).
612
+ sql += ` ORDER BY ${this.buildOrderBy(args.orderBy, freshParams)}`;
557
613
  }
558
614
  if (effectiveLimit !== undefined) {
559
615
  freshParams.push(Number(effectiveLimit));
@@ -581,11 +637,16 @@ export class QueryInterface {
581
637
  params.push(v);
582
638
  }
583
639
  }
584
- // 4. LIMIT param
640
+ // 4. ORDER BY params (vector KNN ordering binds a `$n::vector` query vector).
641
+ // Mirrors buildOrderBy's push order — between cursor and LIMIT.
642
+ if (args?.orderBy) {
643
+ this.collectOrderByParams(args.orderBy, params);
644
+ }
645
+ // 5. LIMIT param
585
646
  if (effectiveLimit !== undefined) {
586
647
  params.push(Number(effectiveLimit));
587
648
  }
588
- // 5. OFFSET param
649
+ // 6. OFFSET param
589
650
  if (args?.offset !== undefined) {
590
651
  params.push(Number(args.offset));
591
652
  }
@@ -1276,6 +1337,15 @@ export class QueryInterface {
1276
1337
  }
1277
1338
  }
1278
1339
  let sql = `SELECT ${selectExprs.join(', ')} FROM ${this.q(this.table)}${whereSql} GROUP BY ${groupCols.join(', ')}`;
1340
+ // HAVING — filter whole groups by their aggregate values.
1341
+ // Appends to the same `params` array, so placeholders continue from the
1342
+ // WHERE clause's parameter positions (this.p(params.length) below).
1343
+ if (args.having) {
1344
+ const havingClauses = this.buildHavingClauses(args.having, params);
1345
+ if (havingClauses.length > 0) {
1346
+ sql += ` HAVING ${havingClauses.join(' AND ')}`;
1347
+ }
1348
+ }
1279
1349
  // ORDER BY
1280
1350
  if (args.orderBy) {
1281
1351
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
@@ -1343,6 +1413,117 @@ export class QueryInterface {
1343
1413
  tag: `${this.table}.groupBy`,
1344
1414
  };
1345
1415
  }
1416
+ /**
1417
+ * Build the SQL fragments for a {@link HavingClause}.
1418
+ *
1419
+ * Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
1420
+ * from a **schema-validated, quoted** column identifier — `this.toColumn()`
1421
+ * throws {@link ValidationError} for unknown fields and `this.q()` quotes via
1422
+ * the dialect, so no unvalidated identifier ever reaches the SQL string. Every
1423
+ * comparison value is pushed onto the shared `params` array and referenced by
1424
+ * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
1425
+ * interpolation of user values.
1426
+ */
1427
+ buildHavingClauses(having, params) {
1428
+ const clauses = [];
1429
+ // Maps the per-field aggregate key to its SQL function name. The set of
1430
+ // allowed keys is fixed here — any other key on a field's filter object is
1431
+ // rejected by ValidationError below (never interpolated).
1432
+ const aggFnByKey = {
1433
+ _sum: 'SUM',
1434
+ _avg: 'AVG',
1435
+ _min: 'MIN',
1436
+ _max: 'MAX',
1437
+ _count: 'COUNT',
1438
+ };
1439
+ for (const [key, value] of Object.entries(having)) {
1440
+ if (value === undefined)
1441
+ continue;
1442
+ // Top-level `_count` (no field) → COUNT(*) for the whole group.
1443
+ if (key === '_count') {
1444
+ clauses.push(...this.buildHavingNumericClauses('COUNT(*)', value, params));
1445
+ continue;
1446
+ }
1447
+ // Otherwise `key` is a field name mapping to a per-aggregate filter object.
1448
+ if (typeof value !== 'object' || value === null) {
1449
+ throw new ValidationError(`[turbine] Invalid having filter for field "${key}" on table "${this.table}": ` +
1450
+ `expected an aggregate object like { _sum: { gt: 100 } }.`);
1451
+ }
1452
+ // toColumn validates the field against schema metadata (throws
1453
+ // ValidationError on unknown columns) and q() quotes the identifier — no
1454
+ // unvalidated identifier ever reaches the SQL string.
1455
+ const quotedCol = this.q(this.toColumn(key));
1456
+ for (const [aggKey, filter] of Object.entries(value)) {
1457
+ if (filter === undefined)
1458
+ continue;
1459
+ const fn = aggFnByKey[aggKey];
1460
+ if (!fn) {
1461
+ throw new ValidationError(`[turbine] Unknown aggregate "${aggKey}" in having for field "${key}" on table "${this.table}". ` +
1462
+ `Supported: ${Object.keys(aggFnByKey).join(', ')}.`);
1463
+ }
1464
+ const expr = `${fn}(${quotedCol})`;
1465
+ clauses.push(...this.buildHavingNumericClauses(expr, filter, params));
1466
+ }
1467
+ }
1468
+ return clauses;
1469
+ }
1470
+ /**
1471
+ * Convert a single having filter into one or more parameterized SQL
1472
+ * comparisons against the given aggregate expression. A bare number is
1473
+ * shorthand for equality. Unknown operator keys throw {@link ValidationError}.
1474
+ */
1475
+ buildHavingNumericClauses(expr, filter, params) {
1476
+ // Bare number → equality.
1477
+ if (typeof filter === 'number') {
1478
+ params.push(filter);
1479
+ return [`${expr} = ${this.p(params.length)}`];
1480
+ }
1481
+ if (typeof filter !== 'object' || filter === null) {
1482
+ throw new ValidationError(`[turbine] Invalid having filter on "${expr}" for table "${this.table}": expected a number or operator object.`);
1483
+ }
1484
+ const op = filter;
1485
+ const allowedKeys = new Set(['equals', 'not', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn']);
1486
+ for (const k of Object.keys(op)) {
1487
+ if (!allowedKeys.has(k)) {
1488
+ throw new ValidationError(`[turbine] Unknown having operator "${k}" on "${expr}" for table "${this.table}". ` +
1489
+ `Supported: ${[...allowedKeys].join(', ')}.`);
1490
+ }
1491
+ }
1492
+ const clauses = [];
1493
+ if (op.equals !== undefined) {
1494
+ params.push(op.equals);
1495
+ clauses.push(`${expr} = ${this.p(params.length)}`);
1496
+ }
1497
+ if (op.not !== undefined) {
1498
+ params.push(op.not);
1499
+ clauses.push(`${expr} != ${this.p(params.length)}`);
1500
+ }
1501
+ if (op.gt !== undefined) {
1502
+ params.push(op.gt);
1503
+ clauses.push(`${expr} > ${this.p(params.length)}`);
1504
+ }
1505
+ if (op.gte !== undefined) {
1506
+ params.push(op.gte);
1507
+ clauses.push(`${expr} >= ${this.p(params.length)}`);
1508
+ }
1509
+ if (op.lt !== undefined) {
1510
+ params.push(op.lt);
1511
+ clauses.push(`${expr} < ${this.p(params.length)}`);
1512
+ }
1513
+ if (op.lte !== undefined) {
1514
+ params.push(op.lte);
1515
+ clauses.push(`${expr} <= ${this.p(params.length)}`);
1516
+ }
1517
+ if (op.in !== undefined) {
1518
+ params.push(op.in);
1519
+ clauses.push(`${expr} = ANY(${this.p(params.length)})`);
1520
+ }
1521
+ if (op.notIn !== undefined) {
1522
+ params.push(op.notIn);
1523
+ clauses.push(`${expr} != ALL(${this.p(params.length)})`);
1524
+ }
1525
+ return clauses;
1526
+ }
1346
1527
  // -------------------------------------------------------------------------
1347
1528
  // aggregate — standalone aggregation without groupBy
1348
1529
  // -------------------------------------------------------------------------
@@ -1687,6 +1868,17 @@ export class QueryInterface {
1687
1868
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1688
1869
  continue;
1689
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
+ }
1690
1882
  // JSON filter
1691
1883
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1692
1884
  const jKeys = Object.keys(value).sort();
@@ -1811,6 +2003,14 @@ export class QueryInterface {
1811
2003
  if (value === null)
1812
2004
  continue;
1813
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
+ }
1814
2014
  // JSONB filter
1815
2015
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1816
2016
  const colType = this.getColumnPgType(rawColumn);
@@ -1907,6 +2107,37 @@ export class QueryInterface {
1907
2107
  params.push(filter.hasSome);
1908
2108
  // isEmpty has no params (IS NULL / IS NOT NULL)
1909
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
+ }
1910
2141
  /**
1911
2142
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1912
2143
  * buildSelectWithRelations / buildRelationSubquery.
@@ -2003,6 +2234,27 @@ export class QueryInterface {
2003
2234
  const targetMeta = this.schema.tables[targetTable];
2004
2235
  if (!targetMeta)
2005
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
+ }
2006
2258
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2007
2259
  // Non-wrapped path: nested relations BEFORE where/limit
2008
2260
  if (!willWrap && spec.with) {
@@ -2174,6 +2426,12 @@ export class QueryInterface {
2174
2426
  andClauses.push(`${column} IS NULL`);
2175
2427
  continue;
2176
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
+ }
2177
2435
  // Handle JSONB filter operators (for json/jsonb columns)
2178
2436
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2179
2437
  const colType = this.getColumnPgType(rawColumn);
@@ -2380,8 +2638,17 @@ export class QueryInterface {
2380
2638
  }
2381
2639
  return clauses;
2382
2640
  }
2383
- /** Build ORDER BY clause from an object */
2384
- 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) {
2385
2652
  // Dev-only: validate that orderBy fields exist in the table schema
2386
2653
  if (process.env.NODE_ENV !== 'production') {
2387
2654
  for (const key of Object.keys(orderBy)) {
@@ -2398,12 +2665,85 @@ export class QueryInterface {
2398
2665
  if (meta && !(key in meta.columnMap)) {
2399
2666
  throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2400
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
+ }
2401
2679
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2402
2680
  return `${this.toSqlColumn(key)} ${safeDir}`;
2403
2681
  })
2404
2682
  .join(', ');
2405
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
+ }
2406
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
+ }
2407
2747
  parseRow(row, table) {
2408
2748
  const parsed = {};
2409
2749
  const meta = this.schema.tables[table];
@@ -2411,12 +2751,16 @@ export class QueryInterface {
2411
2751
  // Fast path: use pre-computed maps (avoids regex per column per row)
2412
2752
  const reverseMap = meta.reverseColumnMap;
2413
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);
2414
2757
  const keys = Object.keys(row);
2415
2758
  for (let i = 0; i < keys.length; i++) {
2416
2759
  const col = keys[i];
2417
2760
  const value = row[col];
2418
2761
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
2419
- 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)) {
2420
2764
  parsed[field] = new Date(value);
2421
2765
  }
2422
2766
  else {
@@ -2460,14 +2804,15 @@ export class QueryInterface {
2460
2804
  if (typeof rawValue === 'string') {
2461
2805
  try {
2462
2806
  const jsonVal = JSON.parse(rawValue);
2463
- // 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.
2464
2809
  if (Array.isArray(jsonVal)) {
2465
2810
  parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2466
- ? this.parseRow(item, relDef.to)
2811
+ ? this.parseNestedRow(item, relDef.to)
2467
2812
  : item);
2468
2813
  }
2469
2814
  else if (typeof jsonVal === 'object' && jsonVal !== null) {
2470
- parsed[relName] = this.parseRow(jsonVal, relDef.to);
2815
+ parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
2471
2816
  }
2472
2817
  else {
2473
2818
  parsed[relName] = jsonVal;
@@ -2479,10 +2824,12 @@ export class QueryInterface {
2479
2824
  }
2480
2825
  }
2481
2826
  else if (Array.isArray(rawValue)) {
2482
- 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);
2483
2830
  }
2484
2831
  else if (typeof rawValue === 'object' && rawValue !== null) {
2485
- parsed[relName] = this.parseRow(rawValue, relDef.to);
2832
+ parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
2486
2833
  }
2487
2834
  else {
2488
2835
  parsed[relName] = rawValue;
@@ -2683,6 +3030,12 @@ export class QueryInterface {
2683
3030
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
2684
3031
  // so we must NOT build them here (they would push orphaned params).
2685
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
+ }
2686
3039
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
2687
3040
  if (!willWrap && spec !== true && spec.with) {
2688
3041
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2779,6 +3132,146 @@ export class QueryInterface {
2779
3132
  // belongsTo / hasOne — return single object
2780
3133
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
2781
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
+ }
2782
3275
  /**
2783
3276
  * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
2784
3277
  * Used to detect JSONB/array columns for specialized operators.
@@ -2872,6 +3365,39 @@ export class QueryInterface {
2872
3365
  }
2873
3366
  return clauses;
2874
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
+ }
2875
3401
  /**
2876
3402
  * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2877
3403
  * The config name is validated to prevent injection (only alphanumeric + underscore).