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
@@ -15,7 +15,7 @@ import { CircularRelationError, NotFoundError, OptimisticLockError, RelationErro
15
15
  import { missingIndexForRelation } from '../index-advisor.js';
16
16
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
- import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, stripFields, } from './batched-loader.js';
18
+ import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, resolveCountRelations, stripFields, } from './batched-loader.js';
19
19
  import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
20
20
  // ---------------------------------------------------------------------------
21
21
  // Internal detection helpers — used by QueryInterface
@@ -224,6 +224,21 @@ function isVectorFilter(value) {
224
224
  function isVectorOrderBy(value) {
225
225
  return isVectorFilter(value);
226
226
  }
227
+ /** Check if an orderBy value is an explicit `{ sort, nulls? }` spec. */
228
+ function isOrderBySpec(value) {
229
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && 'sort' in value;
230
+ }
231
+ /**
232
+ * Normalize an orderBy value into `{ direction, nulls }`. Accepts a plain
233
+ * direction string or an {@link OrderBySpec}. Used by every ORDER BY compile
234
+ * path (findMany, groupBy, relation inner subqueries).
235
+ */
236
+ function normalizeOrderBy(value) {
237
+ if (isOrderBySpec(value)) {
238
+ return { dir: value.sort.toLowerCase() === 'desc' ? 'DESC' : 'ASC', nulls: value.nulls };
239
+ }
240
+ return { dir: String(value).toLowerCase() === 'desc' ? 'DESC' : 'ASC' };
241
+ }
227
242
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
228
243
  export class QueryInterface {
229
244
  pool;
@@ -243,6 +258,13 @@ export class QueryInterface {
243
258
  relationLoadStrategy;
244
259
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
245
260
  jsonEncoding;
261
+ /**
262
+ * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
263
+ * multi-tenancy). AND-merged into every query on the keyed table and every
264
+ * relation subquery targeting it. Undefined when none are configured, in
265
+ * which case every path is byte-identical to the pre-0.28 behavior.
266
+ */
267
+ globalFilters;
246
268
  /**
247
269
  * Tracks tables that have already triggered an unlimited-query warning so
248
270
  * the user is not spammed once per row. Per-instance state — each
@@ -274,6 +296,15 @@ export class QueryInterface {
274
296
  options;
275
297
  /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
276
298
  currentAction = 'raw';
299
+ /**
300
+ * The active query's `skipGlobalFilters` opt-out, set at the top of each
301
+ * `build*` method and read deep in the (synchronous) SQL-build + param-collect
302
+ * tree — so relation subqueries, relation filters, `_count`, and relation
303
+ * `orderBy` all see it without threading it through dozens of signatures.
304
+ * Only load-bearing when {@link globalFilters} is configured; build+collect are
305
+ * synchronous per call, so this transient is never observed across an await.
306
+ */
307
+ currentSkip;
277
308
  constructor(pool, table, schema, middlewares, options) {
278
309
  this.pool = pool;
279
310
  this.table = table;
@@ -295,6 +326,10 @@ export class QueryInterface {
295
326
  this.dialect = options?.dialect ?? postgresDialect;
296
327
  this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
297
328
  this.jsonEncoding = options?.jsonEncoding ?? 'object';
329
+ // Only retain the map when it has at least one entry, so `globalFilters`
330
+ // stays `undefined` (and every merge path a no-op) for the common case.
331
+ this.globalFilters =
332
+ options?.globalFilters && Object.keys(options.globalFilters).length > 0 ? options.globalFilters : undefined;
298
333
  this.txScoped = options?._txScoped ?? false;
299
334
  this.options = options;
300
335
  // Pre-compute column type lookup maps (TASK-26)
@@ -414,7 +449,7 @@ export class QueryInterface {
414
449
  * and unlimited-warnings silenced — a relation load must fetch every matching
415
450
  * child, and the per-relation `limit` is applied client-side by the loader.
416
451
  */
417
- batchedContext(timeout) {
452
+ batchedContext(timeout, skip) {
418
453
  const childOptions = {
419
454
  ...this.options,
420
455
  defaultLimit: undefined,
@@ -429,6 +464,22 @@ export class QueryInterface {
429
464
  buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
430
465
  inClauseParam: (values) => this.inParam(values),
431
466
  paramPlaceholder: (index) => this.p(index),
467
+ skipGlobalFilters: skip,
468
+ tableGlobalFilter: (table, alias, precedingParams) => {
469
+ const gf = this.resolveGlobalFilter(table, skip);
470
+ if (!gf)
471
+ return null;
472
+ const meta = this.schema.tables[table];
473
+ if (!meta)
474
+ return null;
475
+ // Seed the param array with `precedingParams` placeholders so
476
+ // buildAliasWhere numbers the gf params after the already-bound ones.
477
+ const seeded = new Array(precedingParams).fill(undefined);
478
+ const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
479
+ if (!clause)
480
+ return null;
481
+ return { clause, params: seeded.slice(precedingParams) };
482
+ },
432
483
  };
433
484
  }
434
485
  /**
@@ -440,13 +491,18 @@ export class QueryInterface {
440
491
  */
441
492
  async runFindManyBatched(args) {
442
493
  const withClause = args.with;
494
+ // Capture the opt-out from the ARGS before any await: this.currentSkip is
495
+ // instance state on a cached accessor, so a concurrent build during the
496
+ // base-query await would overwrite it (tenant query loading relations with
497
+ // another query's skipGlobalFilters).
498
+ const skip = args.skipGlobalFilters;
443
499
  const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
444
500
  // baseArgs.with is always undefined here; the cast just bridges the R generic.
445
501
  const deferred = this.buildFindMany(baseArgs);
446
502
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
447
503
  const entities = deferred.transform(result);
448
504
  if (entities.length > 0) {
449
- await loadRelationsBatched(this.batchedContext(args.timeout), entities, withClause, args.timeout);
505
+ await loadRelationsBatched(this.batchedContext(args.timeout, skip), entities, withClause, args.timeout);
450
506
  }
451
507
  stripFields(entities, strip);
452
508
  return entities;
@@ -668,18 +724,22 @@ export class QueryInterface {
668
724
  const entity = deferred.transform(result);
669
725
  if (!entity)
670
726
  return null;
671
- await loadRelationsBatched(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
727
+ await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters), [entity], withClause, args.timeout);
672
728
  stripFields([entity], proj.strip);
673
729
  return entity;
674
730
  }
675
731
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
676
732
  buildFindUnique(args) {
733
+ this.currentSkip = args.skipGlobalFilters;
677
734
  const columnsList = this.resolveColumns(args.select, args.omit);
678
- const whereObj = args.where;
735
+ // A global filter turns the where into `{ AND: [...] }`, which the
736
+ // `isSimpleWhere` test below rejects → the general (buildWhereClause) path
737
+ // handles the merge and its params uniformly.
738
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
679
739
  const colKey = columnsList ? columnsList.join(',') : '*';
680
740
  const whereFingerprint = this.fingerprintWhere(whereObj);
681
741
  const withFp = args.with ? this.withFingerprint(args.with) : '';
682
- const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
742
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}${this.globalFilterCacheSegment()}`;
683
743
  const params = [];
684
744
  // Check if all where values are simple (plain equality, no operators/null/OR).
685
745
  // Keys are sorted to match fingerprintWhere — insertion order here would let
@@ -835,24 +895,21 @@ export class QueryInterface {
835
895
  }
836
896
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
837
897
  buildFindMany(args) {
898
+ this.currentSkip = args?.skipGlobalFilters;
838
899
  const columnsList = this.resolveColumns(args?.select, args?.omit);
839
900
  const colKey = columnsList ? columnsList.join(',') : '*';
840
- const whereObj = (args?.where ?? {});
901
+ // AND-merge this table's global filter into the user where; `hasWhere` gates
902
+ // the build/collect just like `args?.where` did (a merged filter can make
903
+ // an otherwise-absent where present).
904
+ const effWhere = this.mergeGlobalFilter(args?.where);
905
+ const hasWhere = effWhere !== undefined;
906
+ const whereObj = (effWhere ?? {});
841
907
  // Build fingerprint for cache lookup
842
- const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
908
+ const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
843
909
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
844
910
  const orderFp = args?.orderBy
845
911
  ? Object.entries(args.orderBy)
846
- .map(([k, d]) => {
847
- // Vector KNN ordering changes the emitted SQL operator by metric and
848
- // adds a `::vector` param, so the metric + direction must be part of
849
- // the cache key — otherwise two KNN queries differing only in metric
850
- // would collide on a single cached SQL string.
851
- if (isVectorOrderBy(d)) {
852
- return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
853
- }
854
- return `${k}:${d}`;
855
- })
912
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
856
913
  .join(',')
857
914
  : '';
858
915
  const cursorFp = args?.cursor
@@ -865,12 +922,12 @@ export class QueryInterface {
865
922
  const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
866
923
  const limitFp = effectiveLimit !== undefined ? '1' : '0';
867
924
  const offsetFp = args?.offset !== undefined ? '1' : '0';
868
- const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}`;
925
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
869
926
  const params = [];
870
927
  const entry = this.acquireSql(ck, () => {
871
928
  // Fresh build — generates SQL and populates freshParams
872
929
  const freshParams = [];
873
- const { sql: freshWhereSql } = args?.where
930
+ const { sql: freshWhereSql } = hasWhere
874
931
  ? (() => {
875
932
  const clause = this.buildWhereClause(whereObj, freshParams);
876
933
  return { sql: clause ? ` WHERE ${clause}` : '' };
@@ -900,8 +957,11 @@ export class QueryInterface {
900
957
  if (cursorEntries.length > 0) {
901
958
  const cursorConditions = cursorEntries.map(([k, v]) => {
902
959
  const col = this.toSqlColumn(k);
903
- const dir = args.orderBy?.[k] ?? 'asc';
904
- const op = dir === 'desc' ? '<' : '>';
960
+ // orderBy values can be the { sort, nulls } spec form — normalize
961
+ // before comparing, or a desc spec would seek the ascending side.
962
+ const dir = args.orderBy?.[k];
963
+ const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
964
+ const op = desc ? '<' : '>';
905
965
  freshParams.push(v);
906
966
  return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
907
967
  });
@@ -948,8 +1008,8 @@ export class QueryInterface {
948
1008
  return sql;
949
1009
  });
950
1010
  // Collect params in exact build order:
951
- // 1. WHERE params
952
- if (args?.where) {
1011
+ // 1. WHERE params (includes the AND-merged global filter, if any)
1012
+ if (hasWhere) {
953
1013
  this.collectWhereParams(whereObj, params);
954
1014
  }
955
1015
  // 2. WITH relation params
@@ -1161,6 +1221,8 @@ export class QueryInterface {
1161
1221
  });
1162
1222
  }
1163
1223
  buildCreate(args) {
1224
+ this.assertWritable('create');
1225
+ this.assertNoGeneratedColumns(args.data, 'create');
1164
1226
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
1165
1227
  const columns = entries.map(([k]) => this.toSqlColumn(k));
1166
1228
  const params = entries.map(([, v]) => v);
@@ -1235,6 +1297,10 @@ export class QueryInterface {
1235
1297
  tag: `${this.table}.createMany`,
1236
1298
  };
1237
1299
  }
1300
+ this.assertWritable('createMany');
1301
+ for (const row of args.data) {
1302
+ this.assertNoGeneratedColumns(row, 'createMany');
1303
+ }
1238
1304
  const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
1239
1305
  const columns = keys.map((k) => this.toColumn(k));
1240
1306
  const rowValues = args.data.map((row) => {
@@ -1272,12 +1338,22 @@ export class QueryInterface {
1272
1338
  });
1273
1339
  }
1274
1340
  buildUpdate(args) {
1341
+ this.assertWritable('update');
1342
+ this.currentSkip = args.skipGlobalFilters;
1275
1343
  const dataObj = args.data;
1276
- const whereObj = args.where;
1344
+ this.assertNoGeneratedColumns(dataObj, 'update');
1345
+ const userWhere = args.where;
1277
1346
  const lock = args.optimisticLock;
1347
+ // The empty-`where` guard checks the USER predicate only — a global filter
1348
+ // must never turn an unguarded mass update into an allowed one.
1349
+ const userHasPredicate = !this.userPredicateIsEmpty(userWhere) || !!lock;
1350
+ this.assertMutationHasPredicate('update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
1351
+ // The SQL is built from the global-filter-merged where (soft-delete keeps an
1352
+ // update from touching already-deleted rows).
1353
+ const whereObj = (this.mergeGlobalFilter(userWhere) ?? {});
1278
1354
  const setFp = this.fingerprintSet(dataObj);
1279
1355
  const whereFp = this.fingerprintWhere(whereObj);
1280
- const ck = lock ? null : `u:${setFp}|${whereFp}`;
1356
+ const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1281
1357
  const params = [];
1282
1358
  const buildSql = () => {
1283
1359
  const freshParams = [];
@@ -1295,7 +1371,6 @@ export class QueryInterface {
1295
1371
  const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
1296
1372
  whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
1297
1373
  }
1298
- this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
1299
1374
  // Engines that inject their returning shape MID-statement (SQL Server
1300
1375
  // `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
1301
1376
  // absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
@@ -1309,9 +1384,6 @@ export class QueryInterface {
1309
1384
  const entry = this.acquireSql(ck, buildSql);
1310
1385
  sql = entry.sql;
1311
1386
  preparedName = entry.name;
1312
- if (whereFp === '') {
1313
- this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
1314
- }
1315
1387
  }
1316
1388
  else {
1317
1389
  sql = buildSql();
@@ -1445,27 +1517,24 @@ export class QueryInterface {
1445
1517
  });
1446
1518
  }
1447
1519
  buildDelete(args) {
1448
- const whereObj = args.where;
1520
+ this.assertWritable('delete');
1521
+ this.currentSkip = args.skipGlobalFilters;
1522
+ // Guard the USER predicate (a global filter must not satisfy the guard).
1523
+ this.assertMutationHasPredicate('delete', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1524
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1449
1525
  const whereFp = this.fingerprintWhere(whereObj);
1450
- const ck = `d:${whereFp}`;
1526
+ const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
1451
1527
  const params = [];
1452
- // We need to check the mutation predicate. Build the whereSql to test it.
1453
- // On cache hit we still need to validate (the shape may be empty).
1454
1528
  const entry = this.acquireSql(ck, () => {
1455
1529
  const freshParams = [];
1456
1530
  const clause = this.buildWhereClause(whereObj, freshParams);
1457
1531
  const whereSql = clause ? ` WHERE ${clause}` : '';
1458
- this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
1459
1532
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
1460
1533
  // absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
1461
1534
  return this.dialect.buildDeleteStatement
1462
1535
  ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1463
1536
  : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1464
1537
  });
1465
- // On cache hit, still validate the predicate
1466
- if (whereFp === '') {
1467
- this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
1468
- }
1469
1538
  this.collectWhereParams(whereObj, params);
1470
1539
  return {
1471
1540
  sql: entry.sql,
@@ -1505,6 +1574,10 @@ export class QueryInterface {
1505
1574
  });
1506
1575
  }
1507
1576
  buildUpsert(args) {
1577
+ this.assertWritable('upsert');
1578
+ this.assertNoGeneratedColumns(args.create, 'upsert');
1579
+ this.assertNoGeneratedColumns(args.update, 'upsert');
1580
+ this.currentSkip = args.skipGlobalFilters;
1508
1581
  // Build the INSERT part from create data
1509
1582
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
1510
1583
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
@@ -1523,12 +1596,23 @@ export class QueryInterface {
1523
1596
  });
1524
1597
  const updateParams = updateEntries.map(([, v]) => v);
1525
1598
  const params = [...createParams, ...updateParams];
1599
+ // Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
1600
+ // upsert never resurrects a soft-deleted row or writes across tenants. Only
1601
+ // on engines whose upsert can carry a predicate (Postgres); the gf params
1602
+ // continue the placeholder numbering after create+update params.
1603
+ let updateWhere;
1604
+ if (this.dialect.supportsUpsertUpdateWhere) {
1605
+ const gf = this.resolveGlobalFilter(this.table);
1606
+ if (gf)
1607
+ updateWhere = this.buildWhereClause(gf, params) ?? undefined;
1608
+ }
1526
1609
  const sql = this.dialect.buildUpsertStatement({
1527
1610
  table: this.q(this.table),
1528
1611
  insertColumns: columns,
1529
1612
  valuePlaceholders: placeholders,
1530
1613
  conflictColumns,
1531
1614
  updateSetClauses: setClauses,
1615
+ updateWhere,
1532
1616
  returning: '*',
1533
1617
  });
1534
1618
  return {
@@ -1551,7 +1635,7 @@ export class QueryInterface {
1551
1635
  reselect: this.dialect.resultStrategy === 'reselect'
1552
1636
  ? async (exec) => {
1553
1637
  await exec(sql, params);
1554
- const sel = this.buildReselectByWhere(args.where);
1638
+ const sel = this.buildReselectByWhere((this.mergeGlobalFilter(args.where) ?? {}));
1555
1639
  return exec(sel.sql, sel.params);
1556
1640
  }
1557
1641
  : undefined,
@@ -1568,11 +1652,15 @@ export class QueryInterface {
1568
1652
  });
1569
1653
  }
1570
1654
  buildUpdateMany(args) {
1655
+ this.assertWritable('updateMany');
1656
+ this.currentSkip = args.skipGlobalFilters;
1571
1657
  const dataObj = args.data;
1572
- const whereObj = args.where;
1658
+ this.assertNoGeneratedColumns(dataObj, 'updateMany');
1659
+ this.assertMutationHasPredicate('updateMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1660
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1573
1661
  const setFp = this.fingerprintSet(dataObj);
1574
1662
  const whereFp = this.fingerprintWhere(whereObj);
1575
- const ck = `um:${setFp}|${whereFp}`;
1663
+ const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1576
1664
  const params = [];
1577
1665
  const entry = this.acquireSql(ck, () => {
1578
1666
  const freshParams = [];
@@ -1580,12 +1668,8 @@ export class QueryInterface {
1580
1668
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1581
1669
  const whereClause = this.buildWhereClause(whereObj, freshParams);
1582
1670
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1583
- this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
1584
1671
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1585
1672
  });
1586
- if (whereFp === '') {
1587
- this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
1588
- }
1589
1673
  this.collectSetParams(dataObj, params);
1590
1674
  this.collectWhereParams(whereObj, params);
1591
1675
  return {
@@ -1607,20 +1691,19 @@ export class QueryInterface {
1607
1691
  });
1608
1692
  }
1609
1693
  buildDeleteMany(args) {
1610
- const whereObj = args.where;
1694
+ this.assertWritable('deleteMany');
1695
+ this.currentSkip = args.skipGlobalFilters;
1696
+ this.assertMutationHasPredicate('deleteMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1697
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1611
1698
  const whereFp = this.fingerprintWhere(whereObj);
1612
- const ck = `dm:${whereFp}`;
1699
+ const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
1613
1700
  const params = [];
1614
1701
  const entry = this.acquireSql(ck, () => {
1615
1702
  const freshParams = [];
1616
1703
  const clause = this.buildWhereClause(whereObj, freshParams);
1617
1704
  const whereSql = clause ? ` WHERE ${clause}` : '';
1618
- this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
1619
1705
  return `DELETE FROM ${this.q(this.table)}${whereSql}`;
1620
1706
  });
1621
- if (whereFp === '') {
1622
- this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
1623
- }
1624
1707
  this.collectWhereParams(whereObj, params);
1625
1708
  return {
1626
1709
  sql: entry.sql,
@@ -1641,17 +1724,20 @@ export class QueryInterface {
1641
1724
  });
1642
1725
  }
1643
1726
  buildCount(args) {
1644
- const whereObj = (args?.where ?? {});
1645
- const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
1646
- const ck = `cnt:${whereFp}`;
1727
+ this.currentSkip = args?.skipGlobalFilters;
1728
+ const effWhere = this.mergeGlobalFilter(args?.where);
1729
+ const hasWhere = effWhere !== undefined;
1730
+ const whereObj = (effWhere ?? {});
1731
+ const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1732
+ const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
1647
1733
  const params = [];
1648
1734
  const entry = this.acquireSql(ck, () => {
1649
1735
  const freshParams = [];
1650
- const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1736
+ const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
1651
1737
  const whereSql = clause ? ` WHERE ${clause}` : '';
1652
1738
  return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1653
1739
  });
1654
- if (args?.where) {
1740
+ if (hasWhere) {
1655
1741
  this.collectWhereParams(whereObj, params);
1656
1742
  }
1657
1743
  return {
@@ -1681,9 +1767,13 @@ export class QueryInterface {
1681
1767
  }
1682
1768
  }
1683
1769
  }
1770
+ this.currentSkip = args.skipGlobalFilters;
1684
1771
  const groupColsRaw = args.by.map((k) => this.toColumn(k));
1685
1772
  const groupCols = groupColsRaw.map((c) => this.q(c));
1686
- const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
1773
+ const gbWhere = this.mergeGlobalFilter(args.where);
1774
+ const { sql: whereSql, params } = gbWhere
1775
+ ? this.buildWhere(gbWhere)
1776
+ : { sql: '', params: [] };
1687
1777
  // Build SELECT expressions: group-by columns + aggregate functions
1688
1778
  const selectExprs = [...groupCols];
1689
1779
  // _count
@@ -1926,7 +2016,11 @@ export class QueryInterface {
1926
2016
  });
1927
2017
  }
1928
2018
  buildAggregate(args) {
1929
- const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
2019
+ this.currentSkip = args.skipGlobalFilters;
2020
+ const aggWhere = this.mergeGlobalFilter(args.where);
2021
+ const { sql: whereSql, params } = aggWhere
2022
+ ? this.buildWhere(aggWhere)
2023
+ : { sql: '', params: [] };
1930
2024
  const meta = this.schema.tables[this.table];
1931
2025
  if (meta) {
1932
2026
  for (const group of [args._sum, args._avg, args._min, args._max]) {
@@ -2104,6 +2198,36 @@ export class QueryInterface {
2104
2198
  }
2105
2199
  return null;
2106
2200
  }
2201
+ /**
2202
+ * Reject any write against a view (H4). Views are introspected with
2203
+ * `isView: true` and are read-only in every engine; a write raises a
2204
+ * {@link ValidationError} (E003) rather than emitting SQL Postgres would
2205
+ * reject (or, worse, silently applying to an updatable view).
2206
+ */
2207
+ assertWritable(operation) {
2208
+ if (this.tableMeta.isView) {
2209
+ throw new ValidationError(`[turbine] Cannot ${operation} "${this.table}": it is a view (read-only). ` +
2210
+ 'Views support reads (findMany/findFirst/…) but not writes.');
2211
+ }
2212
+ }
2213
+ /**
2214
+ * Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
2215
+ * column (H3). Postgres computes these from other columns and errors if you
2216
+ * try to write them; we fail early with a clear {@link ValidationError} (E003)
2217
+ * instead of surfacing a cryptic driver error. Undefined values are ignored
2218
+ * (they're stripped from the statement anyway).
2219
+ */
2220
+ assertNoGeneratedColumns(data, operation) {
2221
+ for (const [key, value] of Object.entries(data)) {
2222
+ if (value === undefined)
2223
+ continue;
2224
+ const col = this.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
2225
+ if (col?.isGeneratedStored) {
2226
+ throw new ValidationError(`[turbine] Cannot ${operation} "${this.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
2227
+ 'column whose value the database computes — remove it from your data.');
2228
+ }
2229
+ }
2230
+ }
2107
2231
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
2108
2232
  toColumn(field) {
2109
2233
  const mapped = this.tableMeta.columnMap[field];
@@ -2433,16 +2557,7 @@ export class QueryInterface {
2433
2557
  'none' in filterObj ||
2434
2558
  'is' in filterObj ||
2435
2559
  'isNot' in filterObj) {
2436
- if (filterObj.some !== undefined && filterObj.some !== null)
2437
- this.collectRelFilterParams(relationDef.to, filterObj.some, params);
2438
- if (filterObj.none !== undefined && filterObj.none !== null)
2439
- this.collectRelFilterParams(relationDef.to, filterObj.none, params);
2440
- if (filterObj.every !== undefined && filterObj.every !== null)
2441
- this.collectRelFilterParams(relationDef.to, filterObj.every, params);
2442
- if (filterObj.is !== undefined && filterObj.is !== null)
2443
- this.collectRelFilterParams(relationDef.to, filterObj.is, params);
2444
- if (filterObj.isNot !== undefined && filterObj.isNot !== null)
2445
- this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
2560
+ this.collectRelationFilterParams(relationDef, filterObj, params);
2446
2561
  continue;
2447
2562
  }
2448
2563
  }
@@ -2490,7 +2605,45 @@ export class QueryInterface {
2490
2605
  params.push(value);
2491
2606
  }
2492
2607
  }
2493
- /** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
2608
+ /**
2609
+ * Param-collect mirror of {@link buildRelationFilter} for one relation-filter
2610
+ * object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
2611
+ * present branch and in the canonical order some→none→every→is→isNot, the
2612
+ * branch's sub-where params THEN the target table's global-filter params —
2613
+ * exactly the order buildRelationFilter emits. When no global filter applies
2614
+ * the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
2615
+ * Shared by every collect site that mirrors buildRelationFilter
2616
+ * (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
2617
+ */
2618
+ collectRelationFilterParams(relDef, filterObj, params) {
2619
+ const target = relDef.to;
2620
+ if (filterObj.some !== undefined && filterObj.some !== null) {
2621
+ this.collectRelFilterParams(target, filterObj.some, params);
2622
+ this.collectTargetGlobalFilterExists(target, params);
2623
+ }
2624
+ if (filterObj.none !== undefined && filterObj.none !== null) {
2625
+ this.collectRelFilterParams(target, filterObj.none, params);
2626
+ this.collectTargetGlobalFilterExists(target, params);
2627
+ }
2628
+ if (filterObj.every !== undefined && filterObj.every !== null) {
2629
+ // gf is only emitted (build) when the `every` sub-where compiles to a
2630
+ // filter — otherwise `every` is trivially true and no subquery is built.
2631
+ if (this.buildSubWhereForRelation(target, filterObj.every, []) !== null) {
2632
+ this.collectRelFilterParams(target, filterObj.every, params);
2633
+ this.collectTargetGlobalFilterExists(target, params);
2634
+ }
2635
+ }
2636
+ if (filterObj.is !== undefined) {
2637
+ if (filterObj.is !== null)
2638
+ this.collectRelFilterParams(target, filterObj.is, params);
2639
+ this.collectTargetGlobalFilterExists(target, params);
2640
+ }
2641
+ if (filterObj.isNot !== undefined) {
2642
+ if (filterObj.isNot !== null)
2643
+ this.collectRelFilterParams(target, filterObj.isNot, params);
2644
+ this.collectTargetGlobalFilterExists(target, params);
2645
+ }
2646
+ }
2494
2647
  collectRelFilterParams(targetTable, subWhere, params) {
2495
2648
  const meta = this.schema.tables[targetTable];
2496
2649
  if (!meta)
@@ -2518,17 +2671,9 @@ export class QueryInterface {
2518
2671
  if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
2519
2672
  const norm = this.normalizeRelationFilter(nestedRel, value);
2520
2673
  if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
2521
- // Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
2522
- if (norm.some != null)
2523
- this.collectRelFilterParams(nestedRel.to, norm.some, params);
2524
- if (norm.none != null)
2525
- this.collectRelFilterParams(nestedRel.to, norm.none, params);
2526
- if (norm.every != null)
2527
- this.collectRelFilterParams(nestedRel.to, norm.every, params);
2528
- if (norm.is != null)
2529
- this.collectRelFilterParams(nestedRel.to, norm.is, params);
2530
- if (norm.isNot != null)
2531
- this.collectRelFilterParams(nestedRel.to, norm.isNot, params);
2674
+ // Mirrors buildRelationFilter (somenoneeveryis→isNot, each: sub-where
2675
+ // params then target global-filter params).
2676
+ this.collectRelationFilterParams(nestedRel, norm, params);
2532
2677
  continue;
2533
2678
  }
2534
2679
  }
@@ -2608,6 +2753,21 @@ export class QueryInterface {
2608
2753
  // never push a param that the build path rejected (or vice versa).
2609
2754
  this.vectorOperator(key, rawColumn, dir.distance.metric);
2610
2755
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2756
+ continue;
2757
+ }
2758
+ // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2759
+ // subquery as `_count` — mirror its global-filter params. To-one relation
2760
+ // orderBy carries the target's global filter once per ordered column.
2761
+ if (this.isRelationOrderByValue(dir)) {
2762
+ const relDef = this.tableMeta.relations[key];
2763
+ if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2764
+ this.collectRelationCountParams(relDef, params);
2765
+ }
2766
+ else if (relDef) {
2767
+ for (const _col of Object.keys(dir)) {
2768
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
2769
+ }
2770
+ }
2611
2771
  }
2612
2772
  }
2613
2773
  }
@@ -2643,6 +2803,19 @@ export class QueryInterface {
2643
2803
  const spec = withClause[relName];
2644
2804
  if (!spec)
2645
2805
  continue;
2806
+ // Reserved `_count` key — fingerprint by the selected relation set so
2807
+ // `_count: true` and `_count: { posts: true }` never share a cache entry.
2808
+ if (relName === '_count') {
2809
+ const c = spec;
2810
+ parts.push(c === true
2811
+ ? '_count(*)'
2812
+ : `_count(${Object.entries(c)
2813
+ .filter(([, v]) => v)
2814
+ .map(([k]) => k)
2815
+ .sort()
2816
+ .join(',')})`);
2817
+ continue;
2818
+ }
2646
2819
  const relDef = meta.relations[relName];
2647
2820
  if (!relDef) {
2648
2821
  parts.push(`unknown:${relName}`);
@@ -2675,9 +2848,9 @@ export class QueryInterface {
2675
2848
  if (opts.where) {
2676
2849
  subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
2677
2850
  }
2678
- // orderBy shape
2851
+ // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2679
2852
  if (opts.orderBy) {
2680
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
2853
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
2681
2854
  subParts.push(`o=${oEntries.join(',')}`);
2682
2855
  }
2683
2856
  // limit presence
@@ -2708,6 +2881,15 @@ export class QueryInterface {
2708
2881
  continue;
2709
2882
  this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
2710
2883
  }
2884
+ // `_count` global-filter params — mirror buildSelectWithRelations, which
2885
+ // appends the count subqueries (and any target-filter params) AFTER every
2886
+ // relation subquery, in resolveCountRelations order.
2887
+ const countSpec = withClause._count;
2888
+ if (countSpec !== undefined) {
2889
+ for (const rel of resolveCountRelations(meta, countSpec)) {
2890
+ this.collectRelationCountParams(rel, params);
2891
+ }
2892
+ }
2711
2893
  }
2712
2894
  /**
2713
2895
  * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
@@ -2725,6 +2907,7 @@ export class QueryInterface {
2725
2907
  if (spec.where) {
2726
2908
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2727
2909
  }
2910
+ this.collectTargetGlobalFilterAlias(targetTable, params);
2728
2911
  if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2729
2912
  params.push(Number(spec.limit));
2730
2913
  }
@@ -2754,6 +2937,9 @@ export class QueryInterface {
2754
2937
  if (spec.where) {
2755
2938
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2756
2939
  }
2940
+ // Global filter on the target — mirrors targetGlobalFilterAlias in
2941
+ // buildRelationSubquery (pushed after spec.where, before limit).
2942
+ this.collectTargetGlobalFilterAlias(targetTable, params);
2757
2943
  // limit param — only hasMany parameterizes its limit (mirrors
2758
2944
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2759
2945
  // pushing one here would orphan a param and desync the collect path.
@@ -2823,14 +3009,148 @@ export class QueryInterface {
2823
3009
  return { sql: '', params: [] };
2824
3010
  return { sql: ` WHERE ${clause}`, params };
2825
3011
  }
3012
+ // -------------------------------------------------------------------------
3013
+ // Global filters (soft-delete / multi-tenancy — WS-G)
3014
+ //
3015
+ // A configured global filter for a table is AND-merged into the compiled WHERE
3016
+ // of every query on that table (via {@link mergeGlobalFilter}, so the merge is
3017
+ // captured in the where fingerprint/collect for free) and into every relation
3018
+ // subquery targeting it (rendered at build time against the subquery's alias/
3019
+ // table by the `*GlobalFilterAlias`/`*GlobalFilterExists` helpers, with the
3020
+ // shape folded into the SQL-cache key via {@link globalFilterCacheSegment}).
3021
+ // Function filters are evaluated per resolve — at query-build time — enabling
3022
+ // per-request tenancy via a closure. They must return a STABLE shape (same
3023
+ // keys/operators); only values may vary between calls.
3024
+ // -------------------------------------------------------------------------
2826
3025
  /**
2827
- * Refuse mutations with an empty predicate unless explicitly opted in.
2828
- *
2829
- * An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
2830
- * mutation with no filter — a common footgun when a caller's filter
2831
- * value accidentally resolves to `undefined`. This guard throws
2832
- * `ValidationError` in that case unless `allowFullTableScan: true`.
3026
+ * Resolve the configured global filter for `table`, evaluating a function
3027
+ * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
3028
+ * no filter applies, the query opted out, or the filter is empty.
3029
+ */
3030
+ resolveGlobalFilter(table, skip = this.currentSkip) {
3031
+ const filters = this.globalFilters;
3032
+ if (!filters)
3033
+ return null;
3034
+ if (skip === true)
3035
+ return null;
3036
+ if (Array.isArray(skip) && skip.includes(table))
3037
+ return null;
3038
+ const raw = filters[table];
3039
+ if (raw === undefined)
3040
+ return null;
3041
+ const resolved = typeof raw === 'function' ? raw() : raw;
3042
+ if (resolved === null || resolved === undefined)
3043
+ return null;
3044
+ const obj = resolved;
3045
+ // An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
3046
+ // nothing — treat it as absent so it never emits a dangling clause.
3047
+ if (Object.keys(obj).every((k) => obj[k] === undefined))
3048
+ return null;
3049
+ return obj;
3050
+ }
3051
+ /**
3052
+ * AND-merge this table's resolved global filter into a user `where`. Either
3053
+ * side may be absent. When no filter applies the user where is returned by
3054
+ * reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
3055
+ */
3056
+ mergeGlobalFilter(userWhere) {
3057
+ const gf = this.resolveGlobalFilter(this.table);
3058
+ if (!gf)
3059
+ return userWhere;
3060
+ if (userWhere === undefined)
3061
+ return gf;
3062
+ return { AND: [userWhere, gf] };
3063
+ }
3064
+ /**
3065
+ * SQL clause for `targetTable`'s global filter rendered against `alias`
3066
+ * (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
3067
+ * `params`; returns `''` when no filter applies. Mirror:
3068
+ * {@link collectTargetGlobalFilterAlias}.
3069
+ */
3070
+ targetGlobalFilterAlias(targetTable, alias, params) {
3071
+ const gf = this.resolveGlobalFilter(targetTable);
3072
+ if (!gf)
3073
+ return '';
3074
+ const meta = this.schema.tables[targetTable];
3075
+ if (!meta)
3076
+ return '';
3077
+ return this.buildAliasWhere(targetTable, meta, alias, gf, params) ?? '';
3078
+ }
3079
+ /** Param-collect mirror of {@link targetGlobalFilterAlias}. */
3080
+ collectTargetGlobalFilterAlias(targetTable, params) {
3081
+ const gf = this.resolveGlobalFilter(targetTable);
3082
+ if (!gf)
3083
+ return;
3084
+ const meta = this.schema.tables[targetTable];
3085
+ if (!meta)
3086
+ return;
3087
+ this.collectAliasWhereParams(targetTable, meta, gf, params);
3088
+ }
3089
+ /**
3090
+ * SQL clause for `targetTable`'s global filter rendered against the bare
3091
+ * (unaliased) table name — the form used inside relation-filter `EXISTS`
3092
+ * subqueries. Pushes its params; `''` when none. Mirror:
3093
+ * {@link collectTargetGlobalFilterExists}.
2833
3094
  */
3095
+ targetGlobalFilterExists(targetTable, params) {
3096
+ const gf = this.resolveGlobalFilter(targetTable);
3097
+ if (!gf)
3098
+ return '';
3099
+ return this.buildSubWhereForRelation(targetTable, gf, params) ?? '';
3100
+ }
3101
+ /** Param-collect mirror of {@link targetGlobalFilterExists}. */
3102
+ collectTargetGlobalFilterExists(targetTable, params) {
3103
+ const gf = this.resolveGlobalFilter(targetTable);
3104
+ if (!gf)
3105
+ return;
3106
+ this.collectRelFilterParams(targetTable, gf, params);
3107
+ }
3108
+ /**
3109
+ * Value-invariant SQL-cache-key segment for the active global-filter
3110
+ * environment. Relation-subquery / relation-filter / `_count` / relation-
3111
+ * `orderBy` global filters are rendered at build time but their SHAPE is not
3112
+ * otherwise in the where/with fingerprint, so this segment guards the cache:
3113
+ * two different filter shapes never collide on one cached SQL text, while two
3114
+ * function-filter results of the SAME shape (differing only in values) share
3115
+ * the entry and bind their own params. Empty (`''`) when no filter applies, so
3116
+ * cache keys stay byte-identical when the feature is unused.
3117
+ */
3118
+ globalFilterCacheSegment() {
3119
+ const filters = this.globalFilters;
3120
+ if (!filters)
3121
+ return '';
3122
+ const parts = [];
3123
+ for (const table of Object.keys(filters).sort()) {
3124
+ // Function filters for OTHER tables may be request-scoped closures that
3125
+ // throw outside their own context; a query on an unrelated table must not
3126
+ // break on them. A throwing filter can't have contributed SQL to this
3127
+ // query either (merging it would have thrown first), so a constant
3128
+ // marker keeps the key shape-distinct without evaluating it.
3129
+ let gf;
3130
+ try {
3131
+ gf = this.resolveGlobalFilter(table);
3132
+ }
3133
+ catch {
3134
+ parts.push(`${table}:!`);
3135
+ continue;
3136
+ }
3137
+ if (gf)
3138
+ parts.push(`${table}:${this.fingerprintWhere(gf)}`);
3139
+ }
3140
+ return parts.length ? `|gf=${parts.join(';')}` : '';
3141
+ }
3142
+ /**
3143
+ * True when the USER-supplied `where` compiles to no predicate (`{}`,
3144
+ * `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
3145
+ * signal the empty-`where` guard needs — the compiled emptiness, NOT the
3146
+ * fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
3147
+ * any configured global filter, so a global filter never lets an unguarded
3148
+ * mass mutation through.
3149
+ */
3150
+ userPredicateIsEmpty(userWhere) {
3151
+ const throwaway = [];
3152
+ return this.buildWhereClause(userWhere, throwaway) === null;
3153
+ }
2834
3154
  assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
2835
3155
  if (whereSql.length > 0)
2836
3156
  return;
@@ -3002,55 +3322,69 @@ export class QueryInterface {
3002
3322
  // belongsTo: parent.fk = child.pk
3003
3323
  correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
3004
3324
  }
3005
- // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
3325
+ // The target table's global filter (soft-delete / tenancy) restricts the
3326
+ // DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
3327
+ // ignore filtered-out rows, and `every` quantifies over only the surviving
3328
+ // rows ("every NON-deleted related row matches P"). It is ANDed into the
3329
+ // correlation and its params pushed AFTER the per-branch filter — mirrored
3330
+ // exactly in collectWhereParams' relation-filter branch. `qt` is the bare
3331
+ // target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
3332
+ const gfAnd = () => {
3333
+ const gf = this.targetGlobalFilterExists(targetTable, params);
3334
+ return gf ? ` AND ${gf}` : '';
3335
+ };
3336
+ // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
3006
3337
  if (filterObj.some !== undefined) {
3007
3338
  const subWhere = filterObj.some;
3008
3339
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3009
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3010
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3340
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3341
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3011
3342
  }
3012
- // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
3343
+ // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
3013
3344
  if (filterObj.none !== undefined) {
3014
3345
  const subWhere = filterObj.none;
3015
3346
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3016
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3017
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3347
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3348
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3018
3349
  }
3019
- // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
3350
+ // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
3020
3351
  if (filterObj.every !== undefined) {
3021
3352
  const subWhere = filterObj.every;
3022
3353
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3023
3354
  if (filterClause) {
3024
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation} AND NOT (${filterClause}))`);
3355
+ // gf params pushed AFTER filter params (collect mirrors this order), but
3356
+ // placed textually inside the domain so it restricts which rows count.
3357
+ const gf = gfAnd();
3358
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
3025
3359
  }
3026
3360
  else {
3027
- // "every" with empty filter = true (all match trivially)
3361
+ // "every" with empty filter = true (all match trivially) — gf irrelevant.
3028
3362
  }
3029
3363
  }
3030
3364
  // "is": EXISTS — for to-one relations (same SQL as "some").
3031
3365
  // `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
3032
3366
  if (filterObj.is !== undefined) {
3033
3367
  if (filterObj.is === null) {
3034
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
3368
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
3035
3369
  }
3036
3370
  else {
3037
3371
  const subWhere = filterObj.is;
3038
3372
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3039
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3040
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3373
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3374
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3041
3375
  }
3042
3376
  }
3043
3377
  // "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
3044
3378
  // `isNot: null` = "a related row exists" → EXISTS.
3045
3379
  if (filterObj.isNot !== undefined) {
3046
3380
  if (filterObj.isNot === null) {
3047
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
3381
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
3048
3382
  }
3049
3383
  else {
3050
3384
  const subWhere = filterObj.isNot;
3051
3385
  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})`);
3386
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3387
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3054
3388
  }
3055
3389
  }
3056
3390
  return clauses.length > 0 ? clauses.join(' AND ') : null;
@@ -3245,17 +3579,9 @@ export class QueryInterface {
3245
3579
  if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
3246
3580
  const norm = this.normalizeRelationFilter(aliasRel, value);
3247
3581
  if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
3248
- // Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
3249
- if (norm.some != null)
3250
- this.collectRelFilterParams(aliasRel.to, norm.some, params);
3251
- if (norm.none != null)
3252
- this.collectRelFilterParams(aliasRel.to, norm.none, params);
3253
- if (norm.every != null)
3254
- this.collectRelFilterParams(aliasRel.to, norm.every, params);
3255
- if (norm.is != null)
3256
- this.collectRelFilterParams(aliasRel.to, norm.is, params);
3257
- if (norm.isNot != null)
3258
- this.collectRelFilterParams(aliasRel.to, norm.isNot, params);
3582
+ // Mirrors buildRelationFilter (somenoneeveryis→isNot, each: sub-where
3583
+ // params then target global-filter params).
3584
+ this.collectRelationFilterParams(aliasRel, norm, params);
3259
3585
  continue;
3260
3586
  }
3261
3587
  }
@@ -3402,10 +3728,37 @@ export class QueryInterface {
3402
3728
  * findMany path). When `params` is omitted (groupBy / relation path) a vector
3403
3729
  * ordering throws — KNN ordering is only supported at the top level.
3404
3730
  */
3731
+ /**
3732
+ * Value-shape fingerprint for a single orderBy entry, so two queries whose
3733
+ * ORDER BY differs only in nulls placement, vector metric, or relation-count
3734
+ * vs relation-column never collide on one cached SQL string. Captures the
3735
+ * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3736
+ */
3737
+ orderByEntryFingerprint(d) {
3738
+ // Vector KNN ordering changes the emitted operator by metric and adds a
3739
+ // `::vector` param, so metric + direction must be part of the cache key.
3740
+ if (isVectorOrderBy(d)) {
3741
+ return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3742
+ }
3743
+ if (isOrderBySpec(d))
3744
+ return `spec(${d.sort},${d.nulls ?? ''})`;
3745
+ if (d && typeof d === 'object') {
3746
+ // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
3747
+ return `rel(${Object.entries(d)
3748
+ .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3749
+ .sort()
3750
+ .join(',')})`;
3751
+ }
3752
+ return String(d);
3753
+ }
3405
3754
  buildOrderBy(orderBy, params) {
3406
- // Dev-only: validate that orderBy fields exist in the table schema
3755
+ // Dev-only: validate that orderBy fields exist in the table schema. Relation
3756
+ // orderBy keys (object values that are neither a vector nor an OrderBySpec)
3757
+ // are validated in the relation branch below, so skip them here.
3407
3758
  if (process.env.NODE_ENV !== 'production') {
3408
- for (const key of Object.keys(orderBy)) {
3759
+ for (const [key, value] of Object.entries(orderBy)) {
3760
+ if (this.isRelationOrderByValue(value) && this.tableMeta.relations[key])
3761
+ continue;
3409
3762
  const snakeKey = camelToSnake(key);
3410
3763
  if (!this.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in this.tableMeta.columnMap)) {
3411
3764
  console.warn(`[turbine] Unknown orderBy field "${key}" for table "${this.tableMeta.name}". ` +
@@ -3414,28 +3767,217 @@ export class QueryInterface {
3414
3767
  }
3415
3768
  }
3416
3769
  const meta = this.schema.tables[this.table];
3770
+ let relOrdCounter = 0;
3417
3771
  return Object.entries(orderBy)
3418
- .map(([key, dir]) => {
3419
- if (meta && !(key in meta.columnMap)) {
3420
- throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3421
- `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3422
- }
3772
+ .map(([key, value]) => {
3423
3773
  // Vector KNN ordering: { distance: { to, metric, direction? } }
3424
- if (isVectorOrderBy(dir)) {
3774
+ if (isVectorOrderBy(value)) {
3775
+ if (meta && !(key in meta.columnMap)) {
3776
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3777
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3778
+ }
3425
3779
  if (!params) {
3426
3780
  throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
3427
3781
  }
3428
3782
  const rawColumn = this.toColumn(key);
3429
- const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
3430
- const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
3431
- const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3783
+ const operator = this.vectorOperator(key, rawColumn, value.distance.metric);
3784
+ const placeholder = this.pushVectorParam(key, rawColumn, value.distance.to, params);
3785
+ const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3432
3786
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3433
3787
  }
3434
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3435
- return `${this.toSqlColumn(key)} ${safeDir}`;
3788
+ // Relation ordering: an object value that is not a vector or OrderBySpec,
3789
+ // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3790
+ // { name: 'asc' } }`).
3791
+ if (this.isRelationOrderByValue(value)) {
3792
+ return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
3793
+ }
3794
+ // Scalar column ordering — a plain direction or an OrderBySpec (nulls).
3795
+ if (meta && !(key in meta.columnMap)) {
3796
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3797
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3798
+ }
3799
+ const { dir, nulls } = normalizeOrderBy(value);
3800
+ return `${this.toSqlColumn(key)} ${dir}${this.nullsSuffix(nulls)}`;
3436
3801
  })
3437
3802
  .join(', ');
3438
3803
  }
3804
+ /**
3805
+ * True when an orderBy value is a relation-ordering object: a plain object
3806
+ * that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
3807
+ * in the orderBy clause is a relation name.
3808
+ */
3809
+ isRelationOrderByValue(value) {
3810
+ return (typeof value === 'object' &&
3811
+ value !== null &&
3812
+ !Array.isArray(value) &&
3813
+ !isVectorOrderBy(value) &&
3814
+ !isOrderBySpec(value));
3815
+ }
3816
+ /**
3817
+ * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
3818
+ * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
3819
+ * other engine a caller asking for explicit nulls placement gets a clear
3820
+ * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
3821
+ */
3822
+ nullsSuffix(nulls) {
3823
+ if (!nulls)
3824
+ return '';
3825
+ if (this.dialect.name !== 'postgresql' && this.dialect.name !== 'sqlite') {
3826
+ throw new UnsupportedFeatureError('NULLS FIRST/LAST ordering', this.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
3827
+ }
3828
+ return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3829
+ }
3830
+ /**
3831
+ * Compile a relation ordering term. For a to-many relation the only allowed
3832
+ * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
3833
+ * to-one relation each entry names a target column and becomes a correlated
3834
+ * scalar subquery (supporting {@link OrderBySpec} nulls placement).
3835
+ *
3836
+ * Validation: relation must exist (E005); to-many only allows `_count`, and
3837
+ * to-one only allows real target columns (E003).
3838
+ */
3839
+ buildRelationOrderBy(relName, value, alias, params) {
3840
+ const relDef = this.tableMeta.relations[relName];
3841
+ if (!relDef) {
3842
+ throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3843
+ `Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
3844
+ }
3845
+ // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3846
+ if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
3847
+ const keys = Object.keys(value);
3848
+ if (keys.length !== 1 || keys[0] !== '_count') {
3849
+ throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
3850
+ `(got: ${keys.join(', ') || '(empty)'}).`);
3851
+ }
3852
+ const { dir } = normalizeOrderBy(value._count);
3853
+ return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
3854
+ }
3855
+ // To-one: each entry orders by a correlated scalar subquery on a target column.
3856
+ const targetMeta = this.schema.tables[relDef.to];
3857
+ if (!targetMeta)
3858
+ throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3859
+ const qTarget = this.q(relDef.to);
3860
+ const qParent = this.q(this.table);
3861
+ // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3862
+ const correlation = relDef.type === 'belongsTo'
3863
+ ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
3864
+ : this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3865
+ const entries = Object.entries(value);
3866
+ if (entries.length === 0) {
3867
+ throw new ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
3868
+ }
3869
+ return entries
3870
+ .map(([col, dirValue]) => {
3871
+ const snakeCol = camelToSnake(col);
3872
+ if (!targetMeta.allColumns.includes(snakeCol)) {
3873
+ throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3874
+ }
3875
+ const { dir, nulls } = normalizeOrderBy(dirValue);
3876
+ // Target's global filter applies here too — otherwise ordering keys off
3877
+ // a soft-deleted / other-tenant related row's value (matches the with
3878
+ // subquery semantics for belongsTo/hasOne).
3879
+ let where = correlation;
3880
+ if (params) {
3881
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
3882
+ if (gf)
3883
+ where += ` AND ${gf}`;
3884
+ }
3885
+ return `(SELECT ${alias}.${this.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${this.limitOneClause()}) ${dir}${this.nullsSuffix(nulls)}`;
3886
+ })
3887
+ .join(', ');
3888
+ }
3889
+ /**
3890
+ * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3891
+ * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
3892
+ * manyToMany counts junction rows via the source key. Shared by the `_count`
3893
+ * `with` key and to-many relation orderBy.
3894
+ *
3895
+ * When `params` is supplied and the target has a global filter, it is
3896
+ * AND-merged so the count only sees surviving rows (a soft-deleted child is
3897
+ * not counted): hasMany filters the counted rows directly; manyToMany adds an
3898
+ * `EXISTS` on the target through the junction (the junction rows themselves
3899
+ * carry no filter). Params are mirrored by {@link collectRelationCountParams}.
3900
+ */
3901
+ buildRelationCountExpr(relDef, parentRef, alias, params) {
3902
+ const qParent = this.q(parentRef);
3903
+ const count = this.castAgg('COUNT(*)', 'int');
3904
+ if (relDef.type === 'manyToMany') {
3905
+ if (!relDef.through) {
3906
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
3907
+ }
3908
+ const qJ = this.q(relDef.through.table);
3909
+ const jalias = `${alias}j`;
3910
+ const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
3911
+ const refKeys = normalizeKeyColumns(relDef.referenceKey);
3912
+ let where = sourceKeys
3913
+ .map((jc, i) => `${jalias}.${this.q(jc)} = ${qParent}.${this.q(refKeys[i])}`)
3914
+ .join(' AND ');
3915
+ if (params) {
3916
+ const targetExists = this.manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params);
3917
+ if (targetExists)
3918
+ where += ` AND ${targetExists}`;
3919
+ }
3920
+ return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
3921
+ }
3922
+ // hasMany: child FK correlates to the parent reference key.
3923
+ const qTarget = this.q(relDef.to);
3924
+ let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3925
+ if (params) {
3926
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
3927
+ if (gf)
3928
+ where += ` AND ${gf}`;
3929
+ }
3930
+ return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
3931
+ }
3932
+ /**
3933
+ * `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
3934
+ * a manyToMany `_count` to targets that survive their global filter. `''` when
3935
+ * the target has no filter. Pushes gf params; mirror:
3936
+ * {@link collectManyToManyTargetGlobalFilter}.
3937
+ */
3938
+ manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params) {
3939
+ const gf = this.resolveGlobalFilter(relDef.to);
3940
+ if (!gf || !relDef.through)
3941
+ return '';
3942
+ const tMeta = this.schema.tables[relDef.to];
3943
+ if (!tMeta || tMeta.primaryKey.length === 0)
3944
+ return '';
3945
+ const talias = `${alias}t`;
3946
+ const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
3947
+ const pk = tMeta.primaryKey;
3948
+ if (targetKeys.length !== pk.length)
3949
+ return '';
3950
+ const join = targetKeys.map((jc, i) => `${talias}.${this.q(pk[i])} = ${jalias}.${this.q(jc)}`).join(' AND ');
3951
+ const gfClause = this.buildAliasWhere(relDef.to, tMeta, talias, gf, params);
3952
+ const gfAnd = gfClause ? ` AND ${gfClause}` : '';
3953
+ return `EXISTS (SELECT 1 FROM ${this.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
3954
+ }
3955
+ /** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
3956
+ collectManyToManyTargetGlobalFilter(relDef, params) {
3957
+ const gf = this.resolveGlobalFilter(relDef.to);
3958
+ if (!gf || !relDef.through)
3959
+ return;
3960
+ const tMeta = this.schema.tables[relDef.to];
3961
+ if (!tMeta || tMeta.primaryKey.length === 0)
3962
+ return;
3963
+ const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
3964
+ if (targetKeys.length !== tMeta.primaryKey.length)
3965
+ return;
3966
+ this.collectAliasWhereParams(relDef.to, tMeta, gf, params);
3967
+ }
3968
+ /**
3969
+ * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
3970
+ * params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
3971
+ * when a filter applies — no-op otherwise.
3972
+ */
3973
+ collectRelationCountParams(relDef, params) {
3974
+ if (relDef.type === 'manyToMany') {
3975
+ this.collectManyToManyTargetGlobalFilter(relDef, params);
3976
+ }
3977
+ else {
3978
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
3979
+ }
3980
+ }
3439
3981
  // -------------------------------------------------------------------------
3440
3982
  // pgvector helpers (similarity search)
3441
3983
  // -------------------------------------------------------------------------
@@ -3564,6 +4106,19 @@ export class QueryInterface {
3564
4106
  const meta = this.schema.tables[table];
3565
4107
  if (!meta)
3566
4108
  return parsed;
4109
+ // Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
4110
+ // parseRow copies these unknown columns through under their raw key.
4111
+ let countObj;
4112
+ for (const key of Object.keys(parsed)) {
4113
+ if (key.startsWith('_count__')) {
4114
+ if (countObj === undefined)
4115
+ countObj = {};
4116
+ countObj[key.slice('_count__'.length)] = Number(parsed[key]);
4117
+ delete parsed[key];
4118
+ }
4119
+ }
4120
+ if (countObj)
4121
+ parsed._count = countObj;
3567
4122
  for (const [relName, relDef] of Object.entries(meta.relations)) {
3568
4123
  const rawValue = row[relName];
3569
4124
  if (rawValue === undefined)
@@ -3830,6 +4385,9 @@ export class QueryInterface {
3830
4385
  const relationSelects = [];
3831
4386
  const aliasCounter = { n: 0 };
3832
4387
  for (const [relName, relSpec] of sortedEntries(withClause)) {
4388
+ // `_count` is a reserved key handled after the relation subqueries.
4389
+ if (relName === '_count')
4390
+ continue;
3833
4391
  const relDef = meta.relations[relName];
3834
4392
  if (!relDef) {
3835
4393
  throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3839,6 +4397,18 @@ export class QueryInterface {
3839
4397
  const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
3840
4398
  relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
3841
4399
  }
4400
+ // Reserved `_count` key → one correlated COUNT(*) scalar subquery per
4401
+ // selected to-many relation, aliased `_count__<rel>`. Appended after the
4402
+ // relation subqueries; the only params they can push come from a global
4403
+ // filter on the counted target (mirrored at the tail of collectWithParams).
4404
+ // Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
4405
+ const countSpec = withClause._count;
4406
+ if (countSpec !== undefined) {
4407
+ for (const rel of resolveCountRelations(meta, countSpec)) {
4408
+ const expr = this.buildRelationCountExpr(rel, table, `t${aliasCounter.n++}`, params);
4409
+ relationSelects.push(`${expr} AS ${this.q(`_count__${rel.name}`)}`);
4410
+ }
4411
+ }
3842
4412
  return [baseCols, ...relationSelects].join(', ');
3843
4413
  }
3844
4414
  /**
@@ -4039,13 +4609,13 @@ export class QueryInterface {
4039
4609
  let orderClause = '';
4040
4610
  if (relOrderEntries.length > 0) {
4041
4611
  const orders = relOrderEntries
4042
- .map(([k, dir]) => {
4612
+ .map(([k, dirValue]) => {
4043
4613
  const col = camelToSnake(k);
4044
4614
  if (!targetMeta.allColumns.includes(col)) {
4045
4615
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4046
4616
  }
4047
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4048
- return `${alias}.${this.q(col)} ${safeDir}`;
4617
+ const { dir, nulls } = normalizeOrderBy(dirValue);
4618
+ return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4049
4619
  })
4050
4620
  .join(', ');
4051
4621
  orderClause = ` ORDER BY ${orders}`;
@@ -4071,6 +4641,12 @@ export class QueryInterface {
4071
4641
  if (extra)
4072
4642
  whereClause += ` AND ${extra}`;
4073
4643
  }
4644
+ // Global filter on the target table (soft-delete / tenancy) — AND-merged so
4645
+ // a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
4646
+ // mirrored by collectRelationSubqueryParams.
4647
+ const gfExtra = this.targetGlobalFilterAlias(targetTable, alias, params);
4648
+ if (gfExtra)
4649
+ whereClause += ` AND ${gfExtra}`;
4074
4650
  // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
4075
4651
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
4076
4652
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
@@ -4181,13 +4757,13 @@ export class QueryInterface {
4181
4757
  let orderClause = '';
4182
4758
  if (relOrderEntries.length > 0) {
4183
4759
  const orders = relOrderEntries
4184
- .map(([k, dir]) => {
4760
+ .map(([k, dirValue]) => {
4185
4761
  const col = camelToSnake(k);
4186
4762
  if (!targetMeta.allColumns.includes(col)) {
4187
4763
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4188
4764
  }
4189
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4190
- return `${talias}.${this.q(col)} ${safeDir}`;
4765
+ const { dir, nulls } = normalizeOrderBy(dirValue);
4766
+ return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4191
4767
  })
4192
4768
  .join(', ');
4193
4769
  orderClause = ` ORDER BY ${orders}`;
@@ -4199,6 +4775,11 @@ export class QueryInterface {
4199
4775
  if (extra)
4200
4776
  whereClause += ` AND ${extra}`;
4201
4777
  }
4778
+ // Global filter on the target table (mirrors collectRelationSubqueryParams'
4779
+ // m2m branch: after spec.where, before limit).
4780
+ const gfExtra = this.targetGlobalFilterAlias(targetTable, talias, params);
4781
+ if (gfExtra)
4782
+ whereClause += ` AND ${gfExtra}`;
4202
4783
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
4203
4784
  let limitClause = '';
4204
4785
  if (spec !== true && spec.limit !== undefined) {