turbine-orm 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +58 -39
  2. package/dist/cjs/cli/destructive.js +233 -18
  3. package/dist/cjs/cli/index.js +56 -12
  4. package/dist/cjs/cli/mcp.js +23 -2
  5. package/dist/cjs/cli/migrate.js +28 -1
  6. package/dist/cjs/cli/pii-tags.js +111 -0
  7. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  8. package/dist/cjs/cli/studio.js +158 -0
  9. package/dist/cjs/cli/ui.js +8 -3
  10. package/dist/cjs/client.js +21 -1
  11. package/dist/cjs/dialect.js +2 -0
  12. package/dist/cjs/index-advisor.js +0 -0
  13. package/dist/cjs/index-stats.js +118 -6
  14. package/dist/cjs/mssql.js +5 -0
  15. package/dist/cjs/mysql.js +5 -0
  16. package/dist/cjs/nested-write.js +248 -18
  17. package/dist/cjs/observe.js +21 -15
  18. package/dist/cjs/powdb.js +3 -0
  19. package/dist/cjs/powql.js +13 -0
  20. package/dist/cjs/prisma-compat.js +9 -0
  21. package/dist/cjs/query/aggregates.js +41 -1
  22. package/dist/cjs/query/batched-loader.js +70 -6
  23. package/dist/cjs/query/builder.js +3 -3
  24. package/dist/cjs/query/relations.js +12 -2
  25. package/dist/cjs/query/where.js +36 -1
  26. package/dist/cjs/sqlite.js +5 -0
  27. package/dist/cli/destructive.d.ts +9 -3
  28. package/dist/cli/destructive.js +233 -18
  29. package/dist/cli/index.js +57 -13
  30. package/dist/cli/mcp.d.ts +7 -0
  31. package/dist/cli/mcp.js +23 -2
  32. package/dist/cli/migrate.d.ts +2 -1
  33. package/dist/cli/migrate.js +28 -1
  34. package/dist/cli/pii-tags.d.ts +53 -0
  35. package/dist/cli/pii-tags.js +106 -0
  36. package/dist/cli/studio-ui.generated.js +1 -1
  37. package/dist/cli/studio.d.ts +42 -0
  38. package/dist/cli/studio.js +157 -0
  39. package/dist/cli/ui.js +8 -3
  40. package/dist/client.js +21 -1
  41. package/dist/dialect.d.ts +19 -0
  42. package/dist/dialect.js +2 -0
  43. package/dist/index-advisor.d.ts +7 -0
  44. package/dist/index-advisor.js +0 -0
  45. package/dist/index-stats.d.ts +52 -1
  46. package/dist/index-stats.js +117 -5
  47. package/dist/mssql.js +5 -0
  48. package/dist/mysql.js +5 -0
  49. package/dist/nested-write.js +249 -19
  50. package/dist/observe.d.ts +0 -1
  51. package/dist/observe.js +21 -15
  52. package/dist/powdb.js +3 -0
  53. package/dist/powql.js +13 -0
  54. package/dist/prisma-compat.js +9 -0
  55. package/dist/query/aggregates.d.ts +18 -0
  56. package/dist/query/aggregates.js +40 -1
  57. package/dist/query/batched-loader.d.ts +29 -1
  58. package/dist/query/batched-loader.js +69 -6
  59. package/dist/query/builder.js +4 -4
  60. package/dist/query/relations.js +12 -2
  61. package/dist/query/types.d.ts +16 -0
  62. package/dist/query/where.d.ts +18 -1
  63. package/dist/query/where.js +34 -1
  64. package/dist/sqlite.js +5 -0
  65. package/package.json +3 -2
package/dist/observe.js CHANGED
@@ -13,6 +13,15 @@
13
13
  * carries SQL text or bound parameter values.
14
14
  */
15
15
  import pg from 'pg';
16
+ import { ValidationError } from './errors.js';
17
+ /**
18
+ * Buffer key: minute bucket + model + action. Joined on a NUL, which cannot
19
+ * appear in a table name or an action, so `model: 'a:b'` and `action: 'c'`
20
+ * can never collapse into the same series as `model: 'a'` / `action: 'b:c'`.
21
+ */
22
+ function bufferKey(bucket, model, action) {
23
+ return `${bucket.getTime()}\u0000${model}\u0000${action}`;
24
+ }
16
25
  function floorToMinute(date) {
17
26
  const d = new Date(date);
18
27
  d.setSeconds(0, 0);
@@ -139,31 +148,28 @@ export class HttpJsonSink {
139
148
  export class ObserveEngine {
140
149
  sink;
141
150
  buffer = new Map();
142
- currentBucket;
143
151
  flushIntervalMs;
144
152
  timer;
145
153
  listener;
146
154
  stopped = false;
147
155
  constructor(config) {
148
156
  if (!config.sink && !config.connectionString) {
149
- throw new Error('ObserveEngine requires either a connectionString or a sink');
157
+ throw new ValidationError('ObserveEngine requires either a connectionString or a sink');
150
158
  }
151
159
  this.sink =
152
160
  config.sink ??
153
161
  new PgMetricsSink({ connectionString: config.connectionString, retentionDays: config.retentionDays ?? 30 });
154
162
  this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
155
- this.currentBucket = floorToMinute(new Date());
156
163
  this.listener = (event) => {
157
164
  if (this.stopped)
158
165
  return;
159
- const nowBucket = floorToMinute(new Date());
160
- if (nowBucket.getTime() !== this.currentBucket.getTime()) {
161
- this.currentBucket = nowBucket;
162
- }
163
- const key = `${event.model}:${event.action}`;
166
+ // Bucket by the event's own timestamp so a late-arriving event is
167
+ // attributed to the minute it happened in, not the minute it was seen.
168
+ const bucket = floorToMinute(event.timestamp);
169
+ const key = bufferKey(bucket, event.model, event.action);
164
170
  let entry = this.buffer.get(key);
165
171
  if (!entry) {
166
- entry = { durations: [], errors: 0 };
172
+ entry = { bucket, model: event.model, action: event.action, durations: [], errors: 0 };
167
173
  this.buffer.set(key, entry);
168
174
  }
169
175
  entry.durations.push(event.duration);
@@ -187,19 +193,19 @@ export class ObserveEngine {
187
193
  async flush() {
188
194
  if (this.buffer.size === 0)
189
195
  return;
190
- const bucket = this.currentBucket;
191
196
  const entries = new Map(this.buffer);
192
197
  this.buffer.clear();
193
198
  const rows = [];
194
- for (const [key, entry] of entries) {
195
- const [model, action] = key.split(':');
199
+ for (const entry of entries.values()) {
196
200
  const sorted = entry.durations.slice().sort((a, b) => a - b);
197
201
  const count = sorted.length;
198
202
  const avg = sorted.reduce((s, v) => s + v, 0) / count;
199
203
  rows.push({
200
- bucket,
201
- model: model ?? '',
202
- action: action ?? '',
204
+ // Each entry carries the minute it accumulated in, so a flush that
205
+ // spans a rollover emits one correctly stamped row per minute.
206
+ bucket: entry.bucket,
207
+ model: entry.model,
208
+ action: entry.action,
203
209
  count,
204
210
  avg,
205
211
  p50: percentile(sorted, 0.5),
package/dist/powdb.js CHANGED
@@ -90,6 +90,9 @@ export const powdbDialect = {
90
90
  resultStrategy: 'returning',
91
91
  supportsReturning: true,
92
92
  supportsVector: false,
93
+ // PowQL has no tsvector/tsquery surface and no array column type.
94
+ supportsFullTextSearch: false,
95
+ supportsArrayColumns: false,
93
96
  supportsListenNotify: false,
94
97
  supportsRLS: false,
95
98
  supportsAdvisoryLock: false,
package/dist/powql.js CHANGED
@@ -38,6 +38,7 @@ import { randomUUID } from 'node:crypto';
38
38
  import { NotFoundError, ReadOnlyError, TimeoutError, UnsupportedFeatureError, ValidationError } from './errors.js';
39
39
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from './nested-write.js';
40
40
  import { ALL_POWDB_CAPABILITIES, coerceNativeValue, isJsonColumn, isStaleFramePowdbError, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, rowToEntity, } from './powdb.js';
41
+ import { assertAggregatePiiOptIn } from './query/aggregates.js';
41
42
  import { expandCompoundUniqueWhere } from './query/compound-unique.js';
42
43
  import { isJsonFilter, isRelationPickOrderBy, orderByEntries } from './query/filters.js';
43
44
  import { escapeLike } from './query/utils.js';
@@ -2246,6 +2247,10 @@ export class PowqlInterface {
2246
2247
  const acc = {};
2247
2248
  for (const field of Object.keys(spec).filter((f) => spec[f])) {
2248
2249
  const powfn = fn.slice(1); // sum/avg/min/max
2250
+ // Same PII contract as the SQL engines: _min/_max return a stored cell.
2251
+ if (fn === '_min' || fn === '_max') {
2252
+ assertAggregatePiiOptIn(this.table, this.meta, field, this.column(field).name, `aggregate ${fn}`, args.includePii);
2253
+ }
2249
2254
  acc[field] = await scalar(`${powfn}(${this.qt}${filter} { ${this.ref(field)} })`);
2250
2255
  }
2251
2256
  result[fn] = acc;
@@ -2297,6 +2302,7 @@ export class PowqlInterface {
2297
2302
  for (const entry of args.by) {
2298
2303
  if (typeof entry === 'string') {
2299
2304
  const col = this.column(entry);
2305
+ assertAggregatePiiOptIn(this.table, this.meta, entry, col.name, 'groupBy `by` key', args.includePii);
2300
2306
  claim(entry, `column "${col.name}"`);
2301
2307
  if (col.name !== entry)
2302
2308
  claim(col.name, `column "${col.name}"`);
@@ -2310,6 +2316,7 @@ export class PowqlInterface {
2310
2316
  if (!isJsonColumn(col)) {
2311
2317
  throw new ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
2312
2318
  }
2319
+ assertAggregatePiiOptIn(this.table, this.meta, entry.field, col.name, 'groupBy JSON `by` key', args.includePii);
2313
2320
  this.assertJsonPath('group key', entry.field, entry.path);
2314
2321
  const pathExpr = this.jsonPathExpr(col, entry.path, params);
2315
2322
  const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
@@ -2359,6 +2366,9 @@ export class PowqlInterface {
2359
2366
  const alias = `agg_${aggN++}`;
2360
2367
  if (target === true) {
2361
2368
  const col = this.column(key);
2369
+ if (fn === '_min' || fn === '_max') {
2370
+ assertAggregatePiiOptIn(this.table, this.meta, key, col.name, `groupBy ${fn}`, args.includePii);
2371
+ }
2362
2372
  claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
2363
2373
  const inner = `.${col.name}`;
2364
2374
  proj.push(`${alias}: ${powfn}(${inner})`);
@@ -2371,6 +2381,9 @@ export class PowqlInterface {
2371
2381
  if (!isJsonColumn(col)) {
2372
2382
  throw new ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
2373
2383
  }
2384
+ if (fn === '_min' || fn === '_max') {
2385
+ assertAggregatePiiOptIn(this.table, this.meta, target.field, col.name, `groupBy ${fn} JSON target`, args.includePii);
2386
+ }
2374
2387
  this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
2375
2388
  const alwaysNumeric = fn === '_sum' || fn === '_avg';
2376
2389
  if (alwaysNumeric && target.type === 'text') {
@@ -417,6 +417,11 @@ function translateReadArgs(ctx, mm, prismaArgs) {
417
417
  t.relationLoadStrategy = prismaArgs.relationLoadStrategy;
418
418
  if (typeof prismaArgs.timeout === 'number')
419
419
  t.timeout = prismaArgs.timeout;
420
+ // Turbine-only passthrough. Prisma has no PII concept, so a compat caller
421
+ // whose schema tags columns needs SOME way to opt in; without this the
422
+ // adapter is a one-way door into redacted reads and refused aggregates.
423
+ if (prismaArgs.includePii !== undefined)
424
+ t.includePii = prismaArgs.includePii;
420
425
  if (ctx.options.stablePkOrder)
421
426
  t.stableRelationOrder = true;
422
427
  translateCursor(ctx, mm, prismaArgs, t);
@@ -651,6 +656,10 @@ function translateAggregateArgs(ctx, mm, args, isGroupBy) {
651
656
  }
652
657
  if (typeof args.timeout === 'number')
653
658
  t.timeout = args.timeout;
659
+ // Turbine-only passthrough: the PII gate on groupBy keys and _min/_max needs
660
+ // an opt-in that Prisma's arg shape has no equivalent for.
661
+ if (args.includePii !== undefined)
662
+ t.includePii = args.includePii;
654
663
  if (isGroupBy) {
655
664
  if (Array.isArray(args.by))
656
665
  t.by = args.by.map((f) => renameField(mm, f));
@@ -8,9 +8,27 @@
8
8
  * stay class-resident, reached through the ctx. See builder.ts for the thin
9
9
  * delegating methods (buildGroupBy / buildAggregate).
10
10
  */
11
+ import type { TableMetadata } from '../schema.js';
11
12
  import type { DeferredQuery } from './deferred.js';
12
13
  import type { AggregateArgs, AggregateResult, GroupByArgs, GroupByOrderBy, HavingClause, HavingFilter } from './types.js';
13
14
  import type { BuilderCtx } from './where.js';
15
+ /**
16
+ * Enforce the PII contract on the aggregate surface. A PII-tagged
17
+ * (`defineSchema` `pii: true`) column is excluded from every default
18
+ * projection, and a value-returning aggregate is a projection by another name:
19
+ * `groupBy({ by: ['email'] })` emits one row per distinct plaintext email, and
20
+ * `_min`/`_max` return a stored cell verbatim. Both therefore REQUIRE the same
21
+ * `includePii: true` opt-in reads use.
22
+ *
23
+ * Deliberately NOT gated: `_count` (a count, never a value), `_sum` / `_avg`
24
+ * (a computed total across many rows, not a stored cell), and `where` /
25
+ * `orderBy` / `having` on PII columns (they return no values at all). Untagged
26
+ * schemas short-circuit on the `pii` lookup, so their SQL is byte-identical.
27
+ *
28
+ * Shared with the PowQL aggregate paths (src/powql.ts) so every engine applies
29
+ * one policy.
30
+ */
31
+ export declare function assertAggregatePiiOptIn(table: string, meta: TableMetadata | undefined, field: string, column: string, usage: string, includePii: boolean | undefined): void;
14
32
  export declare function buildGroupBy<T extends object>(qi: BuilderCtx, args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
15
33
  /**
16
34
  * Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
@@ -12,6 +12,34 @@ import { UnsupportedFeatureError, ValidationError } from '../errors.js';
12
12
  import { snakeToCamel } from '../schema.js';
13
13
  import { isJsonPathOrderBy, isVectorOrderBy, normalizeOrderBy, orderByEntries } from './filters.js';
14
14
  import * as whereMod from './where.js';
15
+ /**
16
+ * Enforce the PII contract on the aggregate surface. A PII-tagged
17
+ * (`defineSchema` `pii: true`) column is excluded from every default
18
+ * projection, and a value-returning aggregate is a projection by another name:
19
+ * `groupBy({ by: ['email'] })` emits one row per distinct plaintext email, and
20
+ * `_min`/`_max` return a stored cell verbatim. Both therefore REQUIRE the same
21
+ * `includePii: true` opt-in reads use.
22
+ *
23
+ * Deliberately NOT gated: `_count` (a count, never a value), `_sum` / `_avg`
24
+ * (a computed total across many rows, not a stored cell), and `where` /
25
+ * `orderBy` / `having` on PII columns (they return no values at all). Untagged
26
+ * schemas short-circuit on the `pii` lookup, so their SQL is byte-identical.
27
+ *
28
+ * Shared with the PowQL aggregate paths (src/powql.ts) so every engine applies
29
+ * one policy.
30
+ */
31
+ export function assertAggregatePiiOptIn(table, meta, field, column, usage, includePii) {
32
+ if (includePii === true || !meta)
33
+ return;
34
+ const colMeta = meta.columns.find((c) => c.name === column);
35
+ if (!colMeta?.pii)
36
+ return;
37
+ throw new ValidationError(`[turbine] ${usage} on column "${field}" of table "${table}" is refused: that column is ` +
38
+ 'PII-tagged (`pii: true`), and this aggregate returns its stored values, which are excluded ' +
39
+ 'from every default projection. Pass `includePii: true` on this call to opt in. ' +
40
+ '`_count` over a PII column (a count, not a value) and `where` / `orderBy` / `having` on PII ' +
41
+ 'columns need no opt-in.');
42
+ }
15
43
  export function buildGroupBy(qi, args) {
16
44
  const meta = qi.schema.tables[qi.table];
17
45
  if (meta) {
@@ -65,6 +93,7 @@ export function buildGroupBy(qi, args) {
65
93
  for (const entry of args.by) {
66
94
  if (typeof entry === 'string') {
67
95
  const col = qi.toColumn(entry);
96
+ assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', args.includePii);
68
97
  claimResultKey(entry, `column "${col}"`);
69
98
  // The emitted output column is the snake_case name; claim it too (when
70
99
  // it differs from the result key) so a JSON alias like 'created_at'
@@ -78,6 +107,7 @@ export function buildGroupBy(qi, args) {
78
107
  }
79
108
  else {
80
109
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
110
+ assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', args.includePii);
81
111
  params.push(whereMod.jsonPathParam(qi, entry.path));
82
112
  const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
83
113
  const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
@@ -141,6 +171,9 @@ export function buildGroupBy(qi, args) {
141
171
  continue;
142
172
  if (target === true) {
143
173
  const col = qi.toColumn(key);
174
+ if (aggKey === '_min' || aggKey === '_max') {
175
+ assertAggregatePiiOptIn(qi.table, meta, key, col, `groupBy ${aggKey}`, args.includePii);
176
+ }
144
177
  // Aggregate output aliases share the same output-name namespace as
145
178
  // the group keys: `_sum: { totalPrice: true, total_price: {json} }`
146
179
  // would emit two "_sum_total_price" columns and silently drop one.
@@ -152,6 +185,9 @@ export function buildGroupBy(qi, args) {
152
185
  continue;
153
186
  }
154
187
  const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
188
+ if (aggKey === '_min' || aggKey === '_max') {
189
+ assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, args.includePii);
190
+ }
155
191
  const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
156
192
  if (alwaysNumeric && target.type === 'text') {
157
193
  throw new ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${qi.table}": ` +
@@ -611,11 +647,13 @@ export function buildAggregate(qi, args) {
611
647
  }
612
648
  }
613
649
  }
614
- // _min
650
+ // _min / _max return a stored cell verbatim, so a PII-tagged column needs the
651
+ // same `includePii` opt-in a row projection needs. _count / _sum / _avg do not.
615
652
  if (args._min) {
616
653
  for (const [field, enabled] of Object.entries(args._min)) {
617
654
  if (enabled) {
618
655
  const col = qi.toColumn(field);
656
+ assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _min', args.includePii);
619
657
  selectExprs.push(`MIN(${qi.q(col)}) AS ${qi.q(`_min_${col}`)}`);
620
658
  }
621
659
  }
@@ -625,6 +663,7 @@ export function buildAggregate(qi, args) {
625
663
  for (const [field, enabled] of Object.entries(args._max)) {
626
664
  if (enabled) {
627
665
  const col = qi.toColumn(field);
666
+ assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _max', args.includePii);
628
667
  selectExprs.push(`MAX(${qi.q(col)}) AS ${qi.q(`_max_${col}`)}`);
629
668
  }
630
669
  }
@@ -112,6 +112,19 @@ export interface RelationLoadContext {
112
112
  params: unknown[];
113
113
  } | null;
114
114
  }
115
+ /**
116
+ * The default projection of `meta` expressed in FIELD names: which fields the
117
+ * default (no `select`/`omit`) projection hides, and which it returns. Today the
118
+ * only hidden class is PII-tagged columns, and only when `includePii` is off.
119
+ *
120
+ * Returns `undefined` for the overwhelmingly common untagged case, so callers
121
+ * keep the `select: undefined, omit: undefined` fast path and the emitted SQL
122
+ * stays byte-identical.
123
+ */
124
+ export declare function defaultProjectionFields(meta: TableMetadata, includePii: boolean | undefined): {
125
+ hidden: ReadonlySet<string>;
126
+ visible: string[];
127
+ } | undefined;
115
128
  /**
116
129
  * Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
117
130
  * query result, returning the adjusted projection plus the list of fields that
@@ -121,7 +134,22 @@ export interface RelationLoadContext {
121
134
  * keys) so a caller's `select: { title: true }` on a relation still stitches even
122
135
  * though the FK was not requested — and the FK never appears in the output.
123
136
  */
124
- export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[]): {
137
+ export declare function includeKeysForBatching(select: Record<string, boolean> | undefined, omit: Record<string, boolean> | undefined, fields: string[],
138
+ /**
139
+ * The default projection for this table when it is NOT `select`/`omit`-driven:
140
+ * `hidden` are fields the default projection leaves out (today: PII-tagged
141
+ * columns without `includePii`), `visible` is everything it does return.
142
+ *
143
+ * Without this, a correlation key that is itself PII-tagged is absent from
144
+ * every row, the loader sees no keys, and it silently hands back empty
145
+ * relation arrays. Passing it turns that case into an explicit select that
146
+ * re-adds only the key, which is then stripped like any other stitch-only
147
+ * field, so no PII value ever reaches the caller.
148
+ */
149
+ defaultProjection?: {
150
+ hidden: ReadonlySet<string>;
151
+ visible: string[];
152
+ }): {
125
153
  select?: Record<string, boolean>;
126
154
  omit?: Record<string, boolean>;
127
155
  strip: string[];
@@ -61,6 +61,29 @@ import { ownLookup } from './utils.js';
61
61
  const MAX_RELATION_KEYS = 32_000;
62
62
  /** Nesting cap — parity with the join strategy's depth-10 guard. */
63
63
  const MAX_DEPTH = 10;
64
+ /**
65
+ * The default projection of `meta` expressed in FIELD names: which fields the
66
+ * default (no `select`/`omit`) projection hides, and which it returns. Today the
67
+ * only hidden class is PII-tagged columns, and only when `includePii` is off.
68
+ *
69
+ * Returns `undefined` for the overwhelmingly common untagged case, so callers
70
+ * keep the `select: undefined, omit: undefined` fast path and the emitted SQL
71
+ * stays byte-identical.
72
+ */
73
+ export function defaultProjectionFields(meta, includePii) {
74
+ if (includePii)
75
+ return undefined;
76
+ const hidden = new Set();
77
+ const visible = [];
78
+ for (const col of meta.columns) {
79
+ const field = meta.reverseColumnMap[col.name] ?? col.name;
80
+ if (col.pii)
81
+ hidden.add(field);
82
+ else
83
+ visible.push(field);
84
+ }
85
+ return hidden.size === 0 ? undefined : { hidden, visible };
86
+ }
64
87
  /**
65
88
  * Adjust a `select`/`omit` pair so that `fields` are guaranteed present in the
66
89
  * query result, returning the adjusted projection plus the list of fields that
@@ -70,7 +93,19 @@ const MAX_DEPTH = 10;
70
93
  * keys) so a caller's `select: { title: true }` on a relation still stitches even
71
94
  * though the FK was not requested — and the FK never appears in the output.
72
95
  */
73
- export function includeKeysForBatching(select, omit, fields) {
96
+ export function includeKeysForBatching(select, omit, fields,
97
+ /**
98
+ * The default projection for this table when it is NOT `select`/`omit`-driven:
99
+ * `hidden` are fields the default projection leaves out (today: PII-tagged
100
+ * columns without `includePii`), `visible` is everything it does return.
101
+ *
102
+ * Without this, a correlation key that is itself PII-tagged is absent from
103
+ * every row, the loader sees no keys, and it silently hands back empty
104
+ * relation arrays. Passing it turns that case into an explicit select that
105
+ * re-adds only the key, which is then stripped like any other stitch-only
106
+ * field, so no PII value ever reaches the caller.
107
+ */
108
+ defaultProjection) {
74
109
  const unique = [...new Set(fields)];
75
110
  if (select) {
76
111
  const next = { ...select };
@@ -94,7 +129,19 @@ export function includeKeysForBatching(select, omit, fields) {
94
129
  }
95
130
  return { select, omit: next, strip };
96
131
  }
97
- // Neither select nor omit every column is already present; nothing to strip.
132
+ // Neither select nor omit. Every column the DEFAULT projection returns is
133
+ // already present, so normally there is nothing to strip; the exception is a
134
+ // key the default projection hides (a PII-tagged correlation column), which
135
+ // has to be asked for explicitly.
136
+ const hiddenKeys = defaultProjection ? unique.filter((f) => defaultProjection.hidden.has(f)) : [];
137
+ if (hiddenKeys.length > 0 && defaultProjection) {
138
+ const explicit = {};
139
+ for (const f of defaultProjection.visible)
140
+ explicit[f] = true;
141
+ for (const f of hiddenKeys)
142
+ explicit[f] = true;
143
+ return { select: explicit, omit: undefined, strip: hiddenKeys };
144
+ }
98
145
  return { select, omit, strip: [] };
99
146
  }
100
147
  /** Delete stitch-only key fields from each row (no-op when `fields` is empty). */
@@ -272,7 +319,7 @@ async function loadToOneOrMany(ctx, parents, rel, relName, options, timeout, dep
272
319
  }
273
320
  // The follow-up must project the child correlation key even if the caller's
274
321
  // select/omit excluded it; strip it back off afterwards so the shape matches join.
275
- const proj = includeKeysForBatching(options.select, options.omit, [childKeyField]);
322
+ const proj = includeKeysForBatching(options.select, options.omit, [childKeyField], defaultProjectionFields(targetMeta, ctx.includePii));
276
323
  const child = ctx.makeChild(rel.to);
277
324
  const chunks = [];
278
325
  for (let i = 0; i < keys.length; i += MAX_RELATION_KEYS)
@@ -374,7 +421,7 @@ async function loadManyToMany(ctx, parents, rel, relName, options, timeout, dept
374
421
  }
375
422
  }
376
423
  // (2) Target rows by PK, honouring the relation's own where/select/omit/orderBy.
377
- const proj = includeKeysForBatching(options.select, options.omit, [targetPkField]);
424
+ const proj = includeKeysForBatching(options.select, options.omit, [targetPkField], defaultProjectionFields(targetMeta, ctx.includePii));
378
425
  const child = ctx.makeChild(rel.to);
379
426
  const targetVals = [...targetValSet];
380
427
  const tChunks = [];
@@ -527,9 +574,25 @@ async function loadOneCount(ctx, parents, rel) {
527
574
  // ---------------------------------------------------------------------------
528
575
  // Small helpers
529
576
  // ---------------------------------------------------------------------------
530
- /** Merge the batched correlation predicate (`key IN chunk`) into the relation's own where. */
577
+ /**
578
+ * AND the batched correlation predicate (`key IN chunk`) onto the relation's own
579
+ * `where`, matching the join strategy, which appends the correlation with
580
+ * ` AND <extra>` and so never lets one predicate replace the other.
581
+ *
582
+ * A flat spread is kept for the overwhelmingly common case where the caller's
583
+ * `where` does not name the correlation field, so the emitted SQL is unchanged
584
+ * there. When it DOES name it (e.g. `with: { posts: { where: { userId: 1 } } }`,
585
+ * or a belongsTo `where` on the child's PK), a bare spread would let the chunk
586
+ * predicate silently overwrite the caller's filter and return rows the join
587
+ * strategy excludes; the two are combined with `AND` instead so both apply.
588
+ */
531
589
  function mergeChildWhere(where, keyField, chunk) {
532
- return { ...(where ?? {}), [keyField]: { in: chunk } };
590
+ const correlation = { [keyField]: { in: chunk } };
591
+ if (!where)
592
+ return correlation;
593
+ if (Object.hasOwn(where, keyField))
594
+ return { AND: [where, correlation] };
595
+ return { ...where, ...correlation };
533
596
  }
534
597
  /** Distinct, non-null values of `field` across `rows`. */
535
598
  function uniqueKeys(rows, field) {
@@ -16,7 +16,7 @@ import { missingIndexForRelation, schemaHasIndexInfo } from '../index-advisor.js
16
16
  import { executeNestedCreate, executeNestedUpdate, hasRelationFields, } from '../nested-write.js';
17
17
  import { camelToSnake, normalizeKeyColumns, snakeToCamel } from '../schema.js';
18
18
  import * as aggMod from './aggregates.js';
19
- import { includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
19
+ import { defaultProjectionFields, includeKeysForBatching, loadRelationsBatched, neededParentKeyFields, rejectNestedPickOrder, resolveCountRelations, stripFields, } from './batched-loader.js';
20
20
  import { expandCompoundUniqueWhere } from './compound-unique.js';
21
21
  import { isJsonPathOrderBy, isOrderBySpec, isRelationPickOrderBy, isVectorOrderBy, isWhereOperator, orderByEntries, sortedEntries, } from './filters.js';
22
22
  import * as relationsMod from './relations.js';
@@ -745,7 +745,7 @@ export class QueryInterface {
745
745
  rejectNestedPickOrder(batchedWith);
746
746
  const skip = args.skipGlobalFilters;
747
747
  const needed = neededParentKeyFields(this.tableMeta, batchedWith);
748
- const proj = includeKeysForBatching(args.select, args.omit, needed);
748
+ const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
749
749
  const hasJoin = Object.keys(joinWith).length > 0;
750
750
  // Force the residual `with` onto the join plan so the base query never
751
751
  // re-enters this auto planning.
@@ -858,7 +858,7 @@ export class QueryInterface {
858
858
  */
859
859
  prepareBatchedBase(args, withClause) {
860
860
  const needed = neededParentKeyFields(this.tableMeta, withClause);
861
- const proj = includeKeysForBatching(args.select, args.omit, needed);
861
+ const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
862
862
  const baseArgs = {
863
863
  ...args,
864
864
  with: undefined,
@@ -1164,7 +1164,7 @@ export class QueryInterface {
1164
1164
  // Same scope-rule parity as runFindManyBatched: reject before querying.
1165
1165
  rejectNestedPickOrder(withClause);
1166
1166
  const needed = neededParentKeyFields(this.tableMeta, withClause);
1167
- const proj = includeKeysForBatching(args.select, args.omit, needed);
1167
+ const proj = includeKeysForBatching(args.select, args.omit, needed, defaultProjectionFields(this.tableMeta, args.includePii));
1168
1168
  const baseArgs = { ...args, with: undefined, select: proj.select, omit: proj.omit };
1169
1169
  const deferred = this.buildFindUnique(baseArgs);
1170
1170
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout, deferred.preparedName);
@@ -480,8 +480,18 @@ export function buildRelationOrderBy(qi, relName, value, alias, params, ctx, lat
480
480
  const parentRef = ctx?.parentRef ?? qi.table;
481
481
  const relDef = ownerMeta.relations[relName];
482
482
  if (!relDef) {
483
- throw new RelationError(`[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
484
- `Available: ${Object.keys(ownerMeta.relations).join(', ')}`);
483
+ // A table with no relations at all would otherwise render a dangling
484
+ // "Available: " and read as a broken message; and the most likely cause of
485
+ // landing here on such a table is an orderBy VALUE of the wrong shape on a
486
+ // scalar column, which deserves to be named rather than reported as a
487
+ // missing relation.
488
+ const known = Object.keys(ownerMeta.relations);
489
+ const isColumn = Object.hasOwn(ownerMeta.columnMap, relName) || ownerMeta.allColumns.includes(relName);
490
+ throw new RelationError(isColumn
491
+ ? `[turbine] orderBy on "${ownerTable}.${relName}" got a relation-shaped value, but "${relName}" is a ` +
492
+ `column. Order a column with 'asc' / 'desc' (or { sort, nulls }); the object form is for relations.`
493
+ : `[turbine] Unknown relation "${relName}" in orderBy on table "${ownerTable}". ` +
494
+ (known.length > 0 ? `Available: ${known.join(', ')}` : `"${ownerTable}" has no relations.`));
485
495
  }
486
496
  // Pick-row ordering (`{ pick, by }`): order by a value from ONE related
487
497
  // row: a correlated scalar subquery with its own ORDER BY … LIMIT 1.
@@ -802,6 +802,14 @@ export interface GroupByArgs<T> {
802
802
  * grouped results; combine with `limit` and a deterministic `orderBy`.
803
803
  */
804
804
  offset?: number;
805
+ /**
806
+ * Opt in to grouping on / aggregating PII-tagged (`defineSchema` `pii: true`)
807
+ * columns. A PII column in `by`, and `_min` / `_max` over a PII column, return
808
+ * STORED VALUES, so without this they throw `ValidationError` (E003).
809
+ * `_count` (a count, not a value), `_sum` / `_avg`, and `where` / `orderBy` /
810
+ * `having` on PII columns stay allowed.
811
+ */
812
+ includePii?: boolean;
805
813
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
806
814
  timeout?: number;
807
815
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
@@ -894,6 +902,14 @@ export interface AggregateArgs<T> {
894
902
  _min?: Partial<Record<keyof T & string, boolean>>;
895
903
  /** Maximum value of fields */
896
904
  _max?: Partial<Record<keyof T & string, boolean>>;
905
+ /**
906
+ * Opt in to aggregating PII-tagged (`defineSchema` `pii: true`) columns.
907
+ * `_min` / `_max` return a STORED VALUE, so without this they throw
908
+ * `ValidationError` (E003) on a PII column. `_count` (a count, not a value)
909
+ * and `_sum` / `_avg` (a computed total over many rows) stay allowed, as do
910
+ * `where` filters on PII columns.
911
+ */
912
+ includePii?: boolean;
897
913
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
898
914
  timeout?: number;
899
915
  /** Opt out of configured {@link GlobalFilters}. See {@link SkipGlobalFilters}. */
@@ -170,7 +170,7 @@ export declare function collectOperatorParams(qi: BuilderCtx, column: string, op
170
170
  */
171
171
  export declare function collectJsonFilterParams(qi: BuilderCtx, filter: JsonFilter, params: unknown[], column: string): void;
172
172
  /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
173
- export declare function collectArrayFilterParams(_qi: BuilderCtx, filter: ArrayFilter, params: unknown[]): void;
173
+ export declare function collectArrayFilterParams(qi: BuilderCtx, filter: ArrayFilter, params: unknown[]): void;
174
174
  /**
175
175
  * Collect params for a vector distance WHERE filter. Mirrors
176
176
  * {@link buildVectorFilterClauses}: the `$n::vector` query vector first, then
@@ -386,6 +386,23 @@ export declare function columnRefSql(qi: BuilderCtx, ref: ColumnRef, ctx: Column
386
386
  * pushing nothing and the referenced name lives in the fingerprint.
387
387
  */
388
388
  export declare function buildOperatorClauses(qi: BuilderCtx, column: string, op: WhereOperator, params: unknown[], refCtx?: ColumnRefContext): string[];
389
+ /**
390
+ * Gate the full-text `search` filter on {@link Dialect.supportsFullTextSearch}.
391
+ * The clause it guards is `to_tsvector(...) @@ to_tsquery(...)`, which only
392
+ * PostgreSQL parses, so every other engine gets a typed
393
+ * {@link UnsupportedFeatureError} (E017) instead of a raw driver syntax error.
394
+ * Called from BOTH the build and the param-collect side (mirroring the vector
395
+ * gate) so the two paths can never diverge.
396
+ */
397
+ export declare function requireFullTextSearch(qi: BuilderCtx): void;
398
+ /**
399
+ * Gate the array filter operators (`has` / `hasEvery` / `hasSome` / `isEmpty`)
400
+ * on {@link Dialect.supportsArrayColumns}. They compile to PostgreSQL array
401
+ * operators (`= ANY(col)`, `@>`, `&&`, `cardinality(col)`) over a native array
402
+ * column, which no other supported engine has. Called from BOTH the build and
403
+ * the param-collect side.
404
+ */
405
+ export declare function requireArrayColumns(qi: BuilderCtx): void;
389
406
  /**
390
407
  * Resolve a {@link VectorMetric} to its pgvector distance operator from a
391
408
  * fixed allow-list, validating the target column is actually a `vector`
@@ -148,6 +148,9 @@ export function collectScalarParams(qi, key, value, params) {
148
148
  collectArrayFilterParams(qi, value, params);
149
149
  return;
150
150
  case 'textsearch':
151
+ // Same gate the build path applies, so the collect path never diverges
152
+ // (it throws before any param is pushed).
153
+ requireFullTextSearch(qi);
151
154
  params.push(value.search);
152
155
  return;
153
156
  case 'operator':
@@ -284,7 +287,8 @@ export function collectJsonFilterParams(qi, filter, params, column) {
284
287
  }
285
288
  }
286
289
  /** Collect params from array filter. Mirrors buildArrayFilterClauses. */
287
- export function collectArrayFilterParams(_qi, filter, params) {
290
+ export function collectArrayFilterParams(qi, filter, params) {
291
+ requireArrayColumns(qi);
288
292
  if (filter.has !== undefined)
289
293
  params.push(filter.has);
290
294
  if (filter.hasEvery !== undefined)
@@ -1161,6 +1165,33 @@ export function buildOperatorClauses(qi, column, op, params, refCtx) {
1161
1165
  }
1162
1166
  return clauses;
1163
1167
  }
1168
+ /**
1169
+ * Gate the full-text `search` filter on {@link Dialect.supportsFullTextSearch}.
1170
+ * The clause it guards is `to_tsvector(...) @@ to_tsquery(...)`, which only
1171
+ * PostgreSQL parses, so every other engine gets a typed
1172
+ * {@link UnsupportedFeatureError} (E017) instead of a raw driver syntax error.
1173
+ * Called from BOTH the build and the param-collect side (mirroring the vector
1174
+ * gate) so the two paths can never diverge.
1175
+ */
1176
+ export function requireFullTextSearch(qi) {
1177
+ if (qi.dialect.supportsFullTextSearch)
1178
+ return;
1179
+ throw new UnsupportedFeatureError('the full-text search filter (`search`)', qi.dialect.name, 'Full-text `search` compiles to PostgreSQL to_tsvector/to_tsquery. ' +
1180
+ 'Use `contains` (LIKE) on this engine, or run the query on PostgreSQL.');
1181
+ }
1182
+ /**
1183
+ * Gate the array filter operators (`has` / `hasEvery` / `hasSome` / `isEmpty`)
1184
+ * on {@link Dialect.supportsArrayColumns}. They compile to PostgreSQL array
1185
+ * operators (`= ANY(col)`, `@>`, `&&`, `cardinality(col)`) over a native array
1186
+ * column, which no other supported engine has. Called from BOTH the build and
1187
+ * the param-collect side.
1188
+ */
1189
+ export function requireArrayColumns(qi) {
1190
+ if (qi.dialect.supportsArrayColumns)
1191
+ return;
1192
+ throw new UnsupportedFeatureError('the array column filter set (`has` / `hasEvery` / `hasSome` / `isEmpty`)', qi.dialect.name, 'Array filters compile to PostgreSQL array operators over a native array ' +
1193
+ 'column; this engine has no array column type.');
1194
+ }
1164
1195
  /**
1165
1196
  * Resolve a {@link VectorMetric} to its pgvector distance operator from a
1166
1197
  * fixed allow-list, validating the target column is actually a `vector`
@@ -1378,6 +1409,7 @@ export function castJsonNumeric(qi, extract) {
1378
1409
  * Supports: has, hasEvery, hasSome, isEmpty.
1379
1410
  */
1380
1411
  export function buildArrayFilterClauses(qi, column, filter, params, pgType) {
1412
+ requireArrayColumns(qi);
1381
1413
  const clauses = [];
1382
1414
  const elementType = getArrayElementType(qi, pgType);
1383
1415
  if (filter.has !== undefined) {
@@ -1443,6 +1475,7 @@ export function buildVectorFilterClauses(qi, field, rawColumn, filter, params) {
1443
1475
  * The config name is validated to prevent injection (only alphanumeric + underscore).
1444
1476
  */
1445
1477
  export function buildTextSearchClause(qi, column, filter, params) {
1478
+ requireFullTextSearch(qi);
1446
1479
  const config = filter.config ?? 'english';
1447
1480
  if (!validateTextSearchConfig(config)) {
1448
1481
  throw new ValidationError(`[turbine] Invalid text search config "${config}": only alphanumeric characters and underscores are allowed.`);