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
@@ -118,6 +118,152 @@ function pkWhere(tableMeta, row) {
118
118
  }
119
119
  return where;
120
120
  }
121
+ /**
122
+ * The child-side predicate that ties a hasMany/hasOne relation's rows to THIS
123
+ * parent: `child.foreignKey = parent.referenceKey`. This is the exact
124
+ * correlation the connect/connectOrCreate/set paths already write when they
125
+ * point a child AT the parent, read back here as a filter.
126
+ *
127
+ * Returns `null` when the parent's reference key is null/undefined: SQL
128
+ * equality never matches NULL, so no child can be related and every scoped
129
+ * operation must report not-found rather than run unscoped.
130
+ */
131
+ function parentCorrelationWhere(ctx, rel, parentRow) {
132
+ const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
133
+ const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
134
+ const childTable = ctx.schema.tables[rel.to];
135
+ const parentTable = ctx.schema.tables[rel.from];
136
+ const where = {};
137
+ for (let i = 0; i < fks.length; i++) {
138
+ const fkField = childTable?.reverseColumnMap[fks[i]] ?? fks[i];
139
+ const refField = parentTable?.reverseColumnMap[refs[i]] ?? refs[i];
140
+ const value = parentRow[refField];
141
+ if (value === null || value === undefined)
142
+ return null;
143
+ where[fkField] = value;
144
+ }
145
+ return where;
146
+ }
147
+ /**
148
+ * The related-side predicate for a belongsTo relation: the parent holds the
149
+ * foreign key, so the related row this parent points at satisfies
150
+ * `related.referenceKey = parent.foreignKey`. The mirror image of
151
+ * {@link parentCorrelationWhere}, which reads the hasMany/hasOne direction.
152
+ *
153
+ * Returns `null` when the parent's foreign key is null/undefined: the parent
154
+ * points at nothing, so no related row is in scope.
155
+ */
156
+ function belongsToCorrelationWhere(ctx, rel, parentRow, parentTable) {
157
+ const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
158
+ const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
159
+ const parentMeta = ctx.schema.tables[parentTable];
160
+ const relatedTable = ctx.schema.tables[rel.to];
161
+ const where = {};
162
+ for (let i = 0; i < fks.length; i++) {
163
+ const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
164
+ const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
165
+ const value = parentRow[fkField];
166
+ if (value === null || value === undefined)
167
+ return null;
168
+ where[refField] = value;
169
+ }
170
+ return where;
171
+ }
172
+ /**
173
+ * AND the caller-supplied child `where` with the parent correlation so a nested
174
+ * delete/update/disconnect can only ever touch rows that actually belong to the
175
+ * parent being written.
176
+ *
177
+ * The flat merge is used whenever the two predicates name disjoint fields (the
178
+ * overwhelmingly common case: the caller addresses the child by its primary
179
+ * key). It keeps every caller key at the top level, so compound-unique selector
180
+ * expansion still sees them. When the caller's `where` names a correlation
181
+ * field itself, the two are combined with `AND` instead, so neither predicate
182
+ * can silently overwrite the other.
183
+ */
184
+ function scopeWhereToParent(target, correlation) {
185
+ for (const key of Object.keys(correlation)) {
186
+ if (Object.hasOwn(target, key))
187
+ return { AND: [target, correlation] };
188
+ }
189
+ return { ...target, ...correlation };
190
+ }
191
+ /**
192
+ * The caller's own selector must name at least one bound value.
193
+ *
194
+ * The parent correlation is ANDed onto every nested delete/update/disconnect
195
+ * target, which makes the merged predicate non-empty by construction. That
196
+ * defeats the empty-where guard downstream: `delete: {}` or
197
+ * `delete: { id: req.body.postId }` with an undefined body field would compile
198
+ * to `WHERE user_id = $1` and remove EVERY child of this parent instead of
199
+ * throwing. So the caller's half is checked here, before the merge.
200
+ *
201
+ * `true` is accepted only on a to-one relation, where it means "the single
202
+ * related row" and the correlation alone identifies it. On a to-many it would
203
+ * mean "all of them", which is never what a caller spelled `delete: true` for.
204
+ */
205
+ function assertTargetSelectsSomething(target, op, relName, rel) {
206
+ const toOne = rel.type === 'hasOne' || rel.type === 'belongsTo';
207
+ if (target === true) {
208
+ if (toOne)
209
+ return;
210
+ throw new errors_js_1.ValidationError(`[turbine] Nested ${op} on to-many relation "${relName}" needs a "where" selector: ` +
211
+ `"${op}: true" would ${op} every related "${rel.to}" row.`);
212
+ }
213
+ if (target && typeof target === 'object' && !Array.isArray(target)) {
214
+ const bound = Object.values(target).some((v) => v !== undefined);
215
+ if (bound)
216
+ return;
217
+ }
218
+ throw new errors_js_1.ValidationError(`[turbine] Nested ${op} on relation "${relName}" requires a selector with at least one defined value. ` +
219
+ `An empty or all-undefined "where" would ${op} every related "${rel.to}" row of this parent.`);
220
+ }
221
+ /**
222
+ * many-to-many relations have no nested-write branch on any engine: the junction
223
+ * row would have to be written too, and there is no safe default for what to put
224
+ * in its extra columns. Refusing loudly is the only honest option, because the
225
+ * alternative (falling off the end of the dispatch) drops the write silently and
226
+ * reports success.
227
+ */
228
+ function manyToManyUnsupported(relName, rel) {
229
+ return new errors_js_1.ValidationError(`[turbine] Nested writes are not supported on the many-to-many relation "${relName}" ` +
230
+ `(via the "${rel.through?.table ?? 'junction'}" junction table). Write the junction rows directly ` +
231
+ `(db.${rel.through?.table ?? 'junction'}.create / createMany) inside the same $transaction.`);
232
+ }
233
+ /**
234
+ * The E001 raised when a nested delete/update/disconnect target is not a child
235
+ * of this parent (it belongs to another parent, or does not exist at all).
236
+ * Matches Prisma's behavior: the nested where is scoped to the relation, so a
237
+ * row outside the relation is simply "not found".
238
+ */
239
+ function notRelatedToParent(op, relName, rel, target) {
240
+ // Object form, not the legacy string one: `err.table` / `err.where` /
241
+ // `err.operation` are a documented part of the NotFoundError contract, and a
242
+ // string-constructed error silently drops all three.
243
+ return new errors_js_1.NotFoundError({
244
+ table: rel.to,
245
+ where: target,
246
+ operation: `nested ${op}`,
247
+ message: `[turbine] Nested ${op} on relation "${relName}": no "${rel.to}" record matching ` +
248
+ `${(0, errors_js_1.describeTargetForMessage)(target)} is related to this parent. Either it does not exist, ` +
249
+ `or it belongs to a different parent (a nested ${op} can only touch this parent's rows).`,
250
+ });
251
+ }
252
+ /** Re-tag a NotFoundError from a scoped child write as a relation-scoped miss. */
253
+ function rethrowAsNotRelated(err, op, relName, rel, target) {
254
+ if (err instanceof errors_js_1.NotFoundError) {
255
+ // Only re-tag a miss on THIS relation's own write. A NotFoundError raised
256
+ // deeper in the tree already names its own relation, and re-tagging it here
257
+ // would rename someone else's failure; keep it, and preserve the original
258
+ // as `cause` in either direction.
259
+ if (err.operation?.startsWith('nested '))
260
+ throw err;
261
+ const tagged = notRelatedToParent(op, relName, rel, target);
262
+ tagged.cause ??= err;
263
+ throw tagged;
264
+ }
265
+ throw err;
266
+ }
121
267
  // ---------------------------------------------------------------------------
122
268
  // executeNestedCreate
123
269
  // ---------------------------------------------------------------------------
@@ -155,6 +301,9 @@ async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
155
301
  if (rel.type === 'belongsTo') {
156
302
  Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
157
303
  }
304
+ else if (rel.type === 'manyToMany') {
305
+ throw manyToManyUnsupported(relName, rel);
306
+ }
158
307
  }
159
308
  // Insert the parent row (scalars + resolved belongsTo foreign keys)
160
309
  const parentRow = (await ctx.tx.table(tableName).create({
@@ -225,7 +374,7 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
225
374
  await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
226
375
  // disconnect
227
376
  if (ops.disconnect !== undefined) {
228
- await processDisconnect(ctx, rel, ops.disconnect, relName);
377
+ await processDisconnect(ctx, rel, ops.disconnect, relName, parentRow);
229
378
  }
230
379
  // set
231
380
  if (ops.set !== undefined) {
@@ -233,15 +382,15 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
233
382
  }
234
383
  // delete
235
384
  if (ops.delete !== undefined) {
236
- await processDelete(ctx, rel, ops.delete);
385
+ await processDelete(ctx, rel, ops.delete, relName, parentRow);
237
386
  }
238
387
  // update
239
388
  if (ops.update !== undefined) {
240
- await processNestedUpdate(ctx, rel, ops.update);
389
+ await processNestedUpdate(ctx, rel, ops.update, relName, parentRow);
241
390
  }
242
391
  // upsert
243
392
  if (ops.upsert !== undefined) {
244
- await processNestedUpsert(ctx, rel, ops.upsert, parentRow);
393
+ await processNestedUpsert(ctx, rel, ops.upsert, parentRow, relName);
245
394
  }
246
395
  }
247
396
  else if (rel.type === 'belongsTo') {
@@ -275,6 +424,9 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
275
424
  });
276
425
  }
277
426
  }
427
+ else {
428
+ throw manyToManyUnsupported(relName, rel);
429
+ }
278
430
  }
279
431
  // Final read with all touched relations
280
432
  const withClause = {};
@@ -481,7 +633,7 @@ async function connectOrCreate(ctx, rel, op, parentRow) {
481
633
  await ctx.tx.table(rel.to).update({ where: op.where, data: updateData });
482
634
  }
483
635
  }
484
- async function processDisconnect(ctx, rel, disconnectArg, relName) {
636
+ async function processDisconnect(ctx, rel, disconnectArg, relName, parentRow) {
485
637
  const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
486
638
  const childTable = ctx.schema.tables[rel.to];
487
639
  if (!childTable)
@@ -495,13 +647,26 @@ async function processDisconnect(ctx, rel, disconnectArg, relName) {
495
647
  throw new errors_js_1.ValidationError(`[turbine] Cannot disconnect "${relName}": foreign key column(s) ${fks.join(', ')} on "${rel.to}" are NOT NULL. Use delete instead.`);
496
648
  }
497
649
  const items = toArray(disconnectArg);
650
+ if (items.length === 0)
651
+ return;
498
652
  const nullData = {};
499
653
  for (const fk of fks) {
500
654
  const field = childTable.reverseColumnMap[fk] ?? fk;
501
655
  nullData[field] = null;
502
656
  }
657
+ // Disconnect nulls the child's FK, so an unscoped target would strip ANOTHER
658
+ // parent's child off that parent. Scope every target to this parent's rows.
659
+ const correlation = parentCorrelationWhere(ctx, rel, parentRow);
503
660
  for (const target of items) {
504
- await ctx.tx.table(rel.to).update({ where: target, data: nullData });
661
+ assertTargetSelectsSomething(target, 'disconnect', relName, rel);
662
+ if (!correlation)
663
+ throw notRelatedToParent('disconnect', relName, rel, target);
664
+ try {
665
+ await ctx.tx.table(rel.to).update({ where: scopeWhereToParent(target, correlation), data: nullData });
666
+ }
667
+ catch (err) {
668
+ rethrowAsNotRelated(err, 'disconnect', relName, rel, target);
669
+ }
505
670
  }
506
671
  }
507
672
  async function processSet(ctx, rel, setItems, parentRow) {
@@ -510,12 +675,23 @@ async function processSet(ctx, rel, setItems, parentRow) {
510
675
  const childTable = ctx.schema.tables[rel.to];
511
676
  if (!childTable)
512
677
  return;
513
- // Build parent FK match for finding current children
678
+ // Build parent FK match for finding current children. `set` clears the
679
+ // current children with `allowFullTableScan: true`, which is exactly what
680
+ // disables the empty-where guard downstream, so a null/undefined reference
681
+ // key here would null the FK of EVERY row in the child table. Same guard as
682
+ // parentCorrelationWhere, spelled inline because `set` also needs the
683
+ // (identical) forward direction for the reconnect below.
514
684
  const parentWhere = {};
515
685
  for (let i = 0; i < fks.length; i++) {
516
686
  const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
517
687
  const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
518
- parentWhere[fkField] = parentRow[refField];
688
+ const value = parentRow[refField];
689
+ if (value === null || value === undefined) {
690
+ throw new errors_js_1.ValidationError(`[turbine] Nested set on relation "${rel.name}" cannot run: the parent's reference key ` +
691
+ `"${refField}" is ${value === null ? 'null' : 'missing from the loaded row'}, so no child rows ` +
692
+ `can be correlated to this parent.`);
693
+ }
694
+ parentWhere[fkField] = value;
519
695
  }
520
696
  // Disconnect all current children
521
697
  const nullData = {};
@@ -542,24 +718,49 @@ async function processSet(ctx, rel, setItems, parentRow) {
542
718
  // ---------------------------------------------------------------------------
543
719
  // update / upsert operations (update-context only)
544
720
  // ---------------------------------------------------------------------------
545
- async function processNestedUpdate(ctx, rel, updateArg) {
721
+ async function processNestedUpdate(ctx, rel, updateArg, relName, parentRow) {
546
722
  const items = toArray(updateArg);
723
+ if (items.length === 0)
724
+ return;
725
+ const correlation = parentCorrelationWhere(ctx, rel, parentRow);
547
726
  for (const item of items) {
548
727
  if (!item.where || !item.data) {
549
728
  throw new errors_js_1.ValidationError(`[turbine] Nested update on "${rel.name}" requires both "where" and "data" fields.`);
550
729
  }
551
- await ctx.tx.table(rel.to).update({ where: item.where, data: item.data });
730
+ assertTargetSelectsSomething(item.where, 'update', relName, rel);
731
+ if (!correlation)
732
+ throw notRelatedToParent('update', relName, rel, item.where);
733
+ try {
734
+ await ctx.tx.table(rel.to).update({ where: scopeWhereToParent(item.where, correlation), data: item.data });
735
+ }
736
+ catch (err) {
737
+ rethrowAsNotRelated(err, 'update', relName, rel, item.where);
738
+ }
552
739
  }
553
740
  }
554
- async function processNestedUpsert(ctx, rel, upsertArg, parentRow) {
741
+ async function processNestedUpsert(ctx, rel, upsertArg, parentRow, relName) {
555
742
  const items = toArray(upsertArg);
743
+ if (items.length === 0)
744
+ return;
745
+ // The upsert's `where` is scoped to the relation, so a row that matches it but
746
+ // belongs to ANOTHER parent is not "existing" here: it is never updated, and
747
+ // the create branch runs instead (any resulting unique-constraint violation is
748
+ // surfaced to the caller rather than silently rewriting a stranger's row).
749
+ const correlation = parentCorrelationWhere(ctx, rel, parentRow);
556
750
  for (const item of items) {
557
751
  if (!item.where || !item.create || !item.update) {
558
752
  throw new errors_js_1.ValidationError(`[turbine] Nested upsert on "${rel.name}" requires "where", "create", and "update" fields.`);
559
753
  }
560
- const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
561
- if (existing) {
562
- await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
754
+ assertTargetSelectsSomething(item.where, 'upsert', relName, rel);
755
+ const scoped = correlation ? scopeWhereToParent(item.where, correlation) : null;
756
+ const existing = scoped ? await ctx.tx.table(rel.to).findUnique({ where: scoped }) : null;
757
+ if (existing && scoped) {
758
+ try {
759
+ await ctx.tx.table(rel.to).update({ where: scoped, data: item.update });
760
+ }
761
+ catch (err) {
762
+ rethrowAsNotRelated(err, 'upsert', relName, rel, item.where);
763
+ }
563
764
  }
564
765
  else {
565
766
  const injected = injectForeignKey(item.create, rel, parentRow, ctx.schema);
@@ -590,9 +791,25 @@ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTabl
590
791
  if (!item.where || !item.create || !item.update) {
591
792
  throw new errors_js_1.ValidationError(`[turbine] Nested upsert on belongsTo "${rel.name}" requires "where", "create", and "update" fields.`);
592
793
  }
593
- const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
794
+ // Scope the lookup to the row this parent actually points at. Without the
795
+ // correlation, a `where` naming any other row would update a record with no
796
+ // relationship to the parent being written. A miss falls through to the
797
+ // create branch (and re-points the parent's FK), which is the same choice the
798
+ // hasMany/hasOne nested upsert makes: an upsert whose target is out of the
799
+ // relation creates a row owned by this parent rather than rewriting a
800
+ // stranger's.
801
+ const correlation = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
802
+ // findMany, not findUnique: the scoped where ANDs a non-unique-looking
803
+ // correlation onto the caller's selector. The correlation targets the
804
+ // relation's reference key (unique or the PK by construction), so at most one
805
+ // row can come back.
806
+ const existing = correlation
807
+ ? ((await ctx.tx.table(rel.to).findMany({ where: scopeWhereToParent(item.where, correlation) }))[0] ??
808
+ null)
809
+ : null;
594
810
  if (existing) {
595
- await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
811
+ const relatedMeta = ctx.schema.tables[rel.to];
812
+ await ctx.tx.table(rel.to).update({ where: pkWhere(relatedMeta, existing), data: item.update });
596
813
  }
597
814
  else {
598
815
  // Create the related row, then update parent's FK to point at it
@@ -613,9 +830,22 @@ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTabl
613
830
  });
614
831
  }
615
832
  }
616
- async function processDelete(ctx, rel, deleteArg) {
833
+ async function processDelete(ctx, rel, deleteArg, relName, parentRow) {
617
834
  const items = toArray(deleteArg);
835
+ if (items.length === 0)
836
+ return;
837
+ // Without the correlation this deletes ANY row the caller can name: a
838
+ // cross-tenant delete primitive for any endpoint that forwards a client id.
839
+ const correlation = parentCorrelationWhere(ctx, rel, parentRow);
618
840
  for (const target of items) {
619
- await ctx.tx.table(rel.to).delete({ where: target });
841
+ assertTargetSelectsSomething(target, 'delete', relName, rel);
842
+ if (!correlation)
843
+ throw notRelatedToParent('delete', relName, rel, target);
844
+ try {
845
+ await ctx.tx.table(rel.to).delete({ where: scopeWhereToParent(target, correlation) });
846
+ }
847
+ catch (err) {
848
+ rethrowAsNotRelated(err, 'delete', relName, rel, target);
849
+ }
620
850
  }
621
851
  }
@@ -21,6 +21,15 @@ exports.ObserveEngine = exports.HttpJsonSink = exports.PgMetricsSink = void 0;
21
21
  exports.floorToMinute = floorToMinute;
22
22
  exports.percentile = percentile;
23
23
  const pg_1 = __importDefault(require("pg"));
24
+ const errors_js_1 = require("./errors.js");
25
+ /**
26
+ * Buffer key: minute bucket + model + action. Joined on a NUL, which cannot
27
+ * appear in a table name or an action, so `model: 'a:b'` and `action: 'c'`
28
+ * can never collapse into the same series as `model: 'a'` / `action: 'b:c'`.
29
+ */
30
+ function bufferKey(bucket, model, action) {
31
+ return `${bucket.getTime()}\u0000${model}\u0000${action}`;
32
+ }
24
33
  function floorToMinute(date) {
25
34
  const d = new Date(date);
26
35
  d.setSeconds(0, 0);
@@ -149,31 +158,28 @@ exports.HttpJsonSink = HttpJsonSink;
149
158
  class ObserveEngine {
150
159
  sink;
151
160
  buffer = new Map();
152
- currentBucket;
153
161
  flushIntervalMs;
154
162
  timer;
155
163
  listener;
156
164
  stopped = false;
157
165
  constructor(config) {
158
166
  if (!config.sink && !config.connectionString) {
159
- throw new Error('ObserveEngine requires either a connectionString or a sink');
167
+ throw new errors_js_1.ValidationError('ObserveEngine requires either a connectionString or a sink');
160
168
  }
161
169
  this.sink =
162
170
  config.sink ??
163
171
  new PgMetricsSink({ connectionString: config.connectionString, retentionDays: config.retentionDays ?? 30 });
164
172
  this.flushIntervalMs = config.flushIntervalMs ?? 60_000;
165
- this.currentBucket = floorToMinute(new Date());
166
173
  this.listener = (event) => {
167
174
  if (this.stopped)
168
175
  return;
169
- const nowBucket = floorToMinute(new Date());
170
- if (nowBucket.getTime() !== this.currentBucket.getTime()) {
171
- this.currentBucket = nowBucket;
172
- }
173
- const key = `${event.model}:${event.action}`;
176
+ // Bucket by the event's own timestamp so a late-arriving event is
177
+ // attributed to the minute it happened in, not the minute it was seen.
178
+ const bucket = floorToMinute(event.timestamp);
179
+ const key = bufferKey(bucket, event.model, event.action);
174
180
  let entry = this.buffer.get(key);
175
181
  if (!entry) {
176
- entry = { durations: [], errors: 0 };
182
+ entry = { bucket, model: event.model, action: event.action, durations: [], errors: 0 };
177
183
  this.buffer.set(key, entry);
178
184
  }
179
185
  entry.durations.push(event.duration);
@@ -197,19 +203,19 @@ class ObserveEngine {
197
203
  async flush() {
198
204
  if (this.buffer.size === 0)
199
205
  return;
200
- const bucket = this.currentBucket;
201
206
  const entries = new Map(this.buffer);
202
207
  this.buffer.clear();
203
208
  const rows = [];
204
- for (const [key, entry] of entries) {
205
- const [model, action] = key.split(':');
209
+ for (const entry of entries.values()) {
206
210
  const sorted = entry.durations.slice().sort((a, b) => a - b);
207
211
  const count = sorted.length;
208
212
  const avg = sorted.reduce((s, v) => s + v, 0) / count;
209
213
  rows.push({
210
- bucket,
211
- model: model ?? '',
212
- action: action ?? '',
214
+ // Each entry carries the minute it accumulated in, so a flush that
215
+ // spans a rollover emits one correctly stamped row per minute.
216
+ bucket: entry.bucket,
217
+ model: entry.model,
218
+ action: entry.action,
213
219
  count,
214
220
  avg,
215
221
  p50: percentile(sorted, 0.5),
package/dist/cjs/powdb.js CHANGED
@@ -148,6 +148,9 @@ exports.powdbDialect = {
148
148
  resultStrategy: 'returning',
149
149
  supportsReturning: true,
150
150
  supportsVector: false,
151
+ // PowQL has no tsvector/tsquery surface and no array column type.
152
+ supportsFullTextSearch: false,
153
+ supportsArrayColumns: false,
151
154
  supportsListenNotify: false,
152
155
  supportsRLS: false,
153
156
  supportsAdvisoryLock: false,
package/dist/cjs/powql.js CHANGED
@@ -74,6 +74,7 @@ const node_crypto_1 = require("node:crypto");
74
74
  const errors_js_1 = require("./errors.js");
75
75
  const nested_write_js_1 = require("./nested-write.js");
76
76
  const powdb_js_1 = require("./powdb.js");
77
+ const aggregates_js_1 = require("./query/aggregates.js");
77
78
  const compound_unique_js_1 = require("./query/compound-unique.js");
78
79
  const filters_js_1 = require("./query/filters.js");
79
80
  const utils_js_1 = require("./query/utils.js");
@@ -2282,6 +2283,10 @@ class PowqlInterface {
2282
2283
  const acc = {};
2283
2284
  for (const field of Object.keys(spec).filter((f) => spec[f])) {
2284
2285
  const powfn = fn.slice(1); // sum/avg/min/max
2286
+ // Same PII contract as the SQL engines: _min/_max return a stored cell.
2287
+ if (fn === '_min' || fn === '_max') {
2288
+ (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, field, this.column(field).name, `aggregate ${fn}`, args.includePii);
2289
+ }
2285
2290
  acc[field] = await scalar(`${powfn}(${this.qt}${filter} { ${this.ref(field)} })`);
2286
2291
  }
2287
2292
  result[fn] = acc;
@@ -2333,6 +2338,7 @@ class PowqlInterface {
2333
2338
  for (const entry of args.by) {
2334
2339
  if (typeof entry === 'string') {
2335
2340
  const col = this.column(entry);
2341
+ (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, entry, col.name, 'groupBy `by` key', args.includePii);
2336
2342
  claim(entry, `column "${col.name}"`);
2337
2343
  if (col.name !== entry)
2338
2344
  claim(col.name, `column "${col.name}"`);
@@ -2346,6 +2352,7 @@ class PowqlInterface {
2346
2352
  if (!(0, powdb_js_1.isJsonColumn)(col)) {
2347
2353
  throw new errors_js_1.ValidationError(`[turbine] groupBy JSON group key on "${entry.field}" (table "${this.table}") requires a json column.`);
2348
2354
  }
2355
+ (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, entry.field, col.name, 'groupBy JSON `by` key', args.includePii);
2349
2356
  this.assertJsonPath('group key', entry.field, entry.path);
2350
2357
  const pathExpr = this.jsonPathExpr(col, entry.path, params);
2351
2358
  const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
@@ -2395,6 +2402,9 @@ class PowqlInterface {
2395
2402
  const alias = `agg_${aggN++}`;
2396
2403
  if (target === true) {
2397
2404
  const col = this.column(key);
2405
+ if (fn === '_min' || fn === '_max') {
2406
+ (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, key, col.name, `groupBy ${fn}`, args.includePii);
2407
+ }
2398
2408
  claim(`${fn}_${col.name}`, `${fn} of column "${col.name}"`);
2399
2409
  const inner = `.${col.name}`;
2400
2410
  proj.push(`${alias}: ${powfn}(${inner})`);
@@ -2407,6 +2417,9 @@ class PowqlInterface {
2407
2417
  if (!(0, powdb_js_1.isJsonColumn)(col)) {
2408
2418
  throw new errors_js_1.ValidationError(`[turbine] groupBy ${fn} target "${key}" on "${target.field}" (table "${this.table}") requires a json column.`);
2409
2419
  }
2420
+ if (fn === '_min' || fn === '_max') {
2421
+ (0, aggregates_js_1.assertAggregatePiiOptIn)(this.table, this.meta, target.field, col.name, `groupBy ${fn} JSON target`, args.includePii);
2422
+ }
2410
2423
  this.assertJsonPath(`${fn} target "${key}"`, target.field, target.path);
2411
2424
  const alwaysNumeric = fn === '_sum' || fn === '_avg';
2412
2425
  if (alwaysNumeric && target.type === 'text') {
@@ -421,6 +421,11 @@ function translateReadArgs(ctx, mm, prismaArgs) {
421
421
  t.relationLoadStrategy = prismaArgs.relationLoadStrategy;
422
422
  if (typeof prismaArgs.timeout === 'number')
423
423
  t.timeout = prismaArgs.timeout;
424
+ // Turbine-only passthrough. Prisma has no PII concept, so a compat caller
425
+ // whose schema tags columns needs SOME way to opt in; without this the
426
+ // adapter is a one-way door into redacted reads and refused aggregates.
427
+ if (prismaArgs.includePii !== undefined)
428
+ t.includePii = prismaArgs.includePii;
424
429
  if (ctx.options.stablePkOrder)
425
430
  t.stableRelationOrder = true;
426
431
  translateCursor(ctx, mm, prismaArgs, t);
@@ -655,6 +660,10 @@ function translateAggregateArgs(ctx, mm, args, isGroupBy) {
655
660
  }
656
661
  if (typeof args.timeout === 'number')
657
662
  t.timeout = args.timeout;
663
+ // Turbine-only passthrough: the PII gate on groupBy keys and _min/_max needs
664
+ // an opt-in that Prisma's arg shape has no equivalent for.
665
+ if (args.includePii !== undefined)
666
+ t.includePii = args.includePii;
658
667
  if (isGroupBy) {
659
668
  if (Array.isArray(args.by))
660
669
  t.by = args.by.map((f) => renameField(mm, f));
@@ -43,6 +43,7 @@ var __importStar = (this && this.__importStar) || (function () {
43
43
  };
44
44
  })();
45
45
  Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.assertAggregatePiiOptIn = assertAggregatePiiOptIn;
46
47
  exports.buildGroupBy = buildGroupBy;
47
48
  exports.buildGroupByOrderBy = buildGroupByOrderBy;
48
49
  exports.resolveJsonPathTarget = resolveJsonPathTarget;
@@ -54,6 +55,34 @@ const errors_js_1 = require("../errors.js");
54
55
  const schema_js_1 = require("../schema.js");
55
56
  const filters_js_1 = require("./filters.js");
56
57
  const whereMod = __importStar(require("./where.js"));
58
+ /**
59
+ * Enforce the PII contract on the aggregate surface. A PII-tagged
60
+ * (`defineSchema` `pii: true`) column is excluded from every default
61
+ * projection, and a value-returning aggregate is a projection by another name:
62
+ * `groupBy({ by: ['email'] })` emits one row per distinct plaintext email, and
63
+ * `_min`/`_max` return a stored cell verbatim. Both therefore REQUIRE the same
64
+ * `includePii: true` opt-in reads use.
65
+ *
66
+ * Deliberately NOT gated: `_count` (a count, never a value), `_sum` / `_avg`
67
+ * (a computed total across many rows, not a stored cell), and `where` /
68
+ * `orderBy` / `having` on PII columns (they return no values at all). Untagged
69
+ * schemas short-circuit on the `pii` lookup, so their SQL is byte-identical.
70
+ *
71
+ * Shared with the PowQL aggregate paths (src/powql.ts) so every engine applies
72
+ * one policy.
73
+ */
74
+ function assertAggregatePiiOptIn(table, meta, field, column, usage, includePii) {
75
+ if (includePii === true || !meta)
76
+ return;
77
+ const colMeta = meta.columns.find((c) => c.name === column);
78
+ if (!colMeta?.pii)
79
+ return;
80
+ throw new errors_js_1.ValidationError(`[turbine] ${usage} on column "${field}" of table "${table}" is refused: that column is ` +
81
+ 'PII-tagged (`pii: true`), and this aggregate returns its stored values, which are excluded ' +
82
+ 'from every default projection. Pass `includePii: true` on this call to opt in. ' +
83
+ '`_count` over a PII column (a count, not a value) and `where` / `orderBy` / `having` on PII ' +
84
+ 'columns need no opt-in.');
85
+ }
57
86
  function buildGroupBy(qi, args) {
58
87
  const meta = qi.schema.tables[qi.table];
59
88
  if (meta) {
@@ -107,6 +136,7 @@ function buildGroupBy(qi, args) {
107
136
  for (const entry of args.by) {
108
137
  if (typeof entry === 'string') {
109
138
  const col = qi.toColumn(entry);
139
+ assertAggregatePiiOptIn(qi.table, meta, entry, col, 'groupBy `by` key', args.includePii);
110
140
  claimResultKey(entry, `column "${col}"`);
111
141
  // The emitted output column is the snake_case name; claim it too (when
112
142
  // it differs from the result key) so a JSON alias like 'created_at'
@@ -120,6 +150,7 @@ function buildGroupBy(qi, args) {
120
150
  }
121
151
  else {
122
152
  const col = resolveJsonPathTarget(qi, 'group key', entry.field, entry.path);
153
+ assertAggregatePiiOptIn(qi.table, meta, entry.field, col, 'groupBy JSON `by` key', args.includePii);
123
154
  params.push(whereMod.jsonPathParam(qi, entry.path));
124
155
  const extract = qi.dialect.buildJsonPathExtract(qi.q(col), qi.p(params.length));
125
156
  const alias = entry.alias ?? String(entry.path[entry.path.length - 1]);
@@ -183,6 +214,9 @@ function buildGroupBy(qi, args) {
183
214
  continue;
184
215
  if (target === true) {
185
216
  const col = qi.toColumn(key);
217
+ if (aggKey === '_min' || aggKey === '_max') {
218
+ assertAggregatePiiOptIn(qi.table, meta, key, col, `groupBy ${aggKey}`, args.includePii);
219
+ }
186
220
  // Aggregate output aliases share the same output-name namespace as
187
221
  // the group keys: `_sum: { totalPrice: true, total_price: {json} }`
188
222
  // would emit two "_sum_total_price" columns and silently drop one.
@@ -194,6 +228,9 @@ function buildGroupBy(qi, args) {
194
228
  continue;
195
229
  }
196
230
  const col = resolveJsonPathTarget(qi, `${aggKey} target "${key}"`, target.field, target.path);
231
+ if (aggKey === '_min' || aggKey === '_max') {
232
+ assertAggregatePiiOptIn(qi.table, meta, target.field, col, `groupBy ${aggKey} JSON target`, args.includePii);
233
+ }
197
234
  const alwaysNumeric = aggKey === '_sum' || aggKey === '_avg';
198
235
  if (alwaysNumeric && target.type === 'text') {
199
236
  throw new errors_js_1.ValidationError(`[turbine] groupBy ${aggKey} target "${key}" on table "${qi.table}": ` +
@@ -653,11 +690,13 @@ function buildAggregate(qi, args) {
653
690
  }
654
691
  }
655
692
  }
656
- // _min
693
+ // _min / _max return a stored cell verbatim, so a PII-tagged column needs the
694
+ // same `includePii` opt-in a row projection needs. _count / _sum / _avg do not.
657
695
  if (args._min) {
658
696
  for (const [field, enabled] of Object.entries(args._min)) {
659
697
  if (enabled) {
660
698
  const col = qi.toColumn(field);
699
+ assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _min', args.includePii);
661
700
  selectExprs.push(`MIN(${qi.q(col)}) AS ${qi.q(`_min_${col}`)}`);
662
701
  }
663
702
  }
@@ -667,6 +706,7 @@ function buildAggregate(qi, args) {
667
706
  for (const [field, enabled] of Object.entries(args._max)) {
668
707
  if (enabled) {
669
708
  const col = qi.toColumn(field);
709
+ assertAggregatePiiOptIn(qi.table, meta, field, col, 'aggregate _max', args.includePii);
670
710
  selectExprs.push(`MAX(${qi.q(col)}) AS ${qi.q(`_max_${col}`)}`);
671
711
  }
672
712
  }