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.
- package/README.md +58 -39
- package/dist/cjs/cli/destructive.js +233 -18
- package/dist/cjs/cli/index.js +56 -12
- package/dist/cjs/cli/mcp.js +23 -2
- package/dist/cjs/cli/migrate.js +28 -1
- package/dist/cjs/cli/pii-tags.js +111 -0
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +158 -0
- package/dist/cjs/cli/ui.js +8 -3
- package/dist/cjs/client.js +21 -1
- package/dist/cjs/dialect.js +2 -0
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index-stats.js +118 -6
- package/dist/cjs/mssql.js +5 -0
- package/dist/cjs/mysql.js +5 -0
- package/dist/cjs/nested-write.js +248 -18
- package/dist/cjs/observe.js +21 -15
- package/dist/cjs/powdb.js +3 -0
- package/dist/cjs/powql.js +13 -0
- package/dist/cjs/prisma-compat.js +9 -0
- package/dist/cjs/query/aggregates.js +41 -1
- package/dist/cjs/query/batched-loader.js +70 -6
- package/dist/cjs/query/builder.js +3 -3
- package/dist/cjs/query/relations.js +12 -2
- package/dist/cjs/query/where.js +36 -1
- package/dist/cjs/sqlite.js +5 -0
- package/dist/cli/destructive.d.ts +9 -3
- package/dist/cli/destructive.js +233 -18
- package/dist/cli/index.js +57 -13
- package/dist/cli/mcp.d.ts +7 -0
- package/dist/cli/mcp.js +23 -2
- package/dist/cli/migrate.d.ts +2 -1
- package/dist/cli/migrate.js +28 -1
- package/dist/cli/pii-tags.d.ts +53 -0
- package/dist/cli/pii-tags.js +106 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.d.ts +42 -0
- package/dist/cli/studio.js +157 -0
- package/dist/cli/ui.js +8 -3
- package/dist/client.js +21 -1
- package/dist/dialect.d.ts +19 -0
- package/dist/dialect.js +2 -0
- package/dist/index-advisor.d.ts +7 -0
- package/dist/index-advisor.js +0 -0
- package/dist/index-stats.d.ts +52 -1
- package/dist/index-stats.js +117 -5
- package/dist/mssql.js +5 -0
- package/dist/mysql.js +5 -0
- package/dist/nested-write.js +249 -19
- package/dist/observe.d.ts +0 -1
- package/dist/observe.js +21 -15
- package/dist/powdb.js +3 -0
- package/dist/powql.js +13 -0
- package/dist/prisma-compat.js +9 -0
- package/dist/query/aggregates.d.ts +18 -0
- package/dist/query/aggregates.js +40 -1
- package/dist/query/batched-loader.d.ts +29 -1
- package/dist/query/batched-loader.js +69 -6
- package/dist/query/builder.js +4 -4
- package/dist/query/relations.js +12 -2
- package/dist/query/types.d.ts +16 -0
- package/dist/query/where.d.ts +18 -1
- package/dist/query/where.js +34 -1
- package/dist/sqlite.js +5 -0
- package/package.json +3 -2
package/dist/cjs/nested-write.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/cjs/observe.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
}
|