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
@@ -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,6 +224,14 @@ 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. */
@@ -529,7 +574,16 @@ class QueryInterface {
529
574
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
530
575
  const orderFp = args?.orderBy
531
576
  ? Object.entries(args.orderBy)
532
- .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
+ })
533
587
  .join(',')
534
588
  : '';
535
589
  const cursorFp = args?.cursor
@@ -589,7 +643,9 @@ class QueryInterface {
589
643
  }
590
644
  }
591
645
  if (args?.orderBy) {
592
- 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)}`;
593
649
  }
594
650
  if (effectiveLimit !== undefined) {
595
651
  freshParams.push(Number(effectiveLimit));
@@ -617,11 +673,16 @@ class QueryInterface {
617
673
  params.push(v);
618
674
  }
619
675
  }
620
- // 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
621
682
  if (effectiveLimit !== undefined) {
622
683
  params.push(Number(effectiveLimit));
623
684
  }
624
- // 5. OFFSET param
685
+ // 6. OFFSET param
625
686
  if (args?.offset !== undefined) {
626
687
  params.push(Number(args.offset));
627
688
  }
@@ -1312,6 +1373,15 @@ class QueryInterface {
1312
1373
  }
1313
1374
  }
1314
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
+ }
1315
1385
  // ORDER BY
1316
1386
  if (args.orderBy) {
1317
1387
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
@@ -1379,6 +1449,117 @@ class QueryInterface {
1379
1449
  tag: `${this.table}.groupBy`,
1380
1450
  };
1381
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
+ }
1382
1563
  // -------------------------------------------------------------------------
1383
1564
  // aggregate — standalone aggregation without groupBy
1384
1565
  // -------------------------------------------------------------------------
@@ -1723,6 +1904,17 @@ class QueryInterface {
1723
1904
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1724
1905
  continue;
1725
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
+ }
1726
1918
  // JSON filter
1727
1919
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1728
1920
  const jKeys = Object.keys(value).sort();
@@ -1847,6 +2039,14 @@ class QueryInterface {
1847
2039
  if (value === null)
1848
2040
  continue;
1849
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
+ }
1850
2050
  // JSONB filter
1851
2051
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1852
2052
  const colType = this.getColumnPgType(rawColumn);
@@ -1943,6 +2143,37 @@ class QueryInterface {
1943
2143
  params.push(filter.hasSome);
1944
2144
  // isEmpty has no params (IS NULL / IS NOT NULL)
1945
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
+ }
1946
2177
  /**
1947
2178
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1948
2179
  * buildSelectWithRelations / buildRelationSubquery.
@@ -2039,6 +2270,27 @@ class QueryInterface {
2039
2270
  const targetMeta = this.schema.tables[targetTable];
2040
2271
  if (!targetMeta)
2041
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
+ }
2042
2294
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2043
2295
  // Non-wrapped path: nested relations BEFORE where/limit
2044
2296
  if (!willWrap && spec.with) {
@@ -2210,6 +2462,12 @@ class QueryInterface {
2210
2462
  andClauses.push(`${column} IS NULL`);
2211
2463
  continue;
2212
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
+ }
2213
2471
  // Handle JSONB filter operators (for json/jsonb columns)
2214
2472
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2215
2473
  const colType = this.getColumnPgType(rawColumn);
@@ -2416,8 +2674,17 @@ class QueryInterface {
2416
2674
  }
2417
2675
  return clauses;
2418
2676
  }
2419
- /** Build ORDER BY clause from an object */
2420
- 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) {
2421
2688
  // Dev-only: validate that orderBy fields exist in the table schema
2422
2689
  if (process.env.NODE_ENV !== 'production') {
2423
2690
  for (const key of Object.keys(orderBy)) {
@@ -2434,12 +2701,85 @@ class QueryInterface {
2434
2701
  if (meta && !(key in meta.columnMap)) {
2435
2702
  throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2436
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
+ }
2437
2715
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2438
2716
  return `${this.toSqlColumn(key)} ${safeDir}`;
2439
2717
  })
2440
2718
  .join(', ');
2441
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
+ }
2442
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
+ }
2443
2783
  parseRow(row, table) {
2444
2784
  const parsed = {};
2445
2785
  const meta = this.schema.tables[table];
@@ -2447,12 +2787,16 @@ class QueryInterface {
2447
2787
  // Fast path: use pre-computed maps (avoids regex per column per row)
2448
2788
  const reverseMap = meta.reverseColumnMap;
2449
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);
2450
2793
  const keys = Object.keys(row);
2451
2794
  for (let i = 0; i < keys.length; i++) {
2452
2795
  const col = keys[i];
2453
2796
  const value = row[col];
2454
2797
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
2455
- 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)) {
2456
2800
  parsed[field] = new Date(value);
2457
2801
  }
2458
2802
  else {
@@ -2496,14 +2840,15 @@ class QueryInterface {
2496
2840
  if (typeof rawValue === 'string') {
2497
2841
  try {
2498
2842
  const jsonVal = JSON.parse(rawValue);
2499
- // 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.
2500
2845
  if (Array.isArray(jsonVal)) {
2501
2846
  parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2502
- ? this.parseRow(item, relDef.to)
2847
+ ? this.parseNestedRow(item, relDef.to)
2503
2848
  : item);
2504
2849
  }
2505
2850
  else if (typeof jsonVal === 'object' && jsonVal !== null) {
2506
- parsed[relName] = this.parseRow(jsonVal, relDef.to);
2851
+ parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
2507
2852
  }
2508
2853
  else {
2509
2854
  parsed[relName] = jsonVal;
@@ -2515,10 +2860,12 @@ class QueryInterface {
2515
2860
  }
2516
2861
  }
2517
2862
  else if (Array.isArray(rawValue)) {
2518
- 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);
2519
2866
  }
2520
2867
  else if (typeof rawValue === 'object' && rawValue !== null) {
2521
- parsed[relName] = this.parseRow(rawValue, relDef.to);
2868
+ parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
2522
2869
  }
2523
2870
  else {
2524
2871
  parsed[relName] = rawValue;
@@ -2719,6 +3066,12 @@ class QueryInterface {
2719
3066
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
2720
3067
  // so we must NOT build them here (they would push orphaned params).
2721
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
+ }
2722
3075
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
2723
3076
  if (!willWrap && spec !== true && spec.with) {
2724
3077
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2815,6 +3168,146 @@ class QueryInterface {
2815
3168
  // belongsTo / hasOne — return single object
2816
3169
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
2817
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
+ }
2818
3311
  /**
2819
3312
  * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
2820
3313
  * Used to detect JSONB/array columns for specialized operators.
@@ -2908,6 +3401,39 @@ class QueryInterface {
2908
3401
  }
2909
3402
  return clauses;
2910
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
+ }
2911
3437
  /**
2912
3438
  * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2913
3439
  * The config name is validated to prevent injection (only alphanumeric + underscore).