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
@@ -52,214 +52,10 @@ const index_advisor_js_1 = require("../index-advisor.js");
52
52
  const nested_write_js_1 = require("../nested-write.js");
53
53
  const schema_js_1 = require("../schema.js");
54
54
  const batched_loader_js_1 = require("./batched-loader.js");
55
+ const filters_js_1 = require("./filters.js");
55
56
  const utils_js_1 = require("./utils.js");
56
- // ---------------------------------------------------------------------------
57
- // Internal detection helpers — used by QueryInterface
58
- // ---------------------------------------------------------------------------
59
- /** Check if a value is a where operator object (has at least one known operator key) */
60
- function isWhereOperator(value) {
61
- if (value === null ||
62
- value === undefined ||
63
- typeof value !== 'object' ||
64
- Array.isArray(value) ||
65
- value instanceof Date) {
66
- return false;
67
- }
68
- const keys = Object.keys(value);
69
- return keys.length > 0 && keys.every((k) => utils_js_1.OPERATOR_KEYS.has(k));
70
- }
71
- /**
72
- * True for a *plain object literal* that reached an equality fallthrough
73
- * without matching any known filter shape — the misspelled-operator case.
74
- * Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
75
- * bind values and return false, as do arrays and Dates.
76
- */
77
- function isUnmatchedPlainObject(value) {
78
- if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
79
- return false;
80
- if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
81
- return false;
82
- const proto = Object.getPrototypeOf(value);
83
- return proto === Object.prototype || proto === null;
84
- }
85
- /**
86
- * Fingerprint the SHAPE of a where-operator object. Null-valued `equals` /
87
- * `not` compile to parameterless `IS NULL` / `IS NOT NULL` (different SQL, no
88
- * param pushed), so null-ness is part of the shape — without it a cache entry
89
- * warmed by `{ not: 5 }` would serve `{ not: null }` with a desynced param list.
90
- */
91
- function fingerprintOperatorShape(value) {
92
- const obj = value;
93
- const opKeys = Object.keys(obj)
94
- .filter((k) => k !== 'mode')
95
- .map((k) => ((k === 'equals' || k === 'not') && obj[k] === null ? `${k}:null` : k))
96
- .sort();
97
- const modeStr = value.mode === 'insensitive' ? ':i' : '';
98
- return `op(${opKeys.join(',')}${modeStr})`;
99
- }
100
- /**
101
- * Guard for the value of an `equals` operator reaching the plain-equality
102
- * operator path. A plain object literal can only legitimately be an equality
103
- * value on a json/jsonb column — and those route to the JSONB filter branch
104
- * BEFORE the operator branch, so any plain object that reaches here is a
105
- * mistake (e.g. `{ equals: { foo: 1 } }` on a text column). Shared by the
106
- * SQL-build path and the cache-hit param-collect path so a warmed cache can
107
- * never skip the check.
108
- */
109
- function assertBindableEqualsOperand(value, column) {
110
- if (!isUnmatchedPlainObject(value))
111
- return;
112
- throw new errors_js_1.ValidationError(`[turbine] Plain-object value for operator 'equals' on ${column}: ` +
113
- `objects are only valid 'equals' values on JSON (json/jsonb) columns, ` +
114
- `where 'equals' is the JSONB containment filter.`);
115
- }
116
- /**
117
- * Object keys in sorted order, mirroring the canonical order used by every
118
- * cache fingerprint. The SQL-build and cache-hit param-collect paths MUST
119
- * enumerate object keys in this exact order: fingerprints sort keys, so two
120
- * where clauses with the same fields in different insertion order share one
121
- * cache entry — if build/collect iterated insertion order, the cached SQL's
122
- * `$N` placeholders would bind the wrong values (cross-tenant-leak class).
123
- * Array order (OR/AND members) is positional and is never sorted.
124
- */
125
- function sortedKeys(obj) {
126
- return Object.keys(obj).sort();
127
- }
128
- /** {@link sortedKeys}, but yielding `[key, value]` pairs. */
129
- function sortedEntries(obj) {
130
- return Object.entries(obj).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
131
- }
132
- /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
133
57
  /** Relations already warned about missing FK indexes (once per process, dev only). */
134
58
  const unindexedRelationWarned = new Set();
135
- const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
136
- /** Known JSONB operator keys */
137
- const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
138
- /**
139
- * JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
140
- * appear in any other where-filter shape, so the presence of one of these is
141
- * an unambiguous signal that the user meant a JSON filter. Used by the
142
- * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
143
- * `WhereOperator` for LIKE) is not misclassified. Note `equals` is NOT in this
144
- * set: on non-JSON columns it is a plain equality operator (`WhereOperator`),
145
- * so it must fall through instead of throwing.
146
- */
147
- const JSONB_UNIQUE_KEYS = new Set(['path', 'hasKey']);
148
- /** Check if a value is a JSONB filter object */
149
- function isJsonFilter(value) {
150
- if (value === null ||
151
- value === undefined ||
152
- typeof value !== 'object' ||
153
- Array.isArray(value) ||
154
- value instanceof Date) {
155
- return false;
156
- }
157
- const keys = Object.keys(value);
158
- return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
159
- }
160
- /**
161
- * Returns the first JSON-unique key found in `value`, or `null` if none.
162
- * Used to drive the strict-validation error message.
163
- */
164
- function findJsonUniqueKey(value) {
165
- for (const k of Object.keys(value)) {
166
- if (JSONB_UNIQUE_KEYS.has(k))
167
- return k;
168
- }
169
- return null;
170
- }
171
- /** Known Array operator keys */
172
- const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
173
- /**
174
- * Array operator keys that are *unique* to {@link ArrayFilter}. None of the
175
- * array operators currently overlap with `WhereOperator` or `JsonFilter`, so
176
- * this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
177
- * constant so a future overlap (e.g. a `contains` for arrays) is easy to
178
- * carve out.
179
- */
180
- const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
181
- /** Check if a value is an Array filter object */
182
- function isArrayFilter(value) {
183
- if (value === null ||
184
- value === undefined ||
185
- typeof value !== 'object' ||
186
- Array.isArray(value) ||
187
- value instanceof Date) {
188
- return false;
189
- }
190
- const keys = Object.keys(value);
191
- return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
192
- }
193
- /**
194
- * Returns the first array-unique key found in `value`, or `null` if none.
195
- * Used to drive the strict-validation error message.
196
- */
197
- function findArrayUniqueKey(value) {
198
- for (const k of Object.keys(value)) {
199
- if (ARRAY_UNIQUE_KEYS.has(k))
200
- return k;
201
- }
202
- return null;
203
- }
204
- /** Known text search operator keys */
205
- const TEXT_SEARCH_KEYS = new Set(['search', 'config']);
206
- /** Check if a value is a TextSearchFilter object */
207
- function isTextSearchFilter(value) {
208
- if (value === null ||
209
- value === undefined ||
210
- typeof value !== 'object' ||
211
- Array.isArray(value) ||
212
- value instanceof Date) {
213
- return false;
214
- }
215
- const keys = Object.keys(value);
216
- // Must have 'search' key and only known text search keys
217
- return keys.includes('search') && keys.every((k) => TEXT_SEARCH_KEYS.has(k));
218
- }
219
- /**
220
- * Validate a text search config name. Only alphanumeric characters and
221
- * underscores are allowed to prevent SQL injection via the config parameter.
222
- */
223
- function validateTextSearchConfig(config) {
224
- return /^[a-zA-Z0-9_]+$/.test(config);
225
- }
226
- /**
227
- * pgvector distance metric → operator allow-list. This is the ONLY mapping
228
- * from a user-supplied metric token to a SQL operator; any token not present
229
- * here is rejected, so a user value can never become an arbitrary operator.
230
- *
231
- * - `l2` → `<->` (Euclidean / L2 distance)
232
- * - `cosine` → `<=>` (cosine distance)
233
- * - `ip` → `<#>` (negative inner product)
234
- */
235
- const VECTOR_METRIC_OPERATORS = {
236
- l2: '<->',
237
- cosine: '<=>',
238
- ip: '<#>',
239
- };
240
- /** Comparison keys allowed on a {@link VectorDistanceFilter}. */
241
- const VECTOR_DISTANCE_COMPARATORS = {
242
- lt: '<',
243
- lte: '<=',
244
- gt: '>',
245
- gte: '>=',
246
- };
247
- /** Check if a value is a vector distance WHERE filter: `{ distance: { to, metric } }` */
248
- function isVectorFilter(value) {
249
- if (value === null || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
250
- return false;
251
- }
252
- const dist = value.distance;
253
- return (typeof dist === 'object' &&
254
- dist !== null &&
255
- !Array.isArray(dist) &&
256
- 'to' in dist &&
257
- 'metric' in dist);
258
- }
259
- /** Check if an orderBy value is a vector KNN ordering: `{ distance: { to, metric } }` */
260
- function isVectorOrderBy(value) {
261
- return isVectorFilter(value);
262
- }
263
59
  // biome-ignore lint/complexity/noBannedTypes: {} means "no relations known" — intentional for untyped table access
264
60
  class QueryInterface {
265
61
  pool;
@@ -279,6 +75,13 @@ class QueryInterface {
279
75
  relationLoadStrategy;
280
76
  /** Nested-relation JSON encoding: 'object' (default) or 'positional'. */
281
77
  jsonEncoding;
78
+ /**
79
+ * Client-level automatic WHERE filters keyed by table accessor (soft-delete /
80
+ * multi-tenancy). AND-merged into every query on the keyed table and every
81
+ * relation subquery targeting it. Undefined when none are configured, in
82
+ * which case every path is byte-identical to the pre-0.28 behavior.
83
+ */
84
+ globalFilters;
282
85
  /**
283
86
  * Tracks tables that have already triggered an unlimited-query warning so
284
87
  * the user is not spammed once per row. Per-instance state — each
@@ -310,6 +113,15 @@ class QueryInterface {
310
113
  options;
311
114
  /** Set by executeWithMiddleware so queryWithTimeout can include it in events. */
312
115
  currentAction = 'raw';
116
+ /**
117
+ * The active query's `skipGlobalFilters` opt-out, set at the top of each
118
+ * `build*` method and read deep in the (synchronous) SQL-build + param-collect
119
+ * tree — so relation subqueries, relation filters, `_count`, and relation
120
+ * `orderBy` all see it without threading it through dozens of signatures.
121
+ * Only load-bearing when {@link globalFilters} is configured; build+collect are
122
+ * synchronous per call, so this transient is never observed across an await.
123
+ */
124
+ currentSkip;
313
125
  constructor(pool, table, schema, middlewares, options) {
314
126
  this.pool = pool;
315
127
  this.table = table;
@@ -331,6 +143,10 @@ class QueryInterface {
331
143
  this.dialect = options?.dialect ?? dialect_js_1.postgresDialect;
332
144
  this.relationLoadStrategy = options?.relationLoadStrategy ?? 'join';
333
145
  this.jsonEncoding = options?.jsonEncoding ?? 'object';
146
+ // Only retain the map when it has at least one entry, so `globalFilters`
147
+ // stays `undefined` (and every merge path a no-op) for the common case.
148
+ this.globalFilters =
149
+ options?.globalFilters && Object.keys(options.globalFilters).length > 0 ? options.globalFilters : undefined;
334
150
  this.txScoped = options?._txScoped ?? false;
335
151
  this.options = options;
336
152
  // Pre-compute column type lookup maps (TASK-26)
@@ -450,7 +266,7 @@ class QueryInterface {
450
266
  * and unlimited-warnings silenced — a relation load must fetch every matching
451
267
  * child, and the per-relation `limit` is applied client-side by the loader.
452
268
  */
453
- batchedContext(timeout) {
269
+ batchedContext(timeout, skip) {
454
270
  const childOptions = {
455
271
  ...this.options,
456
272
  defaultLimit: undefined,
@@ -465,6 +281,22 @@ class QueryInterface {
465
281
  buildInClause: (expr, paramRef, negated) => this.inClause(expr, paramRef, negated),
466
282
  inClauseParam: (values) => this.inParam(values),
467
283
  paramPlaceholder: (index) => this.p(index),
284
+ skipGlobalFilters: skip,
285
+ tableGlobalFilter: (table, alias, precedingParams) => {
286
+ const gf = this.resolveGlobalFilter(table, skip);
287
+ if (!gf)
288
+ return null;
289
+ const meta = this.schema.tables[table];
290
+ if (!meta)
291
+ return null;
292
+ // Seed the param array with `precedingParams` placeholders so
293
+ // buildAliasWhere numbers the gf params after the already-bound ones.
294
+ const seeded = new Array(precedingParams).fill(undefined);
295
+ const clause = this.buildAliasWhere(table, meta, alias, gf, seeded);
296
+ if (!clause)
297
+ return null;
298
+ return { clause, params: seeded.slice(precedingParams) };
299
+ },
468
300
  };
469
301
  }
470
302
  /**
@@ -476,13 +308,18 @@ class QueryInterface {
476
308
  */
477
309
  async runFindManyBatched(args) {
478
310
  const withClause = args.with;
311
+ // Capture the opt-out from the ARGS before any await: this.currentSkip is
312
+ // instance state on a cached accessor, so a concurrent build during the
313
+ // base-query await would overwrite it (tenant query loading relations with
314
+ // another query's skipGlobalFilters).
315
+ const skip = args.skipGlobalFilters;
479
316
  const { baseArgs, strip } = this.prepareBatchedBase(args, withClause);
480
317
  // baseArgs.with is always undefined here; the cast just bridges the R generic.
481
318
  const deferred = this.buildFindMany(baseArgs);
482
319
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
483
320
  const entities = deferred.transform(result);
484
321
  if (entities.length > 0) {
485
- await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), entities, withClause, args.timeout);
322
+ await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, skip), entities, withClause, args.timeout);
486
323
  }
487
324
  (0, batched_loader_js_1.stripFields)(entities, strip);
488
325
  return entities;
@@ -704,18 +541,22 @@ class QueryInterface {
704
541
  const entity = deferred.transform(result);
705
542
  if (!entity)
706
543
  return null;
707
- await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout), [entity], withClause, args.timeout);
544
+ await (0, batched_loader_js_1.loadRelationsBatched)(this.batchedContext(args.timeout, args.skipGlobalFilters), [entity], withClause, args.timeout);
708
545
  (0, batched_loader_js_1.stripFields)([entity], proj.strip);
709
546
  return entity;
710
547
  }
711
548
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
712
549
  buildFindUnique(args) {
550
+ this.currentSkip = args.skipGlobalFilters;
713
551
  const columnsList = this.resolveColumns(args.select, args.omit);
714
- const whereObj = args.where;
552
+ // A global filter turns the where into `{ AND: [...] }`, which the
553
+ // `isSimpleWhere` test below rejects → the general (buildWhereClause) path
554
+ // handles the merge and its params uniformly.
555
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
715
556
  const colKey = columnsList ? columnsList.join(',') : '*';
716
557
  const whereFingerprint = this.fingerprintWhere(whereObj);
717
558
  const withFp = args.with ? this.withFingerprint(args.with) : '';
718
- const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}`;
559
+ const ck = `fu:${whereFingerprint}|c=${colKey}|w=${withFp}${this.globalFilterCacheSegment()}`;
719
560
  const params = [];
720
561
  // Check if all where values are simple (plain equality, no operators/null/OR).
721
562
  // Keys are sorted to match fingerprintWhere — insertion order here would let
@@ -728,7 +569,7 @@ class QueryInterface {
728
569
  !whereObj.NOT &&
729
570
  whereKeys.every((k) => {
730
571
  const v = whereObj[k];
731
- return v !== null && !isWhereOperator(v) && !this.tableMeta.relations[k];
572
+ return v !== null && !(0, filters_js_1.isWhereOperator)(v) && !this.tableMeta.relations[k];
732
573
  });
733
574
  // Simple path: plain equality, no operators/null/OR
734
575
  if (!args.with && isSimpleWhere) {
@@ -871,24 +712,21 @@ class QueryInterface {
871
712
  }
872
713
  // biome-ignore lint/complexity/noBannedTypes: {} means "no with clause" — matches TypedWithClause default
873
714
  buildFindMany(args) {
715
+ this.currentSkip = args?.skipGlobalFilters;
874
716
  const columnsList = this.resolveColumns(args?.select, args?.omit);
875
717
  const colKey = columnsList ? columnsList.join(',') : '*';
876
- const whereObj = (args?.where ?? {});
718
+ // AND-merge this table's global filter into the user where; `hasWhere` gates
719
+ // the build/collect just like `args?.where` did (a merged filter can make
720
+ // an otherwise-absent where present).
721
+ const effWhere = this.mergeGlobalFilter(args?.where);
722
+ const hasWhere = effWhere !== undefined;
723
+ const whereObj = (effWhere ?? {});
877
724
  // Build fingerprint for cache lookup
878
- const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
725
+ const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
879
726
  const withFp = args?.with ? this.withFingerprint(args.with) : '';
880
727
  const orderFp = args?.orderBy
881
728
  ? Object.entries(args.orderBy)
882
- .map(([k, d]) => {
883
- // Vector KNN ordering changes the emitted SQL operator by metric and
884
- // adds a `::vector` param, so the metric + direction must be part of
885
- // the cache key — otherwise two KNN queries differing only in metric
886
- // would collide on a single cached SQL string.
887
- if (isVectorOrderBy(d)) {
888
- return `${k}:vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
889
- }
890
- return `${k}:${d}`;
891
- })
729
+ .map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`)
892
730
  .join(',')
893
731
  : '';
894
732
  const cursorFp = args?.cursor
@@ -901,12 +739,12 @@ class QueryInterface {
901
739
  const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
902
740
  const limitFp = effectiveLimit !== undefined ? '1' : '0';
903
741
  const offsetFp = args?.offset !== undefined ? '1' : '0';
904
- const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}`;
742
+ const ck = `fm:${whereFp}|c=${colKey}|o=${orderFp}|l=${limitFp}|off=${offsetFp}|cur=${cursorFp}|d=${distinctFp}|w=${withFp}${this.globalFilterCacheSegment()}`;
905
743
  const params = [];
906
744
  const entry = this.acquireSql(ck, () => {
907
745
  // Fresh build — generates SQL and populates freshParams
908
746
  const freshParams = [];
909
- const { sql: freshWhereSql } = args?.where
747
+ const { sql: freshWhereSql } = hasWhere
910
748
  ? (() => {
911
749
  const clause = this.buildWhereClause(whereObj, freshParams);
912
750
  return { sql: clause ? ` WHERE ${clause}` : '' };
@@ -932,12 +770,15 @@ class QueryInterface {
932
770
  let sql = `SELECT ${distinctPrefix}${selectClause} FROM ${qt}${freshWhereSql}`;
933
771
  if (args?.cursor) {
934
772
  // Sorted (canonical) order — MUST match cursorFp and the cache-hit collect below.
935
- const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
773
+ const cursorEntries = (0, filters_js_1.sortedEntries)(args.cursor).filter(([, v]) => v !== undefined);
936
774
  if (cursorEntries.length > 0) {
937
775
  const cursorConditions = cursorEntries.map(([k, v]) => {
938
776
  const col = this.toSqlColumn(k);
939
- const dir = args.orderBy?.[k] ?? 'asc';
940
- const op = dir === 'desc' ? '<' : '>';
777
+ // orderBy values can be the { sort, nulls } spec form — normalize
778
+ // before comparing, or a desc spec would seek the ascending side.
779
+ const dir = args.orderBy?.[k];
780
+ const desc = (0, filters_js_1.isOrderBySpec)(dir) ? dir.sort === 'desc' : dir === 'desc';
781
+ const op = desc ? '<' : '>';
941
782
  freshParams.push(v);
942
783
  return `${qt}.${col} ${op} ${this.p(freshParams.length)}`;
943
784
  });
@@ -956,7 +797,7 @@ class QueryInterface {
956
797
  // order") need two levels: inner DISTINCT ON ordered by the distinct
957
798
  // columns then the user's order (picks the right representative row),
958
799
  // outer re-ordered by the user's order alone.
959
- if (Object.values(args.orderBy).some((d) => isVectorOrderBy(d))) {
800
+ if (Object.values(args.orderBy).some((d) => (0, filters_js_1.isVectorOrderBy)(d))) {
960
801
  throw new errors_js_1.ValidationError('[turbine] `distinct` cannot be combined with vector distance ordering.');
961
802
  }
962
803
  const userOrder = this.buildOrderBy(args.orderBy, freshParams);
@@ -984,8 +825,8 @@ class QueryInterface {
984
825
  return sql;
985
826
  });
986
827
  // Collect params in exact build order:
987
- // 1. WHERE params
988
- if (args?.where) {
828
+ // 1. WHERE params (includes the AND-merged global filter, if any)
829
+ if (hasWhere) {
989
830
  this.collectWhereParams(whereObj, params);
990
831
  }
991
832
  // 2. WITH relation params
@@ -994,7 +835,7 @@ class QueryInterface {
994
835
  }
995
836
  // 3. Cursor params — sorted (canonical) order, matching cursorFp and the build path.
996
837
  if (args?.cursor) {
997
- const cursorEntries = sortedEntries(args.cursor).filter(([, v]) => v !== undefined);
838
+ const cursorEntries = (0, filters_js_1.sortedEntries)(args.cursor).filter(([, v]) => v !== undefined);
998
839
  for (const [, v] of cursorEntries) {
999
840
  params.push(v);
1000
841
  }
@@ -1197,6 +1038,8 @@ class QueryInterface {
1197
1038
  });
1198
1039
  }
1199
1040
  buildCreate(args) {
1041
+ this.assertWritable('create');
1042
+ this.assertNoGeneratedColumns(args.data, 'create');
1200
1043
  const entries = Object.entries(args.data).filter(([, v]) => v !== undefined);
1201
1044
  const columns = entries.map(([k]) => this.toSqlColumn(k));
1202
1045
  const params = entries.map(([, v]) => v);
@@ -1271,6 +1114,10 @@ class QueryInterface {
1271
1114
  tag: `${this.table}.createMany`,
1272
1115
  };
1273
1116
  }
1117
+ this.assertWritable('createMany');
1118
+ for (const row of args.data) {
1119
+ this.assertNoGeneratedColumns(row, 'createMany');
1120
+ }
1274
1121
  const keys = Object.keys(args.data[0]).filter((k) => args.data[0][k] !== undefined);
1275
1122
  const columns = keys.map((k) => this.toColumn(k));
1276
1123
  const rowValues = args.data.map((row) => {
@@ -1308,12 +1155,22 @@ class QueryInterface {
1308
1155
  });
1309
1156
  }
1310
1157
  buildUpdate(args) {
1158
+ this.assertWritable('update');
1159
+ this.currentSkip = args.skipGlobalFilters;
1311
1160
  const dataObj = args.data;
1312
- const whereObj = args.where;
1161
+ this.assertNoGeneratedColumns(dataObj, 'update');
1162
+ const userWhere = args.where;
1313
1163
  const lock = args.optimisticLock;
1164
+ // The empty-`where` guard checks the USER predicate only — a global filter
1165
+ // must never turn an unguarded mass update into an allowed one.
1166
+ const userHasPredicate = !this.userPredicateIsEmpty(userWhere) || !!lock;
1167
+ this.assertMutationHasPredicate('update', userHasPredicate ? ' WHERE x' : '', args.allowFullTableScan);
1168
+ // The SQL is built from the global-filter-merged where (soft-delete keeps an
1169
+ // update from touching already-deleted rows).
1170
+ const whereObj = (this.mergeGlobalFilter(userWhere) ?? {});
1314
1171
  const setFp = this.fingerprintSet(dataObj);
1315
1172
  const whereFp = this.fingerprintWhere(whereObj);
1316
- const ck = lock ? null : `u:${setFp}|${whereFp}`;
1173
+ const ck = lock ? null : `u:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1317
1174
  const params = [];
1318
1175
  const buildSql = () => {
1319
1176
  const freshParams = [];
@@ -1331,7 +1188,6 @@ class QueryInterface {
1331
1188
  const versionCheck = `${versionCol} = ${this.p(freshParams.length)}`;
1332
1189
  whereSql = whereSql ? `${whereSql} AND ${versionCheck}` : ` WHERE ${versionCheck}`;
1333
1190
  }
1334
- this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
1335
1191
  // Engines that inject their returning shape MID-statement (SQL Server
1336
1192
  // `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
1337
1193
  // absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
@@ -1345,9 +1201,6 @@ class QueryInterface {
1345
1201
  const entry = this.acquireSql(ck, buildSql);
1346
1202
  sql = entry.sql;
1347
1203
  preparedName = entry.name;
1348
- if (whereFp === '') {
1349
- this.assertMutationHasPredicate('update', '', args.allowFullTableScan);
1350
- }
1351
1204
  }
1352
1205
  else {
1353
1206
  sql = buildSql();
@@ -1481,27 +1334,24 @@ class QueryInterface {
1481
1334
  });
1482
1335
  }
1483
1336
  buildDelete(args) {
1484
- const whereObj = args.where;
1337
+ this.assertWritable('delete');
1338
+ this.currentSkip = args.skipGlobalFilters;
1339
+ // Guard the USER predicate (a global filter must not satisfy the guard).
1340
+ this.assertMutationHasPredicate('delete', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1341
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1485
1342
  const whereFp = this.fingerprintWhere(whereObj);
1486
- const ck = `d:${whereFp}`;
1343
+ const ck = `d:${whereFp}${this.globalFilterCacheSegment()}`;
1487
1344
  const params = [];
1488
- // We need to check the mutation predicate. Build the whereSql to test it.
1489
- // On cache hit we still need to validate (the shape may be empty).
1490
1345
  const entry = this.acquireSql(ck, () => {
1491
1346
  const freshParams = [];
1492
1347
  const clause = this.buildWhereClause(whereObj, freshParams);
1493
1348
  const whereSql = clause ? ` WHERE ${clause}` : '';
1494
- this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
1495
1349
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
1496
1350
  // absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
1497
1351
  return this.dialect.buildDeleteStatement
1498
1352
  ? this.dialect.buildDeleteStatement({ table: this.q(this.table), whereSql, returning: '*' })
1499
1353
  : `DELETE FROM ${this.q(this.table)}${whereSql}${this.dialect.buildReturningClause('*')}`;
1500
1354
  });
1501
- // On cache hit, still validate the predicate
1502
- if (whereFp === '') {
1503
- this.assertMutationHasPredicate('delete', '', args.allowFullTableScan);
1504
- }
1505
1355
  this.collectWhereParams(whereObj, params);
1506
1356
  return {
1507
1357
  sql: entry.sql,
@@ -1541,6 +1391,10 @@ class QueryInterface {
1541
1391
  });
1542
1392
  }
1543
1393
  buildUpsert(args) {
1394
+ this.assertWritable('upsert');
1395
+ this.assertNoGeneratedColumns(args.create, 'upsert');
1396
+ this.assertNoGeneratedColumns(args.update, 'upsert');
1397
+ this.currentSkip = args.skipGlobalFilters;
1544
1398
  // Build the INSERT part from create data
1545
1399
  const createEntries = Object.entries(args.create).filter(([, v]) => v !== undefined);
1546
1400
  const columns = createEntries.map(([k]) => this.toSqlColumn(k));
@@ -1559,12 +1413,23 @@ class QueryInterface {
1559
1413
  });
1560
1414
  const updateParams = updateEntries.map(([, v]) => v);
1561
1415
  const params = [...createParams, ...updateParams];
1416
+ // Global filter → restrict the conflict-UPDATE (soft-delete / tenancy) so an
1417
+ // upsert never resurrects a soft-deleted row or writes across tenants. Only
1418
+ // on engines whose upsert can carry a predicate (Postgres); the gf params
1419
+ // continue the placeholder numbering after create+update params.
1420
+ let updateWhere;
1421
+ if (this.dialect.supportsUpsertUpdateWhere) {
1422
+ const gf = this.resolveGlobalFilter(this.table);
1423
+ if (gf)
1424
+ updateWhere = this.buildWhereClause(gf, params) ?? undefined;
1425
+ }
1562
1426
  const sql = this.dialect.buildUpsertStatement({
1563
1427
  table: this.q(this.table),
1564
1428
  insertColumns: columns,
1565
1429
  valuePlaceholders: placeholders,
1566
1430
  conflictColumns,
1567
1431
  updateSetClauses: setClauses,
1432
+ updateWhere,
1568
1433
  returning: '*',
1569
1434
  });
1570
1435
  return {
@@ -1587,7 +1452,7 @@ class QueryInterface {
1587
1452
  reselect: this.dialect.resultStrategy === 'reselect'
1588
1453
  ? async (exec) => {
1589
1454
  await exec(sql, params);
1590
- const sel = this.buildReselectByWhere(args.where);
1455
+ const sel = this.buildReselectByWhere((this.mergeGlobalFilter(args.where) ?? {}));
1591
1456
  return exec(sel.sql, sel.params);
1592
1457
  }
1593
1458
  : undefined,
@@ -1604,11 +1469,15 @@ class QueryInterface {
1604
1469
  });
1605
1470
  }
1606
1471
  buildUpdateMany(args) {
1472
+ this.assertWritable('updateMany');
1473
+ this.currentSkip = args.skipGlobalFilters;
1607
1474
  const dataObj = args.data;
1608
- const whereObj = args.where;
1475
+ this.assertNoGeneratedColumns(dataObj, 'updateMany');
1476
+ this.assertMutationHasPredicate('updateMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1477
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1609
1478
  const setFp = this.fingerprintSet(dataObj);
1610
1479
  const whereFp = this.fingerprintWhere(whereObj);
1611
- const ck = `um:${setFp}|${whereFp}`;
1480
+ const ck = `um:${setFp}|${whereFp}${this.globalFilterCacheSegment()}`;
1612
1481
  const params = [];
1613
1482
  const entry = this.acquireSql(ck, () => {
1614
1483
  const freshParams = [];
@@ -1616,12 +1485,8 @@ class QueryInterface {
1616
1485
  const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, freshParams));
1617
1486
  const whereClause = this.buildWhereClause(whereObj, freshParams);
1618
1487
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
1619
- this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
1620
1488
  return `UPDATE ${this.q(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
1621
1489
  });
1622
- if (whereFp === '') {
1623
- this.assertMutationHasPredicate('updateMany', '', args.allowFullTableScan);
1624
- }
1625
1490
  this.collectSetParams(dataObj, params);
1626
1491
  this.collectWhereParams(whereObj, params);
1627
1492
  return {
@@ -1643,20 +1508,19 @@ class QueryInterface {
1643
1508
  });
1644
1509
  }
1645
1510
  buildDeleteMany(args) {
1646
- const whereObj = args.where;
1511
+ this.assertWritable('deleteMany');
1512
+ this.currentSkip = args.skipGlobalFilters;
1513
+ this.assertMutationHasPredicate('deleteMany', this.userPredicateIsEmpty(args.where) ? '' : ' WHERE x', args.allowFullTableScan);
1514
+ const whereObj = (this.mergeGlobalFilter(args.where) ?? {});
1647
1515
  const whereFp = this.fingerprintWhere(whereObj);
1648
- const ck = `dm:${whereFp}`;
1516
+ const ck = `dm:${whereFp}${this.globalFilterCacheSegment()}`;
1649
1517
  const params = [];
1650
1518
  const entry = this.acquireSql(ck, () => {
1651
1519
  const freshParams = [];
1652
1520
  const clause = this.buildWhereClause(whereObj, freshParams);
1653
1521
  const whereSql = clause ? ` WHERE ${clause}` : '';
1654
- this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
1655
1522
  return `DELETE FROM ${this.q(this.table)}${whereSql}`;
1656
1523
  });
1657
- if (whereFp === '') {
1658
- this.assertMutationHasPredicate('deleteMany', '', args.allowFullTableScan);
1659
- }
1660
1524
  this.collectWhereParams(whereObj, params);
1661
1525
  return {
1662
1526
  sql: entry.sql,
@@ -1677,17 +1541,20 @@ class QueryInterface {
1677
1541
  });
1678
1542
  }
1679
1543
  buildCount(args) {
1680
- const whereObj = (args?.where ?? {});
1681
- const whereFp = args?.where ? this.fingerprintWhere(whereObj) : '';
1682
- const ck = `cnt:${whereFp}`;
1544
+ this.currentSkip = args?.skipGlobalFilters;
1545
+ const effWhere = this.mergeGlobalFilter(args?.where);
1546
+ const hasWhere = effWhere !== undefined;
1547
+ const whereObj = (effWhere ?? {});
1548
+ const whereFp = hasWhere ? this.fingerprintWhere(whereObj) : '';
1549
+ const ck = `cnt:${whereFp}${this.globalFilterCacheSegment()}`;
1683
1550
  const params = [];
1684
1551
  const entry = this.acquireSql(ck, () => {
1685
1552
  const freshParams = [];
1686
- const clause = args?.where ? this.buildWhereClause(whereObj, freshParams) : null;
1553
+ const clause = hasWhere ? this.buildWhereClause(whereObj, freshParams) : null;
1687
1554
  const whereSql = clause ? ` WHERE ${clause}` : '';
1688
1555
  return `SELECT ${this.castAgg('COUNT(*)', 'int')} AS count FROM ${this.q(this.table)}${whereSql}`;
1689
1556
  });
1690
- if (args?.where) {
1557
+ if (hasWhere) {
1691
1558
  this.collectWhereParams(whereObj, params);
1692
1559
  }
1693
1560
  return {
@@ -1717,9 +1584,13 @@ class QueryInterface {
1717
1584
  }
1718
1585
  }
1719
1586
  }
1587
+ this.currentSkip = args.skipGlobalFilters;
1720
1588
  const groupColsRaw = args.by.map((k) => this.toColumn(k));
1721
1589
  const groupCols = groupColsRaw.map((c) => this.q(c));
1722
- const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
1590
+ const gbWhere = this.mergeGlobalFilter(args.where);
1591
+ const { sql: whereSql, params } = gbWhere
1592
+ ? this.buildWhere(gbWhere)
1593
+ : { sql: '', params: [] };
1723
1594
  // Build SELECT expressions: group-by columns + aggregate functions
1724
1595
  const selectExprs = [...groupCols];
1725
1596
  // _count
@@ -1962,7 +1833,11 @@ class QueryInterface {
1962
1833
  });
1963
1834
  }
1964
1835
  buildAggregate(args) {
1965
- const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
1836
+ this.currentSkip = args.skipGlobalFilters;
1837
+ const aggWhere = this.mergeGlobalFilter(args.where);
1838
+ const { sql: whereSql, params } = aggWhere
1839
+ ? this.buildWhere(aggWhere)
1840
+ : { sql: '', params: [] };
1966
1841
  const meta = this.schema.tables[this.table];
1967
1842
  if (meta) {
1968
1843
  for (const group of [args._sum, args._avg, args._min, args._max]) {
@@ -2140,6 +2015,36 @@ class QueryInterface {
2140
2015
  }
2141
2016
  return null;
2142
2017
  }
2018
+ /**
2019
+ * Reject any write against a view (H4). Views are introspected with
2020
+ * `isView: true` and are read-only in every engine; a write raises a
2021
+ * {@link ValidationError} (E003) rather than emitting SQL Postgres would
2022
+ * reject (or, worse, silently applying to an updatable view).
2023
+ */
2024
+ assertWritable(operation) {
2025
+ if (this.tableMeta.isView) {
2026
+ throw new errors_js_1.ValidationError(`[turbine] Cannot ${operation} "${this.table}": it is a view (read-only). ` +
2027
+ 'Views support reads (findMany/findFirst/…) but not writes.');
2028
+ }
2029
+ }
2030
+ /**
2031
+ * Reject a write whose `data` names a `GENERATED ALWAYS AS (...) STORED`
2032
+ * column (H3). Postgres computes these from other columns and errors if you
2033
+ * try to write them; we fail early with a clear {@link ValidationError} (E003)
2034
+ * instead of surfacing a cryptic driver error. Undefined values are ignored
2035
+ * (they're stripped from the statement anyway).
2036
+ */
2037
+ assertNoGeneratedColumns(data, operation) {
2038
+ for (const [key, value] of Object.entries(data)) {
2039
+ if (value === undefined)
2040
+ continue;
2041
+ const col = this.tableMeta.columns.find((c) => c.field === key || c.name === key || c.name === (0, schema_js_1.camelToSnake)(key));
2042
+ if (col?.isGeneratedStored) {
2043
+ throw new errors_js_1.ValidationError(`[turbine] Cannot ${operation} "${this.table}": column "${key}" is a GENERATED ALWAYS AS (…) STORED ` +
2044
+ 'column whose value the database computes — remove it from your data.');
2045
+ }
2046
+ }
2047
+ }
2143
2048
  /** Convert camelCase field name to snake_case column name (unquoted, for non-SQL uses) */
2144
2049
  toColumn(field) {
2145
2050
  const mapped = this.tableMeta.columnMap[field];
@@ -2191,7 +2096,7 @@ class QueryInterface {
2191
2096
  !Buffer.isBuffer(value)) {
2192
2097
  const v = value;
2193
2098
  const keys = Object.keys(v);
2194
- if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
2099
+ if (keys.length === 1 && filters_js_1.UPDATE_OPERATOR_KEYS.has(keys[0])) {
2195
2100
  const op = keys[0];
2196
2101
  const opValue = v[op];
2197
2102
  if (op === 'set') {
@@ -2308,15 +2213,15 @@ class QueryInterface {
2308
2213
  continue;
2309
2214
  }
2310
2215
  // Operator objects
2311
- if (isWhereOperator(value)) {
2312
- parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2216
+ if ((0, filters_js_1.isWhereOperator)(value)) {
2217
+ parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
2313
2218
  continue;
2314
2219
  }
2315
2220
  // Vector distance filter — metric (operator) and present comparators
2316
2221
  // change the SQL shape, so both go in the fingerprint.
2317
- if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2222
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isVectorFilter)(value)) {
2318
2223
  const dist = value.distance;
2319
- const cmps = Object.keys(VECTOR_DISTANCE_COMPARATORS)
2224
+ const cmps = Object.keys(filters_js_1.VECTOR_DISTANCE_COMPARATORS)
2320
2225
  .filter((c) => dist[c] !== undefined)
2321
2226
  .sort()
2322
2227
  .join('|');
@@ -2324,18 +2229,18 @@ class QueryInterface {
2324
2229
  continue;
2325
2230
  }
2326
2231
  // JSON filter
2327
- if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2232
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2328
2233
  const jKeys = Object.keys(value).sort();
2329
2234
  parts.push(`${key}:json(${jKeys.join(',')})`);
2330
2235
  continue;
2331
2236
  }
2332
2237
  // Array filter
2333
- if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
2238
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
2334
2239
  parts.push(`${key}:arr(${this.fingerprintArrayFilter(value)})`);
2335
2240
  continue;
2336
2241
  }
2337
2242
  // Text search filter
2338
- if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
2243
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isTextSearchFilter)(value)) {
2339
2244
  const cfg = value.config ?? 'english';
2340
2245
  parts.push(`${key}:fts(${cfg})`);
2341
2246
  continue;
@@ -2344,7 +2249,7 @@ class QueryInterface {
2344
2249
  // fingerprint distinct from real equality. The build path throws for
2345
2250
  // these on non-JSON columns; sharing `key:eq` would let a cache entry
2346
2251
  // warmed by genuine equality serve the bad filter silently.
2347
- if (isUnmatchedPlainObject(value)) {
2252
+ if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
2348
2253
  parts.push(`${key}:obj(${Object.keys(value)
2349
2254
  .sort()
2350
2255
  .join(',')})`);
@@ -2409,10 +2314,10 @@ class QueryInterface {
2409
2314
  if (value === null) {
2410
2315
  parts.push(`${key}:null`);
2411
2316
  }
2412
- else if (isWhereOperator(value)) {
2413
- parts.push(`${key}:${fingerprintOperatorShape(value)}`);
2317
+ else if ((0, filters_js_1.isWhereOperator)(value)) {
2318
+ parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
2414
2319
  }
2415
- else if (isUnmatchedPlainObject(value)) {
2320
+ else if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
2416
2321
  parts.push(`${key}:obj(${Object.keys(value)
2417
2322
  .sort()
2418
2323
  .join(',')})`);
@@ -2432,7 +2337,7 @@ class QueryInterface {
2432
2337
  */
2433
2338
  collectWhereParams(where, params) {
2434
2339
  // Sorted (canonical) order — MUST match fingerprintWhere and buildWhereClause.
2435
- const keys = sortedKeys(where);
2340
+ const keys = (0, filters_js_1.sortedKeys)(where);
2436
2341
  for (const key of keys) {
2437
2342
  const value = where[key];
2438
2343
  if (value === undefined)
@@ -2469,16 +2374,7 @@ class QueryInterface {
2469
2374
  'none' in filterObj ||
2470
2375
  'is' in filterObj ||
2471
2376
  'isNot' in filterObj) {
2472
- if (filterObj.some !== undefined && filterObj.some !== null)
2473
- this.collectRelFilterParams(relationDef.to, filterObj.some, params);
2474
- if (filterObj.none !== undefined && filterObj.none !== null)
2475
- this.collectRelFilterParams(relationDef.to, filterObj.none, params);
2476
- if (filterObj.every !== undefined && filterObj.every !== null)
2477
- this.collectRelFilterParams(relationDef.to, filterObj.every, params);
2478
- if (filterObj.is !== undefined && filterObj.is !== null)
2479
- this.collectRelFilterParams(relationDef.to, filterObj.is, params);
2480
- if (filterObj.isNot !== undefined && filterObj.isNot !== null)
2481
- this.collectRelFilterParams(relationDef.to, filterObj.isNot, params);
2377
+ this.collectRelationFilterParams(relationDef, filterObj, params);
2482
2378
  continue;
2483
2379
  }
2484
2380
  }
@@ -2487,7 +2383,7 @@ class QueryInterface {
2487
2383
  continue;
2488
2384
  const rawColumn = this.toColumn(key);
2489
2385
  // Vector distance filter — mirrors buildVectorFilterClauses push order.
2490
- if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
2386
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isVectorFilter)(value)) {
2491
2387
  // Validate the same way the build path does so the collect path never
2492
2388
  // diverges (it would throw before any param was pushed).
2493
2389
  this.vectorOperator(key, rawColumn, value.distance.metric);
@@ -2495,7 +2391,7 @@ class QueryInterface {
2495
2391
  continue;
2496
2392
  }
2497
2393
  // JSONB filter
2498
- if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
2394
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2499
2395
  const colType = this.getColumnPgType(rawColumn);
2500
2396
  if (colType === 'json' || colType === 'jsonb') {
2501
2397
  this.collectJsonFilterParams(value, params);
@@ -2503,7 +2399,7 @@ class QueryInterface {
2503
2399
  }
2504
2400
  }
2505
2401
  // Array filter
2506
- if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
2402
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
2507
2403
  const colType = this.getColumnPgType(rawColumn);
2508
2404
  if (colType.startsWith('_')) {
2509
2405
  this.collectArrayFilterParams(value, params);
@@ -2511,12 +2407,12 @@ class QueryInterface {
2511
2407
  }
2512
2408
  }
2513
2409
  // Text search filter
2514
- if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
2410
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isTextSearchFilter)(value)) {
2515
2411
  params.push(value.search);
2516
2412
  continue;
2517
2413
  }
2518
2414
  // Operator objects
2519
- if (isWhereOperator(value)) {
2415
+ if ((0, filters_js_1.isWhereOperator)(value)) {
2520
2416
  this.collectOperatorParams(rawColumn, value, params);
2521
2417
  continue;
2522
2418
  }
@@ -2526,13 +2422,51 @@ class QueryInterface {
2526
2422
  params.push(value);
2527
2423
  }
2528
2424
  }
2529
- /** Collect params from a relation filter sub-where. Mirrors buildSubWhereForRelation. */
2425
+ /**
2426
+ * Param-collect mirror of {@link buildRelationFilter} for one relation-filter
2427
+ * object (`{ some/every/none/is/isNot }`, already normalized). Pushes, per
2428
+ * present branch and in the canonical order some→none→every→is→isNot, the
2429
+ * branch's sub-where params THEN the target table's global-filter params —
2430
+ * exactly the order buildRelationFilter emits. When no global filter applies
2431
+ * the gf calls are no-ops, so this stays byte-identical to the pre-0.28 path.
2432
+ * Shared by every collect site that mirrors buildRelationFilter
2433
+ * (collectWhereParams, collectRelFilterParams, collectAliasWhereParams).
2434
+ */
2435
+ collectRelationFilterParams(relDef, filterObj, params) {
2436
+ const target = relDef.to;
2437
+ if (filterObj.some !== undefined && filterObj.some !== null) {
2438
+ this.collectRelFilterParams(target, filterObj.some, params);
2439
+ this.collectTargetGlobalFilterExists(target, params);
2440
+ }
2441
+ if (filterObj.none !== undefined && filterObj.none !== null) {
2442
+ this.collectRelFilterParams(target, filterObj.none, params);
2443
+ this.collectTargetGlobalFilterExists(target, params);
2444
+ }
2445
+ if (filterObj.every !== undefined && filterObj.every !== null) {
2446
+ // gf is only emitted (build) when the `every` sub-where compiles to a
2447
+ // filter — otherwise `every` is trivially true and no subquery is built.
2448
+ if (this.buildSubWhereForRelation(target, filterObj.every, []) !== null) {
2449
+ this.collectRelFilterParams(target, filterObj.every, params);
2450
+ this.collectTargetGlobalFilterExists(target, params);
2451
+ }
2452
+ }
2453
+ if (filterObj.is !== undefined) {
2454
+ if (filterObj.is !== null)
2455
+ this.collectRelFilterParams(target, filterObj.is, params);
2456
+ this.collectTargetGlobalFilterExists(target, params);
2457
+ }
2458
+ if (filterObj.isNot !== undefined) {
2459
+ if (filterObj.isNot !== null)
2460
+ this.collectRelFilterParams(target, filterObj.isNot, params);
2461
+ this.collectTargetGlobalFilterExists(target, params);
2462
+ }
2463
+ }
2530
2464
  collectRelFilterParams(targetTable, subWhere, params) {
2531
2465
  const meta = this.schema.tables[targetTable];
2532
2466
  if (!meta)
2533
2467
  return;
2534
2468
  // Sorted (canonical) order — MUST match fingerprintRelFilter and buildSubWhereForRelation.
2535
- for (const field of sortedKeys(subWhere)) {
2469
+ for (const field of (0, filters_js_1.sortedKeys)(subWhere)) {
2536
2470
  const value = subWhere[field];
2537
2471
  if (value === undefined)
2538
2472
  continue;
@@ -2554,22 +2488,14 @@ class QueryInterface {
2554
2488
  if (nestedRel && typeof value === 'object' && !Array.isArray(value)) {
2555
2489
  const norm = this.normalizeRelationFilter(nestedRel, value);
2556
2490
  if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
2557
- // Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
2558
- if (norm.some != null)
2559
- this.collectRelFilterParams(nestedRel.to, norm.some, params);
2560
- if (norm.none != null)
2561
- this.collectRelFilterParams(nestedRel.to, norm.none, params);
2562
- if (norm.every != null)
2563
- this.collectRelFilterParams(nestedRel.to, norm.every, params);
2564
- if (norm.is != null)
2565
- this.collectRelFilterParams(nestedRel.to, norm.is, params);
2566
- if (norm.isNot != null)
2567
- this.collectRelFilterParams(nestedRel.to, norm.isNot, params);
2491
+ // Mirrors buildRelationFilter (somenoneeveryis→isNot, each: sub-where
2492
+ // params then target global-filter params).
2493
+ this.collectRelationFilterParams(nestedRel, norm, params);
2568
2494
  continue;
2569
2495
  }
2570
2496
  }
2571
2497
  const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2572
- if (isWhereOperator(value)) {
2498
+ if ((0, filters_js_1.isWhereOperator)(value)) {
2573
2499
  this.collectOperatorParams(col, value, params);
2574
2500
  continue;
2575
2501
  }
@@ -2580,7 +2506,7 @@ class QueryInterface {
2580
2506
  /** Collect params from operator clauses. Mirrors buildOperatorClauses. */
2581
2507
  collectOperatorParams(column, op, params) {
2582
2508
  if (op.equals !== undefined && op.equals !== null) {
2583
- assertBindableEqualsOperand(op.equals, `"${column}"`);
2509
+ (0, filters_js_1.assertBindableEqualsOperand)(op.equals, `"${column}"`);
2584
2510
  params.push(op.equals);
2585
2511
  }
2586
2512
  if (op.gt !== undefined)
@@ -2638,12 +2564,27 @@ class QueryInterface {
2638
2564
  */
2639
2565
  collectOrderByParams(orderBy, params) {
2640
2566
  for (const [key, dir] of Object.entries(orderBy)) {
2641
- if (isVectorOrderBy(dir)) {
2567
+ if ((0, filters_js_1.isVectorOrderBy)(dir)) {
2642
2568
  const rawColumn = this.toColumn(key);
2643
2569
  // Re-run the same validation as buildOrderBy so the collect path can
2644
2570
  // never push a param that the build path rejected (or vice versa).
2645
2571
  this.vectorOperator(key, rawColumn, dir.distance.metric);
2646
2572
  this.pushVectorParam(key, rawColumn, dir.distance.to, params);
2573
+ continue;
2574
+ }
2575
+ // To-many relation orderBy (`{ posts: { _count } }`) uses the same count
2576
+ // subquery as `_count` — mirror its global-filter params. To-one relation
2577
+ // orderBy carries the target's global filter once per ordered column.
2578
+ if (this.isRelationOrderByValue(dir)) {
2579
+ const relDef = this.tableMeta.relations[key];
2580
+ if (relDef && (relDef.type === 'hasMany' || relDef.type === 'manyToMany')) {
2581
+ this.collectRelationCountParams(relDef, params);
2582
+ }
2583
+ else if (relDef) {
2584
+ for (const _col of Object.keys(dir)) {
2585
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
2586
+ }
2587
+ }
2647
2588
  }
2648
2589
  }
2649
2590
  }
@@ -2655,7 +2596,7 @@ class QueryInterface {
2655
2596
  collectVectorFilterParams(field, rawColumn, filter, params) {
2656
2597
  const dist = filter.distance;
2657
2598
  this.pushVectorParam(field, rawColumn, dist.to, params);
2658
- for (const cmp of Object.keys(VECTOR_DISTANCE_COMPARATORS)) {
2599
+ for (const cmp of Object.keys(filters_js_1.VECTOR_DISTANCE_COMPARATORS)) {
2659
2600
  const threshold = dist[cmp];
2660
2601
  if (threshold !== undefined)
2661
2602
  params.push(threshold);
@@ -2679,6 +2620,19 @@ class QueryInterface {
2679
2620
  const spec = withClause[relName];
2680
2621
  if (!spec)
2681
2622
  continue;
2623
+ // Reserved `_count` key — fingerprint by the selected relation set so
2624
+ // `_count: true` and `_count: { posts: true }` never share a cache entry.
2625
+ if (relName === '_count') {
2626
+ const c = spec;
2627
+ parts.push(c === true
2628
+ ? '_count(*)'
2629
+ : `_count(${Object.entries(c)
2630
+ .filter(([, v]) => v)
2631
+ .map(([k]) => k)
2632
+ .sort()
2633
+ .join(',')})`);
2634
+ continue;
2635
+ }
2682
2636
  const relDef = meta.relations[relName];
2683
2637
  if (!relDef) {
2684
2638
  parts.push(`unknown:${relName}`);
@@ -2711,9 +2665,9 @@ class QueryInterface {
2711
2665
  if (opts.where) {
2712
2666
  subParts.push(`w=${this.fingerprintAliasWhere(opts.where, meta.relations[relName]?.to)}`);
2713
2667
  }
2714
- // orderBy shape
2668
+ // orderBy shape (OrderBySpec nulls placement changes the SQL, so fingerprint it)
2715
2669
  if (opts.orderBy) {
2716
- const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${d}`);
2670
+ const oEntries = Object.entries(opts.orderBy).map(([k, d]) => `${k}:${this.orderByEntryFingerprint(d)}`);
2717
2671
  subParts.push(`o=${oEntries.join(',')}`);
2718
2672
  }
2719
2673
  // limit presence
@@ -2738,12 +2692,21 @@ class QueryInterface {
2738
2692
  const meta = this.schema.tables[table ?? this.table];
2739
2693
  if (!meta)
2740
2694
  return;
2741
- for (const [relName, relSpec] of sortedEntries(withClause)) {
2695
+ for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
2742
2696
  const relDef = meta.relations[relName];
2743
2697
  if (!relDef)
2744
2698
  continue;
2745
2699
  this.collectRelationSubqueryParams(relDef, relSpec, params, table ?? this.table);
2746
2700
  }
2701
+ // `_count` global-filter params — mirror buildSelectWithRelations, which
2702
+ // appends the count subqueries (and any target-filter params) AFTER every
2703
+ // relation subquery, in resolveCountRelations order.
2704
+ const countSpec = withClause._count;
2705
+ if (countSpec !== undefined) {
2706
+ for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
2707
+ this.collectRelationCountParams(rel, params);
2708
+ }
2709
+ }
2747
2710
  }
2748
2711
  /**
2749
2712
  * Collect params from a single relation subquery. Mirrors buildRelationSubquery.
@@ -2761,11 +2724,12 @@ class QueryInterface {
2761
2724
  if (spec.where) {
2762
2725
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2763
2726
  }
2727
+ this.collectTargetGlobalFilterAlias(targetTable, params);
2764
2728
  if (spec.limit !== undefined && !this.dialect.inlineLimitOffset) {
2765
2729
  params.push(Number(spec.limit));
2766
2730
  }
2767
2731
  if (spec.with) {
2768
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2732
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
2769
2733
  const nestedRelDef = targetMeta.relations[nestedRelName];
2770
2734
  if (!nestedRelDef)
2771
2735
  continue;
@@ -2779,7 +2743,7 @@ class QueryInterface {
2779
2743
  const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2780
2744
  // Non-wrapped path: nested relations BEFORE where/limit
2781
2745
  if (!willWrap && spec.with) {
2782
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2746
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
2783
2747
  const nestedRelDef = targetMeta.relations[nestedRelName];
2784
2748
  if (!nestedRelDef)
2785
2749
  continue;
@@ -2790,6 +2754,9 @@ class QueryInterface {
2790
2754
  if (spec.where) {
2791
2755
  this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2792
2756
  }
2757
+ // Global filter on the target — mirrors targetGlobalFilterAlias in
2758
+ // buildRelationSubquery (pushed after spec.where, before limit).
2759
+ this.collectTargetGlobalFilterAlias(targetTable, params);
2793
2760
  // limit param — only hasMany parameterizes its limit (mirrors
2794
2761
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2795
2762
  // pushing one here would orphan a param and desync the collect path.
@@ -2799,7 +2766,7 @@ class QueryInterface {
2799
2766
  }
2800
2767
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
2801
2768
  if (willWrap && spec.with) {
2802
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
2769
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
2803
2770
  const nestedRelDef = targetMeta.relations[nestedRelName];
2804
2771
  if (!nestedRelDef)
2805
2772
  continue;
@@ -2821,7 +2788,7 @@ class QueryInterface {
2821
2788
  !(v instanceof Date) &&
2822
2789
  !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
2823
2790
  const keys = Object.keys(v);
2824
- if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
2791
+ if (keys.length === 1 && filters_js_1.UPDATE_OPERATOR_KEYS.has(keys[0])) {
2825
2792
  parts.push(`${k}:${keys[0]}`);
2826
2793
  continue;
2827
2794
  }
@@ -2843,7 +2810,7 @@ class QueryInterface {
2843
2810
  !(typeof Buffer !== 'undefined' && Buffer.isBuffer(v))) {
2844
2811
  const obj = v;
2845
2812
  const keys = Object.keys(obj);
2846
- if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
2813
+ if (keys.length === 1 && filters_js_1.UPDATE_OPERATOR_KEYS.has(keys[0])) {
2847
2814
  params.push(obj[keys[0]]);
2848
2815
  continue;
2849
2816
  }
@@ -2859,14 +2826,148 @@ class QueryInterface {
2859
2826
  return { sql: '', params: [] };
2860
2827
  return { sql: ` WHERE ${clause}`, params };
2861
2828
  }
2829
+ // -------------------------------------------------------------------------
2830
+ // Global filters (soft-delete / multi-tenancy — WS-G)
2831
+ //
2832
+ // A configured global filter for a table is AND-merged into the compiled WHERE
2833
+ // of every query on that table (via {@link mergeGlobalFilter}, so the merge is
2834
+ // captured in the where fingerprint/collect for free) and into every relation
2835
+ // subquery targeting it (rendered at build time against the subquery's alias/
2836
+ // table by the `*GlobalFilterAlias`/`*GlobalFilterExists` helpers, with the
2837
+ // shape folded into the SQL-cache key via {@link globalFilterCacheSegment}).
2838
+ // Function filters are evaluated per resolve — at query-build time — enabling
2839
+ // per-request tenancy via a closure. They must return a STABLE shape (same
2840
+ // keys/operators); only values may vary between calls.
2841
+ // -------------------------------------------------------------------------
2862
2842
  /**
2863
- * Refuse mutations with an empty predicate unless explicitly opted in.
2864
- *
2865
- * An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
2866
- * mutation with no filter — a common footgun when a caller's filter
2867
- * value accidentally resolves to `undefined`. This guard throws
2868
- * `ValidationError` in that case unless `allowFullTableScan: true`.
2843
+ * Resolve the configured global filter for `table`, evaluating a function
2844
+ * filter, honoring the active query's `skipGlobalFilters`. Returns `null` when
2845
+ * no filter applies, the query opted out, or the filter is empty.
2846
+ */
2847
+ resolveGlobalFilter(table, skip = this.currentSkip) {
2848
+ const filters = this.globalFilters;
2849
+ if (!filters)
2850
+ return null;
2851
+ if (skip === true)
2852
+ return null;
2853
+ if (Array.isArray(skip) && skip.includes(table))
2854
+ return null;
2855
+ const raw = filters[table];
2856
+ if (raw === undefined)
2857
+ return null;
2858
+ const resolved = typeof raw === 'function' ? raw() : raw;
2859
+ if (resolved === null || resolved === undefined)
2860
+ return null;
2861
+ const obj = resolved;
2862
+ // An all-undefined filter (e.g. `{ tenantId: undefined }`) contributes
2863
+ // nothing — treat it as absent so it never emits a dangling clause.
2864
+ if (Object.keys(obj).every((k) => obj[k] === undefined))
2865
+ return null;
2866
+ return obj;
2867
+ }
2868
+ /**
2869
+ * AND-merge this table's resolved global filter into a user `where`. Either
2870
+ * side may be absent. When no filter applies the user where is returned by
2871
+ * reference, so fingerprints/SQL stay byte-identical to the pre-0.28 path.
2869
2872
  */
2873
+ mergeGlobalFilter(userWhere) {
2874
+ const gf = this.resolveGlobalFilter(this.table);
2875
+ if (!gf)
2876
+ return userWhere;
2877
+ if (userWhere === undefined)
2878
+ return gf;
2879
+ return { AND: [userWhere, gf] };
2880
+ }
2881
+ /**
2882
+ * SQL clause for `targetTable`'s global filter rendered against `alias`
2883
+ * (relation subqueries, `_count`, relation `orderBy`). Pushes its params to
2884
+ * `params`; returns `''` when no filter applies. Mirror:
2885
+ * {@link collectTargetGlobalFilterAlias}.
2886
+ */
2887
+ targetGlobalFilterAlias(targetTable, alias, params) {
2888
+ const gf = this.resolveGlobalFilter(targetTable);
2889
+ if (!gf)
2890
+ return '';
2891
+ const meta = this.schema.tables[targetTable];
2892
+ if (!meta)
2893
+ return '';
2894
+ return this.buildAliasWhere(targetTable, meta, alias, gf, params) ?? '';
2895
+ }
2896
+ /** Param-collect mirror of {@link targetGlobalFilterAlias}. */
2897
+ collectTargetGlobalFilterAlias(targetTable, params) {
2898
+ const gf = this.resolveGlobalFilter(targetTable);
2899
+ if (!gf)
2900
+ return;
2901
+ const meta = this.schema.tables[targetTable];
2902
+ if (!meta)
2903
+ return;
2904
+ this.collectAliasWhereParams(targetTable, meta, gf, params);
2905
+ }
2906
+ /**
2907
+ * SQL clause for `targetTable`'s global filter rendered against the bare
2908
+ * (unaliased) table name — the form used inside relation-filter `EXISTS`
2909
+ * subqueries. Pushes its params; `''` when none. Mirror:
2910
+ * {@link collectTargetGlobalFilterExists}.
2911
+ */
2912
+ targetGlobalFilterExists(targetTable, params) {
2913
+ const gf = this.resolveGlobalFilter(targetTable);
2914
+ if (!gf)
2915
+ return '';
2916
+ return this.buildSubWhereForRelation(targetTable, gf, params) ?? '';
2917
+ }
2918
+ /** Param-collect mirror of {@link targetGlobalFilterExists}. */
2919
+ collectTargetGlobalFilterExists(targetTable, params) {
2920
+ const gf = this.resolveGlobalFilter(targetTable);
2921
+ if (!gf)
2922
+ return;
2923
+ this.collectRelFilterParams(targetTable, gf, params);
2924
+ }
2925
+ /**
2926
+ * Value-invariant SQL-cache-key segment for the active global-filter
2927
+ * environment. Relation-subquery / relation-filter / `_count` / relation-
2928
+ * `orderBy` global filters are rendered at build time but their SHAPE is not
2929
+ * otherwise in the where/with fingerprint, so this segment guards the cache:
2930
+ * two different filter shapes never collide on one cached SQL text, while two
2931
+ * function-filter results of the SAME shape (differing only in values) share
2932
+ * the entry and bind their own params. Empty (`''`) when no filter applies, so
2933
+ * cache keys stay byte-identical when the feature is unused.
2934
+ */
2935
+ globalFilterCacheSegment() {
2936
+ const filters = this.globalFilters;
2937
+ if (!filters)
2938
+ return '';
2939
+ const parts = [];
2940
+ for (const table of Object.keys(filters).sort()) {
2941
+ // Function filters for OTHER tables may be request-scoped closures that
2942
+ // throw outside their own context; a query on an unrelated table must not
2943
+ // break on them. A throwing filter can't have contributed SQL to this
2944
+ // query either (merging it would have thrown first), so a constant
2945
+ // marker keeps the key shape-distinct without evaluating it.
2946
+ let gf;
2947
+ try {
2948
+ gf = this.resolveGlobalFilter(table);
2949
+ }
2950
+ catch {
2951
+ parts.push(`${table}:!`);
2952
+ continue;
2953
+ }
2954
+ if (gf)
2955
+ parts.push(`${table}:${this.fingerprintWhere(gf)}`);
2956
+ }
2957
+ return parts.length ? `|gf=${parts.join(';')}` : '';
2958
+ }
2959
+ /**
2960
+ * True when the USER-supplied `where` compiles to no predicate (`{}`,
2961
+ * `{ id: undefined }`, `{ OR: [{ a: undefined }] }`, …). This is the exact
2962
+ * signal the empty-`where` guard needs — the compiled emptiness, NOT the
2963
+ * fingerprint (which is non-empty for an all-undefined `OR`/`AND`). It ignores
2964
+ * any configured global filter, so a global filter never lets an unguarded
2965
+ * mass mutation through.
2966
+ */
2967
+ userPredicateIsEmpty(userWhere) {
2968
+ const throwaway = [];
2969
+ return this.buildWhereClause(userWhere, throwaway) === null;
2970
+ }
2870
2971
  assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
2871
2972
  if (whereSql.length > 0)
2872
2973
  return;
@@ -2882,7 +2983,7 @@ class QueryInterface {
2882
2983
  */
2883
2984
  buildWhereClause(where, params) {
2884
2985
  // Sorted (canonical) order — MUST match fingerprintWhere and collectWhereParams.
2885
- const keys = sortedKeys(where);
2986
+ const keys = (0, filters_js_1.sortedKeys)(where);
2886
2987
  if (keys.length === 0)
2887
2988
  return null;
2888
2989
  const andClauses = [];
@@ -2950,13 +3051,13 @@ class QueryInterface {
2950
3051
  continue;
2951
3052
  }
2952
3053
  // Handle vector distance filter (pgvector): `{ distance: { to, metric, lt } }`
2953
- if (typeof value === 'object' && !Array.isArray(value) && isVectorFilter(value)) {
3054
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isVectorFilter)(value)) {
2954
3055
  const vecClauses = this.buildVectorFilterClauses(key, rawColumn, value, params);
2955
3056
  andClauses.push(...vecClauses);
2956
3057
  continue;
2957
3058
  }
2958
3059
  // Handle JSONB filter operators (for json/jsonb columns)
2959
- if (typeof value === 'object' && !Array.isArray(value) && isJsonFilter(value)) {
3060
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isJsonFilter)(value)) {
2960
3061
  const colType = this.getColumnPgType(rawColumn);
2961
3062
  if (colType === 'json' || colType === 'jsonb') {
2962
3063
  const jsonClauses = this.buildJsonFilterClauses(column, value, params);
@@ -2968,14 +3069,14 @@ class QueryInterface {
2968
3069
  // equality (the previous behaviour) wasted hours of debugging time. Only
2969
3070
  // throw when the operator is unambiguously JSON-specific — `contains` is
2970
3071
  // shared with WhereOperator's LIKE so it must continue to fall through.
2971
- const jsonKey = findJsonUniqueKey(value);
3072
+ const jsonKey = (0, filters_js_1.findJsonUniqueKey)(value);
2972
3073
  if (jsonKey) {
2973
3074
  throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
2974
3075
  `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
2975
3076
  }
2976
3077
  }
2977
3078
  // Handle Array filter operators (for array columns)
2978
- if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
3079
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isArrayFilter)(value)) {
2979
3080
  const colType = this.getColumnPgType(rawColumn);
2980
3081
  if (colType.startsWith('_')) {
2981
3082
  const arrayClauses = this.buildArrayFilterClauses(column, value, params, colType);
@@ -2985,20 +3086,20 @@ class QueryInterface {
2985
3086
  // Strict validation: array operators (`has`, `hasEvery`, ...) on a
2986
3087
  // non-array column always indicate a mistake. None of these keys
2987
3088
  // overlap with other filter shapes so we can throw unconditionally.
2988
- const arrayKey = findArrayUniqueKey(value);
3089
+ const arrayKey = (0, filters_js_1.findArrayUniqueKey)(value);
2989
3090
  if (arrayKey) {
2990
3091
  throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
2991
3092
  `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
2992
3093
  }
2993
3094
  }
2994
3095
  // Handle full-text search filter
2995
- if (typeof value === 'object' && !Array.isArray(value) && isTextSearchFilter(value)) {
3096
+ if (typeof value === 'object' && !Array.isArray(value) && (0, filters_js_1.isTextSearchFilter)(value)) {
2996
3097
  const tsClause = this.buildTextSearchClause(column, value, params);
2997
3098
  andClauses.push(tsClause);
2998
3099
  continue;
2999
3100
  }
3000
3101
  // Handle operator objects
3001
- if (isWhereOperator(value)) {
3102
+ if ((0, filters_js_1.isWhereOperator)(value)) {
3002
3103
  const opClauses = this.buildOperatorClauses(column, value, params);
3003
3104
  andClauses.push(...opClauses);
3004
3105
  continue;
@@ -3038,55 +3139,69 @@ class QueryInterface {
3038
3139
  // belongsTo: parent.fk = child.pk
3039
3140
  correlation = this.dialect.buildCorrelation(qt, relDef.referenceKey, qSelf, relDef.foreignKey);
3040
3141
  }
3041
- // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
3142
+ // The target table's global filter (soft-delete / tenancy) restricts the
3143
+ // DOMAIN of correlated rows in EVERY branch: `some`/`none`/`is`/`isNot`
3144
+ // ignore filtered-out rows, and `every` quantifies over only the surviving
3145
+ // rows ("every NON-deleted related row matches P"). It is ANDed into the
3146
+ // correlation and its params pushed AFTER the per-branch filter — mirrored
3147
+ // exactly in collectWhereParams' relation-filter branch. `qt` is the bare
3148
+ // target table, matching the `FROM ${qt}` here (see targetGlobalFilterExists).
3149
+ const gfAnd = () => {
3150
+ const gf = this.targetGlobalFilterExists(targetTable, params);
3151
+ return gf ? ` AND ${gf}` : '';
3152
+ };
3153
+ // "some": EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
3042
3154
  if (filterObj.some !== undefined) {
3043
3155
  const subWhere = filterObj.some;
3044
3156
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3045
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3046
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3157
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3158
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3047
3159
  }
3048
- // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter)
3160
+ // "none": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND filter AND gf)
3049
3161
  if (filterObj.none !== undefined) {
3050
3162
  const subWhere = filterObj.none;
3051
3163
  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})`);
3164
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3165
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3054
3166
  }
3055
- // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND NOT (filter))
3167
+ // "every": NOT EXISTS (SELECT 1 FROM target WHERE correlation AND gf AND NOT (filter))
3056
3168
  if (filterObj.every !== undefined) {
3057
3169
  const subWhere = filterObj.every;
3058
3170
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3059
3171
  if (filterClause) {
3060
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation} AND NOT (${filterClause}))`);
3172
+ // gf params pushed AFTER filter params (collect mirrors this order), but
3173
+ // placed textually inside the domain so it restricts which rows count.
3174
+ const gf = gfAnd();
3175
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gf} AND NOT (${filterClause}))`);
3061
3176
  }
3062
3177
  else {
3063
- // "every" with empty filter = true (all match trivially)
3178
+ // "every" with empty filter = true (all match trivially) — gf irrelevant.
3064
3179
  }
3065
3180
  }
3066
3181
  // "is": EXISTS — for to-one relations (same SQL as "some").
3067
3182
  // `is: null` = "no related row" (Prisma semantics) → NOT EXISTS.
3068
3183
  if (filterObj.is !== undefined) {
3069
3184
  if (filterObj.is === null) {
3070
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
3185
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
3071
3186
  }
3072
3187
  else {
3073
3188
  const subWhere = filterObj.is;
3074
3189
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3075
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3076
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3190
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3191
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3077
3192
  }
3078
3193
  }
3079
3194
  // "isNot": NOT EXISTS — for to-one relations (same SQL as "none").
3080
3195
  // `isNot: null` = "a related row exists" → EXISTS.
3081
3196
  if (filterObj.isNot !== undefined) {
3082
3197
  if (filterObj.isNot === null) {
3083
- clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation})`);
3198
+ clauses.push(`EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${gfAnd()})`);
3084
3199
  }
3085
3200
  else {
3086
3201
  const subWhere = filterObj.isNot;
3087
3202
  const filterClause = this.buildSubWhereForRelation(targetTable, subWhere, params);
3088
- const fullWhere = filterClause ? `${correlation} AND ${filterClause}` : correlation;
3089
- clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${fullWhere})`);
3203
+ const filterAnd = filterClause ? ` AND ${filterClause}` : '';
3204
+ clauses.push(`NOT EXISTS (SELECT 1 FROM ${qt} WHERE ${correlation}${filterAnd}${gfAnd()})`);
3090
3205
  }
3091
3206
  }
3092
3207
  return clauses.length > 0 ? clauses.join(' AND ') : null;
@@ -3102,7 +3217,7 @@ class QueryInterface {
3102
3217
  const qt = this.q(targetTable);
3103
3218
  const conditions = [];
3104
3219
  // Sorted (canonical) order — MUST match fingerprintRelFilter and collectRelFilterParams.
3105
- for (const field of sortedKeys(subWhere)) {
3220
+ for (const field of (0, filters_js_1.sortedKeys)(subWhere)) {
3106
3221
  const value = subWhere[field];
3107
3222
  if (value === undefined)
3108
3223
  continue;
@@ -3149,7 +3264,7 @@ class QueryInterface {
3149
3264
  conditions.push(`${qCol} IS NULL`);
3150
3265
  continue;
3151
3266
  }
3152
- if (isWhereOperator(value)) {
3267
+ if ((0, filters_js_1.isWhereOperator)(value)) {
3153
3268
  const opClauses = this.buildOperatorClauses(qCol, value, params);
3154
3269
  conditions.push(...opClauses);
3155
3270
  continue;
@@ -3177,7 +3292,7 @@ class QueryInterface {
3177
3292
  * columns (object equality).
3178
3293
  */
3179
3294
  assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
3180
- if (!isUnmatchedPlainObject(value))
3295
+ if (!(0, filters_js_1.isUnmatchedPlainObject)(value))
3181
3296
  return;
3182
3297
  if (columnPgType === 'json' || columnPgType === 'jsonb')
3183
3298
  return;
@@ -3202,7 +3317,7 @@ class QueryInterface {
3202
3317
  buildAliasWhere(targetTable, targetMeta, alias, where, params) {
3203
3318
  const clauses = [];
3204
3319
  // Sorted (canonical) order — MUST match fingerprintAliasWhere and collectAliasWhereParams.
3205
- for (const key of sortedKeys(where)) {
3320
+ for (const key of (0, filters_js_1.sortedKeys)(where)) {
3206
3321
  const value = where[key];
3207
3322
  if (value === undefined)
3208
3323
  continue;
@@ -3245,7 +3360,7 @@ class QueryInterface {
3245
3360
  clauses.push(`${qCol} IS NULL`);
3246
3361
  continue;
3247
3362
  }
3248
- if (isWhereOperator(value)) {
3363
+ if ((0, filters_js_1.isWhereOperator)(value)) {
3249
3364
  clauses.push(...this.buildOperatorClauses(qCol, value, params));
3250
3365
  continue;
3251
3366
  }
@@ -3258,7 +3373,7 @@ class QueryInterface {
3258
3373
  /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
3259
3374
  collectAliasWhereParams(targetTable, targetMeta, where, params) {
3260
3375
  // Sorted (canonical) order — MUST match fingerprintAliasWhere and buildAliasWhere.
3261
- for (const key of sortedKeys(where)) {
3376
+ for (const key of (0, filters_js_1.sortedKeys)(where)) {
3262
3377
  const value = where[key];
3263
3378
  if (value === undefined)
3264
3379
  continue;
@@ -3281,22 +3396,14 @@ class QueryInterface {
3281
3396
  if (aliasRel && typeof value === 'object' && !Array.isArray(value)) {
3282
3397
  const norm = this.normalizeRelationFilter(aliasRel, value);
3283
3398
  if ('some' in norm || 'every' in norm || 'none' in norm || 'is' in norm || 'isNot' in norm) {
3284
- // Same order as buildRelationFilter pushes params: some, none, every, is, isNot.
3285
- if (norm.some != null)
3286
- this.collectRelFilterParams(aliasRel.to, norm.some, params);
3287
- if (norm.none != null)
3288
- this.collectRelFilterParams(aliasRel.to, norm.none, params);
3289
- if (norm.every != null)
3290
- this.collectRelFilterParams(aliasRel.to, norm.every, params);
3291
- if (norm.is != null)
3292
- this.collectRelFilterParams(aliasRel.to, norm.is, params);
3293
- if (norm.isNot != null)
3294
- this.collectRelFilterParams(aliasRel.to, norm.isNot, params);
3399
+ // Mirrors buildRelationFilter (somenoneeveryis→isNot, each: sub-where
3400
+ // params then target global-filter params).
3401
+ this.collectRelationFilterParams(aliasRel, norm, params);
3295
3402
  continue;
3296
3403
  }
3297
3404
  }
3298
3405
  const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
3299
- if (isWhereOperator(value)) {
3406
+ if ((0, filters_js_1.isWhereOperator)(value)) {
3300
3407
  this.collectOperatorParams(col, value, params);
3301
3408
  continue;
3302
3409
  }
@@ -3350,11 +3457,11 @@ class QueryInterface {
3350
3457
  continue;
3351
3458
  }
3352
3459
  }
3353
- if (isWhereOperator(value)) {
3354
- parts.push(`${key}:${fingerprintOperatorShape(value)}`);
3460
+ if ((0, filters_js_1.isWhereOperator)(value)) {
3461
+ parts.push(`${key}:${(0, filters_js_1.fingerprintOperatorShape)(value)}`);
3355
3462
  continue;
3356
3463
  }
3357
- if (isUnmatchedPlainObject(value)) {
3464
+ if ((0, filters_js_1.isUnmatchedPlainObject)(value)) {
3358
3465
  parts.push(`${key}:obj(${Object.keys(value)
3359
3466
  .sort()
3360
3467
  .join(',')})`);
@@ -3375,7 +3482,7 @@ class QueryInterface {
3375
3482
  clauses.push(`${column} IS NULL`);
3376
3483
  }
3377
3484
  else {
3378
- assertBindableEqualsOperand(op.equals, column);
3485
+ (0, filters_js_1.assertBindableEqualsOperand)(op.equals, column);
3379
3486
  params.push(op.equals);
3380
3487
  clauses.push(`${column} = ${this.p(params.length)}`);
3381
3488
  }
@@ -3438,10 +3545,37 @@ class QueryInterface {
3438
3545
  * findMany path). When `params` is omitted (groupBy / relation path) a vector
3439
3546
  * ordering throws — KNN ordering is only supported at the top level.
3440
3547
  */
3548
+ /**
3549
+ * Value-shape fingerprint for a single orderBy entry, so two queries whose
3550
+ * ORDER BY differs only in nulls placement, vector metric, or relation-count
3551
+ * vs relation-column never collide on one cached SQL string. Captures the
3552
+ * SQL-shaping bits (direction, nulls, metric, relation keys) — never values.
3553
+ */
3554
+ orderByEntryFingerprint(d) {
3555
+ // Vector KNN ordering changes the emitted operator by metric and adds a
3556
+ // `::vector` param, so metric + direction must be part of the cache key.
3557
+ if ((0, filters_js_1.isVectorOrderBy)(d)) {
3558
+ return `vec(${d.distance.metric},${d.distance.direction ?? 'asc'})`;
3559
+ }
3560
+ if ((0, filters_js_1.isOrderBySpec)(d))
3561
+ return `spec(${d.sort},${d.nulls ?? ''})`;
3562
+ if (d && typeof d === 'object') {
3563
+ // Relation ordering (`{ _count: 'desc' }` or `{ name: 'asc' }`).
3564
+ return `rel(${Object.entries(d)
3565
+ .map(([k, v]) => `${k}=${this.orderByEntryFingerprint(v)}`)
3566
+ .sort()
3567
+ .join(',')})`;
3568
+ }
3569
+ return String(d);
3570
+ }
3441
3571
  buildOrderBy(orderBy, params) {
3442
- // Dev-only: validate that orderBy fields exist in the table schema
3572
+ // Dev-only: validate that orderBy fields exist in the table schema. Relation
3573
+ // orderBy keys (object values that are neither a vector nor an OrderBySpec)
3574
+ // are validated in the relation branch below, so skip them here.
3443
3575
  if (process.env.NODE_ENV !== 'production') {
3444
- for (const key of Object.keys(orderBy)) {
3576
+ for (const [key, value] of Object.entries(orderBy)) {
3577
+ if (this.isRelationOrderByValue(value) && this.tableMeta.relations[key])
3578
+ continue;
3445
3579
  const snakeKey = (0, schema_js_1.camelToSnake)(key);
3446
3580
  if (!this.tableMeta.columns.some((c) => c.name === snakeKey) && !(key in this.tableMeta.columnMap)) {
3447
3581
  console.warn(`[turbine] Unknown orderBy field "${key}" for table "${this.tableMeta.name}". ` +
@@ -3450,28 +3584,217 @@ class QueryInterface {
3450
3584
  }
3451
3585
  }
3452
3586
  const meta = this.schema.tables[this.table];
3587
+ let relOrdCounter = 0;
3453
3588
  return Object.entries(orderBy)
3454
- .map(([key, dir]) => {
3455
- if (meta && !(key in meta.columnMap)) {
3456
- throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3457
- `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3458
- }
3589
+ .map(([key, value]) => {
3459
3590
  // Vector KNN ordering: { distance: { to, metric, direction? } }
3460
- if (isVectorOrderBy(dir)) {
3591
+ if ((0, filters_js_1.isVectorOrderBy)(value)) {
3592
+ if (meta && !(key in meta.columnMap)) {
3593
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3594
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3595
+ }
3461
3596
  if (!params) {
3462
3597
  throw new errors_js_1.ValidationError(`[turbine] Vector distance ordering on "${key}" is only supported in a top-level findMany orderBy.`);
3463
3598
  }
3464
3599
  const rawColumn = this.toColumn(key);
3465
- const operator = this.vectorOperator(key, rawColumn, dir.distance.metric);
3466
- const placeholder = this.pushVectorParam(key, rawColumn, dir.distance.to, params);
3467
- const safeDir = dir.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3600
+ const operator = this.vectorOperator(key, rawColumn, value.distance.metric);
3601
+ const placeholder = this.pushVectorParam(key, rawColumn, value.distance.to, params);
3602
+ const safeDir = value.distance.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3468
3603
  return `${this.q(rawColumn)} ${operator} ${placeholder} ${safeDir}`;
3469
3604
  }
3470
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3471
- return `${this.toSqlColumn(key)} ${safeDir}`;
3605
+ // Relation ordering: an object value that is not a vector or OrderBySpec,
3606
+ // keyed by a relation name (`{ posts: { _count: 'desc' } }` / `{ author:
3607
+ // { name: 'asc' } }`).
3608
+ if (this.isRelationOrderByValue(value)) {
3609
+ return this.buildRelationOrderBy(key, value, `ord${relOrdCounter++}`, params);
3610
+ }
3611
+ // Scalar column ordering — a plain direction or an OrderBySpec (nulls).
3612
+ if (meta && !(key in meta.columnMap)) {
3613
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
3614
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
3615
+ }
3616
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(value);
3617
+ return `${this.toSqlColumn(key)} ${dir}${this.nullsSuffix(nulls)}`;
3618
+ })
3619
+ .join(', ');
3620
+ }
3621
+ /**
3622
+ * True when an orderBy value is a relation-ordering object: a plain object
3623
+ * that is neither a vector KNN ordering nor an {@link OrderBySpec}. Its key
3624
+ * in the orderBy clause is a relation name.
3625
+ */
3626
+ isRelationOrderByValue(value) {
3627
+ return (typeof value === 'object' &&
3628
+ value !== null &&
3629
+ !Array.isArray(value) &&
3630
+ !(0, filters_js_1.isVectorOrderBy)(value) &&
3631
+ !(0, filters_js_1.isOrderBySpec)(value));
3632
+ }
3633
+ /**
3634
+ * Render the ` NULLS FIRST` / ` NULLS LAST` suffix for a column ordering.
3635
+ * Only PostgreSQL and SQLite support the `NULLS FIRST/LAST` grammar — on any
3636
+ * other engine a caller asking for explicit nulls placement gets a clear
3637
+ * {@link UnsupportedFeatureError} (E017) instead of broken SQL.
3638
+ */
3639
+ nullsSuffix(nulls) {
3640
+ if (!nulls)
3641
+ return '';
3642
+ if (this.dialect.name !== 'postgresql' && this.dialect.name !== 'sqlite') {
3643
+ throw new errors_js_1.UnsupportedFeatureError('NULLS FIRST/LAST ordering', this.dialect.name, 'Explicit nulls placement in orderBy is only available on PostgreSQL and SQLite.');
3644
+ }
3645
+ return nulls === 'first' ? ' NULLS FIRST' : ' NULLS LAST';
3646
+ }
3647
+ /**
3648
+ * Compile a relation ordering term. For a to-many relation the only allowed
3649
+ * key is `_count`, which becomes a correlated `COUNT(*)` subquery. For a
3650
+ * to-one relation each entry names a target column and becomes a correlated
3651
+ * scalar subquery (supporting {@link OrderBySpec} nulls placement).
3652
+ *
3653
+ * Validation: relation must exist (E005); to-many only allows `_count`, and
3654
+ * to-one only allows real target columns (E003).
3655
+ */
3656
+ buildRelationOrderBy(relName, value, alias, params) {
3657
+ const relDef = this.tableMeta.relations[relName];
3658
+ if (!relDef) {
3659
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${this.table}". ` +
3660
+ `Available: ${Object.keys(this.tableMeta.relations).join(', ')}`);
3661
+ }
3662
+ // To-many: only `_count` is meaningful → correlated COUNT(*) subquery.
3663
+ if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
3664
+ const keys = Object.keys(value);
3665
+ if (keys.length !== 1 || keys[0] !== '_count') {
3666
+ throw new errors_js_1.ValidationError(`[turbine] orderBy on to-many relation "${relName}" only supports "_count" ` +
3667
+ `(got: ${keys.join(', ') || '(empty)'}).`);
3668
+ }
3669
+ const { dir } = (0, filters_js_1.normalizeOrderBy)(value._count);
3670
+ return `${this.buildRelationCountExpr(relDef, this.table, alias, params)} ${dir}`;
3671
+ }
3672
+ // To-one: each entry orders by a correlated scalar subquery on a target column.
3673
+ const targetMeta = this.schema.tables[relDef.to];
3674
+ if (!targetMeta)
3675
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${relDef.to}"`);
3676
+ const qTarget = this.q(relDef.to);
3677
+ const qParent = this.q(this.table);
3678
+ // belongsTo: alias.referenceKey = parent.foreignKey; hasOne: reversed.
3679
+ const correlation = relDef.type === 'belongsTo'
3680
+ ? this.dialect.buildCorrelation(alias, relDef.referenceKey, qParent, relDef.foreignKey)
3681
+ : this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3682
+ const entries = Object.entries(value);
3683
+ if (entries.length === 0) {
3684
+ throw new errors_js_1.ValidationError(`[turbine] orderBy on to-one relation "${relName}" needs at least one target column.`);
3685
+ }
3686
+ return entries
3687
+ .map(([col, dirValue]) => {
3688
+ const snakeCol = (0, schema_js_1.camelToSnake)(col);
3689
+ if (!targetMeta.allColumns.includes(snakeCol)) {
3690
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${col}" in orderBy on relation "${relName}" (table "${relDef.to}").`);
3691
+ }
3692
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
3693
+ // Target's global filter applies here too — otherwise ordering keys off
3694
+ // a soft-deleted / other-tenant related row's value (matches the with
3695
+ // subquery semantics for belongsTo/hasOne).
3696
+ let where = correlation;
3697
+ if (params) {
3698
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
3699
+ if (gf)
3700
+ where += ` AND ${gf}`;
3701
+ }
3702
+ return `(SELECT ${alias}.${this.q(snakeCol)} FROM ${qTarget} ${alias} WHERE ${where}${this.limitOneClause()}) ${dir}${this.nullsSuffix(nulls)}`;
3472
3703
  })
3473
3704
  .join(', ');
3474
3705
  }
3706
+ /**
3707
+ * Build a correlated `(SELECT COUNT(*) …)` scalar subquery for a to-many
3708
+ * relation, correlated to `parentRef`. hasMany counts child rows via the FK;
3709
+ * manyToMany counts junction rows via the source key. Shared by the `_count`
3710
+ * `with` key and to-many relation orderBy.
3711
+ *
3712
+ * When `params` is supplied and the target has a global filter, it is
3713
+ * AND-merged so the count only sees surviving rows (a soft-deleted child is
3714
+ * not counted): hasMany filters the counted rows directly; manyToMany adds an
3715
+ * `EXISTS` on the target through the junction (the junction rows themselves
3716
+ * carry no filter). Params are mirrored by {@link collectRelationCountParams}.
3717
+ */
3718
+ buildRelationCountExpr(relDef, parentRef, alias, params) {
3719
+ const qParent = this.q(parentRef);
3720
+ const count = this.castAgg('COUNT(*)', 'int');
3721
+ if (relDef.type === 'manyToMany') {
3722
+ if (!relDef.through) {
3723
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relDef.name}" is missing its \`through\` junction.`);
3724
+ }
3725
+ const qJ = this.q(relDef.through.table);
3726
+ const jalias = `${alias}j`;
3727
+ const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.sourceKey);
3728
+ const refKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.referenceKey);
3729
+ let where = sourceKeys
3730
+ .map((jc, i) => `${jalias}.${this.q(jc)} = ${qParent}.${this.q(refKeys[i])}`)
3731
+ .join(' AND ');
3732
+ if (params) {
3733
+ const targetExists = this.manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params);
3734
+ if (targetExists)
3735
+ where += ` AND ${targetExists}`;
3736
+ }
3737
+ return `(SELECT ${count} FROM ${qJ} ${jalias} WHERE ${where})`;
3738
+ }
3739
+ // hasMany: child FK correlates to the parent reference key.
3740
+ const qTarget = this.q(relDef.to);
3741
+ let where = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3742
+ if (params) {
3743
+ const gf = this.targetGlobalFilterAlias(relDef.to, alias, params);
3744
+ if (gf)
3745
+ where += ` AND ${gf}`;
3746
+ }
3747
+ return `(SELECT ${count} FROM ${qTarget} ${alias} WHERE ${where})`;
3748
+ }
3749
+ /**
3750
+ * `EXISTS (SELECT 1 FROM <target> <talias> WHERE <join> AND <gf>)` restricting
3751
+ * a manyToMany `_count` to targets that survive their global filter. `''` when
3752
+ * the target has no filter. Pushes gf params; mirror:
3753
+ * {@link collectManyToManyTargetGlobalFilter}.
3754
+ */
3755
+ manyToManyTargetGlobalFilterExists(relDef, alias, jalias, params) {
3756
+ const gf = this.resolveGlobalFilter(relDef.to);
3757
+ if (!gf || !relDef.through)
3758
+ return '';
3759
+ const tMeta = this.schema.tables[relDef.to];
3760
+ if (!tMeta || tMeta.primaryKey.length === 0)
3761
+ return '';
3762
+ const talias = `${alias}t`;
3763
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
3764
+ const pk = tMeta.primaryKey;
3765
+ if (targetKeys.length !== pk.length)
3766
+ return '';
3767
+ const join = targetKeys.map((jc, i) => `${talias}.${this.q(pk[i])} = ${jalias}.${this.q(jc)}`).join(' AND ');
3768
+ const gfClause = this.buildAliasWhere(relDef.to, tMeta, talias, gf, params);
3769
+ const gfAnd = gfClause ? ` AND ${gfClause}` : '';
3770
+ return `EXISTS (SELECT 1 FROM ${this.q(relDef.to)} ${talias} WHERE ${join}${gfAnd})`;
3771
+ }
3772
+ /** Param-collect mirror of {@link manyToManyTargetGlobalFilterExists}. */
3773
+ collectManyToManyTargetGlobalFilter(relDef, params) {
3774
+ const gf = this.resolveGlobalFilter(relDef.to);
3775
+ if (!gf || !relDef.through)
3776
+ return;
3777
+ const tMeta = this.schema.tables[relDef.to];
3778
+ if (!tMeta || tMeta.primaryKey.length === 0)
3779
+ return;
3780
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(relDef.through.targetKey);
3781
+ if (targetKeys.length !== tMeta.primaryKey.length)
3782
+ return;
3783
+ this.collectAliasWhereParams(relDef.to, tMeta, gf, params);
3784
+ }
3785
+ /**
3786
+ * Param-collect mirror of {@link buildRelationCountExpr}'s global-filter
3787
+ * params (hasMany direct filter, or manyToMany EXISTS-on-target). Only pushes
3788
+ * when a filter applies — no-op otherwise.
3789
+ */
3790
+ collectRelationCountParams(relDef, params) {
3791
+ if (relDef.type === 'manyToMany') {
3792
+ this.collectManyToManyTargetGlobalFilter(relDef, params);
3793
+ }
3794
+ else {
3795
+ this.collectTargetGlobalFilterAlias(relDef.to, params);
3796
+ }
3797
+ }
3475
3798
  // -------------------------------------------------------------------------
3476
3799
  // pgvector helpers (similarity search)
3477
3800
  // -------------------------------------------------------------------------
@@ -3490,10 +3813,10 @@ class QueryInterface {
3490
3813
  throw new errors_js_1.ValidationError(`[turbine] Column "${field}" on table "${this.table}" is not a vector column ` +
3491
3814
  `(actual type: ${colType}); cannot apply a vector distance operation.`);
3492
3815
  }
3493
- const op = VECTOR_METRIC_OPERATORS[metric];
3816
+ const op = filters_js_1.VECTOR_METRIC_OPERATORS[metric];
3494
3817
  if (!op) {
3495
3818
  throw new errors_js_1.ValidationError(`[turbine] Unknown vector metric "${metric}" for column "${field}". ` +
3496
- `Valid metrics: ${Object.keys(VECTOR_METRIC_OPERATORS).join(', ')}.`);
3819
+ `Valid metrics: ${Object.keys(filters_js_1.VECTOR_METRIC_OPERATORS).join(', ')}.`);
3497
3820
  }
3498
3821
  return op;
3499
3822
  }
@@ -3600,6 +3923,19 @@ class QueryInterface {
3600
3923
  const meta = this.schema.tables[table];
3601
3924
  if (!meta)
3602
3925
  return parsed;
3926
+ // Assemble reserved `_count__<rel>` scalar columns into a `_count` object.
3927
+ // parseRow copies these unknown columns through under their raw key.
3928
+ let countObj;
3929
+ for (const key of Object.keys(parsed)) {
3930
+ if (key.startsWith('_count__')) {
3931
+ if (countObj === undefined)
3932
+ countObj = {};
3933
+ countObj[key.slice('_count__'.length)] = Number(parsed[key]);
3934
+ delete parsed[key];
3935
+ }
3936
+ }
3937
+ if (countObj)
3938
+ parsed._count = countObj;
3603
3939
  for (const [relName, relDef] of Object.entries(meta.relations)) {
3604
3940
  const rawValue = row[relName];
3605
3941
  if (rawValue === undefined)
@@ -3709,7 +4045,7 @@ class QueryInterface {
3709
4045
  if (!meta)
3710
4046
  return {};
3711
4047
  const shapes = {};
3712
- for (const [relName, relSpec] of sortedEntries(withClause)) {
4048
+ for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
3713
4049
  const relDef = meta.relations[relName];
3714
4050
  if (!relDef)
3715
4051
  continue; // buildSelectWithRelations already threw for this
@@ -3732,7 +4068,7 @@ class QueryInterface {
3732
4068
  const keys = targetColumns.map((col) => targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col));
3733
4069
  const nested = {};
3734
4070
  if (spec !== true && spec.with) {
3735
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
4071
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
3736
4072
  const nestedRelDef = targetMeta.relations[nestedRelName];
3737
4073
  if (!nestedRelDef)
3738
4074
  continue;
@@ -3865,7 +4201,10 @@ class QueryInterface {
3865
4201
  const baseCols = cols.map((col) => `${qtbl}.${this.q(col)}`).join(', ');
3866
4202
  const relationSelects = [];
3867
4203
  const aliasCounter = { n: 0 };
3868
- for (const [relName, relSpec] of sortedEntries(withClause)) {
4204
+ for (const [relName, relSpec] of (0, filters_js_1.sortedEntries)(withClause)) {
4205
+ // `_count` is a reserved key handled after the relation subqueries.
4206
+ if (relName === '_count')
4207
+ continue;
3869
4208
  const relDef = meta.relations[relName];
3870
4209
  if (!relDef) {
3871
4210
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
@@ -3875,6 +4214,18 @@ class QueryInterface {
3875
4214
  const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
3876
4215
  relationSelects.push(`(${subquery}) AS ${this.q(relName)}`);
3877
4216
  }
4217
+ // Reserved `_count` key → one correlated COUNT(*) scalar subquery per
4218
+ // selected to-many relation, aliased `_count__<rel>`. Appended after the
4219
+ // relation subqueries; the only params they can push come from a global
4220
+ // filter on the counted target (mirrored at the tail of collectWithParams).
4221
+ // Read via a cast so WithClause keeps its narrow `true | WithOptions` type.
4222
+ const countSpec = withClause._count;
4223
+ if (countSpec !== undefined) {
4224
+ for (const rel of (0, batched_loader_js_1.resolveCountRelations)(meta, countSpec)) {
4225
+ const expr = this.buildRelationCountExpr(rel, table, `t${aliasCounter.n++}`, params);
4226
+ relationSelects.push(`${expr} AS ${this.q(`_count__${rel.name}`)}`);
4227
+ }
4228
+ }
3878
4229
  return [baseCols, ...relationSelects].join(', ');
3879
4230
  }
3880
4231
  /**
@@ -4054,7 +4405,7 @@ class QueryInterface {
4054
4405
  }
4055
4406
  // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
4056
4407
  if (!willWrap && spec !== true && spec.with) {
4057
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
4408
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
4058
4409
  const nestedRelDef = targetMeta.relations[nestedRelName];
4059
4410
  if (!nestedRelDef) {
4060
4411
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -4075,13 +4426,13 @@ class QueryInterface {
4075
4426
  let orderClause = '';
4076
4427
  if (relOrderEntries.length > 0) {
4077
4428
  const orders = relOrderEntries
4078
- .map(([k, dir]) => {
4429
+ .map(([k, dirValue]) => {
4079
4430
  const col = (0, schema_js_1.camelToSnake)(k);
4080
4431
  if (!targetMeta.allColumns.includes(col)) {
4081
4432
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4082
4433
  }
4083
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4084
- return `${alias}.${this.q(col)} ${safeDir}`;
4434
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
4435
+ return `${alias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4085
4436
  })
4086
4437
  .join(', ');
4087
4438
  orderClause = ` ORDER BY ${orders}`;
@@ -4107,6 +4458,12 @@ class QueryInterface {
4107
4458
  if (extra)
4108
4459
  whereClause += ` AND ${extra}`;
4109
4460
  }
4461
+ // Global filter on the target table (soft-delete / tenancy) — AND-merged so
4462
+ // a `with` never surfaces filtered-out child rows. Pushed AFTER spec.where,
4463
+ // mirrored by collectRelationSubqueryParams.
4464
+ const gfExtra = this.targetGlobalFilterAlias(targetTable, alias, params);
4465
+ if (gfExtra)
4466
+ whereClause += ` AND ${gfExtra}`;
4110
4467
  // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
4111
4468
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
4112
4469
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
@@ -4132,7 +4489,7 @@ class QueryInterface {
4132
4489
  ]);
4133
4490
  // Build nested relation subqueries referencing innerAlias
4134
4491
  if (spec !== true && spec.with) {
4135
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
4492
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
4136
4493
  const nestedRelDef = targetMeta.relations[nestedRelName];
4137
4494
  if (!nestedRelDef) {
4138
4495
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -4217,13 +4574,13 @@ class QueryInterface {
4217
4574
  let orderClause = '';
4218
4575
  if (relOrderEntries.length > 0) {
4219
4576
  const orders = relOrderEntries
4220
- .map(([k, dir]) => {
4577
+ .map(([k, dirValue]) => {
4221
4578
  const col = (0, schema_js_1.camelToSnake)(k);
4222
4579
  if (!targetMeta.allColumns.includes(col)) {
4223
4580
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
4224
4581
  }
4225
- const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4226
- return `${talias}.${this.q(col)} ${safeDir}`;
4582
+ const { dir, nulls } = (0, filters_js_1.normalizeOrderBy)(dirValue);
4583
+ return `${talias}.${this.q(col)} ${dir}${this.nullsSuffix(nulls)}`;
4227
4584
  })
4228
4585
  .join(', ');
4229
4586
  orderClause = ` ORDER BY ${orders}`;
@@ -4235,6 +4592,11 @@ class QueryInterface {
4235
4592
  if (extra)
4236
4593
  whereClause += ` AND ${extra}`;
4237
4594
  }
4595
+ // Global filter on the target table (mirrors collectRelationSubqueryParams'
4596
+ // m2m branch: after spec.where, before limit).
4597
+ const gfExtra = this.targetGlobalFilterAlias(targetTable, talias, params);
4598
+ if (gfExtra)
4599
+ whereClause += ` AND ${gfExtra}`;
4238
4600
  // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
4239
4601
  let limitClause = '';
4240
4602
  if (spec !== true && spec.limit !== undefined) {
@@ -4253,7 +4615,7 @@ class QueryInterface {
4253
4615
  ]);
4254
4616
  // Nested relations reference the inner alias.
4255
4617
  if (spec !== true && spec.with) {
4256
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
4618
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
4257
4619
  const nestedRelDef = targetMeta.relations[nestedRelName];
4258
4620
  if (!nestedRelDef) {
4259
4621
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -4276,7 +4638,7 @@ class QueryInterface {
4276
4638
  `${talias}.${this.q(col)}`,
4277
4639
  ]);
4278
4640
  if (spec !== true && spec.with) {
4279
- for (const [nestedRelName, nestedSpec] of sortedEntries(spec.with)) {
4641
+ for (const [nestedRelName, nestedSpec] of (0, filters_js_1.sortedEntries)(spec.with)) {
4280
4642
  const nestedRelDef = targetMeta.relations[nestedRelName];
4281
4643
  if (!nestedRelDef) {
4282
4644
  throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
@@ -4402,7 +4764,7 @@ class QueryInterface {
4402
4764
  const placeholder = this.pushVectorParam(field, rawColumn, dist.to, params);
4403
4765
  const distanceExpr = `${this.q(rawColumn)} ${operator} ${placeholder}`;
4404
4766
  const clauses = [];
4405
- for (const [cmp, sqlOp] of Object.entries(VECTOR_DISTANCE_COMPARATORS)) {
4767
+ for (const [cmp, sqlOp] of Object.entries(filters_js_1.VECTOR_DISTANCE_COMPARATORS)) {
4406
4768
  const threshold = dist[cmp];
4407
4769
  if (threshold === undefined)
4408
4770
  continue;
@@ -4424,7 +4786,7 @@ class QueryInterface {
4424
4786
  */
4425
4787
  buildTextSearchClause(column, filter, params) {
4426
4788
  const config = filter.config ?? 'english';
4427
- if (!validateTextSearchConfig(config)) {
4789
+ if (!(0, filters_js_1.validateTextSearchConfig)(config)) {
4428
4790
  throw new errors_js_1.ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);
4429
4791
  }
4430
4792
  params.push(filter.search);