turbine-orm 0.35.0 → 0.36.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 (68) hide show
  1. package/README.md +18 -16
  2. package/dist/cjs/cli/index.js +109 -16
  3. package/dist/cjs/cli/migrate.js +78 -3
  4. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  5. package/dist/cjs/cli/studio.js +333 -22
  6. package/dist/cjs/cli/ui.js +7 -1
  7. package/dist/cjs/dialect.js +1 -1
  8. package/dist/cjs/generate.js +23 -2
  9. package/dist/cjs/index.js +2 -1
  10. package/dist/cjs/mssql.js +22 -5
  11. package/dist/cjs/powdb.js +41 -1
  12. package/dist/cjs/powql.js +80 -25
  13. package/dist/cjs/query/aggregates.js +683 -0
  14. package/dist/cjs/query/batched-loader.js +2 -0
  15. package/dist/cjs/query/builder.js +297 -4504
  16. package/dist/cjs/query/filters.js +12 -0
  17. package/dist/cjs/query/relations.js +1698 -0
  18. package/dist/cjs/query/where-compile.js +180 -0
  19. package/dist/cjs/query/where.js +1491 -0
  20. package/dist/cjs/query/writes.js +680 -0
  21. package/dist/cjs/schema-builder.js +6 -0
  22. package/dist/cjs/schema-metadata.js +4 -0
  23. package/dist/cjs/schema-sql.js +265 -3
  24. package/dist/cjs/sqlite.js +1 -1
  25. package/dist/cli/index.d.ts +8 -2
  26. package/dist/cli/index.js +111 -18
  27. package/dist/cli/migrate.d.ts +24 -1
  28. package/dist/cli/migrate.js +77 -3
  29. package/dist/cli/studio-ui.generated.js +1 -1
  30. package/dist/cli/studio.d.ts +46 -13
  31. package/dist/cli/studio.js +331 -23
  32. package/dist/cli/ui.js +7 -1
  33. package/dist/dialect.d.ts +15 -6
  34. package/dist/dialect.js +1 -1
  35. package/dist/generate.js +23 -2
  36. package/dist/index.d.ts +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/mssql.js +22 -5
  39. package/dist/powdb.d.ts +20 -0
  40. package/dist/powdb.js +40 -0
  41. package/dist/powql.d.ts +33 -1
  42. package/dist/powql.js +80 -25
  43. package/dist/query/aggregates.d.ts +74 -0
  44. package/dist/query/aggregates.js +641 -0
  45. package/dist/query/batched-loader.d.ts +6 -0
  46. package/dist/query/batched-loader.js +2 -0
  47. package/dist/query/builder.d.ts +62 -829
  48. package/dist/query/builder.js +302 -4509
  49. package/dist/query/deferred.d.ts +7 -0
  50. package/dist/query/filters.d.ts +7 -0
  51. package/dist/query/filters.js +11 -0
  52. package/dist/query/relations.d.ts +441 -0
  53. package/dist/query/relations.js +1627 -0
  54. package/dist/query/types.d.ts +15 -0
  55. package/dist/query/where-compile.d.ts +139 -0
  56. package/dist/query/where-compile.js +175 -0
  57. package/dist/query/where.d.ts +494 -0
  58. package/dist/query/where.js +1431 -0
  59. package/dist/query/writes.d.ts +131 -0
  60. package/dist/query/writes.js +626 -0
  61. package/dist/schema-builder.d.ts +18 -3
  62. package/dist/schema-builder.js +6 -0
  63. package/dist/schema-metadata.js +4 -0
  64. package/dist/schema-sql.d.ts +60 -3
  65. package/dist/schema-sql.js +261 -4
  66. package/dist/schema.d.ts +10 -0
  67. package/dist/sqlite.js +1 -1
  68. package/package.json +2 -2
package/dist/generate.js CHANGED
@@ -161,8 +161,13 @@ export function generateTypes(schema, options) {
161
161
  for (const col of table.columns) {
162
162
  const pkNote = table.primaryKey.includes(col.name) ? ' (primary key)' : '';
163
163
  const nullNote = col.nullable ? ' (nullable)' : '';
164
- lines.push(` /** Column: ${col.name} ${col.pgType}${pkNote}${nullNote} */`);
165
- lines.push(` ${col.field}: ${columnTsType(col, schema.enums)};`);
164
+ // PII columns are excluded from default projections, so the field is
165
+ // absent unless the query names it in `select` or passes `includePii`.
166
+ // The emitted type marks it optional so it tells the truth about absence.
167
+ const piiNote = col.pii ? ' (PII: absent unless selected or includePii)' : '';
168
+ const optional = col.pii ? '?' : '';
169
+ lines.push(` /** Column: ${col.name}, ${col.pgType}${pkNote}${nullNote}${piiNote} */`);
170
+ lines.push(` ${col.field}${optional}: ${columnTsType(col, schema.enums)};`);
166
171
  }
167
172
  lines.push('}');
168
173
  lines.push('');
@@ -524,6 +529,17 @@ export function generateMetadata(schema, options) {
524
529
  lines.push(` { name: '${escSQ(idx.name)}', columns: [${idx.columns.map((c) => `'${escSQ(c)}'`).join(', ')}], unique: ${idx.unique}, definition: ${JSON.stringify(idx.definition)} },`);
525
530
  }
526
531
  lines.push(' ],');
532
+ // checks: introspected named CHECK constraints. Emitted only when present
533
+ // (byte-stable for check-less tables) and sorted by name so `--no-timestamp`
534
+ // output is deterministic regardless of catalog row order.
535
+ if (table.checks && table.checks.length > 0) {
536
+ const sortedChecks = [...table.checks].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
537
+ lines.push(' checks: [');
538
+ for (const chk of sortedChecks) {
539
+ lines.push(` { name: '${escSQ(chk.name)}', expression: ${JSON.stringify(chk.expression)} },`);
540
+ }
541
+ lines.push(' ],');
542
+ }
527
543
  // isView — read-only marker; the runtime write guard reads it.
528
544
  if (table.isView)
529
545
  lines.push(' isView: true,');
@@ -727,6 +743,11 @@ function serializeColumn(col) {
727
743
  if (col.generationExpression !== undefined) {
728
744
  parts.push(`generationExpression: '${escSQ(col.generationExpression)}'`);
729
745
  }
746
+ // PII marker: emitted only when set, so untagged schemas stay byte-identical.
747
+ // Introspection never sets this (code-first declaration), but a metadata
748
+ // object built from `defineSchema` (pii: true) carries it through codegen.
749
+ if (col.pii)
750
+ parts.push(`pii: true`);
730
751
  if (col.maxLength !== undefined)
731
752
  parts.push(`maxLength: ${col.maxLength}`);
732
753
  return `{ ${parts.join(', ')} }`;
package/dist/index.d.ts CHANGED
@@ -49,7 +49,7 @@ export type { CheckMetadata, ColumnMetadata, IndexMetadata, ReferentialAction, R
49
49
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, } from './schema.js';
50
50
  export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnIndexDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, type DocFieldIndexDef, defineSchema, isDocFieldIndexDef, type ManyToManyDef, type ReferenceDef, type SchemaDef, type SchemaIndexDef, type TableDef, table, } from './schema-builder.js';
51
51
  export { schemaDefToMetadata } from './schema-metadata.js';
52
- export { type AlterColumnDef, type AlterDef, type DiffResult, type PushResult, type SchemaSqlOptions, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
52
+ export { type AlterColumnDef, type AlterDef, DestructivePushRefusal, type DiffResult, type PushResult, type SchemaSqlOptions, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
53
53
  export { type DefinedSeed, defineSeed, type SeedFunction } from './seed.js';
54
54
  export { type TurbineHttpOptions, turbineHttp } from './serverless.js';
55
55
  export { buildTypedSql, TypedSqlQuery } from './typed-sql.js';
package/dist/index.js CHANGED
@@ -59,7 +59,7 @@ table, } from './schema-builder.js';
59
59
  // Schema metadata bridge — defineSchema() → SchemaMetadata without a live DB
60
60
  export { schemaDefToMetadata } from './schema-metadata.js';
61
61
  // Schema SQL — generate DDL, diff, and push
62
- export { schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
62
+ export { DestructivePushRefusal, schemaDiff, schemaPush, schemaToSQL, schemaToSQLString, } from './schema-sql.js';
63
63
  // Seed helper
64
64
  export { defineSeed } from './seed.js';
65
65
  // Serverless / edge factory
package/dist/mssql.js CHANGED
@@ -448,6 +448,23 @@ function mssqlColumnType(type, maxLength) {
448
448
  // ---------------------------------------------------------------------------
449
449
  // mssqlDialect — the full Dialect contract for SQL Server 2016+
450
450
  // ---------------------------------------------------------------------------
451
+ /**
452
+ * Render the SQL Server `OUTPUT` clause for a write's returning selection.
453
+ * `'*'` → ` OUTPUT INSERTED.*` (byte-identical to the historical default); a
454
+ * quoted column list → ` OUTPUT INSERTED.[c1], INSERTED.[c2]`; each column
455
+ * carries its own `INSERTED.`/`DELETED.` prefix (a bare comma list is invalid
456
+ * T-SQL). Used to exclude PII columns from a write's returned row. Empty
457
+ * selection → no clause.
458
+ */
459
+ function mssqlOutput(returning, alias) {
460
+ if (!returning)
461
+ return '';
462
+ if (returning === '*')
463
+ return ` OUTPUT ${alias}.*`;
464
+ if (returning.length === 0)
465
+ return '';
466
+ return ` OUTPUT ${returning.map((col) => `${alias}.${col}`).join(', ')}`;
467
+ }
451
468
  /**
452
469
  * SQL Server 2016+ implementation of the {@link Dialect} contract. Bracket
453
470
  * identifier quoting (`[…]`), named `@pN` placeholders, the `FOR JSON PATH`
@@ -511,7 +528,7 @@ export const mssqlDialect = {
511
528
  return '';
512
529
  },
513
530
  buildInsertStatement(input) {
514
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
531
+ const out = mssqlOutput(input.returning, 'INSERTED');
515
532
  return `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES (${input.valuePlaceholders.join(', ')})`;
516
533
  },
517
534
  buildBulkInsertStatement(input) {
@@ -531,7 +548,7 @@ export const mssqlDialect = {
531
548
  const placeholders = input.rowValues
532
549
  .map((row) => `(${row.map(() => this.paramPlaceholder(++n)).join(', ')})`)
533
550
  .join(', ');
534
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
551
+ const out = mssqlOutput(input.returning, 'INSERTED');
535
552
  // skipDuplicates has no single-statement equivalent here; ignored (documented).
536
553
  return {
537
554
  sql: `INSERT INTO ${input.table} (${input.columns.join(', ')})${out} VALUES ${placeholders}`,
@@ -546,7 +563,7 @@ export const mssqlDialect = {
546
563
  const on = input.conflictColumns.map((c) => `T.${c} = S.${c}`).join(' AND ');
547
564
  const insertCols = input.insertColumns.join(', ');
548
565
  const sourceVals = input.insertColumns.map((c) => `S.${c}`).join(', ');
549
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
566
+ const out = mssqlOutput(input.returning, 'INSERTED');
550
567
  return (`MERGE INTO ${input.table} AS T ` +
551
568
  `USING (VALUES (${input.valuePlaceholders.join(', ')})) AS S (${insertCols}) ` +
552
569
  `ON (${on}) ` +
@@ -557,11 +574,11 @@ export const mssqlDialect = {
557
574
  // UPDATE/DELETE inject OUTPUT mid-statement (between SET and WHERE / FROM and
558
575
  // WHERE) — a trailing clause would be invalid T-SQL.
559
576
  buildUpdateStatement(input) {
560
- const out = input.returning ? ` OUTPUT INSERTED.${input.returning}` : '';
577
+ const out = mssqlOutput(input.returning, 'INSERTED');
561
578
  return `UPDATE ${input.table} SET ${input.setClauses.join(', ')}${out}${input.whereSql}`;
562
579
  },
563
580
  buildDeleteStatement(input) {
564
- const out = input.returning ? ` OUTPUT DELETED.${input.returning}` : '';
581
+ const out = mssqlOutput(input.returning, 'DELETED');
565
582
  return `DELETE FROM ${input.table}${out}${input.whereSql}`;
566
583
  },
567
584
  // SQL Server has no LIMIT — emit OFFSET/FETCH, injecting a stable ORDER BY when
package/dist/powdb.d.ts CHANGED
@@ -580,6 +580,26 @@ interface EmbeddedModule {
580
580
  * as data and cannot break out of the literal or inject a second statement.
581
581
  */
582
582
  export declare function encodePowqlLiteral(value: unknown): string;
583
+ /**
584
+ * The newest PowDB engine LINE (major.minor) whose lexer escape handling
585
+ * {@link encodePowqlString} is VERIFIED against by reading
586
+ * `crates/query/src/lexer.rs`. The legacy materialize path
587
+ * ({@link materializePowql}) inlines encoded string literals directly into query
588
+ * text, so it is only injection-safe while the lexer recognizes exactly the
589
+ * escape set the escaper emits (`\"`, `\\`, `\n`, `\t`, everything else raw). If
590
+ * a future engine line teaches the lexer new escapes (e.g. `\u`, `\x`), a string
591
+ * that the escaper leaves raw could be re-interpreted by the lexer and break out
592
+ * of the literal, turning the fallback into an injection primitive.
593
+ *
594
+ * CONTRACT: bump this ceiling ONLY after re-verifying the escape handling in
595
+ * `crates/query/src/lexer.rs` for the newer line AND confirming
596
+ * {@link encodePowqlString} still escapes every breakout vector the lexer
597
+ * recognizes. The legacy path guards on this value (see
598
+ * {@link PowdbEmbeddedPool.exec}): an embedded addon whose engine line exceeds
599
+ * the ceiling yet still routes through the string wire is refused rather than
600
+ * materialized.
601
+ */
602
+ export declare const POWQL_LEXER_TESTED_CEILING = "0.15";
583
603
  /**
584
604
  * Substitute every `$N` placeholder in a generator-produced PowQL template with
585
605
  * the encoded literal of `params[N-1]`. Safe because the template is produced by
package/dist/powdb.js CHANGED
@@ -1372,6 +1372,26 @@ export function encodePowqlLiteral(value) {
1372
1372
  return encodePowqlString(value);
1373
1373
  throw new ValidationError(`[turbine] Value of type ${typeof value} cannot be encoded as a PowDB literal.`);
1374
1374
  }
1375
+ /**
1376
+ * The newest PowDB engine LINE (major.minor) whose lexer escape handling
1377
+ * {@link encodePowqlString} is VERIFIED against by reading
1378
+ * `crates/query/src/lexer.rs`. The legacy materialize path
1379
+ * ({@link materializePowql}) inlines encoded string literals directly into query
1380
+ * text, so it is only injection-safe while the lexer recognizes exactly the
1381
+ * escape set the escaper emits (`\"`, `\\`, `\n`, `\t`, everything else raw). If
1382
+ * a future engine line teaches the lexer new escapes (e.g. `\u`, `\x`), a string
1383
+ * that the escaper leaves raw could be re-interpreted by the lexer and break out
1384
+ * of the literal, turning the fallback into an injection primitive.
1385
+ *
1386
+ * CONTRACT: bump this ceiling ONLY after re-verifying the escape handling in
1387
+ * `crates/query/src/lexer.rs` for the newer line AND confirming
1388
+ * {@link encodePowqlString} still escapes every breakout vector the lexer
1389
+ * recognizes. The legacy path guards on this value (see
1390
+ * {@link PowdbEmbeddedPool.exec}): an embedded addon whose engine line exceeds
1391
+ * the ceiling yet still routes through the string wire is refused rather than
1392
+ * materialized.
1393
+ */
1394
+ export const POWQL_LEXER_TESTED_CEILING = '0.15';
1375
1395
  /** Escape a string into a PowQL `"…"` literal, matching the engine lexer's escape rules. */
1376
1396
  function encodePowqlString(s) {
1377
1397
  let out = '"';
@@ -1474,6 +1494,26 @@ export class PowdbEmbeddedPool {
1474
1494
  // Legacy string wire (addon < 0.14): the engine takes no params array, so
1475
1495
  // materialize each `$N` into a PowQL literal. Byte-for-byte unchanged, kept
1476
1496
  // live and tested as the pre-0.14 fallback.
1497
+ //
1498
+ // Safety assertion (see {@link POWQL_LEXER_TESTED_CEILING}): an addon whose
1499
+ // engine line EXCEEDS the escaper's verified lexer ceiling must never be
1500
+ // materialized: a newer lexer may recognize escapes the escaper leaves raw,
1501
+ // making the inline path an injection primitive. Reachability: addons >= 0.14
1502
+ // expose `queryWithParams` and take the native path above, so the ONLY way to
1503
+ // reach this branch with a newer-than-ceiling engine is the anomalous
1504
+ // newer-addon-without-native case (feature-detect failed). Refuse it rather
1505
+ // than inline-encode against an unverified lexer.
1506
+ const engineSem = parsePowdbSemver(this.capabilities.engineVersion);
1507
+ const ceiling = parsePowdbSemver(POWQL_LEXER_TESTED_CEILING);
1508
+ if (engineSem &&
1509
+ ceiling &&
1510
+ (engineSem.major > ceiling.major || (engineSem.major === ceiling.major && engineSem.minor > ceiling.minor))) {
1511
+ throw new ValidationError(`[turbine] Refusing the PowDB legacy string wire: this embedded addon reports engine ` +
1512
+ `${this.capabilities.engineVersion}, which is newer than the escaper's verified lexer range ` +
1513
+ `(<= ${POWQL_LEXER_TESTED_CEILING}) AND such an addon exposes the parameterized native API, so ` +
1514
+ `reaching the legacy materialize path means the queryWithParams feature-detect failed. Upgrade ` +
1515
+ `turbine-orm so its PowQL escaper is verified against this engine line before running on it.`);
1516
+ }
1477
1517
  const materialized = materializePowql(powql, params);
1478
1518
  return adaptResult(normalizeEmbeddedResult(this.db.query(materialized)));
1479
1519
  }
package/dist/powql.d.ts CHANGED
@@ -175,8 +175,40 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
175
175
  private resolveRelationCondition;
176
176
  /** Resolve a manyToMany filter through the junction to `{ sourceRefField: { in|notIn: [...] } }`. */
177
177
  private resolveManyToManyCondition;
178
- /** Resolve the set of columns to project, honouring `select` / `omit`. */
178
+ /**
179
+ * Resolve the set of columns to project, honouring `select` / `omit` and the
180
+ * query-level `includePii` opt-in. PII-tagged (`defineSchema` `pii: true`)
181
+ * columns are EXCLUDED from a default (or omit-only) projection unless
182
+ * `includePii` is true; an explicit `select` naming a PII column IS the opt-in
183
+ * and returns it regardless. Untagged tables project exactly as before.
184
+ */
179
185
  private projectedColumns;
186
+ /**
187
+ * The snake_case names of this table's PII-tagged columns. Empty for a table
188
+ * with no `pii: true` column, so untagged tables keep their prior projection.
189
+ */
190
+ private piiColumnNames;
191
+ /**
192
+ * The camelCase field names of this table's PII-tagged columns: the read
193
+ * policy applied to a write's returned row (create/update/upsert/delete accept
194
+ * no `includePii`/`select`, so their result always drops PII; you may still
195
+ * write PII fields freely).
196
+ *
197
+ * SPEC LIMITATION (PowQL): the driver contract
198
+ * (`docs/integrations/powql-for-drivers.md`) exposes `returning` only as a
199
+ * bare keyword that hands back every column; it accepts NO column list, so
200
+ * (unlike the SQL engines, which emit an explicit non-PII `RETURNING`/`OUTPUT`
201
+ * projection) the create/update/delete `returning` paths cannot exclude PII at
202
+ * the query-language level and must strip it here after the fact. This is the
203
+ * client-side strip of last resort, not defense-in-depth, for those paths; we
204
+ * do NOT reverse-engineer an undocumented projection form. The upsert path is
205
+ * different: it has no `returning` and reselects by PK through the read
206
+ * projection ({@link projectedColumns}), which already omits PII, so PII never
207
+ * crosses the wire there. If a future spec revision lets `returning` take a
208
+ * projection, switch the write paths to emit the non-PII list and this strip
209
+ * becomes a no-op like {@link parseWriteRow} on the SQL engines.
210
+ */
211
+ private stripWritePii;
180
212
  /** `{ .c1, .c2, … }` projection clause. */
181
213
  private projection;
182
214
  /**
package/dist/powql.js CHANGED
@@ -617,10 +617,17 @@ export class PowqlInterface {
617
617
  // -------------------------------------------------------------------------
618
618
  // Projection / order
619
619
  // -------------------------------------------------------------------------
620
- /** Resolve the set of columns to project, honouring `select` / `omit`. */
621
- projectedColumns(select, omit) {
620
+ /**
621
+ * Resolve the set of columns to project, honouring `select` / `omit` and the
622
+ * query-level `includePii` opt-in. PII-tagged (`defineSchema` `pii: true`)
623
+ * columns are EXCLUDED from a default (or omit-only) projection unless
624
+ * `includePii` is true; an explicit `select` naming a PII column IS the opt-in
625
+ * and returns it regardless. Untagged tables project exactly as before.
626
+ */
627
+ projectedColumns(select, omit, includePii) {
622
628
  let cols = this.meta.columns.map((c) => c.name);
623
- if (select && Object.keys(select).length) {
629
+ const hasSelect = select && Object.keys(select).length;
630
+ if (hasSelect) {
624
631
  const picked = new Set(Object.entries(select)
625
632
  .filter(([, v]) => v)
626
633
  .map(([k]) => this.column(k).name));
@@ -629,6 +636,14 @@ export class PowqlInterface {
629
636
  picked.add(pk);
630
637
  cols = cols.filter((c) => picked.has(c));
631
638
  }
639
+ else if (!includePii) {
640
+ // Default / omit-only projection: drop PII columns (kept above only when a
641
+ // caller names them in `select`). PK is never PII in practice; if one is
642
+ // tagged it is still dropped here, so tag sensitive data, not keys.
643
+ const pii = this.piiColumnNames();
644
+ if (pii.size)
645
+ cols = cols.filter((c) => !pii.has(c));
646
+ }
632
647
  if (omit && Object.keys(omit).length) {
633
648
  const dropped = new Set(Object.entries(omit)
634
649
  .filter(([, v]) => v)
@@ -637,6 +652,47 @@ export class PowqlInterface {
637
652
  }
638
653
  return cols;
639
654
  }
655
+ /**
656
+ * The snake_case names of this table's PII-tagged columns. Empty for a table
657
+ * with no `pii: true` column, so untagged tables keep their prior projection.
658
+ */
659
+ piiColumnNames() {
660
+ const out = new Set();
661
+ for (const col of this.meta.columns) {
662
+ if (col.pii)
663
+ out.add(col.name);
664
+ }
665
+ return out;
666
+ }
667
+ /**
668
+ * The camelCase field names of this table's PII-tagged columns: the read
669
+ * policy applied to a write's returned row (create/update/upsert/delete accept
670
+ * no `includePii`/`select`, so their result always drops PII; you may still
671
+ * write PII fields freely).
672
+ *
673
+ * SPEC LIMITATION (PowQL): the driver contract
674
+ * (`docs/integrations/powql-for-drivers.md`) exposes `returning` only as a
675
+ * bare keyword that hands back every column; it accepts NO column list, so
676
+ * (unlike the SQL engines, which emit an explicit non-PII `RETURNING`/`OUTPUT`
677
+ * projection) the create/update/delete `returning` paths cannot exclude PII at
678
+ * the query-language level and must strip it here after the fact. This is the
679
+ * client-side strip of last resort, not defense-in-depth, for those paths; we
680
+ * do NOT reverse-engineer an undocumented projection form. The upsert path is
681
+ * different: it has no `returning` and reselects by PK through the read
682
+ * projection ({@link projectedColumns}), which already omits PII, so PII never
683
+ * crosses the wire there. If a future spec revision lets `returning` take a
684
+ * projection, switch the write paths to emit the non-PII list and this strip
685
+ * becomes a no-op like {@link parseWriteRow} on the SQL engines.
686
+ */
687
+ stripWritePii(entity) {
688
+ if (!entity)
689
+ return entity;
690
+ for (const col of this.meta.columns) {
691
+ if (col.pii)
692
+ delete entity[col.field];
693
+ }
694
+ return entity;
695
+ }
640
696
  /** `{ .c1, .c2, … }` projection clause. */
641
697
  projection(cols) {
642
698
  return `{ ${cols.map((c) => `.${c}`).join(', ')} }`;
@@ -841,10 +897,7 @@ export class PowqlInterface {
841
897
  const { rows, native, resolvedWhere } = await this.runFind(args, 'findMany');
842
898
  const entities = this.shape(rows, native);
843
899
  if (args.with) {
844
- await this.loadRelations(entities, args.with, args.timeout, 0, {
845
- args,
846
- resolvedWhere,
847
- });
900
+ await this.loadRelations(entities, args.with, args.timeout, 0, { args, resolvedWhere }, args.includePii === true);
848
901
  }
849
902
  return entities;
850
903
  });
@@ -861,7 +914,7 @@ export class PowqlInterface {
861
914
  }
862
915
  const resolvedWhere = await this.resolveRelationFilters(args.where, args.timeout);
863
916
  const where = this.buildWhere(resolvedWhere, params);
864
- const cols = this.projectedColumns(args.select, args.omit);
917
+ const cols = this.projectedColumns(args.select, args.omit, args.includePii === true);
865
918
  const distinct = args.distinct?.length ? ' distinct' : '';
866
919
  const filter = where ? ` filter ${where}` : '';
867
920
  const order = this.buildOrder(args.orderBy, params);
@@ -913,7 +966,7 @@ export class PowqlInterface {
913
966
  return null;
914
967
  const entities = this.shape(rows, native);
915
968
  if (args.with)
916
- await this.loadRelations(entities, args.with, args.timeout);
969
+ await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
917
970
  return entities[0];
918
971
  });
919
972
  }
@@ -924,7 +977,7 @@ export class PowqlInterface {
924
977
  return null;
925
978
  const entities = this.shape(rows, native);
926
979
  if (args.with)
927
- await this.loadRelations(entities, args.with, args.timeout);
980
+ await this.loadRelations(entities, args.with, args.timeout, 0, undefined, args.includePii === true);
928
981
  return entities[0];
929
982
  });
930
983
  }
@@ -954,7 +1007,7 @@ export class PowqlInterface {
954
1007
  * the default `'batched'` strategy) keeps the loaders. Output is byte-equal
955
1008
  * either way (the join reuses the same stitch / shape helpers).
956
1009
  */
957
- async loadRelations(parents, withClause, timeout, depth = 0, parent) {
1010
+ async loadRelations(parents, withClause, timeout, depth = 0, parent, includePii = false) {
958
1011
  if (depth >= 10) {
959
1012
  throw new ValidationError(`[turbine] Nested 'with' on PowDB exceeded depth 10 (relation cycle?).`);
960
1013
  }
@@ -974,7 +1027,7 @@ export class PowqlInterface {
974
1027
  throw new ValidationError(`[turbine] Unknown relation "${relName}" on "${this.table}".`);
975
1028
  if (strategyIsJoin && parent && this.joinEligible(rel, opt, parent.args, parents.length)) {
976
1029
  if (this.capabilities.serverJoins) {
977
- await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout);
1030
+ await this.loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
978
1031
  continue;
979
1032
  }
980
1033
  // An otherwise-eligible relation the engine cannot join: a PER-QUERY
@@ -986,7 +1039,7 @@ export class PowqlInterface {
986
1039
  }
987
1040
  }
988
1041
  if (rel.type === 'manyToMany') {
989
- await this.loadManyToMany(parents, rel, relName, opt, timeout);
1042
+ await this.loadManyToMany(parents, rel, relName, opt, timeout, includePii);
990
1043
  continue;
991
1044
  }
992
1045
  const fk = normalizeKeyColumns(rel.foreignKey);
@@ -1043,6 +1096,7 @@ export class PowqlInterface {
1043
1096
  where: childWhere,
1044
1097
  with: options.with,
1045
1098
  timeout: options.timeout ?? timeout,
1099
+ includePii,
1046
1100
  }));
1047
1101
  for (const child of children) {
1048
1102
  const k = this.joinKey(child[childKeyField]);
@@ -1079,7 +1133,7 @@ export class PowqlInterface {
1079
1133
  * single-key N+1 loaders; the junction's source/target columns must be single
1080
1134
  * (composite junction keys would need PowQL tuple-`in`, which it lacks).
1081
1135
  */
1082
- async loadManyToMany(parents, rel, relName, opt, timeout) {
1136
+ async loadManyToMany(parents, rel, relName, opt, timeout, includePii = false) {
1083
1137
  const through = rel.through;
1084
1138
  if (!through)
1085
1139
  throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
@@ -1143,6 +1197,7 @@ export class PowqlInterface {
1143
1197
  where,
1144
1198
  with: options.with,
1145
1199
  timeout: options.timeout ?? timeout,
1200
+ includePii,
1146
1201
  }));
1147
1202
  for (const t of targets)
1148
1203
  targetByPk.set(String(t[targetPkField]), t);
@@ -1260,9 +1315,9 @@ export class PowqlInterface {
1260
1315
  return tableMeta.indexes.some((idx) => idx.unique && !idx.docPath && idx.columns.length === 1 && idx.columns[0] === col);
1261
1316
  }
1262
1317
  /** Dispatch one eligible relation to the correct native-join loader. */
1263
- async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout) {
1318
+ async loadRelationViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
1264
1319
  if (rel.type === 'manyToMany') {
1265
- await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout);
1320
+ await this.loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii);
1266
1321
  return;
1267
1322
  }
1268
1323
  const options = (opt === true ? {} : opt);
@@ -1280,7 +1335,7 @@ export class PowqlInterface {
1280
1335
  const childKeyCol = rel.type === 'belongsTo' ? rk[0] : fk[0];
1281
1336
  const parentKeyField = this.meta.reverseColumnMap[parentKeyCol] ?? parentKeyCol;
1282
1337
  const params = [];
1283
- const childCols = this.joinChildCols(targetQi, options);
1338
+ const childCols = this.joinChildCols(targetQi, options, includePii);
1284
1339
  const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 'c', params, options.timeout ?? timeout);
1285
1340
  const order = targetQi.buildOrder(options.orderBy, params, 'c');
1286
1341
  const limitClause = options.limit !== undefined ? ` limit ${this.param(options.limit, params)}` : '';
@@ -1304,7 +1359,7 @@ export class PowqlInterface {
1304
1359
  * → the already-fetched side (alias `p`), correlating `__tpk` from the
1305
1360
  * junction's source key. Always a list, stitched exactly like the loader.
1306
1361
  */
1307
- async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout) {
1362
+ async loadManyToManyViaJoin(parents, rel, relName, opt, parent, timeout, includePii = false) {
1308
1363
  const through = rel.through;
1309
1364
  if (!through)
1310
1365
  throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing its junction (\`through\`).`);
@@ -1319,7 +1374,7 @@ export class PowqlInterface {
1319
1374
  const targetPkCol = targetMeta.primaryKey[0];
1320
1375
  const parentRefField = this.meta.reverseColumnMap[sourceRefCol] ?? sourceRefCol;
1321
1376
  const params = [];
1322
- const childCols = this.joinChildCols(targetQi, options);
1377
+ const childCols = this.joinChildCols(targetQi, options, includePii);
1323
1378
  const filter = await this.joinFilter(targetQi, parent.resolvedWhere, options.where, 't', params, options.timeout ?? timeout);
1324
1379
  const proj = this.joinProjection(childCols, `j.${quotePowqlIdent(sourceJCol)}`, 't');
1325
1380
  const powql = `${targetQi.qt} as t ` +
@@ -1338,8 +1393,8 @@ export class PowqlInterface {
1338
1393
  * with a loud guard: a real column named `__tpk` would collide with the
1339
1394
  * reserved correlation alias, so refuse rather than silently mis-stitch.
1340
1395
  */
1341
- joinChildCols(targetQi, options) {
1342
- const cols = targetQi.projectedColumns(options.select, options.omit);
1396
+ joinChildCols(targetQi, options, includePii = false) {
1397
+ const cols = targetQi.projectedColumns(options.select, options.omit, includePii);
1343
1398
  if (cols.includes('__tpk')) {
1344
1399
  throw new ValidationError(`[turbine] relation target "${targetQi.table}" has a column named "__tpk", which collides with the reserved ` +
1345
1400
  `join correlation alias. Rename the column or load this relation with relationLoadStrategy: 'batched'.`);
@@ -1468,7 +1523,7 @@ export class PowqlInterface {
1468
1523
  .join(', ');
1469
1524
  // `returning` surfaces the inserted row (all columns, schema order) in one round-trip.
1470
1525
  const { rows, native } = await this.exec(`insert ${this.qt} { ${body} } returning`, params, args.timeout, 'create');
1471
- const row = rows.length ? this.shape(rows, native)[0] : null;
1526
+ const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
1472
1527
  if (!row)
1473
1528
  throw new NotFoundError({ table: this.table, where: data });
1474
1529
  return row;
@@ -1486,7 +1541,7 @@ export class PowqlInterface {
1486
1541
  });
1487
1542
  // Multi-row insert with `returning` hands back every inserted row in one round-trip.
1488
1543
  const { rows, native } = await this.exec(`insert ${this.qt} ${tuples.join(', ')} returning`, params, args.timeout, 'createMany');
1489
- return this.shape(rows, native);
1544
+ return this.shape(rows, native).map((r) => this.stripWritePii(r));
1490
1545
  });
1491
1546
  }
1492
1547
  async update(args) {
@@ -1501,7 +1556,7 @@ export class PowqlInterface {
1501
1556
  const setClause = this.buildUpdateAssignments(args.data, params);
1502
1557
  // `returning` hands back the post-update row(s); take the first (single-row contract).
1503
1558
  const { rows, native } = await this.exec(`${this.qt} filter ${where} update { ${setClause} } returning`, params, args.timeout, 'update');
1504
- const row = rows.length ? this.shape(rows, native)[0] : null;
1559
+ const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
1505
1560
  if (!row)
1506
1561
  throw new NotFoundError({ table: this.table, where: args.where });
1507
1562
  return row;
@@ -1647,7 +1702,7 @@ export class PowqlInterface {
1647
1702
  this.assertCompiledWhere(where, false, 'delete');
1648
1703
  // `returning` hands back the deleted row(s) — no separate pre-image reselect needed.
1649
1704
  const { rows, native } = await this.exec(`${this.qt} filter ${where} delete returning`, params, args.timeout, 'delete');
1650
- const row = rows.length ? this.shape(rows, native)[0] : null;
1705
+ const row = rows.length ? this.stripWritePii(this.shape(rows, native)[0]) : null;
1651
1706
  if (!row)
1652
1707
  throw new NotFoundError({ table: this.table, where: args.where });
1653
1708
  return row;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * turbine-orm: aggregate / groupBy compilation (extracted from builder.ts)
3
+ *
4
+ * buildAggregate + buildGroupBy and their helpers (HAVING clauses, groupBy
5
+ * ordering, DISTINCT-ON sources, JSON-path aggregate targets). All functions
6
+ * take a {@link BuilderCtx} first argument; WHERE compilation is reused from
7
+ * where.ts (via `whereMod`), and the shared orderBy / row-parse primitives
8
+ * stay class-resident, reached through the ctx. See builder.ts for the thin
9
+ * delegating methods (buildGroupBy / buildAggregate).
10
+ */
11
+ import type { DeferredQuery } from './deferred.js';
12
+ import type { AggregateArgs, AggregateResult, GroupByArgs, GroupByOrderBy, HavingClause, HavingFilter } from './types.js';
13
+ import type { BuilderCtx } from './where.js';
14
+ export declare function buildGroupBy<T extends object>(qi: BuilderCtx, args: GroupByArgs<T>): DeferredQuery<Record<string, unknown>[]>;
15
+ /**
16
+ * Compile a groupBy `orderBy` into an ORDER BY body. Unlike findMany ORDER BY
17
+ * ({@link buildOrderBy}, which validates keys against the table's physical
18
+ * columns), groupBy ordering targets the columns the RESULT actually
19
+ * contains: plain by-fields, JSON group-key aliases, and requested aggregates
20
+ * (`_count` / `_sum` / `_avg` / `_min` / `_max`). Each key re-emits the exact
21
+ * SELECT expression that produced it (`byOrderExprs` / `aggOrderExprs`),
22
+ * mirroring how HAVING re-emits aggregate expressions, so no dialect ever has
23
+ * to accept a SELECT-alias reference in ORDER BY, and any already-bound
24
+ * JSON-path placeholder is reused verbatim (ORDER BY is the last clause, so
25
+ * no `$n` renumbering). An aggregate key that was not requested, or an unknown
26
+ * by-key, throws {@link ValidationError} E003 listing the valid keys.
27
+ */
28
+ export declare function buildGroupByOrderBy(qi: BuilderCtx, orderBy: GroupByOrderBy, byOrderExprs: Map<string, string>, aggOrderExprs: Map<string, string>): string;
29
+ /**
30
+ * Validate a JSON-path target (group key or aggregate target) in groupBy:
31
+ * the field must resolve to a real json/jsonb column and the path must be a
32
+ * non-empty array of keys/indexes. Returns the resolved snake_case column.
33
+ */
34
+ export declare function resolveJsonPathTarget(qi: BuilderCtx, context: string, field: string, path: (string | number)[]): string;
35
+ /**
36
+ * Build the `distinctOn` row source for groupBy (PostgreSQL only: other
37
+ * engines throw {@link UnsupportedFeatureError} E017):
38
+ *
39
+ * ```sql
40
+ * (SELECT DISTINCT ON ("c1") * FROM "table"<WHERE> ORDER BY "c1", <orderBy>) AS "table"
41
+ * ```
42
+ *
43
+ * The wrapper is aliased as the table name so every outer expression (group
44
+ * keys, aggregates, HAVING, ORDER BY) is byte-identical to the plain path.
45
+ * `distinctOn.orderBy` is required (it decides which row survives) and
46
+ * supports plain columns, {@link OrderBySpec} nulls, and JSON-path specs;
47
+ * JSON paths push their text[] param here, after the WHERE params.
48
+ */
49
+ export declare function buildDistinctOnSource<T extends object>(qi: BuilderCtx, distinctOn: NonNullable<GroupByArgs<T>['distinctOn']>, whereSql: string, params: unknown[]): string;
50
+ /**
51
+ * Build the SQL fragments for a {@link HavingClause}.
52
+ *
53
+ * Each aggregate expression (`COUNT(*)`, `SUM("col")`, etc.) is constructed
54
+ * from a **schema-validated, quoted** column identifier: `qi.toColumn()`
55
+ * throws {@link ValidationError} for unknown fields and `qi.q()` quotes via
56
+ * the dialect, so no unvalidated identifier ever reaches the SQL string. Every
57
+ * comparison value is pushed onto the shared `params` array and referenced by
58
+ * a `$N` placeholder via {@link buildHavingNumericClauses} — there is no string
59
+ * interpolation of user values.
60
+ *
61
+ * `jsonAggExprs` (from {@link buildGroupBy}) maps `alias:aggKey` to the
62
+ * exact aggregate expression a JSON-path aggregate emitted in SELECT
63
+ * (including its already-bound path placeholder), so HAVING on a JSON-path
64
+ * aggregate alias reuses the same expression instead of resolving the alias
65
+ * as a column.
66
+ */
67
+ export declare function buildHavingClauses<T extends object>(qi: BuilderCtx, having: HavingClause<T>, params: unknown[], jsonAggExprs?: Map<string, string>): string[];
68
+ /**
69
+ * Convert a single having filter into one or more parameterized SQL
70
+ * comparisons against the given aggregate expression. A bare number is
71
+ * shorthand for equality. Unknown operator keys throw {@link ValidationError}.
72
+ */
73
+ export declare function buildHavingNumericClauses(qi: BuilderCtx, expr: string, filter: HavingFilter, params: unknown[]): string[];
74
+ export declare function buildAggregate<T extends object>(qi: BuilderCtx, args: AggregateArgs<T>): DeferredQuery<AggregateResult<T>>;