turbine-orm 0.27.1 → 0.28.1

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 (64) hide show
  1. package/README.md +19 -15
  2. package/dist/cjs/cli/config.js +20 -3
  3. package/dist/cjs/cli/index.js +273 -71
  4. package/dist/cjs/cli/mcp.js +788 -0
  5. package/dist/cjs/cli/migrate.js +95 -20
  6. package/dist/cjs/cli/studio.js +3 -2
  7. package/dist/cjs/client.js +267 -34
  8. package/dist/cjs/dialect.js +2 -0
  9. package/dist/cjs/errors.js +15 -1
  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/powdb.js +1 -1
  14. package/dist/cjs/powql.js +1 -1
  15. package/dist/cjs/query/batched-loader.js +148 -0
  16. package/dist/cjs/query/builder.js +763 -401
  17. package/dist/cjs/query/deferred.js +7 -0
  18. package/dist/cjs/query/filters.js +251 -0
  19. package/dist/cjs/schema-builder.js +59 -4
  20. package/dist/cjs/schema-sql.js +315 -6
  21. package/dist/cjs/seed.js +66 -0
  22. package/dist/cli/config.d.ts +9 -2
  23. package/dist/cli/config.js +19 -3
  24. package/dist/cli/index.d.ts +52 -1
  25. package/dist/cli/index.js +272 -74
  26. package/dist/cli/mcp.d.ts +17 -0
  27. package/dist/cli/mcp.js +781 -0
  28. package/dist/cli/migrate.d.ts +37 -0
  29. package/dist/cli/migrate.js +92 -20
  30. package/dist/cli/studio.d.ts +3 -2
  31. package/dist/cli/studio.js +3 -2
  32. package/dist/client.d.ts +136 -1
  33. package/dist/client.js +267 -34
  34. package/dist/dialect.d.ts +17 -0
  35. package/dist/dialect.js +2 -0
  36. package/dist/errors.js +15 -1
  37. package/dist/generate.d.ts +17 -0
  38. package/dist/generate.js +171 -10
  39. package/dist/index.d.ts +4 -3
  40. package/dist/index.js +2 -0
  41. package/dist/introspect.d.ts +20 -1
  42. package/dist/introspect.js +175 -4
  43. package/dist/powdb.d.ts +1 -1
  44. package/dist/powdb.js +1 -1
  45. package/dist/powql.d.ts +1 -1
  46. package/dist/powql.js +1 -1
  47. package/dist/query/batched-loader.d.ts +29 -2
  48. package/dist/query/batched-loader.js +148 -1
  49. package/dist/query/builder.d.ts +151 -122
  50. package/dist/query/builder.js +701 -339
  51. package/dist/query/deferred.d.ts +130 -0
  52. package/dist/query/deferred.js +6 -0
  53. package/dist/query/filters.d.ts +120 -0
  54. package/dist/query/filters.js +232 -0
  55. package/dist/query/index.d.ts +1 -1
  56. package/dist/query/types.d.ts +113 -8
  57. package/dist/schema-builder.d.ts +73 -8
  58. package/dist/schema-builder.js +59 -4
  59. package/dist/schema-sql.d.ts +67 -0
  60. package/dist/schema-sql.js +310 -6
  61. package/dist/schema.d.ts +53 -0
  62. package/dist/seed.d.ts +4 -0
  63. package/dist/seed.js +63 -0
  64. package/package.json +4 -5
@@ -15,215 +15,11 @@ 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
+ import { assertBindableEqualsOperand, findArrayUniqueKey, findJsonUniqueKey, fingerprintOperatorShape, isArrayFilter, isJsonFilter, isOrderBySpec, isTextSearchFilter, isUnmatchedPlainObject, isVectorFilter, isVectorOrderBy, isWhereOperator, normalizeOrderBy, sortedEntries, sortedKeys, UPDATE_OPERATOR_KEYS, VECTOR_DISTANCE_COMPARATORS, VECTOR_METRIC_OPERATORS, validateTextSearchConfig, } from './filters.js';
19
20
  import { escapeLike, LRUCache, OPERATOR_KEYS, parseDbDate, sqlToPreparedName } from './utils.js';
20
- // ---------------------------------------------------------------------------
21
- // Internal detection helpers — used by QueryInterface
22
- // ---------------------------------------------------------------------------
23
- /** Check if a value is a where operator object (has at least one known operator key) */
24
- function isWhereOperator(value) {
25
- if (value === null ||
26
- value === undefined ||
27
- typeof value !== 'object' ||
28
- Array.isArray(value) ||
29
- value instanceof Date) {
30
- return false;
31
- }
32
- const keys = Object.keys(value);
33
- return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
34
- }
35
- /**
36
- * True for a *plain object literal* that reached an equality fallthrough
37
- * without matching any known filter shape — the misspelled-operator case.
38
- * Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
39
- * bind values and return false, as do arrays and Dates.
40
- */
41
- function isUnmatchedPlainObject(value) {
42
- if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
43
- return false;
44
- if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
45
- return false;
46
- const proto = Object.getPrototypeOf(value);
47
- return proto === Object.prototype || proto === null;
48
- }
49
- /**
50
- * Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
51
- * `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
52
- * param pushed), so null-ness is part of the shape — without it a cache entry
53
- * warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
54
- */
55
- function fingerprintOperatorShape(value) {
56
- const obj = value;
57
- const opKeys = Object.keys(obj)
58
- .filter((k) => k !== 'mode')
59
- .map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
60
- .sort();
61
- const modeStr = value.mode === 'insensitive' ? ':i' : '';
62
- return `op(${opKeys.join(',')}${modeStr})`;
63
- }
64
- /**
65
- * Guard for the value of an `equals` operator reaching the plain-equality
66
- * operator path. A plain object literal can only legitimately be an equality
67
- * value on a json/jsonb column — and those route to the JSONB filter branch
68
- * BEFORE the operator branch, so any plain object that reaches here is a
69
- * mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
70
- * SQL-build path and the cache-hit param-collect path so a warmed cache can
71
- * never skip the check.
72
- */
73
- function assertBindableEqualsOperand(value, column) {
74
- if (!isUnmatchedPlainObject(value))
75
- return;
76
- throw new ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
77
- `objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
78
- `where 'equals' is the JSONB containment filter.`);
79
- }
80
- /**
81
- * Object keys in sorted order, mirroring the canonical order used by every
82
- * cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
83
- * enumerate object keys in this exact order: fingerprints sort keys, so two
84
- * where clauses with the same fields in different insertion order share one
85
- * cache entry — if build/collect iterated insertion order, the cached SQL's
86
- * `$N` placeholders would bind the wrong values (cross-tenant-leak class).
87
- * Array order (OR/AND members) is positional and is never sorted.
88
- */
89
- function sortedKeys(obj) {
90
- return Object.keys(obj).sort();
91
- }
92
- /** {@link sortedKeys}, but yielding `[key, value]` pairs. */
93
- function sortedEntries(obj) {
94
- return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
95
- }
96
- /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
97
21
  /** Relations already warned about missing FK indexes (once per process, dev only). */
98
22
  const unindexedRelationWarned = new Set();
99
- const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
100
- /** Known JSONB operator keys */
101
- const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
102
- /**
103
- * JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
104
- * appear in any other where-filter shape, so the presence of one of these is
105
- * an unambiguous signal that the user meant a JSON filter. Used by the
106
- * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
107
- * `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
108
- * set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
109
- * so it must fall through instead of throwing.
110
- */
111
- const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
112
- /** Check if a value is a JSONB filter object */
113
- function isJsonFilter(value) {
114
- if (value === null ||
115
- value === undefined ||
116
- typeof value !== 'object' ||
117
- Array.isArray(value) ||
118
- value instanceof Date) {
119
- return false;
120
- }
121
- const keys = Object.keys(value);
122
- return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
123
- }
124
- /**
125
- * Returns the first JSON-unique key found in `value`, or `null` if none.
126
- * Used to drive the strict-validation error message.
127
- */
128
- function findJsonUniqueKey(value) {
129
- for (const k of Object.keys(value)) {
130
- if (JSONB_UNIQUE_KEYS.has(k))
131
- return k;
132
- }
133
- return null;
134
- }
135
- /** Known Array operator keys */
136
- const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
137
- /**
138
- * Array operator keys that are *unique* to {@link ArrayFilter}. None of the
139
- * array operators currently overlap with `WhereOperator` or `JsonFilter`, so
140
- * this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
141
- * constant so a future overlap (e.g. a `contains` for arrays) is easy to
142
- * carve out.
143
- */
144
- const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
145
- /** Check if a value is an Array filter object */
146
- function isArrayFilter(value) {
147
- if (value === null ||
148
- value === undefined ||
149
- typeof value !== 'object' ||
150
- Array.isArray(value) ||
151
- value instanceof Date) {
152
- return false;
153
- }
154
- const keys = Object.keys(value);
155
- return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
156
- }
157
- /**
158
- * Returns the first array-unique key found in `value`, or `null` if none.
159
- * Used to drive the strict-validation error message.
160
- */
161
- function findArrayUniqueKey(value) {
162
- for (const k of Object.keys(value)) {
163
- if (ARRAY_UNIQUE_KEYS.has(k))
164
- return k;
165
- }
166
- return null;
167
- }
168
- /** Known text search operator keys */
169
- const TEXT_SEARCH_KEYS = new Set(['search', 'config']);
170
- /** Check if a value is a TextSearchFilter object */
171
- function isTextSearchFilter(value) {
172
- if (value === null ||
173
- value === undefined ||
174
- typeof value !== 'object' ||
175
- Array.isArray(value) ||
176
- value instanceof Date) {
177
- return false;
178
- }
179
- const keys = Object.keys(value);
180
- // Must have 'search' key and only known text search keys
181
- return keys.includes('search') && keys.every((k) => TEXT_SEARCH_KEYS.has(k));
182
- }
183
- /**
184
- * Validate a text search config name. Only alphanumeric characters and
185
- * underscores are allowed to prevent SQL injection via the config parameter.
186
- */
187
- function validateTextSearchConfig(config) {
188
- return /^[a-zA-Z0-9_]+$/.test(config);
189
- }
190
- /**
191
- * pgvector distance metric → operator allow-list. This is the ONLY mapping
192
- * from a user-supplied metric token to a SQL operator; any token not present
193
- * here is rejected, so a user value can never become an arbitrary operator.
194
- *
195
- * - `l2` → `<->` (Euclidean / L2 distance)
196
- * - `cosine` → `<=>` (cosine distance)
197
- * - `ip` → `<#>` (negative inner product)
198
- */
199
- const VECTOR_METRIC_OPERATORS = {
200
- l2: '<->',
201
- cosine: '<=>',
202
- ip: '<#>',
203
- };
204
- /** Comparison keys allowed on a {@link VectorDistanceFilter}. */
205
- const VECTOR_DISTANCE_COMPARATORS = {
206
- lt: '<',
207
- lte: '<=',
208
- gt: '>',
209
- gte: '>=',
210
- };
211
- /** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
212
- function isVectorFilter(value) {
213
- if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
214
- return false;
215
- }
216
- const dist = value.distance;
217
- return (typeof dist === 'object' &&
218
- dist !== null &&
219
- !Array.isArray(dist) &&
220
- 'to' in dist &&
221
- 'metric' in dist);
222
- }
223
- /** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
224
- function isVectorOrderBy(value) {
225
- return isVectorFilter(value);
226
- }
227
23
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
228
24
  export class QueryInterface {
229
25
  pool;
@@ -243,6 +39,13 @@ export class QueryInterface {
243
39
  relationLoadStrategy;
244
40
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
245
41
  jsonEncoding;
42
+ /**
43
+ * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
44
+ * multi-tenancy). AND-merged into every query on the keyed table and every
45
+ * relation subquery targeting it. Undefined when none are configured, in
46
+ * which case every path is byte-identical to the pre-0.28 behavior.
47
+ */
48
+ globalFilters;
246
49
  /**
247
50
  * Tracks tables that have already triggered an unlimited-query warning so
248
51
  * the user is not spammed once per row. Per-instance state — each
@@ -274,6 +77,15 @@ export class QueryInterface {
274
77
  options;
275
78
  /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
276
79
  currentAction = 'raw';
80
+ /**
81
+ * The active query's `skipGlobalFilters` opt-out, set at the top of each
82
+ * `build*` method and read deep in the (synchronous) SQL-build + param-collect
83
+ * tree — so relation subqueries, relation filters, `_count`, and relation
84
+ * `orderBy` all see it without threading it through dozens of signatures.
85
+ * Only load-bearing when {@link globalFilters} is configured; build+collect are
86
+ * synchronous per call, so this transient is never observed across an await.
87
+ */
88
+ currentSkip;
277
89
  constructor(pool, table, schema, middlewares, options) {
278
90
  this.pool = pool;
279
91
  this.table = table;
@@ -295,6 +107,10 @@ export class QueryInterface {
295
107
  this.dialect = options?.dialect ?? postgresDialect;
296
108
  this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
297
109
  this.jsonEncoding = options?.jsonEncoding ?? 'object';
110
+ // Only retain the map when it has at least one entry, so `globalFilters`
111
+ // stays `undefined` (and every merge path a no-op) for the common case.
112
+ this.globalFilters =
113
+ options?.globalFilters && Object.keys(options.globalFilters).length > 0 ? options.globalFilters : undefined;
298
114
  this.txScoped = options?._txScoped ?? false;
299
115
  this.options = options;
300
116
  // Pre-compute column type lookup maps (TASK-26)
@@ -414,7 +230,7 @@ export class QueryInterface {
414
230
  * and unlimited-warnings silenced — a relation load must fetch every matching
415
231
  * child, and the per-relation `limit` is applied client-side by the loader.
416
232
  */
417
- batchedContext(timeout) {
233
+ batchedContext(timeout, skip) {
418
234
  const childOptions = {
419
235
  ...this.options,
420
236
  defaultLimit: undefined,
@@ -429,6 +245,22 @@ export class QueryInterface {
429
245
  buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
430
246
  inClauseParam: (values) => this.inParam(values),
431
247
  paramPlaceholder: (index) => this.p(index),
248
+ skipGlobalFilters: skip,
249
+ tableGlobalFilter: (table, alias, precedingParams) => {
250
+ const gf = this.resolveGlobalFilter(table, skip);
251
+ if (!gf)
252
+ return null;
253
+ const meta = this.schema.tables[table];
254
+ if (!meta)
255
+ return null;
256
+ // Seed the param array with `precedingParams` placeholders so
257
+ // buildAliasWhere numbers the gf params after the already-bound ones.
258
+ const seeded = new Array(precedingParams).fill(undefined);
259
+ const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
260
+ if (!clause)
261
+ return null;
262
+ return { clause, params: seeded.slice(precedingParams) };
263
+ },
432
264
  };
433
265
  }
434
266
  /**
@@ -440,13 +272,18 @@ export class QueryInterface {
440
272
  */
441
273
  async runFindManyBatched(args) {
442
274
  const withClause = args.with;
275
+ // Capture the opt-out from the ARGS before any await: this.currentSkip is
276
+ // instance state on a cached accessor, so a concurrent build during the
277
+ // base-query await would overwrite it (tenant query loading relations with
278
+ // another query's skipGlobalFilters).
279
+ const skip = args.skipGlobalFilters;
443
280
  const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
444
281
  // baseArgs.with is always undefined here; the cast just bridges the R generic.
445
282
  const deferred = this.buildFindMany(baseArgs);
446
283
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
447
284
  const entities = deferred.transform(result);
448
285
  if (entities.length > 0) {
449
- await loadRelationsBatched(this.batchedContext(args.timeout), entities, withClause, args.timeout);
286
+ await loadRelationsBatched(this.batchedContext(args.timeout, skip), entities, withClause, args.timeout);
450
287
  }
451
288
  stripFields(entities, strip);
452
289
  return entities;
@@ -668,18 +505,22 @@ export class QueryInterface {
668
505
  const entity = deferred.transform(result);
669
506
  if (!entity)
670
507
  return null;
671
- await loadRelationsBatched(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
508
+ await loadRelationsBatched(this.batchedContext(args.timeout, args.skipGlobalFilters), [entity], withClause, args.timeout);
672
509
  stripFields([entity], proj.strip);
673
510
  return entity;
674
511
  }
675
512
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
676
513
  buildFindUnique(args) {
514
+ this.currentSkip = args.skipGlobalFilters;
677
515
  const columnsList = this.resolveColumns(args.select, args.omit);
678
- const whereObj = args.where;
516
+ // A global filter turns the where into `{ AND: [...] }`, which the
517
+ // `isSimpleWhere` test below rejects → the general (buildWhereClause) path
518
+ // handles the merge and its params uniformly.
519
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
679
520
  const colKey = columnsList ? columnsList.join(',') : '*';
680
521
  const whereFingerprint = this.fingerprintWhere(whereObj);
681
522
  const withFp = args.with ? this.withFingerprint(args.with) : '';
682
- const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
523
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}${this.globalFilterCacheSegment()}`;
683
524
  const params = [];
684
525
  // Check if all where values are simple (plain equality, no operators/null/OR).
685
526
  // Keys are sorted to match fingerprintWhere — insertion order here would let
@@ -835,24 +676,21 @@ export class QueryInterface {
835
676
  }
836
677
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
837
678
  buildFindMany(args) {
679
+ this.currentSkip = args?.skipGlobalFilters;
838
680
  const columnsList = this.resolveColumns(args?.select, args?.omit);
839
681
  const colKey = columnsList ? columnsList.join(',') : '*';
840
- const whereObj = (args?.where ?? {});
682
+ // AND-merge this table's global filter into the user where; `hasWhere` gates
683
+ // the build/collect just like `args?.where` did (a merged filter can make
684
+ // an otherwise-absent where present).
685
+ const effWhere = this.mergeGlobalFilter(args?.where);
686
+ const hasWhere = effWhere !== undefined;
687
+ const whereObj = (effWhere ?? {});
841
688
  // Build fingerprint for cache lookup
842
- const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
689
+ const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
843
690
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
844
691
  const orderFp = args?.orderBy
845
692
  ? 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
- })
693
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
856
694
  .join(',')
857
695
  : '';
858
696
  const cursorFp = args?.cursor
@@ -865,12 +703,12 @@ export class QueryInterface {
865
703
  const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
866
704
  const limitFp = effectiveLimit !== undefined ? '1' : '0';
867
705
  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}`;
706
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
869
707
  const params = [];
870
708
  const entry = this.acquireSql(ck, () => {
871
709
  // Fresh build — generates SQL and populates freshParams
872
710
  const freshParams = [];
873
- const { sql: freshWhereSql } = args?.where
711
+ const { sql: freshWhereSql } = hasWhere
874
712
  ? (() => {
875
713
  const clause = this.buildWhereClause(whereObj, freshParams);
876
714
  return { sql: clause ? ` WHERE ${clause}` : '' };
@@ -900,8 +738,11 @@ export class QueryInterface {
900
738
  if (cursorEntries.length > 0) {
901
739
  const cursorConditions = cursorEntries.map(([k, v]) => {
902
740
  const col = this.toSqlColumn(k);
903
- const dir = args.orderBy?.[k] ?? 'asc';
904
- const op = dir === 'desc' ? '<' : '>';
741
+ // orderBy values can be the { sort, nulls } spec form — normalize
742
+ // before comparing, or a desc spec would seek the ascending side.
743
+ const dir = args.orderBy?.[k];
744
+ const desc = isOrderBySpec(dir) ? dir.sort === 'desc' : dir === 'desc';
745
+ const op = desc ? '<' : '>';
905
746
  freshParams.push(v);
906
747
  return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
907
748
  });
@@ -948,8 +789,8 @@ export class QueryInterface {
948
789
  return sql;
949
790
  });
950
791
  // Collect params in exact build order:
951
- // 1. WHERE params
952
- if (args?.where) {
792
+ // 1. WHERE params (includes the AND-merged global filter, if any)
793
+ if (hasWhere) {
953
794
  this.collectWhereParams(whereObj, params);
954
795
  }
955
796
  // 2. WITH relation params
@@ -1161,6 +1002,8 @@ export class QueryInterface {
1161
1002
  });
1162
1003
  }
1163
1004
  buildCreate(args) {
1005
+ this.assertWritable('create');
1006
+ this.assertNoGeneratedColumns(args.data, 'create');
1164
1007
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
1165
1008
  const columns = entries.map(([k]) => this.toSqlColumn(k));
1166
1009
  const params = entries.map(([, v]) => v);
@@ -1235,6 +1078,10 @@ export class QueryInterface {
1235
1078
  tag: `${this.table}.createMany`,
1236
1079
  };
1237
1080
  }
1081
+ this.assertWritable('createMany');
1082
+ for (const row of args.data) {
1083
+ this.assertNoGeneratedColumns(row, 'createMany');
1084
+ }
1238
1085
  const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
1239
1086
  const columns = keys.map((k) => this.toColumn(k));
1240
1087
  const rowValues = args.data.map((row) => {
@@ -1272,12 +1119,22 @@ export class QueryInterface {
1272
1119
  });
1273
1120
  }
1274
1121
  buildUpdate(args) {
1122
+ this.assertWritable('update');
1123
+ this.currentSkip = args.skipGlobalFilters;
1275
1124
  const dataObj = args.data;
1276
- const whereObj = args.where;
1125
+ this.assertNoGeneratedColumns(dataObj, 'update');
1126
+ const userWhere = args.where;
1277
1127
  const lock = args.optimisticLock;
1128
+ // The empty-`where` guard checks the USER predicate only — a global filter
1129
+ // must never turn an unguarded mass update into an allowed one.
1130
+ const userHasPredicate = !this.userPredicateIsEmpty(userWhere) || !!lock;
1131
+ this.assertMutationHasPredicate('update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
1132
+ // The SQL is built from the global-filter-merged where (soft-delete keeps an
1133
+ // update from touching already-deleted rows).
1134
+ const whereObj = (this.mergeGlobalFilter(userWhere) ?? {});
1278
1135
  const setFp = this.fingerprintSet(dataObj);
1279
1136
  const whereFp = this.fingerprintWhere(whereObj);
1280
- const ck = lock ? null : `u:${setFp}|${whereFp}`;
1137
+ const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1281
1138
  const params = [];
1282
1139
  const buildSql = () => {
1283
1140
  const freshParams = [];
@@ -1295,7 +1152,6 @@ export class QueryInterface {
1295
1152
  const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
1296
1153
  whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
1297
1154
  }
1298
- this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
1299
1155
  // Engines that inject their returning shape MID-statement (SQL Server
1300
1156
  // `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
1301
1157
  // absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
@@ -1309,9 +1165,6 @@ export class QueryInterface {
1309
1165
  const entry = this.acquireSql(ck, buildSql);
1310
1166
  sql = entry.sql;
1311
1167
  preparedName = entry.name;
1312
- if (whereFp === '') {
1313
- this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
1314
- }
1315
1168
  }
1316
1169
  else {
1317
1170
  sql = buildSql();
@@ -1445,27 +1298,24 @@ export class QueryInterface {
1445
1298
  });
1446
1299
  }
1447
1300
  buildDelete(args) {
1448
- const whereObj = args.where;
1301
+ this.assertWritable('delete');
1302
+ this.currentSkip = args.skipGlobalFilters;
1303
+ // Guard the USER predicate (a global filter must not satisfy the guard).
1304
+ this.assertMutationHasPredicate('delete', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1305
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1449
1306
  const whereFp = this.fingerprintWhere(whereObj);
1450
- const ck = `d:${whereFp}`;
1307
+ const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
1451
1308
  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
1309
  const entry = this.acquireSql(ck, () => {
1455
1310
  const freshParams = [];
1456
1311
  const clause = this.buildWhereClause(whereObj, freshParams);
1457
1312
  const whereSql = clause ? ` WHERE ${clause}` : '';
1458
- this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
1459
1313
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
1460
1314
  // absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
1461
1315
  return this.dialect.buildDeleteStatement
1462
1316
  ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1463
1317
  : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1464
1318
  });
1465
- // On cache hit, still validate the predicate
1466
- if (whereFp === '') {
1467
- this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
1468
- }
1469
1319
  this.collectWhereParams(whereObj, params);
1470
1320
  return {
1471
1321
  sql: entry.sql,
@@ -1505,6 +1355,10 @@ export class QueryInterface {
1505
1355
  });
1506
1356
  }
1507
1357
  buildUpsert(args) {
1358
+ this.assertWritable('upsert');
1359
+ this.assertNoGeneratedColumns(args.create, 'upsert');
1360
+ this.assertNoGeneratedColumns(args.update, 'upsert');
1361
+ this.currentSkip = args.skipGlobalFilters;
1508
1362
  // Build the INSERT part from create data
1509
1363
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
1510
1364
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
@@ -1523,12 +1377,23 @@ export class QueryInterface {
1523
1377
  });
1524
1378
  const updateParams = updateEntries.map(([, v]) => v);
1525
1379
  const params = [...createParams, ...updateParams];
1380
+ // Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
1381
+ // upsert never resurrects a soft-deleted row or writes across tenants. Only
1382
+ // on engines whose upsert can carry a predicate (Postgres); the gf params
1383
+ // continue the placeholder numbering after create+update params.
1384
+ let updateWhere;
1385
+ if (this.dialect.supportsUpsertUpdateWhere) {
1386
+ const gf = this.resolveGlobalFilter(this.table);
1387
+ if (gf)
1388
+ updateWhere = this.buildWhereClause(gf, params) ?? undefined;
1389
+ }
1526
1390
  const sql = this.dialect.buildUpsertStatement({
1527
1391
  table: this.q(this.table),
1528
1392
  insertColumns: columns,
1529
1393
  valuePlaceholders: placeholders,
1530
1394
  conflictColumns,
1531
1395
  updateSetClauses: setClauses,
1396
+ updateWhere,
1532
1397
  returning: '*',
1533
1398
  });
1534
1399
  return {
@@ -1551,7 +1416,7 @@ export class QueryInterface {
1551
1416
  reselect: this.dialect.resultStrategy === 'reselect'
1552
1417
  ? async (exec) => {
1553
1418
  await exec(sql, params);
1554
- const sel = this.buildReselectByWhere(args.where);
1419
+ const sel = this.buildReselectByWhere((this.mergeGlobalFilter(args.where) ?? {}));
1555
1420
  return exec(sel.sql, sel.params);
1556
1421
  }
1557
1422
  : undefined,
@@ -1568,11 +1433,15 @@ export class QueryInterface {
1568
1433
  });
1569
1434
  }
1570
1435
  buildUpdateMany(args) {
1436
+ this.assertWritable('updateMany');
1437
+ this.currentSkip = args.skipGlobalFilters;
1571
1438
  const dataObj = args.data;
1572
- const whereObj = args.where;
1439
+ this.assertNoGeneratedColumns(dataObj, 'updateMany');
1440
+ this.assertMutationHasPredicate('updateMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1441
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1573
1442
  const setFp = this.fingerprintSet(dataObj);
1574
1443
  const whereFp = this.fingerprintWhere(whereObj);
1575
- const ck = `um:${setFp}|${whereFp}`;
1444
+ const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1576
1445
  const params = [];
1577
1446
  const entry = this.acquireSql(ck, () => {
1578
1447
  const freshParams = [];
@@ -1580,12 +1449,8 @@ export class QueryInterface {
1580
1449
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1581
1450
  const whereClause = this.buildWhereClause(whereObj, freshParams);
1582
1451
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1583
- this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
1584
1452
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1585
1453
  });
1586
- if (whereFp === '') {
1587
- this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
1588
- }
1589
1454
  this.collectSetParams(dataObj, params);
1590
1455
  this.collectWhereParams(whereObj, params);
1591
1456
  return {
@@ -1607,20 +1472,19 @@ export class QueryInterface {
1607
1472
  });
1608
1473
  }
1609
1474
  buildDeleteMany(args) {
1610
- const whereObj = args.where;
1475
+ this.assertWritable('deleteMany');
1476
+ this.currentSkip = args.skipGlobalFilters;
1477
+ this.assertMutationHasPredicate('deleteMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1478
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1611
1479
  const whereFp = this.fingerprintWhere(whereObj);
1612
- const ck = `dm:${whereFp}`;
1480
+ const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
1613
1481
  const params = [];
1614
1482
  const entry = this.acquireSql(ck, () => {
1615
1483
  const freshParams = [];
1616
1484
  const clause = this.buildWhereClause(whereObj, freshParams);
1617
1485
  const whereSql = clause ? ` WHERE ${clause}` : '';
1618
- this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
1619
1486
  return `DELETE FROM ${this.q(this.table)}${whereSql}`;
1620
1487
  });
1621
- if (whereFp === '') {
1622
- this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
1623
- }
1624
1488
  this.collectWhereParams(whereObj, params);
1625
1489
  return {
1626
1490
  sql: entry.sql,
@@ -1641,17 +1505,20 @@ export class QueryInterface {
1641
1505
  });
1642
1506
  }
1643
1507
  buildCount(args) {
1644
- const whereObj = (args?.where ?? {});
1645
- const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
1646
- const ck = `cnt:${whereFp}`;
1508
+ this.currentSkip = args?.skipGlobalFilters;
1509
+ const effWhere = this.mergeGlobalFilter(args?.where);
1510
+ const hasWhere = effWhere !== undefined;
1511
+ const whereObj = (effWhere ?? {});
1512
+ const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1513
+ const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
1647
1514
  const params = [];
1648
1515
  const entry = this.acquireSql(ck, () => {
1649
1516
  const freshParams = [];
1650
- const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1517
+ const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
1651
1518
  const whereSql = clause ? ` WHERE ${clause}` : '';
1652
1519
  return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1653
1520
  });
1654
- if (args?.where) {
1521
+ if (hasWhere) {
1655
1522
  this.collectWhereParams(whereObj, params);
1656
1523
  }
1657
1524
  return {
@@ -1681,9 +1548,13 @@ export class QueryInterface {
1681
1548
  }
1682
1549
  }
1683
1550
  }
1551
+ this.currentSkip = args.skipGlobalFilters;
1684
1552
  const groupColsRaw = args.by.map((k) => this.toColumn(k));
1685
1553
  const groupCols = groupColsRaw.map((c) => this.q(c));
1686
- const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
1554
+ const gbWhere = this.mergeGlobalFilter(args.where);
1555
+ const { sql: whereSql, params } = gbWhere
1556
+ ? this.buildWhere(gbWhere)
1557
+ : { sql: '', params: [] };
1687
1558
  // Build SELECT expressions: group-by columns + aggregate functions
1688
1559
  const selectExprs = [...groupCols];
1689
1560
  // _count
@@ -1926,7 +1797,11 @@ export class QueryInterface {
1926
1797
  });
1927
1798
  }
1928
1799
  buildAggregate(args) {
1929
- const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
1800
+ this.currentSkip = args.skipGlobalFilters;
1801
+ const aggWhere = this.mergeGlobalFilter(args.where);
1802
+ const { sql: whereSql, params } = aggWhere
1803
+ ? this.buildWhere(aggWhere)
1804
+ : { sql: '', params: [] };
1930
1805
  const meta = this.schema.tables[this.table];
1931
1806
  if (meta) {
1932
1807
  for (const group of [args._sum, args._avg, args._min, args._max]) {
@@ -2104,6 +1979,36 @@ export class QueryInterface {
2104
1979
  }
2105
1980
  return null;
2106
1981
  }
1982
+ /**
1983
+ * Reject any write against a view (H4). Views are introspected with
1984
+ * `isView: true` and are read-only in every engine; a write raises a
1985
+ * {@link ValidationError} (E003) rather than emitting SQL Postgres would
1986
+ * reject (or, worse, silently applying to an updatable view).
1987
+ */
1988
+ assertWritable(operation) {
1989
+ if (this.tableMeta.isView) {
1990
+ throw new ValidationError(`[turbine] Cannot ${operation} "${this.table}": it is a view (read-only). ` +
1991
+ 'Views support reads (findMany/findFirst/…) but not writes.');
1992
+ }
1993
+ }
1994
+ /**
1995
+ * Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
1996
+ * column (H3). Postgres computes these from other columns and errors if you
1997
+ * try to write them; we fail early with a clear {@link ValidationError} (E003)
1998
+ * instead of surfacing a cryptic driver error. Undefined values are ignored
1999
+ * (they're stripped from the statement anyway).
2000
+ */
2001
+ assertNoGeneratedColumns(data, operation) {
2002
+ for (const [key, value] of Object.entries(data)) {
2003
+ if (value === undefined)
2004
+ continue;
2005
+ const col = this.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === camelToSnake(key));
2006
+ if (col?.isGeneratedStored) {
2007
+ throw new ValidationError(`[turbine] Cannot ${operation} "${this.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
2008
+ 'column whose value the database computes — remove it from your data.');
2009
+ }
2010
+ }
2011
+ }
2107
2012
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
2108
2013
  toColumn(field) {
2109
2014
  const mapped = this.tableMeta.columnMap[field];
@@ -2433,16 +2338,7 @@ export class QueryInterface {
2433
2338
  'none' in filterObj ||
2434
2339
  'is' in filterObj ||
2435
2340
  '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);
2341
+ this.collectRelationFilterParams(relationDef, filterObj, params);
2446
2342
  continue;
2447
2343
  }
2448
2344
  }
@@ -2490,7 +2386,45 @@ export class QueryInterface {
2490
2386
  params.push(value);
2491
2387
  }
2492
2388
  }
2493
- /** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
2389
+ /**
2390
+ * Param-collect mirror of {@link buildRelationFilter} for one relation-filter
2391
+ * object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
2392
+ * present branch and in the canonical order some→none→every→is→isNot, the
2393
+ * branch's sub-where params THEN the target table's global-filter params —
2394
+ * exactly the order buildRelationFilter emits. When no global filter applies
2395
+ * the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
2396
+ * Shared by every collect site that mirrors buildRelationFilter
2397
+ * (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
2398
+ */
2399
+ collectRelationFilterParams(relDef, filterObj, params) {
2400
+ const target = relDef.to;
2401
+ if (filterObj.some !== undefined && filterObj.some !== null) {
2402
+ this.collectRelFilterParams(target, filterObj.some, params);
2403
+ this.collectTargetGlobalFilterExists(target, params);
2404
+ }
2405
+ if (filterObj.none !== undefined && filterObj.none !== null) {
2406
+ this.collectRelFilterParams(target, filterObj.none, params);
2407
+ this.collectTargetGlobalFilterExists(target, params);
2408
+ }
2409
+ if (filterObj.every !== undefined && filterObj.every !== null) {
2410
+ // gf is only emitted (build) when the `every` sub-where compiles to a
2411
+ // filter — otherwise `every` is trivially true and no subquery is built.
2412
+ if (this.buildSubWhereForRelation(target, filterObj.every, []) !== null) {
2413
+ this.collectRelFilterParams(target, filterObj.every, params);
2414
+ this.collectTargetGlobalFilterExists(target, params);
2415
+ }
2416
+ }
2417
+ if (filterObj.is !== undefined) {
2418
+ if (filterObj.is !== null)
2419
+ this.collectRelFilterParams(target, filterObj.is, params);
2420
+ this.collectTargetGlobalFilterExists(target, params);
2421
+ }
2422
+ if (filterObj.isNot !== undefined) {
2423
+ if (filterObj.isNot !== null)
2424
+ this.collectRelFilterParams(target, filterObj.isNot, params);
2425
+ this.collectTargetGlobalFilterExists(target, params);
2426
+ }
2427
+ }
2494
2428
  collectRelFilterParams(targetTable, subWhere, params) {
2495
2429
  const meta = this.schema.tables[targetTable];
2496
2430
  if (!meta)
@@ -2518,17 +2452,9 @@ export class QueryInterface {
2518
2452
  if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
2519
2453
  const norm = this.normalizeRelationFilter(nestedRel, value);
2520
2454
  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);
2455
+ // Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
2456
+ // params then target global-filter params).
2457
+ this.collectRelationFilterParams(nestedRel, norm, params);
2532
2458
  continue;
2533
2459
  }
2534
2460
  }
@@ -2608,6 +2534,21 @@ export class QueryInterface {
2608
2534
  // never push a param that the build path rejected (or vice versa).
2609
2535
  this.vectorOperator(key, rawColumn, dir.distance.metric);
2610
2536
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2537
+ continue;
2538
+ }
2539
+ // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2540
+ // subquery as `_count` — mirror its global-filter params. To-one relation
2541
+ // orderBy carries the target's global filter once per ordered column.
2542
+ if (this.isRelationOrderByValue(dir)) {
2543
+ const relDef = this.tableMeta.relations[key];
2544
+ if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2545
+ this.collectRelationCountParams(relDef, params);
2546
+ }
2547
+ else if (relDef) {
2548
+ for (const _col of Object.keys(dir)) {
2549
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
2550
+ }
2551
+ }
2611
2552
  }
2612
2553
  }
2613
2554
  }
@@ -2643,6 +2584,19 @@ export class QueryInterface {
2643
2584
  const spec = withClause[relName];
2644
2585
  if (!spec)
2645
2586
  continue;
2587
+ // Reserved `_count` key — fingerprint by the selected relation set so
2588
+ // `_count: true` and `_count: { posts: true }` never share a cache entry.
2589
+ if (relName === '_count') {
2590
+ const c = spec;
2591
+ parts.push(c === true
2592
+ ? '_count(*)'
2593
+ : `_count(${Object.entries(c)
2594
+ .filter(([, v]) => v)
2595
+ .map(([k]) => k)
2596
+ .sort()
2597
+ .join(',')})`);
2598
+ continue;
2599
+ }
2646
2600
  const relDef = meta.relations[relName];
2647
2601
  if (!relDef) {
2648
2602
  parts.push(`unknown:${relName}`);
@@ -2675,9 +2629,9 @@ export class QueryInterface {
2675
2629
  if (opts.where) {
2676
2630
  subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
2677
2631
  }
2678
- // orderBy shape
2632
+ // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2679
2633
  if (opts.orderBy) {
2680
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
2634
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
2681
2635
  subParts.push(`o=${oEntries.join(',')}`);
2682
2636
  }
2683
2637
  // limit presence
@@ -2708,6 +2662,15 @@ export class QueryInterface {
2708
2662
  continue;
2709
2663
  this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
2710
2664
  }
2665
+ // `_count` global-filter params — mirror buildSelectWithRelations, which
2666
+ // appends the count subqueries (and any target-filter params) AFTER every
2667
+ // relation subquery, in resolveCountRelations order.
2668
+ const countSpec = withClause._count;
2669
+ if (countSpec !== undefined) {
2670
+ for (const rel of resolveCountRelations(meta, countSpec)) {
2671
+ this.collectRelationCountParams(rel, params);
2672
+ }
2673
+ }
2711
2674
  }
2712
2675
  /**
2713
2676
  * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
@@ -2725,6 +2688,7 @@ export class QueryInterface {
2725
2688
  if (spec.where) {
2726
2689
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2727
2690
  }
2691
+ this.collectTargetGlobalFilterAlias(targetTable, params);
2728
2692
  if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2729
2693
  params.push(Number(spec.limit));
2730
2694
  }
@@ -2754,6 +2718,9 @@ export class QueryInterface {
2754
2718
  if (spec.where) {
2755
2719
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2756
2720
  }
2721
+ // Global filter on the target — mirrors targetGlobalFilterAlias in
2722
+ // buildRelationSubquery (pushed after spec.where, before limit).
2723
+ this.collectTargetGlobalFilterAlias(targetTable, params);
2757
2724
  // limit param — only hasMany parameterizes its limit (mirrors
2758
2725
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2759
2726
  // pushing one here would orphan a param and desync the collect path.
@@ -2823,14 +2790,148 @@ export class QueryInterface {
2823
2790
  return { sql: '', params: [] };
2824
2791
  return { sql: ` WHERE ${clause}`, params };
2825
2792
  }
2793
+ // -------------------------------------------------------------------------
2794
+ // Global filters (soft-delete / multi-tenancy — WS-G)
2795
+ //
2796
+ // A configured global filter for a table is AND-merged into the compiled WHERE
2797
+ // of every query on that table (via {@link mergeGlobalFilter}, so the merge is
2798
+ // captured in the where fingerprint/collect for free) and into every relation
2799
+ // subquery targeting it (rendered at build time against the subquery's alias/
2800
+ // table by the `*GlobalFilterAlias`/`*GlobalFilterExists` helpers, with the
2801
+ // shape folded into the SQL-cache key via {@link globalFilterCacheSegment}).
2802
+ // Function filters are evaluated per resolve — at query-build time — enabling
2803
+ // per-request tenancy via a closure. They must return a STABLE shape (same
2804
+ // keys/operators); only values may vary between calls.
2805
+ // -------------------------------------------------------------------------
2806
+ /**
2807
+ * Resolve the configured global filter for `table`, evaluating a function
2808
+ * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
2809
+ * no filter applies, the query opted out, or the filter is empty.
2810
+ */
2811
+ resolveGlobalFilter(table, skip = this.currentSkip) {
2812
+ const filters = this.globalFilters;
2813
+ if (!filters)
2814
+ return null;
2815
+ if (skip === true)
2816
+ return null;
2817
+ if (Array.isArray(skip) && skip.includes(table))
2818
+ return null;
2819
+ const raw = filters[table];
2820
+ if (raw === undefined)
2821
+ return null;
2822
+ const resolved = typeof raw === 'function' ? raw() : raw;
2823
+ if (resolved === null || resolved === undefined)
2824
+ return null;
2825
+ const obj = resolved;
2826
+ // An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
2827
+ // nothing — treat it as absent so it never emits a dangling clause.
2828
+ if (Object.keys(obj).every((k) => obj[k] === undefined))
2829
+ return null;
2830
+ return obj;
2831
+ }
2826
2832
  /**
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`.
2833
+ * AND-merge this table's resolved global filter into a user `where`. Either
2834
+ * side may be absent. When no filter applies the user where is returned by
2835
+ * reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
2833
2836
  */
2837
+ mergeGlobalFilter(userWhere) {
2838
+ const gf = this.resolveGlobalFilter(this.table);
2839
+ if (!gf)
2840
+ return userWhere;
2841
+ if (userWhere === undefined)
2842
+ return gf;
2843
+ return { AND: [userWhere, gf] };
2844
+ }
2845
+ /**
2846
+ * SQL clause for `targetTable`'s global filter rendered against `alias`
2847
+ * (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
2848
+ * `params`; returns `''` when no filter applies. Mirror:
2849
+ * {@link collectTargetGlobalFilterAlias}.
2850
+ */
2851
+ targetGlobalFilterAlias(targetTable, alias, params) {
2852
+ const gf = this.resolveGlobalFilter(targetTable);
2853
+ if (!gf)
2854
+ return '';
2855
+ const meta = this.schema.tables[targetTable];
2856
+ if (!meta)
2857
+ return '';
2858
+ return this.buildAliasWhere(targetTable, meta, alias, gf, params) ?? '';
2859
+ }
2860
+ /** Param-collect mirror of {@link targetGlobalFilterAlias}. */
2861
+ collectTargetGlobalFilterAlias(targetTable, params) {
2862
+ const gf = this.resolveGlobalFilter(targetTable);
2863
+ if (!gf)
2864
+ return;
2865
+ const meta = this.schema.tables[targetTable];
2866
+ if (!meta)
2867
+ return;
2868
+ this.collectAliasWhereParams(targetTable, meta, gf, params);
2869
+ }
2870
+ /**
2871
+ * SQL clause for `targetTable`'s global filter rendered against the bare
2872
+ * (unaliased) table name — the form used inside relation-filter `EXISTS`
2873
+ * subqueries. Pushes its params; `''` when none. Mirror:
2874
+ * {@link collectTargetGlobalFilterExists}.
2875
+ */
2876
+ targetGlobalFilterExists(targetTable, params) {
2877
+ const gf = this.resolveGlobalFilter(targetTable);
2878
+ if (!gf)
2879
+ return '';
2880
+ return this.buildSubWhereForRelation(targetTable, gf, params) ?? '';
2881
+ }
2882
+ /** Param-collect mirror of {@link targetGlobalFilterExists}. */
2883
+ collectTargetGlobalFilterExists(targetTable, params) {
2884
+ const gf = this.resolveGlobalFilter(targetTable);
2885
+ if (!gf)
2886
+ return;
2887
+ this.collectRelFilterParams(targetTable, gf, params);
2888
+ }
2889
+ /**
2890
+ * Value-invariant SQL-cache-key segment for the active global-filter
2891
+ * environment. Relation-subquery / relation-filter / `_count` / relation-
2892
+ * `orderBy` global filters are rendered at build time but their SHAPE is not
2893
+ * otherwise in the where/with fingerprint, so this segment guards the cache:
2894
+ * two different filter shapes never collide on one cached SQL text, while two
2895
+ * function-filter results of the SAME shape (differing only in values) share
2896
+ * the entry and bind their own params. Empty (`''`) when no filter applies, so
2897
+ * cache keys stay byte-identical when the feature is unused.
2898
+ */
2899
+ globalFilterCacheSegment() {
2900
+ const filters = this.globalFilters;
2901
+ if (!filters)
2902
+ return '';
2903
+ const parts = [];
2904
+ for (const table of Object.keys(filters).sort()) {
2905
+ // Function filters for OTHER tables may be request-scoped closures that
2906
+ // throw outside their own context; a query on an unrelated table must not
2907
+ // break on them. A throwing filter can't have contributed SQL to this
2908
+ // query either (merging it would have thrown first), so a constant
2909
+ // marker keeps the key shape-distinct without evaluating it.
2910
+ let gf;
2911
+ try {
2912
+ gf = this.resolveGlobalFilter(table);
2913
+ }
2914
+ catch {
2915
+ parts.push(`${table}:!`);
2916
+ continue;
2917
+ }
2918
+ if (gf)
2919
+ parts.push(`${table}:${this.fingerprintWhere(gf)}`);
2920
+ }
2921
+ return parts.length ? `|gf=${parts.join(';')}` : '';
2922
+ }
2923
+ /**
2924
+ * True when the USER-supplied `where` compiles to no predicate (`{}`,
2925
+ * `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
2926
+ * signal the empty-`where` guard needs — the compiled emptiness, NOT the
2927
+ * fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
2928
+ * any configured global filter, so a global filter never lets an unguarded
2929
+ * mass mutation through.
2930
+ */
2931
+ userPredicateIsEmpty(userWhere) {
2932
+ const throwaway = [];
2933
+ return this.buildWhereClause(userWhere, throwaway) === null;
2934
+ }
2834
2935
  assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
2835
2936
  if (whereSql.length > 0)
2836
2937
  return;
@@ -3002,55 +3103,69 @@ export class QueryInterface {
3002
3103
  // belongsTo: parent.fk = child.pk
3003
3104
  correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
3004
3105
  }
3005
- // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
3106
+ // The target table's global filter (soft-delete / tenancy) restricts the
3107
+ // DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
3108
+ // ignore filtered-out rows, and `every` quantifies over only the surviving
3109
+ // rows ("every NON-deleted related row matches P"). It is ANDed into the
3110
+ // correlation and its params pushed AFTER the per-branch filter — mirrored
3111
+ // exactly in collectWhereParams' relation-filter branch. `qt` is the bare
3112
+ // target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
3113
+ const gfAnd = () => {
3114
+ const gf = this.targetGlobalFilterExists(targetTable, params);
3115
+ return gf ? ` AND ${gf}` : '';
3116
+ };
3117
+ // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
3006
3118
  if (filterObj.some !== undefined) {
3007
3119
  const subWhere = filterObj.some;
3008
3120
  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})`);
3121
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3122
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3011
3123
  }
3012
- // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
3124
+ // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
3013
3125
  if (filterObj.none !== undefined) {
3014
3126
  const subWhere = filterObj.none;
3015
3127
  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})`);
3128
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3129
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3018
3130
  }
3019
- // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
3131
+ // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
3020
3132
  if (filterObj.every !== undefined) {
3021
3133
  const subWhere = filterObj.every;
3022
3134
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3023
3135
  if (filterClause) {
3024
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation} AND NOT (${filterClause}))`);
3136
+ // gf params pushed AFTER filter params (collect mirrors this order), but
3137
+ // placed textually inside the domain so it restricts which rows count.
3138
+ const gf = gfAnd();
3139
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
3025
3140
  }
3026
3141
  else {
3027
- // "every" with empty filter = true (all match trivially)
3142
+ // "every" with empty filter = true (all match trivially) — gf irrelevant.
3028
3143
  }
3029
3144
  }
3030
3145
  // "is": EXISTS — for to-one relations (same SQL as "some").
3031
3146
  // `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
3032
3147
  if (filterObj.is !== undefined) {
3033
3148
  if (filterObj.is === null) {
3034
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
3149
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
3035
3150
  }
3036
3151
  else {
3037
3152
  const subWhere = filterObj.is;
3038
3153
  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})`);
3154
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3155
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3041
3156
  }
3042
3157
  }
3043
3158
  // "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
3044
3159
  // `isNot: null` = "a related row exists" → EXISTS.
3045
3160
  if (filterObj.isNot !== undefined) {
3046
3161
  if (filterObj.isNot === null) {
3047
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
3162
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
3048
3163
  }
3049
3164
  else {
3050
3165
  const subWhere = filterObj.isNot;
3051
3166
  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})`);
3167
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3168
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3054
3169
  }
3055
3170
  }
3056
3171
  return clauses.length > 0 ? clauses.join(' AND ') : null;
@@ -3245,17 +3360,9 @@ export class QueryInterface {
3245
3360
  if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
3246
3361
  const norm = this.normalizeRelationFilter(aliasRel, value);
3247
3362
  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);
3363
+ // Mirrors buildRelationFilter (some→none→every→is→isNot, each: sub-where
3364
+ // params then target global-filter params).
3365
+ this.collectRelationFilterParams(aliasRel, norm, params);
3259
3366
  continue;
3260
3367
  }
3261
3368
  }
@@ -3402,10 +3509,37 @@ export class QueryInterface {
3402
3509
  * findMany path). When `params` is omitted (groupBy / relation path) a vector
3403
3510
  * ordering throws — KNN ordering is only supported at the top level.
3404
3511
  */
3512
+ /**
3513
+ * Value-shape fingerprint for a single orderBy entry, so two queries whose
3514
+ * ORDER BY differs only in nulls placement, vector metric, or relation-count
3515
+ * vs relation-column never collide on one cached SQL string. Captures the
3516
+ * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3517
+ */
3518
+ orderByEntryFingerprint(d) {
3519
+ // Vector KNN ordering changes the emitted operator by metric and adds a
3520
+ // `::vector` param, so metric + direction must be part of the cache key.
3521
+ if (isVectorOrderBy(d)) {
3522
+ return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3523
+ }
3524
+ if (isOrderBySpec(d))
3525
+ return `spec(${d.sort},${d.nulls ?? ''})`;
3526
+ if (d && typeof d === 'object') {
3527
+ // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
3528
+ return `rel(${Object.entries(d)
3529
+ .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3530
+ .sort()
3531
+ .join(',')})`;
3532
+ }
3533
+ return String(d);
3534
+ }
3405
3535
  buildOrderBy(orderBy, params) {
3406
- // Dev-only: validate that orderBy fields exist in the table schema
3536
+ // Dev-only: validate that orderBy fields exist in the table schema. Relation
3537
+ // orderBy keys (object values that are neither a vector nor an OrderBySpec)
3538
+ // are validated in the relation branch below, so skip them here.
3407
3539
  if (process.env.NODE_ENV !== 'production') {
3408
- for (const key of Object.keys(orderBy)) {
3540
+ for (const [key, value] of Object.entries(orderBy)) {
3541
+ if (this.isRelationOrderByValue(value) && this.tableMeta.relations[key])
3542
+ continue;
3409
3543
  const snakeKey = camelToSnake(key);
3410
3544
  if (!this.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in this.tableMeta.columnMap)) {
3411
3545
  console.warn(`[turbine] Unknown orderBy field "${key}" for table "${this.tableMeta.name}". ` +
@@ -3414,28 +3548,217 @@ export class QueryInterface {
3414
3548
  }
3415
3549
  }
3416
3550
  const meta = this.schema.tables[this.table];
3551
+ let relOrdCounter = 0;
3417
3552
  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
- }
3553
+ .map(([key, value]) => {
3423
3554
  // Vector KNN ordering: { distance: { to, metric, direction? } }
3424
- if (isVectorOrderBy(dir)) {
3555
+ if (isVectorOrderBy(value)) {
3556
+ if (meta && !(key in meta.columnMap)) {
3557
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3558
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3559
+ }
3425
3560
  if (!params) {
3426
3561
  throw new ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
3427
3562
  }
3428
3563
  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';
3564
+ const operator = this.vectorOperator(key, rawColumn, value.distance.metric);
3565
+ const placeholder = this.pushVectorParam(key, rawColumn, value.distance.to, params);
3566
+ const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3432
3567
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3433
3568
  }
3434
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3435
- return `${this.toSqlColumn(key)} ${safeDir}`;
3569
+ // Relation ordering: an object value that is not a vector or OrderBySpec,
3570
+ // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3571
+ // { name: 'asc' } }`).
3572
+ if (this.isRelationOrderByValue(value)) {
3573
+ return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
3574
+ }
3575
+ // Scalar column ordering — a plain direction or an OrderBySpec (nulls).
3576
+ if (meta && !(key in meta.columnMap)) {
3577
+ throw new ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3578
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3579
+ }
3580
+ const { dir, nulls } = normalizeOrderBy(value);
3581
+ return `${this.toSqlColumn(key)} ${dir}${this.nullsSuffix(nulls)}`;
3582
+ })
3583
+ .join(', ');
3584
+ }
3585
+ /**
3586
+ * True when an orderBy value is a relation-ordering object: a plain object
3587
+ * that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
3588
+ * in the orderBy clause is a relation name.
3589
+ */
3590
+ isRelationOrderByValue(value) {
3591
+ return (typeof value === 'object' &&
3592
+ value !== null &&
3593
+ !Array.isArray(value) &&
3594
+ !isVectorOrderBy(value) &&
3595
+ !isOrderBySpec(value));
3596
+ }
3597
+ /**
3598
+ * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
3599
+ * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
3600
+ * other engine a caller asking for explicit nulls placement gets a clear
3601
+ * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
3602
+ */
3603
+ nullsSuffix(nulls) {
3604
+ if (!nulls)
3605
+ return '';
3606
+ if (this.dialect.name !== 'postgresql' && this.dialect.name !== 'sqlite') {
3607
+ throw new UnsupportedFeatureError('NULLS FIRST/LAST ordering', this.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
3608
+ }
3609
+ return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3610
+ }
3611
+ /**
3612
+ * Compile a relation ordering term. For a to-many relation the only allowed
3613
+ * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
3614
+ * to-one relation each entry names a target column and becomes a correlated
3615
+ * scalar subquery (supporting {@link OrderBySpec} nulls placement).
3616
+ *
3617
+ * Validation: relation must exist (E005); to-many only allows `_count`, and
3618
+ * to-one only allows real target columns (E003).
3619
+ */
3620
+ buildRelationOrderBy(relName, value, alias, params) {
3621
+ const relDef = this.tableMeta.relations[relName];
3622
+ if (!relDef) {
3623
+ throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3624
+ `Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
3625
+ }
3626
+ // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3627
+ if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
3628
+ const keys = Object.keys(value);
3629
+ if (keys.length !== 1 || keys[0] !== '_count') {
3630
+ throw new ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
3631
+ `(got: ${keys.join(', ') || '(empty)'}).`);
3632
+ }
3633
+ const { dir } = normalizeOrderBy(value._count);
3634
+ return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
3635
+ }
3636
+ // To-one: each entry orders by a correlated scalar subquery on a target column.
3637
+ const targetMeta = this.schema.tables[relDef.to];
3638
+ if (!targetMeta)
3639
+ throw new RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3640
+ const qTarget = this.q(relDef.to);
3641
+ const qParent = this.q(this.table);
3642
+ // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3643
+ const correlation = relDef.type === 'belongsTo'
3644
+ ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
3645
+ : this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3646
+ const entries = Object.entries(value);
3647
+ if (entries.length === 0) {
3648
+ throw new ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
3649
+ }
3650
+ return entries
3651
+ .map(([col, dirValue]) => {
3652
+ const snakeCol = camelToSnake(col);
3653
+ if (!targetMeta.allColumns.includes(snakeCol)) {
3654
+ throw new ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3655
+ }
3656
+ const { dir, nulls } = normalizeOrderBy(dirValue);
3657
+ // Target's global filter applies here too — otherwise ordering keys off
3658
+ // a soft-deleted / other-tenant related row's value (matches the with
3659
+ // subquery semantics for belongsTo/hasOne).
3660
+ let where = correlation;
3661
+ if (params) {
3662
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
3663
+ if (gf)
3664
+ where += ` AND ${gf}`;
3665
+ }
3666
+ return `(SELECT ${alias}.${this.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${this.limitOneClause()}) ${dir}${this.nullsSuffix(nulls)}`;
3436
3667
  })
3437
3668
  .join(', ');
3438
3669
  }
3670
+ /**
3671
+ * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3672
+ * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
3673
+ * manyToMany counts junction rows via the source key. Shared by the `_count`
3674
+ * `with` key and to-many relation orderBy.
3675
+ *
3676
+ * When `params` is supplied and the target has a global filter, it is
3677
+ * AND-merged so the count only sees surviving rows (a soft-deleted child is
3678
+ * not counted): hasMany filters the counted rows directly; manyToMany adds an
3679
+ * `EXISTS` on the target through the junction (the junction rows themselves
3680
+ * carry no filter). Params are mirrored by {@link collectRelationCountParams}.
3681
+ */
3682
+ buildRelationCountExpr(relDef, parentRef, alias, params) {
3683
+ const qParent = this.q(parentRef);
3684
+ const count = this.castAgg('COUNT(*)', 'int');
3685
+ if (relDef.type === 'manyToMany') {
3686
+ if (!relDef.through) {
3687
+ throw new ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
3688
+ }
3689
+ const qJ = this.q(relDef.through.table);
3690
+ const jalias = `${alias}j`;
3691
+ const sourceKeys = normalizeKeyColumns(relDef.through.sourceKey);
3692
+ const refKeys = normalizeKeyColumns(relDef.referenceKey);
3693
+ let where = sourceKeys
3694
+ .map((jc, i) => `${jalias}.${this.q(jc)} = ${qParent}.${this.q(refKeys[i])}`)
3695
+ .join(' AND ');
3696
+ if (params) {
3697
+ const targetExists = this.manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params);
3698
+ if (targetExists)
3699
+ where += ` AND ${targetExists}`;
3700
+ }
3701
+ return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
3702
+ }
3703
+ // hasMany: child FK correlates to the parent reference key.
3704
+ const qTarget = this.q(relDef.to);
3705
+ let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3706
+ if (params) {
3707
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
3708
+ if (gf)
3709
+ where += ` AND ${gf}`;
3710
+ }
3711
+ return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
3712
+ }
3713
+ /**
3714
+ * `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
3715
+ * a manyToMany `_count` to targets that survive their global filter. `''` when
3716
+ * the target has no filter. Pushes gf params; mirror:
3717
+ * {@link collectManyToManyTargetGlobalFilter}.
3718
+ */
3719
+ manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params) {
3720
+ const gf = this.resolveGlobalFilter(relDef.to);
3721
+ if (!gf || !relDef.through)
3722
+ return '';
3723
+ const tMeta = this.schema.tables[relDef.to];
3724
+ if (!tMeta || tMeta.primaryKey.length === 0)
3725
+ return '';
3726
+ const talias = `${alias}t`;
3727
+ const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
3728
+ const pk = tMeta.primaryKey;
3729
+ if (targetKeys.length !== pk.length)
3730
+ return '';
3731
+ const join = targetKeys.map((jc, i) => `${talias}.${this.q(pk[i])} = ${jalias}.${this.q(jc)}`).join(' AND ');
3732
+ const gfClause = this.buildAliasWhere(relDef.to, tMeta, talias, gf, params);
3733
+ const gfAnd = gfClause ? ` AND ${gfClause}` : '';
3734
+ return `EXISTS (SELECT 1 FROM ${this.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
3735
+ }
3736
+ /** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
3737
+ collectManyToManyTargetGlobalFilter(relDef, params) {
3738
+ const gf = this.resolveGlobalFilter(relDef.to);
3739
+ if (!gf || !relDef.through)
3740
+ return;
3741
+ const tMeta = this.schema.tables[relDef.to];
3742
+ if (!tMeta || tMeta.primaryKey.length === 0)
3743
+ return;
3744
+ const targetKeys = normalizeKeyColumns(relDef.through.targetKey);
3745
+ if (targetKeys.length !== tMeta.primaryKey.length)
3746
+ return;
3747
+ this.collectAliasWhereParams(relDef.to, tMeta, gf, params);
3748
+ }
3749
+ /**
3750
+ * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
3751
+ * params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
3752
+ * when a filter applies — no-op otherwise.
3753
+ */
3754
+ collectRelationCountParams(relDef, params) {
3755
+ if (relDef.type === 'manyToMany') {
3756
+ this.collectManyToManyTargetGlobalFilter(relDef, params);
3757
+ }
3758
+ else {
3759
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
3760
+ }
3761
+ }
3439
3762
  // -------------------------------------------------------------------------
3440
3763
  // pgvector helpers (similarity search)
3441
3764
  // -------------------------------------------------------------------------
@@ -3564,6 +3887,19 @@ export class QueryInterface {
3564
3887
  const meta = this.schema.tables[table];
3565
3888
  if (!meta)
3566
3889
  return parsed;
3890
+ // Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
3891
+ // parseRow copies these unknown columns through under their raw key.
3892
+ let countObj;
3893
+ for (const key of Object.keys(parsed)) {
3894
+ if (key.startsWith('_count__')) {
3895
+ if (countObj === undefined)
3896
+ countObj = {};
3897
+ countObj[key.slice('_count__'.length)] = Number(parsed[key]);
3898
+ delete parsed[key];
3899
+ }
3900
+ }
3901
+ if (countObj)
3902
+ parsed._count = countObj;
3567
3903
  for (const [relName, relDef] of Object.entries(meta.relations)) {
3568
3904
  const rawValue = row[relName];
3569
3905
  if (rawValue === undefined)
@@ -3830,6 +4166,9 @@ export class QueryInterface {
3830
4166
  const relationSelects = [];
3831
4167
  const aliasCounter = { n: 0 };
3832
4168
  for (const [relName, relSpec] of sortedEntries(withClause)) {
4169
+ // `_count` is a reserved key handled after the relation subqueries.
4170
+ if (relName === '_count')
4171
+ continue;
3833
4172
  const relDef = meta.relations[relName];
3834
4173
  if (!relDef) {
3835
4174
  throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3839,6 +4178,18 @@ export class QueryInterface {
3839
4178
  const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
3840
4179
  relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
3841
4180
  }
4181
+ // Reserved `_count` key → one correlated COUNT(*) scalar subquery per
4182
+ // selected to-many relation, aliased `_count__<rel>`. Appended after the
4183
+ // relation subqueries; the only params they can push come from a global
4184
+ // filter on the counted target (mirrored at the tail of collectWithParams).
4185
+ // Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
4186
+ const countSpec = withClause._count;
4187
+ if (countSpec !== undefined) {
4188
+ for (const rel of resolveCountRelations(meta, countSpec)) {
4189
+ const expr = this.buildRelationCountExpr(rel, table, `t${aliasCounter.n++}`, params);
4190
+ relationSelects.push(`${expr} AS ${this.q(`_count__${rel.name}`)}`);
4191
+ }
4192
+ }
3842
4193
  return [baseCols, ...relationSelects].join(', ');
3843
4194
  }
3844
4195
  /**
@@ -4039,13 +4390,13 @@ export class QueryInterface {
4039
4390
  let orderClause = '';
4040
4391
  if (relOrderEntries.length > 0) {
4041
4392
  const orders = relOrderEntries
4042
- .map(([k, dir]) => {
4393
+ .map(([k, dirValue]) => {
4043
4394
  const col = camelToSnake(k);
4044
4395
  if (!targetMeta.allColumns.includes(col)) {
4045
4396
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4046
4397
  }
4047
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4048
- return `${alias}.${this.q(col)} ${safeDir}`;
4398
+ const { dir, nulls } = normalizeOrderBy(dirValue);
4399
+ return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4049
4400
  })
4050
4401
  .join(', ');
4051
4402
  orderClause = ` ORDER BY ${orders}`;
@@ -4071,6 +4422,12 @@ export class QueryInterface {
4071
4422
  if (extra)
4072
4423
  whereClause += ` AND ${extra}`;
4073
4424
  }
4425
+ // Global filter on the target table (soft-delete / tenancy) — AND-merged so
4426
+ // a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
4427
+ // mirrored by collectRelationSubqueryParams.
4428
+ const gfExtra = this.targetGlobalFilterAlias(targetTable, alias, params);
4429
+ if (gfExtra)
4430
+ whereClause += ` AND ${gfExtra}`;
4074
4431
  // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
4075
4432
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
4076
4433
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
@@ -4181,13 +4538,13 @@ export class QueryInterface {
4181
4538
  let orderClause = '';
4182
4539
  if (relOrderEntries.length > 0) {
4183
4540
  const orders = relOrderEntries
4184
- .map(([k, dir]) => {
4541
+ .map(([k, dirValue]) => {
4185
4542
  const col = camelToSnake(k);
4186
4543
  if (!targetMeta.allColumns.includes(col)) {
4187
4544
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4188
4545
  }
4189
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4190
- return `${talias}.${this.q(col)} ${safeDir}`;
4546
+ const { dir, nulls } = normalizeOrderBy(dirValue);
4547
+ return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4191
4548
  })
4192
4549
  .join(', ');
4193
4550
  orderClause = ` ORDER BY ${orders}`;
@@ -4199,6 +4556,11 @@ export class QueryInterface {
4199
4556
  if (extra)
4200
4557
  whereClause += ` AND ${extra}`;
4201
4558
  }
4559
+ // Global filter on the target table (mirrors collectRelationSubqueryParams'
4560
+ // m2m branch: after spec.where, before limit).
4561
+ const gfExtra = this.targetGlobalFilterAlias(targetTable, talias, params);
4562
+ if (gfExtra)
4563
+ whereClause += ` AND ${gfExtra}`;
4202
4564
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
4203
4565
  let limitClause = '';
4204
4566
  if (spec !== true && spec.limit !== undefined) {