turbine-orm 0.27.0 → 0.28.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 (52) hide show
  1. package/README.md +17 -13
  2. package/dist/cjs/cli/config.js +20 -3
  3. package/dist/cjs/cli/destructive.js +47 -31
  4. package/dist/cjs/cli/index.js +273 -71
  5. package/dist/cjs/cli/mcp.js +788 -0
  6. package/dist/cjs/cli/migrate.js +95 -20
  7. package/dist/cjs/cli/studio.js +3 -2
  8. package/dist/cjs/client.js +267 -34
  9. package/dist/cjs/dialect.js +2 -0
  10. package/dist/cjs/generate.js +171 -7
  11. package/dist/cjs/index.js +4 -1
  12. package/dist/cjs/introspect.js +177 -4
  13. package/dist/cjs/query/batched-loader.js +148 -0
  14. package/dist/cjs/query/builder.js +714 -133
  15. package/dist/cjs/schema-builder.js +59 -4
  16. package/dist/cjs/schema-sql.js +315 -6
  17. package/dist/cjs/seed.js +66 -0
  18. package/dist/cli/config.d.ts +9 -2
  19. package/dist/cli/config.js +19 -3
  20. package/dist/cli/destructive.js +47 -31
  21. package/dist/cli/index.d.ts +52 -1
  22. package/dist/cli/index.js +272 -74
  23. package/dist/cli/mcp.d.ts +17 -0
  24. package/dist/cli/mcp.js +781 -0
  25. package/dist/cli/migrate.d.ts +37 -0
  26. package/dist/cli/migrate.js +92 -20
  27. package/dist/cli/studio.d.ts +3 -2
  28. package/dist/cli/studio.js +3 -2
  29. package/dist/client.d.ts +136 -1
  30. package/dist/client.js +267 -34
  31. package/dist/dialect.d.ts +17 -0
  32. package/dist/dialect.js +2 -0
  33. package/dist/generate.d.ts +17 -0
  34. package/dist/generate.js +171 -10
  35. package/dist/index.d.ts +4 -3
  36. package/dist/index.js +2 -0
  37. package/dist/introspect.d.ts +20 -1
  38. package/dist/introspect.js +175 -4
  39. package/dist/query/batched-loader.d.ts +29 -2
  40. package/dist/query/batched-loader.js +148 -1
  41. package/dist/query/builder.d.ts +156 -8
  42. package/dist/query/builder.js +715 -134
  43. package/dist/query/index.d.ts +1 -1
  44. package/dist/query/types.d.ts +113 -8
  45. package/dist/schema-builder.d.ts +73 -8
  46. package/dist/schema-builder.js +59 -4
  47. package/dist/schema-sql.d.ts +67 -0
  48. package/dist/schema-sql.js +310 -6
  49. package/dist/schema.d.ts +53 -0
  50. package/dist/seed.d.ts +4 -0
  51. package/dist/seed.js +63 -0
  52. package/package.json +2 -3
@@ -260,6 +260,21 @@ function isVectorFilter(value) {
260
260
  function isVectorOrderBy(value) {
261
261
  return isVectorFilter(value);
262
262
  }
263
+ /** Check if an orderBy value is an explicit `{ sort, nulls? }` spec. */
264
+ function isOrderBySpec(value) {
265
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && 'sort' in value;
266
+ }
267
+ /**
268
+ * Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
269
+ * direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
270
+ * path (findMany, groupBy, relation inner subqueries).
271
+ */
272
+ function normalizeOrderBy(value) {
273
+ if (isOrderBySpec(value)) {
274
+ return { dir: value.sort.toLowerCase() === 'desc' ? 'DESC' : 'ASC', nulls: value.nulls };
275
+ }
276
+ return { dir: String(value).toLowerCase() === 'desc' ? 'DESC' : 'ASC' };
277
+ }
263
278
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
264
279
  class QueryInterface {
265
280
  pool;
@@ -279,6 +294,13 @@ class QueryInterface {
279
294
  relationLoadStrategy;
280
295
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
281
296
  jsonEncoding;
297
+ /**
298
+ * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
299
+ * multi-tenancy). AND-merged into every query on the keyed table and every
300
+ * relation subquery targeting it. Undefined when none are configured, in
301
+ * which case every path is byte-identical to the pre-0.28 behavior.
302
+ */
303
+ globalFilters;
282
304
  /**
283
305
  * Tracks tables that have already triggered an unlimited-query warning so
284
306
  * the user is not spammed once per row. Per-instance state — each
@@ -310,6 +332,15 @@ class QueryInterface {
310
332
  options;
311
333
  /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
312
334
  currentAction = 'raw';
335
+ /**
336
+ * The active query's `skipGlobalFilters` opt-out, set at the top of each
337
+ * `build*` method and read deep in the (synchronous) SQL-build + param-collect
338
+ * tree — so relation subqueries, relation filters, `_count`, and relation
339
+ * `orderBy` all see it without threading it through dozens of signatures.
340
+ * Only load-bearing when {@link globalFilters} is configured; build+collect are
341
+ * synchronous per call, so this transient is never observed across an await.
342
+ */
343
+ currentSkip;
313
344
  constructor(pool, table, schema, middlewares, options) {
314
345
  this.pool = pool;
315
346
  this.table = table;
@@ -331,6 +362,10 @@ class QueryInterface {
331
362
  this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
332
363
  this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
333
364
  this.jsonEncoding = options?.jsonEncoding ?? 'object';
365
+ // Only retain the map when it has at least one entry, so `globalFilters`
366
+ // stays `undefined` (and every merge path a no-op) for the common case.
367
+ this.globalFilters =
368
+ options?.globalFilters && Object.keys(options.globalFilters).length > 0 ? options.globalFilters : undefined;
334
369
  this.txScoped = options?._txScoped ?? false;
335
370
  this.options = options;
336
371
  // Pre-compute column type lookup maps (TASK-26)
@@ -450,7 +485,7 @@ class QueryInterface {
450
485
  * and unlimited-warnings silenced — a relation load must fetch every matching
451
486
  * child, and the per-relation `limit` is applied client-side by the loader.
452
487
  */
453
- batchedContext(timeout) {
488
+ batchedContext(timeout, skip) {
454
489
  const childOptions = {
455
490
  ...this.options,
456
491
  defaultLimit: undefined,
@@ -465,6 +500,22 @@ class QueryInterface {
465
500
  buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
466
501
  inClauseParam: (values) => this.inParam(values),
467
502
  paramPlaceholder: (index) => this.p(index),
503
+ skipGlobalFilters: skip,
504
+ tableGlobalFilter: (table, alias, precedingParams) => {
505
+ const gf = this.resolveGlobalFilter(table, skip);
506
+ if (!gf)
507
+ return null;
508
+ const meta = this.schema.tables[table];
509
+ if (!meta)
510
+ return null;
511
+ // Seed the param array with `precedingParams` placeholders so
512
+ // buildAliasWhere numbers the gf params after the already-bound ones.
513
+ const seeded = new Array(precedingParams).fill(undefined);
514
+ const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
515
+ if (!clause)
516
+ return null;
517
+ return { clause, params: seeded.slice(precedingParams) };
518
+ },
468
519
  };
469
520
  }
470
521
  /**
@@ -476,13 +527,18 @@ class QueryInterface {
476
527
  */
477
528
  async runFindManyBatched(args) {
478
529
  const withClause = args.with;
530
+ // Capture the opt-out from the ARGS before any await: this.currentSkip is
531
+ // instance state on a cached accessor, so a concurrent build during the
532
+ // base-query await would overwrite it (tenant query loading relations with
533
+ // another query's skipGlobalFilters).
534
+ const skip = args.skipGlobalFilters;
479
535
  const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
480
536
  // baseArgs.with is always undefined here; the cast just bridges the R generic.
481
537
  const deferred = this.buildFindMany(baseArgs);
482
538
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
483
539
  const entities = deferred.transform(result);
484
540
  if (entities.length > 0) {
485
- await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), entities, withClause, args.timeout);
541
+ await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip), entities, withClause, args.timeout);
486
542
  }
487
543
  (0, batched_loader_js_1.stripFields)(entities, strip);
488
544
  return entities;
@@ -704,18 +760,22 @@ class QueryInterface {
704
760
  const entity = deferred.transform(result);
705
761
  if (!entity)
706
762
  return null;
707
- await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
763
+ await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters), [entity], withClause, args.timeout);
708
764
  (0, batched_loader_js_1.stripFields)([entity], proj.strip);
709
765
  return entity;
710
766
  }
711
767
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
712
768
  buildFindUnique(args) {
769
+ this.currentSkip = args.skipGlobalFilters;
713
770
  const columnsList = this.resolveColumns(args.select, args.omit);
714
- const whereObj = args.where;
771
+ // A global filter turns the where into `{ AND: [...] }`, which the
772
+ // `isSimpleWhere` test below rejects → the general (buildWhereClause) path
773
+ // handles the merge and its params uniformly.
774
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
715
775
  const colKey = columnsList ? columnsList.join(',') : '*';
716
776
  const whereFingerprint = this.fingerprintWhere(whereObj);
717
777
  const withFp = args.with ? this.withFingerprint(args.with) : '';
718
- const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
778
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}${this.globalFilterCacheSegment()}`;
719
779
  const params = [];
720
780
  // Check if all where values are simple (plain equality, no operators/null/OR).
721
781
  // Keys are sorted to match fingerprintWhere — insertion order here would let
@@ -871,24 +931,21 @@ class QueryInterface {
871
931
  }
872
932
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
873
933
  buildFindMany(args) {
934
+ this.currentSkip = args?.skipGlobalFilters;
874
935
  const columnsList = this.resolveColumns(args?.select, args?.omit);
875
936
  const colKey = columnsList ? columnsList.join(',') : '*';
876
- const whereObj = (args?.where ?? {});
937
+ // AND-merge this table's global filter into the user where; `hasWhere` gates
938
+ // the build/collect just like `args?.where` did (a merged filter can make
939
+ // an otherwise-absent where present).
940
+ const effWhere = this.mergeGlobalFilter(args?.where);
941
+ const hasWhere = effWhere !== undefined;
942
+ const whereObj = (effWhere ?? {});
877
943
  // Build fingerprint for cache lookup
878
- const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
944
+ const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
879
945
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
880
946
  const orderFp = args?.orderBy
881
947
  ? Object.entries(args.orderBy)
882
- .map(([k, d]) => {
883
- // Vector KNN ordering changes the emitted SQL operator by metric and
884
- // adds a `::vector` param, so the metric + direction must be part of
885
- // the cache key — otherwise two KNN queries differing only in metric
886
- // would collide on a single cached SQL string.
887
- if (isVectorOrderBy(d)) {
888
- return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
889
- }
890
- return `${k}:${d}`;
891
- })
948
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
892
949
  .join(',')
893
950
  : '';
894
951
  const cursorFp = args?.cursor
@@ -901,12 +958,12 @@ class QueryInterface {
901
958
  const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
902
959
  const limitFp = effectiveLimit !== undefined ? '1' : '0';
903
960
  const offsetFp = args?.offset !== undefined ? '1' : '0';
904
- const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}`;
961
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
905
962
  const params = [];
906
963
  const entry = this.acquireSql(ck, () => {
907
964
  // Fresh build — generates SQL and populates freshParams
908
965
  const freshParams = [];
909
- const { sql: freshWhereSql } = args?.where
966
+ const { sql: freshWhereSql } = hasWhere
910
967
  ? (() => {
911
968
  const clause = this.buildWhereClause(whereObj, freshParams);
912
969
  return { sql: clause ? ` WHERE ${clause}` : '' };
@@ -936,8 +993,11 @@ class QueryInterface {
936
993
  if (cursorEntries.length > 0) {
937
994
  const cursorConditions = cursorEntries.map(([k, v]) => {
938
995
  const col = this.toSqlColumn(k);
939
- const dir = args.orderBy?.[k] ?? 'asc';
940
- const op = dir === 'desc' ? '<' : '>';
996
+ // orderBy values can be the { sort, nulls } spec form — normalize
997
+ // before comparing, or a desc spec would seek the ascending side.
998
+ const dir = args.orderBy?.[k];
999
+ const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
1000
+ const op = desc ? '<' : '>';
941
1001
  freshParams.push(v);
942
1002
  return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
943
1003
  });
@@ -984,8 +1044,8 @@ class QueryInterface {
984
1044
  return sql;
985
1045
  });
986
1046
  // Collect params in exact build order:
987
- // 1. WHERE params
988
- if (args?.where) {
1047
+ // 1. WHERE params (includes the AND-merged global filter, if any)
1048
+ if (hasWhere) {
989
1049
  this.collectWhereParams(whereObj, params);
990
1050
  }
991
1051
  // 2. WITH relation params
@@ -1197,6 +1257,8 @@ class QueryInterface {
1197
1257
  });
1198
1258
  }
1199
1259
  buildCreate(args) {
1260
+ this.assertWritable('create');
1261
+ this.assertNoGeneratedColumns(args.data, 'create');
1200
1262
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
1201
1263
  const columns = entries.map(([k]) => this.toSqlColumn(k));
1202
1264
  const params = entries.map(([, v]) => v);
@@ -1271,6 +1333,10 @@ class QueryInterface {
1271
1333
  tag: `${this.table}.createMany`,
1272
1334
  };
1273
1335
  }
1336
+ this.assertWritable('createMany');
1337
+ for (const row of args.data) {
1338
+ this.assertNoGeneratedColumns(row, 'createMany');
1339
+ }
1274
1340
  const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
1275
1341
  const columns = keys.map((k) => this.toColumn(k));
1276
1342
  const rowValues = args.data.map((row) => {
@@ -1308,12 +1374,22 @@ class QueryInterface {
1308
1374
  });
1309
1375
  }
1310
1376
  buildUpdate(args) {
1377
+ this.assertWritable('update');
1378
+ this.currentSkip = args.skipGlobalFilters;
1311
1379
  const dataObj = args.data;
1312
- const whereObj = args.where;
1380
+ this.assertNoGeneratedColumns(dataObj, 'update');
1381
+ const userWhere = args.where;
1313
1382
  const lock = args.optimisticLock;
1383
+ // The empty-`where` guard checks the USER predicate only — a global filter
1384
+ // must never turn an unguarded mass update into an allowed one.
1385
+ const userHasPredicate = !this.userPredicateIsEmpty(userWhere) || !!lock;
1386
+ this.assertMutationHasPredicate('update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
1387
+ // The SQL is built from the global-filter-merged where (soft-delete keeps an
1388
+ // update from touching already-deleted rows).
1389
+ const whereObj = (this.mergeGlobalFilter(userWhere) ?? {});
1314
1390
  const setFp = this.fingerprintSet(dataObj);
1315
1391
  const whereFp = this.fingerprintWhere(whereObj);
1316
- const ck = lock ? null : `u:${setFp}|${whereFp}`;
1392
+ const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1317
1393
  const params = [];
1318
1394
  const buildSql = () => {
1319
1395
  const freshParams = [];
@@ -1331,7 +1407,6 @@ class QueryInterface {
1331
1407
  const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
1332
1408
  whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
1333
1409
  }
1334
- this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
1335
1410
  // Engines that inject their returning shape MID-statement (SQL Server
1336
1411
  // `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
1337
1412
  // absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
@@ -1345,9 +1420,6 @@ class QueryInterface {
1345
1420
  const entry = this.acquireSql(ck, buildSql);
1346
1421
  sql = entry.sql;
1347
1422
  preparedName = entry.name;
1348
- if (whereFp === '') {
1349
- this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
1350
- }
1351
1423
  }
1352
1424
  else {
1353
1425
  sql = buildSql();
@@ -1481,27 +1553,24 @@ class QueryInterface {
1481
1553
  });
1482
1554
  }
1483
1555
  buildDelete(args) {
1484
- const whereObj = args.where;
1556
+ this.assertWritable('delete');
1557
+ this.currentSkip = args.skipGlobalFilters;
1558
+ // Guard the USER predicate (a global filter must not satisfy the guard).
1559
+ this.assertMutationHasPredicate('delete', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1560
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1485
1561
  const whereFp = this.fingerprintWhere(whereObj);
1486
- const ck = `d:${whereFp}`;
1562
+ const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
1487
1563
  const params = [];
1488
- // We need to check the mutation predicate. Build the whereSql to test it.
1489
- // On cache hit we still need to validate (the shape may be empty).
1490
1564
  const entry = this.acquireSql(ck, () => {
1491
1565
  const freshParams = [];
1492
1566
  const clause = this.buildWhereClause(whereObj, freshParams);
1493
1567
  const whereSql = clause ? ` WHERE ${clause}` : '';
1494
- this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
1495
1568
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
1496
1569
  // absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
1497
1570
  return this.dialect.buildDeleteStatement
1498
1571
  ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1499
1572
  : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1500
1573
  });
1501
- // On cache hit, still validate the predicate
1502
- if (whereFp === '') {
1503
- this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
1504
- }
1505
1574
  this.collectWhereParams(whereObj, params);
1506
1575
  return {
1507
1576
  sql: entry.sql,
@@ -1541,6 +1610,10 @@ class QueryInterface {
1541
1610
  });
1542
1611
  }
1543
1612
  buildUpsert(args) {
1613
+ this.assertWritable('upsert');
1614
+ this.assertNoGeneratedColumns(args.create, 'upsert');
1615
+ this.assertNoGeneratedColumns(args.update, 'upsert');
1616
+ this.currentSkip = args.skipGlobalFilters;
1544
1617
  // Build the INSERT part from create data
1545
1618
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
1546
1619
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
@@ -1559,12 +1632,23 @@ class QueryInterface {
1559
1632
  });
1560
1633
  const updateParams = updateEntries.map(([, v]) => v);
1561
1634
  const params = [...createParams, ...updateParams];
1635
+ // Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
1636
+ // upsert never resurrects a soft-deleted row or writes across tenants. Only
1637
+ // on engines whose upsert can carry a predicate (Postgres); the gf params
1638
+ // continue the placeholder numbering after create+update params.
1639
+ let updateWhere;
1640
+ if (this.dialect.supportsUpsertUpdateWhere) {
1641
+ const gf = this.resolveGlobalFilter(this.table);
1642
+ if (gf)
1643
+ updateWhere = this.buildWhereClause(gf, params) ?? undefined;
1644
+ }
1562
1645
  const sql = this.dialect.buildUpsertStatement({
1563
1646
  table: this.q(this.table),
1564
1647
  insertColumns: columns,
1565
1648
  valuePlaceholders: placeholders,
1566
1649
  conflictColumns,
1567
1650
  updateSetClauses: setClauses,
1651
+ updateWhere,
1568
1652
  returning: '*',
1569
1653
  });
1570
1654
  return {
@@ -1587,7 +1671,7 @@ class QueryInterface {
1587
1671
  reselect: this.dialect.resultStrategy === 'reselect'
1588
1672
  ? async (exec) => {
1589
1673
  await exec(sql, params);
1590
- const sel = this.buildReselectByWhere(args.where);
1674
+ const sel = this.buildReselectByWhere((this.mergeGlobalFilter(args.where) ?? {}));
1591
1675
  return exec(sel.sql, sel.params);
1592
1676
  }
1593
1677
  : undefined,
@@ -1604,11 +1688,15 @@ class QueryInterface {
1604
1688
  });
1605
1689
  }
1606
1690
  buildUpdateMany(args) {
1691
+ this.assertWritable('updateMany');
1692
+ this.currentSkip = args.skipGlobalFilters;
1607
1693
  const dataObj = args.data;
1608
- const whereObj = args.where;
1694
+ this.assertNoGeneratedColumns(dataObj, 'updateMany');
1695
+ this.assertMutationHasPredicate('updateMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1696
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1609
1697
  const setFp = this.fingerprintSet(dataObj);
1610
1698
  const whereFp = this.fingerprintWhere(whereObj);
1611
- const ck = `um:${setFp}|${whereFp}`;
1699
+ const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1612
1700
  const params = [];
1613
1701
  const entry = this.acquireSql(ck, () => {
1614
1702
  const freshParams = [];
@@ -1616,12 +1704,8 @@ class QueryInterface {
1616
1704
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1617
1705
  const whereClause = this.buildWhereClause(whereObj, freshParams);
1618
1706
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1619
- this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
1620
1707
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1621
1708
  });
1622
- if (whereFp === '') {
1623
- this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
1624
- }
1625
1709
  this.collectSetParams(dataObj, params);
1626
1710
  this.collectWhereParams(whereObj, params);
1627
1711
  return {
@@ -1643,20 +1727,19 @@ class QueryInterface {
1643
1727
  });
1644
1728
  }
1645
1729
  buildDeleteMany(args) {
1646
- const whereObj = args.where;
1730
+ this.assertWritable('deleteMany');
1731
+ this.currentSkip = args.skipGlobalFilters;
1732
+ this.assertMutationHasPredicate('deleteMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1733
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1647
1734
  const whereFp = this.fingerprintWhere(whereObj);
1648
- const ck = `dm:${whereFp}`;
1735
+ const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
1649
1736
  const params = [];
1650
1737
  const entry = this.acquireSql(ck, () => {
1651
1738
  const freshParams = [];
1652
1739
  const clause = this.buildWhereClause(whereObj, freshParams);
1653
1740
  const whereSql = clause ? ` WHERE ${clause}` : '';
1654
- this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
1655
1741
  return `DELETE FROM ${this.q(this.table)}${whereSql}`;
1656
1742
  });
1657
- if (whereFp === '') {
1658
- this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
1659
- }
1660
1743
  this.collectWhereParams(whereObj, params);
1661
1744
  return {
1662
1745
  sql: entry.sql,
@@ -1677,17 +1760,20 @@ class QueryInterface {
1677
1760
  });
1678
1761
  }
1679
1762
  buildCount(args) {
1680
- const whereObj = (args?.where ?? {});
1681
- const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
1682
- const ck = `cnt:${whereFp}`;
1763
+ this.currentSkip = args?.skipGlobalFilters;
1764
+ const effWhere = this.mergeGlobalFilter(args?.where);
1765
+ const hasWhere = effWhere !== undefined;
1766
+ const whereObj = (effWhere ?? {});
1767
+ const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1768
+ const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
1683
1769
  const params = [];
1684
1770
  const entry = this.acquireSql(ck, () => {
1685
1771
  const freshParams = [];
1686
- const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1772
+ const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
1687
1773
  const whereSql = clause ? ` WHERE ${clause}` : '';
1688
1774
  return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1689
1775
  });
1690
- if (args?.where) {
1776
+ if (hasWhere) {
1691
1777
  this.collectWhereParams(whereObj, params);
1692
1778
  }
1693
1779
  return {
@@ -1717,9 +1803,13 @@ class QueryInterface {
1717
1803
  }
1718
1804
  }
1719
1805
  }
1806
+ this.currentSkip = args.skipGlobalFilters;
1720
1807
  const groupColsRaw = args.by.map((k) => this.toColumn(k));
1721
1808
  const groupCols = groupColsRaw.map((c) => this.q(c));
1722
- const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
1809
+ const gbWhere = this.mergeGlobalFilter(args.where);
1810
+ const { sql: whereSql, params } = gbWhere
1811
+ ? this.buildWhere(gbWhere)
1812
+ : { sql: '', params: [] };
1723
1813
  // Build SELECT expressions: group-by columns + aggregate functions
1724
1814
  const selectExprs = [...groupCols];
1725
1815
  // _count
@@ -1962,7 +2052,11 @@ class QueryInterface {
1962
2052
  });
1963
2053
  }
1964
2054
  buildAggregate(args) {
1965
- const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
2055
+ this.currentSkip = args.skipGlobalFilters;
2056
+ const aggWhere = this.mergeGlobalFilter(args.where);
2057
+ const { sql: whereSql, params } = aggWhere
2058
+ ? this.buildWhere(aggWhere)
2059
+ : { sql: '', params: [] };
1966
2060
  const meta = this.schema.tables[this.table];
1967
2061
  if (meta) {
1968
2062
  for (const group of [args._sum, args._avg, args._min, args._max]) {
@@ -2140,6 +2234,36 @@ class QueryInterface {
2140
2234
  }
2141
2235
  return null;
2142
2236
  }
2237
+ /**
2238
+ * Reject any write against a view (H4). Views are introspected with
2239
+ * `isView: true` and are read-only in every engine; a write raises a
2240
+ * {@link ValidationError} (E003) rather than emitting SQL Postgres would
2241
+ * reject (or, worse, silently applying to an updatable view).
2242
+ */
2243
+ assertWritable(operation) {
2244
+ if (this.tableMeta.isView) {
2245
+ throw new errors_js_1.ValidationError(`[turbine] Cannot ${operation} "${this.table}": it is a view (read-only). ` +
2246
+ 'Views support reads (findMany/findFirst/…) but not writes.');
2247
+ }
2248
+ }
2249
+ /**
2250
+ * Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
2251
+ * column (H3). Postgres computes these from other columns and errors if you
2252
+ * try to write them; we fail early with a clear {@link ValidationError} (E003)
2253
+ * instead of surfacing a cryptic driver error. Undefined values are ignored
2254
+ * (they're stripped from the statement anyway).
2255
+ */
2256
+ assertNoGeneratedColumns(data, operation) {
2257
+ for (const [key, value] of Object.entries(data)) {
2258
+ if (value === undefined)
2259
+ continue;
2260
+ const col = this.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === (0, schema_js_1.camelToSnake)(key));
2261
+ if (col?.isGeneratedStored) {
2262
+ throw new errors_js_1.ValidationError(`[turbine] Cannot ${operation} "${this.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
2263
+ 'column whose value the database computes — remove it from your data.');
2264
+ }
2265
+ }
2266
+ }
2143
2267
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
2144
2268
  toColumn(field) {
2145
2269
  const mapped = this.tableMeta.columnMap[field];
@@ -2469,16 +2593,7 @@ class QueryInterface {
2469
2593
  'none' in filterObj ||
2470
2594
  'is' in filterObj ||
2471
2595
  'isNot' in filterObj) {
2472
- if (filterObj.some !== undefined && filterObj.some !== null)
2473
- this.collectRelFilterParams(relationDef.to, filterObj.some, params);
2474
- if (filterObj.none !== undefined && filterObj.none !== null)
2475
- this.collectRelFilterParams(relationDef.to, filterObj.none, params);
2476
- if (filterObj.every !== undefined && filterObj.every !== null)
2477
- this.collectRelFilterParams(relationDef.to, filterObj.every, params);
2478
- if (filterObj.is !== undefined && filterObj.is !== null)
2479
- this.collectRelFilterParams(relationDef.to, filterObj.is, params);
2480
- if (filterObj.isNot !== undefined && filterObj.isNot !== null)
2481
- this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
2596
+ this.collectRelationFilterParams(relationDef, filterObj, params);
2482
2597
  continue;
2483
2598
  }
2484
2599
  }
@@ -2526,7 +2641,45 @@ class QueryInterface {
2526
2641
  params.push(value);
2527
2642
  }
2528
2643
  }
2529
- /** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
2644
+ /**
2645
+ * Param-collect mirror of {@link buildRelationFilter} for one relation-filter
2646
+ * object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
2647
+ * present branch and in the canonical order some→none→every→is→isNot, the
2648
+ * branch's sub-where params THEN the target table's global-filter params —
2649
+ * exactly the order buildRelationFilter emits. When no global filter applies
2650
+ * the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
2651
+ * Shared by every collect site that mirrors buildRelationFilter
2652
+ * (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
2653
+ */
2654
+ collectRelationFilterParams(relDef, filterObj, params) {
2655
+ const target = relDef.to;
2656
+ if (filterObj.some !== undefined && filterObj.some !== null) {
2657
+ this.collectRelFilterParams(target, filterObj.some, params);
2658
+ this.collectTargetGlobalFilterExists(target, params);
2659
+ }
2660
+ if (filterObj.none !== undefined && filterObj.none !== null) {
2661
+ this.collectRelFilterParams(target, filterObj.none, params);
2662
+ this.collectTargetGlobalFilterExists(target, params);
2663
+ }
2664
+ if (filterObj.every !== undefined && filterObj.every !== null) {
2665
+ // gf is only emitted (build) when the `every` sub-where compiles to a
2666
+ // filter — otherwise `every` is trivially true and no subquery is built.
2667
+ if (this.buildSubWhereForRelation(target, filterObj.every, []) !== null) {
2668
+ this.collectRelFilterParams(target, filterObj.every, params);
2669
+ this.collectTargetGlobalFilterExists(target, params);
2670
+ }
2671
+ }
2672
+ if (filterObj.is !== undefined) {
2673
+ if (filterObj.is !== null)
2674
+ this.collectRelFilterParams(target, filterObj.is, params);
2675
+ this.collectTargetGlobalFilterExists(target, params);
2676
+ }
2677
+ if (filterObj.isNot !== undefined) {
2678
+ if (filterObj.isNot !== null)
2679
+ this.collectRelFilterParams(target, filterObj.isNot, params);
2680
+ this.collectTargetGlobalFilterExists(target, params);
2681
+ }
2682
+ }
2530
2683
  collectRelFilterParams(targetTable, subWhere, params) {
2531
2684
  const meta = this.schema.tables[targetTable];
2532
2685
  if (!meta)
@@ -2554,17 +2707,9 @@ class QueryInterface {
2554
2707
  if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
2555
2708
  const norm = this.normalizeRelationFilter(nestedRel, value);
2556
2709
  if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
2557
- // Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
2558
- if (norm.some != null)
2559
- this.collectRelFilterParams(nestedRel.to, norm.some, params);
2560
- if (norm.none != null)
2561
- this.collectRelFilterParams(nestedRel.to, norm.none, params);
2562
- if (norm.every != null)
2563
- this.collectRelFilterParams(nestedRel.to, norm.every, params);
2564
- if (norm.is != null)
2565
- this.collectRelFilterParams(nestedRel.to, norm.is, params);
2566
- if (norm.isNot != null)
2567
- this.collectRelFilterParams(nestedRel.to, norm.isNot, params);
2710
+ // Mirrors buildRelationFilter (somenoneeveryis→isNot, each: sub-where
2711
+ // params then target global-filter params).
2712
+ this.collectRelationFilterParams(nestedRel, norm, params);
2568
2713
  continue;
2569
2714
  }
2570
2715
  }
@@ -2644,6 +2789,21 @@ class QueryInterface {
2644
2789
  // never push a param that the build path rejected (or vice versa).
2645
2790
  this.vectorOperator(key, rawColumn, dir.distance.metric);
2646
2791
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2792
+ continue;
2793
+ }
2794
+ // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2795
+ // subquery as `_count` — mirror its global-filter params. To-one relation
2796
+ // orderBy carries the target's global filter once per ordered column.
2797
+ if (this.isRelationOrderByValue(dir)) {
2798
+ const relDef = this.tableMeta.relations[key];
2799
+ if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2800
+ this.collectRelationCountParams(relDef, params);
2801
+ }
2802
+ else if (relDef) {
2803
+ for (const _col of Object.keys(dir)) {
2804
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
2805
+ }
2806
+ }
2647
2807
  }
2648
2808
  }
2649
2809
  }
@@ -2679,6 +2839,19 @@ class QueryInterface {
2679
2839
  const spec = withClause[relName];
2680
2840
  if (!spec)
2681
2841
  continue;
2842
+ // Reserved `_count` key — fingerprint by the selected relation set so
2843
+ // `_count: true` and `_count: { posts: true }` never share a cache entry.
2844
+ if (relName === '_count') {
2845
+ const c = spec;
2846
+ parts.push(c === true
2847
+ ? '_count(*)'
2848
+ : `_count(${Object.entries(c)
2849
+ .filter(([, v]) => v)
2850
+ .map(([k]) => k)
2851
+ .sort()
2852
+ .join(',')})`);
2853
+ continue;
2854
+ }
2682
2855
  const relDef = meta.relations[relName];
2683
2856
  if (!relDef) {
2684
2857
  parts.push(`unknown:${relName}`);
@@ -2711,9 +2884,9 @@ class QueryInterface {
2711
2884
  if (opts.where) {
2712
2885
  subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
2713
2886
  }
2714
- // orderBy shape
2887
+ // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2715
2888
  if (opts.orderBy) {
2716
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
2889
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
2717
2890
  subParts.push(`o=${oEntries.join(',')}`);
2718
2891
  }
2719
2892
  // limit presence
@@ -2744,6 +2917,15 @@ class QueryInterface {
2744
2917
  continue;
2745
2918
  this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
2746
2919
  }
2920
+ // `_count` global-filter params — mirror buildSelectWithRelations, which
2921
+ // appends the count subqueries (and any target-filter params) AFTER every
2922
+ // relation subquery, in resolveCountRelations order.
2923
+ const countSpec = withClause._count;
2924
+ if (countSpec !== undefined) {
2925
+ for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
2926
+ this.collectRelationCountParams(rel, params);
2927
+ }
2928
+ }
2747
2929
  }
2748
2930
  /**
2749
2931
  * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
@@ -2761,6 +2943,7 @@ class QueryInterface {
2761
2943
  if (spec.where) {
2762
2944
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2763
2945
  }
2946
+ this.collectTargetGlobalFilterAlias(targetTable, params);
2764
2947
  if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2765
2948
  params.push(Number(spec.limit));
2766
2949
  }
@@ -2790,6 +2973,9 @@ class QueryInterface {
2790
2973
  if (spec.where) {
2791
2974
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2792
2975
  }
2976
+ // Global filter on the target — mirrors targetGlobalFilterAlias in
2977
+ // buildRelationSubquery (pushed after spec.where, before limit).
2978
+ this.collectTargetGlobalFilterAlias(targetTable, params);
2793
2979
  // limit param — only hasMany parameterizes its limit (mirrors
2794
2980
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2795
2981
  // pushing one here would orphan a param and desync the collect path.
@@ -2859,14 +3045,148 @@ class QueryInterface {
2859
3045
  return { sql: '', params: [] };
2860
3046
  return { sql: ` WHERE ${clause}`, params };
2861
3047
  }
3048
+ // -------------------------------------------------------------------------
3049
+ // Global filters (soft-delete / multi-tenancy — WS-G)
3050
+ //
3051
+ // A configured global filter for a table is AND-merged into the compiled WHERE
3052
+ // of every query on that table (via {@link mergeGlobalFilter}, so the merge is
3053
+ // captured in the where fingerprint/collect for free) and into every relation
3054
+ // subquery targeting it (rendered at build time against the subquery's alias/
3055
+ // table by the `*GlobalFilterAlias`/`*GlobalFilterExists` helpers, with the
3056
+ // shape folded into the SQL-cache key via {@link globalFilterCacheSegment}).
3057
+ // Function filters are evaluated per resolve — at query-build time — enabling
3058
+ // per-request tenancy via a closure. They must return a STABLE shape (same
3059
+ // keys/operators); only values may vary between calls.
3060
+ // -------------------------------------------------------------------------
2862
3061
  /**
2863
- * Refuse mutations with an empty predicate unless explicitly opted in.
2864
- *
2865
- * An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
2866
- * mutation with no filter — a common footgun when a caller's filter
2867
- * value accidentally resolves to `undefined`. This guard throws
2868
- * `ValidationError` in that case unless `allowFullTableScan: true`.
3062
+ * Resolve the configured global filter for `table`, evaluating a function
3063
+ * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
3064
+ * no filter applies, the query opted out, or the filter is empty.
3065
+ */
3066
+ resolveGlobalFilter(table, skip = this.currentSkip) {
3067
+ const filters = this.globalFilters;
3068
+ if (!filters)
3069
+ return null;
3070
+ if (skip === true)
3071
+ return null;
3072
+ if (Array.isArray(skip) && skip.includes(table))
3073
+ return null;
3074
+ const raw = filters[table];
3075
+ if (raw === undefined)
3076
+ return null;
3077
+ const resolved = typeof raw === 'function' ? raw() : raw;
3078
+ if (resolved === null || resolved === undefined)
3079
+ return null;
3080
+ const obj = resolved;
3081
+ // An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
3082
+ // nothing — treat it as absent so it never emits a dangling clause.
3083
+ if (Object.keys(obj).every((k) => obj[k] === undefined))
3084
+ return null;
3085
+ return obj;
3086
+ }
3087
+ /**
3088
+ * AND-merge this table's resolved global filter into a user `where`. Either
3089
+ * side may be absent. When no filter applies the user where is returned by
3090
+ * reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
3091
+ */
3092
+ mergeGlobalFilter(userWhere) {
3093
+ const gf = this.resolveGlobalFilter(this.table);
3094
+ if (!gf)
3095
+ return userWhere;
3096
+ if (userWhere === undefined)
3097
+ return gf;
3098
+ return { AND: [userWhere, gf] };
3099
+ }
3100
+ /**
3101
+ * SQL clause for `targetTable`'s global filter rendered against `alias`
3102
+ * (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
3103
+ * `params`; returns `''` when no filter applies. Mirror:
3104
+ * {@link collectTargetGlobalFilterAlias}.
3105
+ */
3106
+ targetGlobalFilterAlias(targetTable, alias, params) {
3107
+ const gf = this.resolveGlobalFilter(targetTable);
3108
+ if (!gf)
3109
+ return '';
3110
+ const meta = this.schema.tables[targetTable];
3111
+ if (!meta)
3112
+ return '';
3113
+ return this.buildAliasWhere(targetTable, meta, alias, gf, params) ?? '';
3114
+ }
3115
+ /** Param-collect mirror of {@link targetGlobalFilterAlias}. */
3116
+ collectTargetGlobalFilterAlias(targetTable, params) {
3117
+ const gf = this.resolveGlobalFilter(targetTable);
3118
+ if (!gf)
3119
+ return;
3120
+ const meta = this.schema.tables[targetTable];
3121
+ if (!meta)
3122
+ return;
3123
+ this.collectAliasWhereParams(targetTable, meta, gf, params);
3124
+ }
3125
+ /**
3126
+ * SQL clause for `targetTable`'s global filter rendered against the bare
3127
+ * (unaliased) table name — the form used inside relation-filter `EXISTS`
3128
+ * subqueries. Pushes its params; `''` when none. Mirror:
3129
+ * {@link collectTargetGlobalFilterExists}.
3130
+ */
3131
+ targetGlobalFilterExists(targetTable, params) {
3132
+ const gf = this.resolveGlobalFilter(targetTable);
3133
+ if (!gf)
3134
+ return '';
3135
+ return this.buildSubWhereForRelation(targetTable, gf, params) ?? '';
3136
+ }
3137
+ /** Param-collect mirror of {@link targetGlobalFilterExists}. */
3138
+ collectTargetGlobalFilterExists(targetTable, params) {
3139
+ const gf = this.resolveGlobalFilter(targetTable);
3140
+ if (!gf)
3141
+ return;
3142
+ this.collectRelFilterParams(targetTable, gf, params);
3143
+ }
3144
+ /**
3145
+ * Value-invariant SQL-cache-key segment for the active global-filter
3146
+ * environment. Relation-subquery / relation-filter / `_count` / relation-
3147
+ * `orderBy` global filters are rendered at build time but their SHAPE is not
3148
+ * otherwise in the where/with fingerprint, so this segment guards the cache:
3149
+ * two different filter shapes never collide on one cached SQL text, while two
3150
+ * function-filter results of the SAME shape (differing only in values) share
3151
+ * the entry and bind their own params. Empty (`''`) when no filter applies, so
3152
+ * cache keys stay byte-identical when the feature is unused.
3153
+ */
3154
+ globalFilterCacheSegment() {
3155
+ const filters = this.globalFilters;
3156
+ if (!filters)
3157
+ return '';
3158
+ const parts = [];
3159
+ for (const table of Object.keys(filters).sort()) {
3160
+ // Function filters for OTHER tables may be request-scoped closures that
3161
+ // throw outside their own context; a query on an unrelated table must not
3162
+ // break on them. A throwing filter can't have contributed SQL to this
3163
+ // query either (merging it would have thrown first), so a constant
3164
+ // marker keeps the key shape-distinct without evaluating it.
3165
+ let gf;
3166
+ try {
3167
+ gf = this.resolveGlobalFilter(table);
3168
+ }
3169
+ catch {
3170
+ parts.push(`${table}:!`);
3171
+ continue;
3172
+ }
3173
+ if (gf)
3174
+ parts.push(`${table}:${this.fingerprintWhere(gf)}`);
3175
+ }
3176
+ return parts.length ? `|gf=${parts.join(';')}` : '';
3177
+ }
3178
+ /**
3179
+ * True when the USER-supplied `where` compiles to no predicate (`{}`,
3180
+ * `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
3181
+ * signal the empty-`where` guard needs — the compiled emptiness, NOT the
3182
+ * fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
3183
+ * any configured global filter, so a global filter never lets an unguarded
3184
+ * mass mutation through.
2869
3185
  */
3186
+ userPredicateIsEmpty(userWhere) {
3187
+ const throwaway = [];
3188
+ return this.buildWhereClause(userWhere, throwaway) === null;
3189
+ }
2870
3190
  assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
2871
3191
  if (whereSql.length > 0)
2872
3192
  return;
@@ -3038,55 +3358,69 @@ class QueryInterface {
3038
3358
  // belongsTo: parent.fk = child.pk
3039
3359
  correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
3040
3360
  }
3041
- // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
3361
+ // The target table's global filter (soft-delete / tenancy) restricts the
3362
+ // DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
3363
+ // ignore filtered-out rows, and `every` quantifies over only the surviving
3364
+ // rows ("every NON-deleted related row matches P"). It is ANDed into the
3365
+ // correlation and its params pushed AFTER the per-branch filter — mirrored
3366
+ // exactly in collectWhereParams' relation-filter branch. `qt` is the bare
3367
+ // target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
3368
+ const gfAnd = () => {
3369
+ const gf = this.targetGlobalFilterExists(targetTable, params);
3370
+ return gf ? ` AND ${gf}` : '';
3371
+ };
3372
+ // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
3042
3373
  if (filterObj.some !== undefined) {
3043
3374
  const subWhere = filterObj.some;
3044
3375
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3045
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3046
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3376
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3377
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3047
3378
  }
3048
- // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
3379
+ // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
3049
3380
  if (filterObj.none !== undefined) {
3050
3381
  const subWhere = filterObj.none;
3051
3382
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3052
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3053
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3383
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3384
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3054
3385
  }
3055
- // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
3386
+ // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
3056
3387
  if (filterObj.every !== undefined) {
3057
3388
  const subWhere = filterObj.every;
3058
3389
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3059
3390
  if (filterClause) {
3060
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation} AND NOT (${filterClause}))`);
3391
+ // gf params pushed AFTER filter params (collect mirrors this order), but
3392
+ // placed textually inside the domain so it restricts which rows count.
3393
+ const gf = gfAnd();
3394
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
3061
3395
  }
3062
3396
  else {
3063
- // "every" with empty filter = true (all match trivially)
3397
+ // "every" with empty filter = true (all match trivially) — gf irrelevant.
3064
3398
  }
3065
3399
  }
3066
3400
  // "is": EXISTS — for to-one relations (same SQL as "some").
3067
3401
  // `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
3068
3402
  if (filterObj.is !== undefined) {
3069
3403
  if (filterObj.is === null) {
3070
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
3404
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
3071
3405
  }
3072
3406
  else {
3073
3407
  const subWhere = filterObj.is;
3074
3408
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3075
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3076
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3409
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3410
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3077
3411
  }
3078
3412
  }
3079
3413
  // "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
3080
3414
  // `isNot: null` = "a related row exists" → EXISTS.
3081
3415
  if (filterObj.isNot !== undefined) {
3082
3416
  if (filterObj.isNot === null) {
3083
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
3417
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
3084
3418
  }
3085
3419
  else {
3086
3420
  const subWhere = filterObj.isNot;
3087
3421
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3088
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3089
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3422
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3423
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3090
3424
  }
3091
3425
  }
3092
3426
  return clauses.length > 0 ? clauses.join(' AND ') : null;
@@ -3281,17 +3615,9 @@ class QueryInterface {
3281
3615
  if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
3282
3616
  const norm = this.normalizeRelationFilter(aliasRel, value);
3283
3617
  if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
3284
- // Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
3285
- if (norm.some != null)
3286
- this.collectRelFilterParams(aliasRel.to, norm.some, params);
3287
- if (norm.none != null)
3288
- this.collectRelFilterParams(aliasRel.to, norm.none, params);
3289
- if (norm.every != null)
3290
- this.collectRelFilterParams(aliasRel.to, norm.every, params);
3291
- if (norm.is != null)
3292
- this.collectRelFilterParams(aliasRel.to, norm.is, params);
3293
- if (norm.isNot != null)
3294
- this.collectRelFilterParams(aliasRel.to, norm.isNot, params);
3618
+ // Mirrors buildRelationFilter (somenoneeveryis→isNot, each: sub-where
3619
+ // params then target global-filter params).
3620
+ this.collectRelationFilterParams(aliasRel, norm, params);
3295
3621
  continue;
3296
3622
  }
3297
3623
  }
@@ -3438,10 +3764,37 @@ class QueryInterface {
3438
3764
  * findMany path). When `params` is omitted (groupBy / relation path) a vector
3439
3765
  * ordering throws — KNN ordering is only supported at the top level.
3440
3766
  */
3767
+ /**
3768
+ * Value-shape fingerprint for a single orderBy entry, so two queries whose
3769
+ * ORDER BY differs only in nulls placement, vector metric, or relation-count
3770
+ * vs relation-column never collide on one cached SQL string. Captures the
3771
+ * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3772
+ */
3773
+ orderByEntryFingerprint(d) {
3774
+ // Vector KNN ordering changes the emitted operator by metric and adds a
3775
+ // `::vector` param, so metric + direction must be part of the cache key.
3776
+ if (isVectorOrderBy(d)) {
3777
+ return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3778
+ }
3779
+ if (isOrderBySpec(d))
3780
+ return `spec(${d.sort},${d.nulls ?? ''})`;
3781
+ if (d && typeof d === 'object') {
3782
+ // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
3783
+ return `rel(${Object.entries(d)
3784
+ .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3785
+ .sort()
3786
+ .join(',')})`;
3787
+ }
3788
+ return String(d);
3789
+ }
3441
3790
  buildOrderBy(orderBy, params) {
3442
- // Dev-only: validate that orderBy fields exist in the table schema
3791
+ // Dev-only: validate that orderBy fields exist in the table schema. Relation
3792
+ // orderBy keys (object values that are neither a vector nor an OrderBySpec)
3793
+ // are validated in the relation branch below, so skip them here.
3443
3794
  if (process.env.NODE_ENV !== 'production') {
3444
- for (const key of Object.keys(orderBy)) {
3795
+ for (const [key, value] of Object.entries(orderBy)) {
3796
+ if (this.isRelationOrderByValue(value) && this.tableMeta.relations[key])
3797
+ continue;
3445
3798
  const snakeKey = (0, schema_js_1.camelToSnake)(key);
3446
3799
  if (!this.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in this.tableMeta.columnMap)) {
3447
3800
  console.warn(`[turbine] Unknown orderBy field "${key}" for table "${this.tableMeta.name}". ` +
@@ -3450,28 +3803,217 @@ class QueryInterface {
3450
3803
  }
3451
3804
  }
3452
3805
  const meta = this.schema.tables[this.table];
3806
+ let relOrdCounter = 0;
3453
3807
  return Object.entries(orderBy)
3454
- .map(([key, dir]) => {
3455
- if (meta && !(key in meta.columnMap)) {
3456
- throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3457
- `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3458
- }
3808
+ .map(([key, value]) => {
3459
3809
  // Vector KNN ordering: { distance: { to, metric, direction? } }
3460
- if (isVectorOrderBy(dir)) {
3810
+ if (isVectorOrderBy(value)) {
3811
+ if (meta && !(key in meta.columnMap)) {
3812
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3813
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3814
+ }
3461
3815
  if (!params) {
3462
3816
  throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
3463
3817
  }
3464
3818
  const rawColumn = this.toColumn(key);
3465
- const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
3466
- const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
3467
- const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3819
+ const operator = this.vectorOperator(key, rawColumn, value.distance.metric);
3820
+ const placeholder = this.pushVectorParam(key, rawColumn, value.distance.to, params);
3821
+ const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3468
3822
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3469
3823
  }
3470
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3471
- return `${this.toSqlColumn(key)} ${safeDir}`;
3824
+ // Relation ordering: an object value that is not a vector or OrderBySpec,
3825
+ // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3826
+ // { name: 'asc' } }`).
3827
+ if (this.isRelationOrderByValue(value)) {
3828
+ return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
3829
+ }
3830
+ // Scalar column ordering — a plain direction or an OrderBySpec (nulls).
3831
+ if (meta && !(key in meta.columnMap)) {
3832
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3833
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3834
+ }
3835
+ const { dir, nulls } = normalizeOrderBy(value);
3836
+ return `${this.toSqlColumn(key)} ${dir}${this.nullsSuffix(nulls)}`;
3472
3837
  })
3473
3838
  .join(', ');
3474
3839
  }
3840
+ /**
3841
+ * True when an orderBy value is a relation-ordering object: a plain object
3842
+ * that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
3843
+ * in the orderBy clause is a relation name.
3844
+ */
3845
+ isRelationOrderByValue(value) {
3846
+ return (typeof value === 'object' &&
3847
+ value !== null &&
3848
+ !Array.isArray(value) &&
3849
+ !isVectorOrderBy(value) &&
3850
+ !isOrderBySpec(value));
3851
+ }
3852
+ /**
3853
+ * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
3854
+ * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
3855
+ * other engine a caller asking for explicit nulls placement gets a clear
3856
+ * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
3857
+ */
3858
+ nullsSuffix(nulls) {
3859
+ if (!nulls)
3860
+ return '';
3861
+ if (this.dialect.name !== 'postgresql' && this.dialect.name !== 'sqlite') {
3862
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST/LAST ordering', this.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
3863
+ }
3864
+ return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3865
+ }
3866
+ /**
3867
+ * Compile a relation ordering term. For a to-many relation the only allowed
3868
+ * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
3869
+ * to-one relation each entry names a target column and becomes a correlated
3870
+ * scalar subquery (supporting {@link OrderBySpec} nulls placement).
3871
+ *
3872
+ * Validation: relation must exist (E005); to-many only allows `_count`, and
3873
+ * to-one only allows real target columns (E003).
3874
+ */
3875
+ buildRelationOrderBy(relName, value, alias, params) {
3876
+ const relDef = this.tableMeta.relations[relName];
3877
+ if (!relDef) {
3878
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3879
+ `Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
3880
+ }
3881
+ // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3882
+ if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
3883
+ const keys = Object.keys(value);
3884
+ if (keys.length !== 1 || keys[0] !== '_count') {
3885
+ throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
3886
+ `(got: ${keys.join(', ') || '(empty)'}).`);
3887
+ }
3888
+ const { dir } = normalizeOrderBy(value._count);
3889
+ return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
3890
+ }
3891
+ // To-one: each entry orders by a correlated scalar subquery on a target column.
3892
+ const targetMeta = this.schema.tables[relDef.to];
3893
+ if (!targetMeta)
3894
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3895
+ const qTarget = this.q(relDef.to);
3896
+ const qParent = this.q(this.table);
3897
+ // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3898
+ const correlation = relDef.type === 'belongsTo'
3899
+ ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
3900
+ : this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3901
+ const entries = Object.entries(value);
3902
+ if (entries.length === 0) {
3903
+ throw new errors_js_1.ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
3904
+ }
3905
+ return entries
3906
+ .map(([col, dirValue]) => {
3907
+ const snakeCol = (0, schema_js_1.camelToSnake)(col);
3908
+ if (!targetMeta.allColumns.includes(snakeCol)) {
3909
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3910
+ }
3911
+ const { dir, nulls } = normalizeOrderBy(dirValue);
3912
+ // Target's global filter applies here too — otherwise ordering keys off
3913
+ // a soft-deleted / other-tenant related row's value (matches the with
3914
+ // subquery semantics for belongsTo/hasOne).
3915
+ let where = correlation;
3916
+ if (params) {
3917
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
3918
+ if (gf)
3919
+ where += ` AND ${gf}`;
3920
+ }
3921
+ return `(SELECT ${alias}.${this.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${this.limitOneClause()}) ${dir}${this.nullsSuffix(nulls)}`;
3922
+ })
3923
+ .join(', ');
3924
+ }
3925
+ /**
3926
+ * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3927
+ * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
3928
+ * manyToMany counts junction rows via the source key. Shared by the `_count`
3929
+ * `with` key and to-many relation orderBy.
3930
+ *
3931
+ * When `params` is supplied and the target has a global filter, it is
3932
+ * AND-merged so the count only sees surviving rows (a soft-deleted child is
3933
+ * not counted): hasMany filters the counted rows directly; manyToMany adds an
3934
+ * `EXISTS` on the target through the junction (the junction rows themselves
3935
+ * carry no filter). Params are mirrored by {@link collectRelationCountParams}.
3936
+ */
3937
+ buildRelationCountExpr(relDef, parentRef, alias, params) {
3938
+ const qParent = this.q(parentRef);
3939
+ const count = this.castAgg('COUNT(*)', 'int');
3940
+ if (relDef.type === 'manyToMany') {
3941
+ if (!relDef.through) {
3942
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
3943
+ }
3944
+ const qJ = this.q(relDef.through.table);
3945
+ const jalias = `${alias}j`;
3946
+ const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
3947
+ const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
3948
+ let where = sourceKeys
3949
+ .map((jc, i) => `${jalias}.${this.q(jc)} = ${qParent}.${this.q(refKeys[i])}`)
3950
+ .join(' AND ');
3951
+ if (params) {
3952
+ const targetExists = this.manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params);
3953
+ if (targetExists)
3954
+ where += ` AND ${targetExists}`;
3955
+ }
3956
+ return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
3957
+ }
3958
+ // hasMany: child FK correlates to the parent reference key.
3959
+ const qTarget = this.q(relDef.to);
3960
+ let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3961
+ if (params) {
3962
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
3963
+ if (gf)
3964
+ where += ` AND ${gf}`;
3965
+ }
3966
+ return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
3967
+ }
3968
+ /**
3969
+ * `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
3970
+ * a manyToMany `_count` to targets that survive their global filter. `''` when
3971
+ * the target has no filter. Pushes gf params; mirror:
3972
+ * {@link collectManyToManyTargetGlobalFilter}.
3973
+ */
3974
+ manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params) {
3975
+ const gf = this.resolveGlobalFilter(relDef.to);
3976
+ if (!gf || !relDef.through)
3977
+ return '';
3978
+ const tMeta = this.schema.tables[relDef.to];
3979
+ if (!tMeta || tMeta.primaryKey.length === 0)
3980
+ return '';
3981
+ const talias = `${alias}t`;
3982
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
3983
+ const pk = tMeta.primaryKey;
3984
+ if (targetKeys.length !== pk.length)
3985
+ return '';
3986
+ const join = targetKeys.map((jc, i) => `${talias}.${this.q(pk[i])} = ${jalias}.${this.q(jc)}`).join(' AND ');
3987
+ const gfClause = this.buildAliasWhere(relDef.to, tMeta, talias, gf, params);
3988
+ const gfAnd = gfClause ? ` AND ${gfClause}` : '';
3989
+ return `EXISTS (SELECT 1 FROM ${this.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
3990
+ }
3991
+ /** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
3992
+ collectManyToManyTargetGlobalFilter(relDef, params) {
3993
+ const gf = this.resolveGlobalFilter(relDef.to);
3994
+ if (!gf || !relDef.through)
3995
+ return;
3996
+ const tMeta = this.schema.tables[relDef.to];
3997
+ if (!tMeta || tMeta.primaryKey.length === 0)
3998
+ return;
3999
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
4000
+ if (targetKeys.length !== tMeta.primaryKey.length)
4001
+ return;
4002
+ this.collectAliasWhereParams(relDef.to, tMeta, gf, params);
4003
+ }
4004
+ /**
4005
+ * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
4006
+ * params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
4007
+ * when a filter applies — no-op otherwise.
4008
+ */
4009
+ collectRelationCountParams(relDef, params) {
4010
+ if (relDef.type === 'manyToMany') {
4011
+ this.collectManyToManyTargetGlobalFilter(relDef, params);
4012
+ }
4013
+ else {
4014
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
4015
+ }
4016
+ }
3475
4017
  // -------------------------------------------------------------------------
3476
4018
  // pgvector helpers (similarity search)
3477
4019
  // -------------------------------------------------------------------------
@@ -3600,6 +4142,19 @@ class QueryInterface {
3600
4142
  const meta = this.schema.tables[table];
3601
4143
  if (!meta)
3602
4144
  return parsed;
4145
+ // Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
4146
+ // parseRow copies these unknown columns through under their raw key.
4147
+ let countObj;
4148
+ for (const key of Object.keys(parsed)) {
4149
+ if (key.startsWith('_count__')) {
4150
+ if (countObj === undefined)
4151
+ countObj = {};
4152
+ countObj[key.slice('_count__'.length)] = Number(parsed[key]);
4153
+ delete parsed[key];
4154
+ }
4155
+ }
4156
+ if (countObj)
4157
+ parsed._count = countObj;
3603
4158
  for (const [relName, relDef] of Object.entries(meta.relations)) {
3604
4159
  const rawValue = row[relName];
3605
4160
  if (rawValue === undefined)
@@ -3866,6 +4421,9 @@ class QueryInterface {
3866
4421
  const relationSelects = [];
3867
4422
  const aliasCounter = { n: 0 };
3868
4423
  for (const [relName, relSpec] of sortedEntries(withClause)) {
4424
+ // `_count` is a reserved key handled after the relation subqueries.
4425
+ if (relName === '_count')
4426
+ continue;
3869
4427
  const relDef = meta.relations[relName];
3870
4428
  if (!relDef) {
3871
4429
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3875,6 +4433,18 @@ class QueryInterface {
3875
4433
  const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
3876
4434
  relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
3877
4435
  }
4436
+ // Reserved `_count` key → one correlated COUNT(*) scalar subquery per
4437
+ // selected to-many relation, aliased `_count__<rel>`. Appended after the
4438
+ // relation subqueries; the only params they can push come from a global
4439
+ // filter on the counted target (mirrored at the tail of collectWithParams).
4440
+ // Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
4441
+ const countSpec = withClause._count;
4442
+ if (countSpec !== undefined) {
4443
+ for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
4444
+ const expr = this.buildRelationCountExpr(rel, table, `t${aliasCounter.n++}`, params);
4445
+ relationSelects.push(`${expr} AS ${this.q(`_count__${rel.name}`)}`);
4446
+ }
4447
+ }
3878
4448
  return [baseCols, ...relationSelects].join(', ');
3879
4449
  }
3880
4450
  /**
@@ -4075,13 +4645,13 @@ class QueryInterface {
4075
4645
  let orderClause = '';
4076
4646
  if (relOrderEntries.length > 0) {
4077
4647
  const orders = relOrderEntries
4078
- .map(([k, dir]) => {
4648
+ .map(([k, dirValue]) => {
4079
4649
  const col = (0, schema_js_1.camelToSnake)(k);
4080
4650
  if (!targetMeta.allColumns.includes(col)) {
4081
4651
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4082
4652
  }
4083
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4084
- return `${alias}.${this.q(col)} ${safeDir}`;
4653
+ const { dir, nulls } = normalizeOrderBy(dirValue);
4654
+ return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4085
4655
  })
4086
4656
  .join(', ');
4087
4657
  orderClause = ` ORDER BY ${orders}`;
@@ -4107,6 +4677,12 @@ class QueryInterface {
4107
4677
  if (extra)
4108
4678
  whereClause += ` AND ${extra}`;
4109
4679
  }
4680
+ // Global filter on the target table (soft-delete / tenancy) — AND-merged so
4681
+ // a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
4682
+ // mirrored by collectRelationSubqueryParams.
4683
+ const gfExtra = this.targetGlobalFilterAlias(targetTable, alias, params);
4684
+ if (gfExtra)
4685
+ whereClause += ` AND ${gfExtra}`;
4110
4686
  // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
4111
4687
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
4112
4688
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
@@ -4217,13 +4793,13 @@ class QueryInterface {
4217
4793
  let orderClause = '';
4218
4794
  if (relOrderEntries.length > 0) {
4219
4795
  const orders = relOrderEntries
4220
- .map(([k, dir]) => {
4796
+ .map(([k, dirValue]) => {
4221
4797
  const col = (0, schema_js_1.camelToSnake)(k);
4222
4798
  if (!targetMeta.allColumns.includes(col)) {
4223
4799
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4224
4800
  }
4225
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4226
- return `${talias}.${this.q(col)} ${safeDir}`;
4801
+ const { dir, nulls } = normalizeOrderBy(dirValue);
4802
+ return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4227
4803
  })
4228
4804
  .join(', ');
4229
4805
  orderClause = ` ORDER BY ${orders}`;
@@ -4235,6 +4811,11 @@ class QueryInterface {
4235
4811
  if (extra)
4236
4812
  whereClause += ` AND ${extra}`;
4237
4813
  }
4814
+ // Global filter on the target table (mirrors collectRelationSubqueryParams'
4815
+ // m2m branch: after spec.where, before limit).
4816
+ const gfExtra = this.targetGlobalFilterAlias(targetTable, talias, params);
4817
+ if (gfExtra)
4818
+ whereClause += ` AND ${gfExtra}`;
4238
4819
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
4239
4820
  let limitClause = '';
4240
4821
  if (spec !== true && spec.limit !== undefined) {