turbine-orm 0.16.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +180 -12
  2. package/dist/adapters/cockroachdb.js +4 -2
  3. package/dist/adapters/index.js +4 -1
  4. package/dist/adapters/yugabytedb.js +4 -2
  5. package/dist/cjs/adapters/cockroachdb.js +4 -2
  6. package/dist/cjs/adapters/index.js +4 -1
  7. package/dist/cjs/adapters/yugabytedb.js +4 -2
  8. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  9. package/dist/cjs/cli/studio.js +35 -73
  10. package/dist/cjs/client.js +164 -0
  11. package/dist/cjs/errors.js +35 -5
  12. package/dist/cjs/generate.js +14 -3
  13. package/dist/cjs/index.js +10 -2
  14. package/dist/cjs/introspect.js +81 -0
  15. package/dist/cjs/nested-write.js +70 -6
  16. package/dist/cjs/query/builder.js +581 -17
  17. package/dist/cjs/realtime.js +147 -0
  18. package/dist/cjs/schema-builder.js +86 -0
  19. package/dist/cjs/schema.js +10 -0
  20. package/dist/cjs/typed-sql.js +149 -0
  21. package/dist/cli/studio-ui.generated.js +1 -1
  22. package/dist/cli/studio.js +35 -73
  23. package/dist/client.d.ts +120 -0
  24. package/dist/client.js +165 -1
  25. package/dist/errors.js +35 -5
  26. package/dist/generate.js +14 -3
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.js +5 -1
  29. package/dist/introspect.js +81 -0
  30. package/dist/nested-write.js +70 -6
  31. package/dist/query/builder.d.ts +104 -1
  32. package/dist/query/builder.js +582 -18
  33. package/dist/query/index.d.ts +1 -1
  34. package/dist/query/types.d.ts +126 -2
  35. package/dist/realtime.d.ts +71 -0
  36. package/dist/realtime.js +144 -0
  37. package/dist/schema-builder.d.ts +68 -1
  38. package/dist/schema-builder.js +85 -0
  39. package/dist/schema.d.ts +18 -1
  40. package/dist/schema.js +10 -0
  41. package/dist/typed-sql.d.ts +101 -0
  42. package/dist/typed-sql.js +145 -0
  43. package/package.json +17 -15
@@ -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
  // -------------------------------------------------------------------------
@@ -1542,12 +1723,24 @@ class QueryInterface {
1542
1723
  */
1543
1724
  resolveColumns(select, omit) {
1544
1725
  if (select) {
1726
+ // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
1727
+ // style) instead of the object shape. Object.entries() would iterate the
1728
+ // numeric indices and throw a cryptic `Unknown field "0"` — catch it early
1729
+ // with an actionable message.
1730
+ if (Array.isArray(select)) {
1731
+ throw new errors_js_1.ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
1732
+ `(e.g. { id: true, name: true }), not an array.`);
1733
+ }
1545
1734
  // Only include columns where value is true
1546
1735
  return Object.entries(select)
1547
1736
  .filter(([, v]) => v)
1548
1737
  .map(([k]) => this.toColumn(k));
1549
1738
  }
1550
1739
  if (omit) {
1740
+ if (Array.isArray(omit)) {
1741
+ throw new errors_js_1.ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
1742
+ `(e.g. { createdAt: true }), not an array.`);
1743
+ }
1551
1744
  // Include all columns except those where value is true
1552
1745
  const omitCols = new Set(Object.entries(omit)
1553
1746
  .filter(([, v]) => v)
@@ -1723,6 +1916,17 @@ class QueryInterface {
1723
1916
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1724
1917
  continue;
1725
1918
  }
1919
+ // Vector distance filter — metric (operator) and present comparators
1920
+ // change the SQL shape, so both go in the fingerprint.
1921
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
1922
+ const dist = value.distance;
1923
+ const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
1924
+ .filter((c) => dist[c] !== undefined)
1925
+ .sort()
1926
+ .join('|');
1927
+ parts.push(`${key}:vec(${dist.metric},${cmps})`);
1928
+ continue;
1929
+ }
1726
1930
  // JSON filter
1727
1931
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1728
1932
  const jKeys = Object.keys(value).sort();
@@ -1847,6 +2051,14 @@ class QueryInterface {
1847
2051
  if (value === null)
1848
2052
  continue;
1849
2053
  const rawColumn = this.toColumn(key);
2054
+ // Vector distance filter — mirrors buildVectorFilterClauses push order.
2055
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2056
+ // Validate the same way the build path does so the collect path never
2057
+ // diverges (it would throw before any param was pushed).
2058
+ this.vectorOperator(key, rawColumn, value.distance.metric);
2059
+ this.collectVectorFilterParams(key, rawColumn, value, params);
2060
+ continue;
2061
+ }
1850
2062
  // JSONB filter
1851
2063
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
1852
2064
  const colType = this.getColumnPgType(rawColumn);
@@ -1943,6 +2155,37 @@ class QueryInterface {
1943
2155
  params.push(filter.hasSome);
1944
2156
  // isEmpty has no params (IS NULL / IS NOT NULL)
1945
2157
  }
2158
+ /**
2159
+ * Collect params for an orderBy clause. Only vector KNN ordering pushes a
2160
+ * param (the `$n::vector` query vector); plain direction ordering is
2161
+ * parameterless. Mirrors buildOrderBy's push order exactly so the cached-SQL
2162
+ * param re-collection stays in lockstep.
2163
+ */
2164
+ collectOrderByParams(orderBy, params) {
2165
+ for (const [key, dir] of Object.entries(orderBy)) {
2166
+ if (isVectorOrderBy(dir)) {
2167
+ const rawColumn = this.toColumn(key);
2168
+ // Re-run the same validation as buildOrderBy so the collect path can
2169
+ // never push a param that the build path rejected (or vice versa).
2170
+ this.vectorOperator(key, rawColumn, dir.distance.metric);
2171
+ this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2172
+ }
2173
+ }
2174
+ }
2175
+ /**
2176
+ * Collect params for a vector distance WHERE filter. Mirrors
2177
+ * {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
2178
+ * the comparison threshold(s).
2179
+ */
2180
+ collectVectorFilterParams(field, rawColumn, filter, params) {
2181
+ const dist = filter.distance;
2182
+ this.pushVectorParam(field, rawColumn, dist.to, params);
2183
+ for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
2184
+ const threshold = dist[cmp];
2185
+ if (threshold !== undefined)
2186
+ params.push(threshold);
2187
+ }
2188
+ }
1946
2189
  /**
1947
2190
  * Produce a fingerprint for a `with` clause tree. Recursion mirrors
1948
2191
  * buildSelectWithRelations / buildRelationSubquery.
@@ -2039,6 +2282,27 @@ class QueryInterface {
2039
2282
  const targetMeta = this.schema.tables[targetTable];
2040
2283
  if (!targetMeta)
2041
2284
  return;
2285
+ // manyToMany param order mirrors buildManyToManySubquery:
2286
+ // where params → limit param → nested-with params (always, both paths).
2287
+ if (relDef.type === 'manyToMany') {
2288
+ if (spec.where) {
2289
+ for (const [, v] of Object.entries(spec.where)) {
2290
+ params.push(v);
2291
+ }
2292
+ }
2293
+ if (spec.limit) {
2294
+ params.push(Number(spec.limit));
2295
+ }
2296
+ if (spec.with) {
2297
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
2298
+ const nestedRelDef = targetMeta.relations[nestedRelName];
2299
+ if (!nestedRelDef)
2300
+ continue;
2301
+ this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2302
+ }
2303
+ }
2304
+ return;
2305
+ }
2042
2306
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2043
2307
  // Non-wrapped path: nested relations BEFORE where/limit
2044
2308
  if (!willWrap && spec.with) {
@@ -2055,8 +2319,10 @@ class QueryInterface {
2055
2319
  params.push(v);
2056
2320
  }
2057
2321
  }
2058
- // limit param
2059
- if (spec.limit) {
2322
+ // limit param — only hasMany parameterizes its limit (mirrors
2323
+ // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2324
+ // pushing one here would orphan a param and desync the collect path.
2325
+ if (relDef.type === 'hasMany' && spec.limit) {
2060
2326
  params.push(Number(spec.limit));
2061
2327
  }
2062
2328
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2210,6 +2476,12 @@ class QueryInterface {
2210
2476
  andClauses.push(`${column} IS NULL`);
2211
2477
  continue;
2212
2478
  }
2479
+ // Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
2480
+ if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2481
+ const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
2482
+ andClauses.push(...vecClauses);
2483
+ continue;
2484
+ }
2213
2485
  // Handle JSONB filter operators (for json/jsonb columns)
2214
2486
  if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2215
2487
  const colType = this.getColumnPgType(rawColumn);
@@ -2258,6 +2530,25 @@ class QueryInterface {
2258
2530
  andClauses.push(...opClauses);
2259
2531
  continue;
2260
2532
  }
2533
+ // Strict validation: a plain (non-array, non-Date) object on a non-JSON
2534
+ // column matched no known filter shape — almost always a misspelled
2535
+ // operator (`startWith` for `startsWith`) or a stray nested object.
2536
+ // Silently treating it as `col = $1` returns wrong rows with no error, so
2537
+ // throw with the offending keys and the supported operator list. JSON/JSONB
2538
+ // columns legitimately accept object values for equality, so they fall
2539
+ // through unchanged.
2540
+ if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
2541
+ const colType = this.getColumnPgType(rawColumn);
2542
+ if (colType !== 'json' && colType !== 'jsonb') {
2543
+ const badKeys = Object.keys(value);
2544
+ throw new errors_js_1.ValidationError(badKeys.length === 0
2545
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
2546
+ `Provide a value or an operator like { gt: 1 }.`
2547
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2548
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
2549
+ `Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
2550
+ }
2551
+ }
2261
2552
  // Plain equality
2262
2553
  params.push(value);
2263
2554
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2416,8 +2707,17 @@ class QueryInterface {
2416
2707
  }
2417
2708
  return clauses;
2418
2709
  }
2419
- /** Build ORDER BY clause from an object */
2420
- buildOrderBy(orderBy) {
2710
+ /**
2711
+ * Build ORDER BY clause from an object.
2712
+ *
2713
+ * Each value is either a plain direction (`'asc'`/`'desc'`) or — for pgvector
2714
+ * columns — a `{ distance: { to, metric, direction? } }` KNN ordering object.
2715
+ * Vector ordering binds the query vector as a `$n::vector` param, so a `params`
2716
+ * array MUST be supplied when a vector ordering may be present (top-level
2717
+ * findMany path). When `params` is omitted (groupBy / relation path) a vector
2718
+ * ordering throws — KNN ordering is only supported at the top level.
2719
+ */
2720
+ buildOrderBy(orderBy, params) {
2421
2721
  // Dev-only: validate that orderBy fields exist in the table schema
2422
2722
  if (process.env.NODE_ENV !== 'production') {
2423
2723
  for (const key of Object.keys(orderBy)) {
@@ -2432,14 +2732,88 @@ class QueryInterface {
2432
2732
  return Object.entries(orderBy)
2433
2733
  .map(([key, dir]) => {
2434
2734
  if (meta && !(key in meta.columnMap)) {
2435
- throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2735
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
2736
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
2737
+ }
2738
+ // Vector KNN ordering: { distance: { to, metric, direction? } }
2739
+ if (isVectorOrderBy(dir)) {
2740
+ if (!params) {
2741
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
2742
+ }
2743
+ const rawColumn = this.toColumn(key);
2744
+ const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
2745
+ const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2746
+ const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2747
+ return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
2436
2748
  }
2437
2749
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
2438
2750
  return `${this.toSqlColumn(key)} ${safeDir}`;
2439
2751
  })
2440
2752
  .join(', ');
2441
2753
  }
2754
+ // -------------------------------------------------------------------------
2755
+ // pgvector helpers (similarity search)
2756
+ // -------------------------------------------------------------------------
2757
+ /**
2758
+ * Resolve a {@link VectorMetric} to its pgvector distance operator from a
2759
+ * fixed allow-list, validating the target column is actually a `vector`
2760
+ * column. Throws {@link ValidationError} for an unknown metric or a
2761
+ * non-vector column — a user-supplied string can never become a SQL operator.
2762
+ */
2763
+ vectorOperator(field, rawColumn, metric) {
2764
+ const colType = this.getColumnPgType(rawColumn);
2765
+ if (colType !== 'vector') {
2766
+ throw new errors_js_1.ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
2767
+ `(actual type: ${colType}); cannot apply a vector distance operation.`);
2768
+ }
2769
+ const op = VECTOR_METRIC_OPERATORS[metric];
2770
+ if (!op) {
2771
+ throw new errors_js_1.ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
2772
+ `Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
2773
+ }
2774
+ return op;
2775
+ }
2776
+ /**
2777
+ * Validate and bind a query vector as a single `$n::vector` parameter.
2778
+ * Every element must be a finite number (no NaN / Infinity / strings) so a
2779
+ * malformed array can never produce a broken `::vector` literal, and the array
2780
+ * is NEVER string-interpolated into the SQL text. Returns the `$n::vector`
2781
+ * placeholder string.
2782
+ */
2783
+ pushVectorParam(field, _rawColumn, to, params) {
2784
+ if (!Array.isArray(to) || to.length === 0) {
2785
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance on "${field}" requires a non-empty array of numbers for "to".`);
2786
+ }
2787
+ for (const el of to) {
2788
+ if (typeof el !== 'number' || !Number.isFinite(el)) {
2789
+ throw new errors_js_1.ValidationError(`[turbine] Vector "to" for column "${field}" must contain only finite numbers; ` +
2790
+ `got ${JSON.stringify(el)}.`);
2791
+ }
2792
+ }
2793
+ // Bind as a pgvector text literal '[1,2,3]'. Elements are already validated
2794
+ // as finite numbers, so the joined string is safe; it is still passed as a
2795
+ // bound param (never interpolated) and cast with ::vector.
2796
+ params.push(`[${to.join(',')}]`);
2797
+ return `${this.p(params.length)}::vector`;
2798
+ }
2442
2799
  /** Parse a flat row: convert snake_case to camelCase + Date coercion */
2800
+ /**
2801
+ * Returns the set of camelCase field names for a table's date columns,
2802
+ * derived once from `meta.dateColumns` (snake_case) via reverseColumnMap and
2803
+ * memoized per table. Used so nested relation rows (camelCase keys) coerce
2804
+ * dates the same way top-level rows do.
2805
+ */
2806
+ getCamelDateFields(table, meta) {
2807
+ let camel = this.camelDateFieldCache.get(table);
2808
+ if (!camel) {
2809
+ camel = new Set();
2810
+ for (const col of meta.dateColumns) {
2811
+ camel.add(meta.reverseColumnMap[col] ?? col);
2812
+ }
2813
+ this.camelDateFieldCache.set(table, camel);
2814
+ }
2815
+ return camel;
2816
+ }
2443
2817
  parseRow(row, table) {
2444
2818
  const parsed = {};
2445
2819
  const meta = this.schema.tables[table];
@@ -2447,12 +2821,16 @@ class QueryInterface {
2447
2821
  // Fast path: use pre-computed maps (avoids regex per column per row)
2448
2822
  const reverseMap = meta.reverseColumnMap;
2449
2823
  const dateCols = meta.dateColumns;
2824
+ // camelCase-keyed date fields, so nested json_build_object rows (whose
2825
+ // keys are already camelCase) get the same Date coercion as top-level rows.
2826
+ const camelDateFields = this.getCamelDateFields(table, meta);
2450
2827
  const keys = Object.keys(row);
2451
2828
  for (let i = 0; i < keys.length; i++) {
2452
2829
  const col = keys[i];
2453
2830
  const value = row[col];
2454
2831
  const field = reverseMap[col] ?? col; // fall back to raw col name, not regex
2455
- if (dateCols.has(col) && value !== null && !(value instanceof Date)) {
2832
+ // Top-level rows are snake_case (dateCols); nested rows are camelCase (camelDateFields).
2833
+ if ((dateCols.has(col) || camelDateFields.has(field)) && value !== null && !(value instanceof Date)) {
2456
2834
  parsed[field] = new Date(value);
2457
2835
  }
2458
2836
  else {
@@ -2496,14 +2874,15 @@ class QueryInterface {
2496
2874
  if (typeof rawValue === 'string') {
2497
2875
  try {
2498
2876
  const jsonVal = JSON.parse(rawValue);
2499
- // After parsing, apply parseRow to each item for snake→camel + date coercion
2877
+ // After parsing, recurse via parseNestedRow so each item gets date
2878
+ // coercion AND its own sub-relations parsed at arbitrary depth.
2500
2879
  if (Array.isArray(jsonVal)) {
2501
2880
  parsed[relName] = jsonVal.map((item) => typeof item === 'object' && item !== null
2502
- ? this.parseRow(item, relDef.to)
2881
+ ? this.parseNestedRow(item, relDef.to)
2503
2882
  : item);
2504
2883
  }
2505
2884
  else if (typeof jsonVal === 'object' && jsonVal !== null) {
2506
- parsed[relName] = this.parseRow(jsonVal, relDef.to);
2885
+ parsed[relName] = this.parseNestedRow(jsonVal, relDef.to);
2507
2886
  }
2508
2887
  else {
2509
2888
  parsed[relName] = jsonVal;
@@ -2515,10 +2894,12 @@ class QueryInterface {
2515
2894
  }
2516
2895
  }
2517
2896
  else if (Array.isArray(rawValue)) {
2518
- parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null ? this.parseRow(item, relDef.to) : item);
2897
+ parsed[relName] = rawValue.map((item) => typeof item === 'object' && item !== null
2898
+ ? this.parseNestedRow(item, relDef.to)
2899
+ : item);
2519
2900
  }
2520
2901
  else if (typeof rawValue === 'object' && rawValue !== null) {
2521
- parsed[relName] = this.parseRow(rawValue, relDef.to);
2902
+ parsed[relName] = this.parseNestedRow(rawValue, relDef.to);
2522
2903
  }
2523
2904
  else {
2524
2905
  parsed[relName] = rawValue;
@@ -2719,6 +3100,12 @@ class QueryInterface {
2719
3100
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
2720
3101
  // so we must NOT build them here (they would push orphaned params).
2721
3102
  const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3103
+ // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3104
+ // where, orderBy, and select/omit are handled there (the target alias is the
3105
+ // row source, exactly like hasMany), so short-circuit before the hasMany logic.
3106
+ if (relDef.type === 'manyToMany') {
3107
+ return this.buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, alias, targetMeta, targetColumns);
3108
+ }
2722
3109
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
2723
3110
  if (!willWrap && spec !== true && spec.with) {
2724
3111
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2775,9 +3162,13 @@ class QueryInterface {
2775
3162
  whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
2776
3163
  }
2777
3164
  }
2778
- // LIMIT
3165
+ // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3166
+ // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3167
+ // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3168
+ // which Postgres rejects with "could not determine data type of parameter $N"
3169
+ // (and shifts every later placeholder by one). To-one relations ignore limit.
2779
3170
  let limitClause = '';
2780
- if (spec !== true && spec.limit) {
3171
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
2781
3172
  params.push(Number(spec.limit));
2782
3173
  limitClause = ` LIMIT ${this.p(params.length)}`;
2783
3174
  }
@@ -2815,6 +3206,146 @@ class QueryInterface {
2815
3206
  // belongsTo / hasOne — return single object
2816
3207
  return `SELECT ${jsonObj} FROM ${qTarget} ${alias} WHERE ${whereClause} LIMIT 1`;
2817
3208
  }
3209
+ /**
3210
+ * Build the json_agg subquery for a `manyToMany` relation, JOINing the target
3211
+ * table through a junction (join) table.
3212
+ *
3213
+ * Shape (no LIMIT/ORDER):
3214
+ * ```sql
3215
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
3216
+ * FROM <target> <talias>
3217
+ * JOIN <junction> <jalias> ON <jalias>.<targetKey> = <talias>.<targetPK>
3218
+ * WHERE <jalias>.<sourceKey> = <parentRef>.<referenceKey>
3219
+ * ```
3220
+ *
3221
+ * With LIMIT/ORDER, the rows are wrapped in an inner subquery so the LIMIT
3222
+ * applies BEFORE aggregation (identical strategy to hasMany).
3223
+ *
3224
+ * Cardinality is always 'many' → empty-array fallback, never NULL.
3225
+ *
3226
+ * IMPORTANT: every `params.push` here MUST be mirrored, in the same order, in
3227
+ * {@link collectRelationSubqueryParams} or pipeline batching will desync.
3228
+ */
3229
+ buildManyToManySubquery(relDef, spec, params, parentRef, aliasCounter, currentDepth, currentPath, talias, targetMeta, targetColumns) {
3230
+ if (!relDef.through) {
3231
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing a \`through\` junction descriptor.`);
3232
+ }
3233
+ const targetTable = relDef.to;
3234
+ const qTarget = this.q(targetTable);
3235
+ const qJunction = this.q(relDef.through.table);
3236
+ const qParent = this.q(parentRef);
3237
+ const jalias = `${talias}j`; // junction alias, distinct from the target alias
3238
+ // JOIN: junction.targetKey = target.<targetPK>. Composite keys pair positionally.
3239
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
3240
+ // The target PK is the column(s) the junction's targetKey references. An empty
3241
+ // introspected PK means we cannot know what to JOIN on — fail loudly rather than
3242
+ // silently guessing `id` and generating a wrong JOIN.
3243
+ if (targetMeta.primaryKey.length === 0) {
3244
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" targets table "${targetTable}" which has no primary key; ` +
3245
+ `cannot determine the join column. Define a primary key or use an explicit through descriptor.`);
3246
+ }
3247
+ const targetPk = targetMeta.primaryKey;
3248
+ if (targetKeys.length !== targetPk.length) {
3249
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.targetKey has ${targetKeys.length} column(s) ` +
3250
+ `but target "${targetTable}" primary key has ${targetPk.length}. Composite keys must pair positionally.`);
3251
+ }
3252
+ const joinOn = targetKeys
3253
+ .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${talias}.${this.q(targetPk[i])}`)
3254
+ .join(' AND ');
3255
+ // Correlation: junction.sourceKey = parent.<referenceKey>.
3256
+ const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
3257
+ const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
3258
+ if (sourceKeys.length !== refKeys.length) {
3259
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}": through.sourceKey has ${sourceKeys.length} column(s) ` +
3260
+ `but referenceKey has ${refKeys.length}. Composite keys must pair positionally.`);
3261
+ }
3262
+ let whereClause = sourceKeys
3263
+ .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3264
+ .join(' AND ');
3265
+ // ORDER BY on the target rows
3266
+ let orderClause = '';
3267
+ if (spec !== true && spec.orderBy) {
3268
+ const orders = Object.entries(spec.orderBy)
3269
+ .map(([k, dir]) => {
3270
+ const col = (0, schema_js_1.camelToSnake)(k);
3271
+ if (!targetMeta.allColumns.includes(col)) {
3272
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3273
+ }
3274
+ const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3275
+ return `${talias}.${this.q(col)} ${safeDir}`;
3276
+ })
3277
+ .join(', ');
3278
+ orderClause = ` ORDER BY ${orders}`;
3279
+ }
3280
+ // Additional WHERE filters on the target — properly parameterized.
3281
+ if (spec !== true && spec.where) {
3282
+ for (const [k, v] of Object.entries(spec.where)) {
3283
+ const col = (0, schema_js_1.camelToSnake)(k);
3284
+ if (!targetMeta.allColumns.includes(col)) {
3285
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3286
+ }
3287
+ params.push(v);
3288
+ whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3289
+ }
3290
+ }
3291
+ // LIMIT
3292
+ let limitClause = '';
3293
+ if (spec !== true && spec.limit) {
3294
+ params.push(Number(spec.limit));
3295
+ limitClause = ` LIMIT ${this.p(params.length)}`;
3296
+ }
3297
+ const fromJoin = `FROM ${qTarget} ${talias} JOIN ${qJunction} ${jalias} ON ${joinOn}`;
3298
+ // When LIMIT or ORDER BY is present, wrap the joined rows in an inner subquery
3299
+ // so the LIMIT applies to rows BEFORE aggregation (same approach as hasMany).
3300
+ if (limitClause || orderClause) {
3301
+ const innerAlias = `${talias}i`;
3302
+ const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${talias}.${this.q(c)}`).join(', ')} ` +
3303
+ `${fromJoin} WHERE ${whereClause}${orderClause}${limitClause}`;
3304
+ const innerJsonPairs = targetColumns.map((col) => [
3305
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
3306
+ `${innerAlias}.${this.q(col)}`,
3307
+ ]);
3308
+ // Nested relations reference the inner alias.
3309
+ if (spec !== true && spec.with) {
3310
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3311
+ const nestedRelDef = targetMeta.relations[nestedRelName];
3312
+ if (!nestedRelDef) {
3313
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
3314
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
3315
+ }
3316
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3317
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3318
+ ? this.dialect.nullJsonLiteral
3319
+ : this.dialect.emptyJsonArrayLiteral;
3320
+ innerJsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3321
+ }
3322
+ }
3323
+ const innerJsonObj = this.dialect.buildJsonObject(innerJsonPairs);
3324
+ return `SELECT ${this.dialect.buildJsonArrayAgg(innerJsonObj)} FROM (${innerSql}) ${innerAlias}`;
3325
+ }
3326
+ // Simple path: build the json object pairs directly off the target alias,
3327
+ // including any nested relations (correlated to the target alias).
3328
+ const jsonPairs = targetColumns.map((col) => [
3329
+ targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col),
3330
+ `${talias}.${this.q(col)}`,
3331
+ ]);
3332
+ if (spec !== true && spec.with) {
3333
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
3334
+ const nestedRelDef = targetMeta.relations[nestedRelName];
3335
+ if (!nestedRelDef) {
3336
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
3337
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
3338
+ }
3339
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, talias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
3340
+ const fallback = nestedRelDef.type === 'belongsTo' || nestedRelDef.type === 'hasOne'
3341
+ ? this.dialect.nullJsonLiteral
3342
+ : this.dialect.emptyJsonArrayLiteral;
3343
+ jsonPairs.push([nestedRelName, `COALESCE((${nestedSub}), ${fallback})`]);
3344
+ }
3345
+ }
3346
+ const jsonObj = this.dialect.buildJsonObject(jsonPairs);
3347
+ return `SELECT ${this.dialect.buildJsonArrayAgg(jsonObj)} ${fromJoin} WHERE ${whereClause}`;
3348
+ }
2818
3349
  /**
2819
3350
  * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
2820
3351
  * Used to detect JSONB/array columns for specialized operators.
@@ -2908,6 +3439,39 @@ class QueryInterface {
2908
3439
  }
2909
3440
  return clauses;
2910
3441
  }
3442
+ /**
3443
+ * Build SQL clauses for a pgvector distance WHERE filter:
3444
+ *
3445
+ * `"embedding" <-> $1::vector < $2`
3446
+ *
3447
+ * The query vector is bound as a `$n::vector` param (never interpolated), the
3448
+ * metric maps to an operator via a fixed allow-list, and each comparison
3449
+ * threshold (`lt`/`lte`/`gt`/`gte`) is its own bound param. Emits one clause
3450
+ * per supplied comparator (all ANDed). Param push order matches
3451
+ * {@link collectVectorFilterParams}.
3452
+ */
3453
+ buildVectorFilterClauses(field, rawColumn, filter, params) {
3454
+ const dist = filter.distance;
3455
+ const operator = this.vectorOperator(field, rawColumn, dist.metric);
3456
+ const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
3457
+ const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
3458
+ const clauses = [];
3459
+ for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
3460
+ const threshold = dist[cmp];
3461
+ if (threshold === undefined)
3462
+ continue;
3463
+ if (typeof threshold !== 'number' || !Number.isFinite(threshold)) {
3464
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance threshold "${cmp}" on "${field}" must be a finite number; ` +
3465
+ `got ${JSON.stringify(threshold)}.`);
3466
+ }
3467
+ params.push(threshold);
3468
+ clauses.push(`${distanceExpr} ${sqlOp} ${this.p(params.length)}`);
3469
+ }
3470
+ if (clauses.length === 0) {
3471
+ throw new errors_js_1.ValidationError(`[turbine] Vector distance filter on "${field}" requires at least one comparison (lt / lte / gt / gte).`);
3472
+ }
3473
+ return clauses;
3474
+ }
2911
3475
  /**
2912
3476
  * Build SQL clause for full-text search using to_tsvector @@ to_tsquery.
2913
3477
  * The config name is validated to prevent injection (only alphanumeric + underscore).