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/index-stats.js
CHANGED
|
@@ -85,6 +85,14 @@ export const STATS_THRESHOLDS = {
|
|
|
85
85
|
*/
|
|
86
86
|
heatMinQueriesPerMin: 1,
|
|
87
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* Placeholder for an index column that is an EXPRESSION, not a plain column
|
|
90
|
+
* (`pg_index.indkey` stores 0 for those, and no `pg_attribute` row has attnum 0).
|
|
91
|
+
* It keeps expression POSITIONS in `IndexStat.columns` instead of silently
|
|
92
|
+
* collapsing `(tenant_id, lower(email))` to `['tenant_id']`, which would make a
|
|
93
|
+
* functional index look like a droppable prefix of an unrelated plain index.
|
|
94
|
+
*/
|
|
95
|
+
export const EXPRESSION_COLUMN = '(expression)';
|
|
88
96
|
/** Build an empty (fully unavailable) snapshot - the honest "no stats" baseline. */
|
|
89
97
|
export function emptyStatsSnapshot(notices = []) {
|
|
90
98
|
return {
|
|
@@ -289,6 +297,30 @@ export function findInvalidIndexes(snapshot) {
|
|
|
289
297
|
function isConstraintBacking(idx) {
|
|
290
298
|
return idx.isPrimary || idx.isUnique || idx.isExclusion === true || idx.isReplicaIdent;
|
|
291
299
|
}
|
|
300
|
+
/** The structured counterpart of {@link describeIndexShape}. */
|
|
301
|
+
function indexShape(idx) {
|
|
302
|
+
const kinds = [];
|
|
303
|
+
if (idx.hasExpressions === true || idx.columns.includes(EXPRESSION_COLUMN))
|
|
304
|
+
kinds.push('expression');
|
|
305
|
+
if (idx.predicate != null)
|
|
306
|
+
kinds.push('partial');
|
|
307
|
+
if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
|
|
308
|
+
kinds.push('non-btree');
|
|
309
|
+
return { kinds, accessMethod: idx.accessMethod ?? null, definition: idx.indexDef ?? null };
|
|
310
|
+
}
|
|
311
|
+
/** Describe an index's non-plain-btree properties, or null when it is plain. */
|
|
312
|
+
function describeIndexShape(idx) {
|
|
313
|
+
const parts = [];
|
|
314
|
+
if (idx.hasExpressions === true || idx.columns.includes(EXPRESSION_COLUMN))
|
|
315
|
+
parts.push('expression index');
|
|
316
|
+
if (idx.predicate != null)
|
|
317
|
+
parts.push(`partial index (WHERE ${idx.predicate})`);
|
|
318
|
+
if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
|
|
319
|
+
parts.push(`${idx.accessMethod} index`);
|
|
320
|
+
if (parts.length === 0)
|
|
321
|
+
return null;
|
|
322
|
+
return `${parts.join('; ')}${idx.indexDef ? `: ${idx.indexDef}` : ''}`;
|
|
323
|
+
}
|
|
292
324
|
/**
|
|
293
325
|
* Indexes never (or barely) scanned since the last stats reset. Report-only:
|
|
294
326
|
* counters reset on a crash/reset and REPLICA READS NEVER FEED PRIMARY COUNTERS,
|
|
@@ -298,9 +330,11 @@ function isConstraintBacking(idx) {
|
|
|
298
330
|
*/
|
|
299
331
|
export function findUnusedIndexes(snapshot, options = {}) {
|
|
300
332
|
const minScans = options.minScans ?? STATS_THRESHOLDS.unusedMinScans;
|
|
333
|
+
const probes = options.relationProbes ?? [];
|
|
301
334
|
return snapshot.indexes
|
|
302
335
|
.filter((idx) => idx.isValid && !isConstraintBacking(idx))
|
|
303
336
|
.filter((idx) => idx.idxScan !== undefined && idx.idxScan < minScans)
|
|
337
|
+
.filter((idx) => !servesRelationProbe(idx, probes))
|
|
304
338
|
.map((idx) => ({
|
|
305
339
|
table: idx.table,
|
|
306
340
|
indexName: idx.indexName,
|
|
@@ -308,6 +342,8 @@ export function findUnusedIndexes(snapshot, options = {}) {
|
|
|
308
342
|
idxScan: idx.idxScan ?? 0,
|
|
309
343
|
sizeBytes: idx.sizeBytes ?? null,
|
|
310
344
|
dropSql: buildDropIndexSql(idx.indexName, { concurrently: true }),
|
|
345
|
+
caveat: describeIndexShape(idx),
|
|
346
|
+
shape: indexShape(idx),
|
|
311
347
|
}))
|
|
312
348
|
.sort((a, b) => (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0) || a.indexName.localeCompare(b.indexName));
|
|
313
349
|
}
|
|
@@ -317,6 +353,52 @@ function isLeadingPrefix(prefix, columns) {
|
|
|
317
353
|
return false;
|
|
318
354
|
return prefix.every((c, i) => columns[i] === c);
|
|
319
355
|
}
|
|
356
|
+
/** Whether two column lists are identical, in order. */
|
|
357
|
+
function sameColumns(a, b) {
|
|
358
|
+
return a.length === b.length && a.every((c, i) => b[i] === c);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Whether this index answers a relation probe Turbine actually issues: the
|
|
362
|
+
* probe's columns are the index's leading columns (an exact match, or a wider
|
|
363
|
+
* index whose prefix serves the probe). Such an index is never handed a DROP,
|
|
364
|
+
* because the missing-index half of the same report demands it.
|
|
365
|
+
*/
|
|
366
|
+
function servesRelationProbe(idx, probes) {
|
|
367
|
+
if (probes.length === 0)
|
|
368
|
+
return false;
|
|
369
|
+
if (idx.accessMethod !== undefined && idx.accessMethod !== 'btree')
|
|
370
|
+
return false;
|
|
371
|
+
if (idx.predicate != null)
|
|
372
|
+
return false;
|
|
373
|
+
return probes.some((p) => p.table === idx.table && (sameColumns(p.columns, idx.columns) || isLeadingPrefix(p.columns, idx.columns)));
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Whether prefix coverage is even a meaningful question for this pair. Only a
|
|
377
|
+
* plain btree has leading-prefix semantics, and only two indexes of the SAME
|
|
378
|
+
* shape are interchangeable:
|
|
379
|
+
*
|
|
380
|
+
* - access method must be btree on BOTH (a GIN index on a column answers
|
|
381
|
+
* queries a btree cannot, and vice versa);
|
|
382
|
+
* - neither may contain an expression column (`lower(email)` is a different
|
|
383
|
+
* lookup from `email`, and an unresolved expression slot makes the column
|
|
384
|
+
* list an unreliable basis for a drop verdict);
|
|
385
|
+
* - the partial predicates must be IDENTICAL (both absent, or the same text).
|
|
386
|
+
* A full index does technically answer a partial index's lookups, but it is
|
|
387
|
+
* not the same object and the partial one exists to be small.
|
|
388
|
+
*
|
|
389
|
+
* Anything unknown (an access method or expression flag the collector could not
|
|
390
|
+
* read) answers "not comparable": doctor stays silent rather than suggesting a
|
|
391
|
+
* drop it cannot justify.
|
|
392
|
+
*/
|
|
393
|
+
function isCoverageComparable(narrow, wider) {
|
|
394
|
+
if (narrow.accessMethod !== 'btree' || wider.accessMethod !== 'btree')
|
|
395
|
+
return false;
|
|
396
|
+
if (narrow.hasExpressions !== false || wider.hasExpressions !== false)
|
|
397
|
+
return false;
|
|
398
|
+
if (narrow.columns.includes(EXPRESSION_COLUMN) || wider.columns.includes(EXPRESSION_COLUMN))
|
|
399
|
+
return false;
|
|
400
|
+
return (narrow.predicate ?? null) === (wider.predicate ?? null);
|
|
401
|
+
}
|
|
320
402
|
/**
|
|
321
403
|
* Non-unique indexes whose column list is a leading prefix of a WIDER index on
|
|
322
404
|
* the same table. A btree serves any leading-prefix lookup, so the narrow index
|
|
@@ -325,6 +407,9 @@ function isLeadingPrefix(prefix, columns) {
|
|
|
325
407
|
* Uniqueness compatibility: only a NON-unique index is ever reported. A unique
|
|
326
408
|
* or primary-key prefix is load-bearing (it enforces a constraint), so it is
|
|
327
409
|
* never called redundant even when a wider index shares its leading columns.
|
|
410
|
+
*
|
|
411
|
+
* Shape compatibility: see {@link isCoverageComparable}. A functional, partial,
|
|
412
|
+
* or non-btree index is never reported as covered by a plain btree.
|
|
328
413
|
*/
|
|
329
414
|
export function findRedundantIndexes(snapshot) {
|
|
330
415
|
const byTable = new Map();
|
|
@@ -347,7 +432,18 @@ export function findRedundantIndexes(snapshot) {
|
|
|
347
432
|
continue;
|
|
348
433
|
if (narrow.columns.length === 0)
|
|
349
434
|
continue;
|
|
350
|
-
|
|
435
|
+
// Prefer a genuinely wider index; fall back to an exact duplicate, which
|
|
436
|
+
// is the most obvious index problem there is and which a strict
|
|
437
|
+
// leading-prefix test (prefix.length < columns.length) can never see. For
|
|
438
|
+
// a duplicate pair only ONE side is reported: the later name, so the
|
|
439
|
+
// report never tells you to drop both copies.
|
|
440
|
+
const wider = list.find((w) => w.indexName !== narrow.indexName &&
|
|
441
|
+
isCoverageComparable(narrow, w) &&
|
|
442
|
+
isLeadingPrefix(narrow.columns, w.columns)) ??
|
|
443
|
+
list.find((w) => w.indexName < narrow.indexName &&
|
|
444
|
+
!isConstraintBacking(w) &&
|
|
445
|
+
isCoverageComparable(narrow, w) &&
|
|
446
|
+
sameColumns(narrow.columns, w.columns));
|
|
351
447
|
if (!wider)
|
|
352
448
|
continue;
|
|
353
449
|
out.push({
|
|
@@ -373,6 +469,7 @@ export function findRedundantIndexes(snapshot) {
|
|
|
373
469
|
*/
|
|
374
470
|
export function auditDoctorIndexes(snapshot, doctorNames, options = {}) {
|
|
375
471
|
const minScans = options.minScans ?? STATS_THRESHOLDS.unusedMinScans;
|
|
472
|
+
const probes = options.relationProbes ?? [];
|
|
376
473
|
const out = [];
|
|
377
474
|
for (const idx of snapshot.indexes) {
|
|
378
475
|
if (!idx.isValid || isConstraintBacking(idx))
|
|
@@ -382,14 +479,16 @@ export function auditDoctorIndexes(snapshot, doctorNames, options = {}) {
|
|
|
382
479
|
continue;
|
|
383
480
|
if (idx.idxScan === undefined || idx.idxScan >= minScans)
|
|
384
481
|
continue;
|
|
482
|
+
const stillProbed = servesRelationProbe(idx, probes);
|
|
385
483
|
out.push({
|
|
386
484
|
table: idx.table,
|
|
387
485
|
indexName: idx.indexName,
|
|
388
486
|
columns: idx.columns,
|
|
389
487
|
idxScan: idx.idxScan,
|
|
390
488
|
sizeBytes: idx.sizeBytes ?? null,
|
|
391
|
-
dropSql: buildDropIndexSql(idx.indexName, { concurrently: true }),
|
|
489
|
+
dropSql: stillProbed ? null : buildDropIndexSql(idx.indexName, { concurrently: true }),
|
|
392
490
|
ambiguous: candidates.length > 1,
|
|
491
|
+
stillProbed,
|
|
393
492
|
});
|
|
394
493
|
}
|
|
395
494
|
return out.sort((a, b) => a.indexName.localeCompare(b.indexName));
|
|
@@ -492,19 +591,28 @@ export async function collectStatsSnapshot(options) {
|
|
|
492
591
|
}
|
|
493
592
|
}
|
|
494
593
|
// --- invalid + all indexes (whole schema, for invalid detection) -------
|
|
495
|
-
const indexRows = await run('pg_index',
|
|
594
|
+
const indexRows = await run('pg_index',
|
|
595
|
+
// The column list LEFT JOINs pg_attribute so an EXPRESSION slot (indkey = 0,
|
|
596
|
+
// which no pg_attribute row matches) survives as a marker instead of being
|
|
597
|
+
// dropped by an inner join, which silently shortened functional indexes.
|
|
598
|
+
`SELECT c.relname AS table_name,
|
|
496
599
|
ic.relname AS index_name,
|
|
497
600
|
i.indisvalid, i.indisunique, i.indisprimary, i.indisreplident,
|
|
498
601
|
EXISTS (SELECT 1 FROM pg_constraint con
|
|
499
602
|
WHERE con.conindid = i.indexrelid AND con.contype = 'x') AS is_exclusion,
|
|
500
603
|
s.idx_scan::text AS idx_scan,
|
|
501
604
|
pg_relation_size(i.indexrelid)::text AS index_size,
|
|
502
|
-
|
|
605
|
+
am.amname AS access_method,
|
|
606
|
+
(i.indexprs IS NOT NULL) AS has_expressions,
|
|
607
|
+
pg_get_expr(i.indpred, i.indrelid) AS predicate,
|
|
608
|
+
pg_get_indexdef(i.indexrelid) AS index_def,
|
|
609
|
+
(SELECT array_agg(coalesce(a.attname::text, '${EXPRESSION_COLUMN}') ORDER BY k.ord)
|
|
503
610
|
FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
|
|
504
|
-
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
|
|
611
|
+
LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum) AS columns
|
|
505
612
|
FROM pg_index i
|
|
506
613
|
JOIN pg_class c ON c.oid = i.indrelid
|
|
507
614
|
JOIN pg_class ic ON ic.oid = i.indexrelid
|
|
615
|
+
JOIN pg_am am ON am.oid = ic.relam
|
|
508
616
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
509
617
|
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.indexrelid
|
|
510
618
|
WHERE n.nspname = $1`, [options.schema]);
|
|
@@ -514,6 +622,10 @@ export async function collectStatsSnapshot(options) {
|
|
|
514
622
|
table: row.table_name,
|
|
515
623
|
indexName: row.index_name,
|
|
516
624
|
columns: row.columns ?? [],
|
|
625
|
+
accessMethod: row.access_method,
|
|
626
|
+
hasExpressions: row.has_expressions,
|
|
627
|
+
predicate: row.predicate,
|
|
628
|
+
indexDef: row.index_def ?? undefined,
|
|
517
629
|
idxScan: row.idx_scan == null ? undefined : Number(row.idx_scan),
|
|
518
630
|
isValid: row.indisvalid,
|
|
519
631
|
isUnique: row.indisunique,
|
package/dist/mssql.js
CHANGED
|
@@ -482,6 +482,11 @@ export const mssqlDialect = {
|
|
|
482
482
|
supportsReturning: false,
|
|
483
483
|
supportsILike: false,
|
|
484
484
|
supportsVector: false,
|
|
485
|
+
// SQL Server full-text is `CONTAINS`/`FREETEXT` over a full-text catalog: a
|
|
486
|
+
// different surface with different semantics, not the emitted tsvector form.
|
|
487
|
+
supportsFullTextSearch: false,
|
|
488
|
+
// No array column type (a JSON column is not an array column).
|
|
489
|
+
supportsArrayColumns: false,
|
|
485
490
|
supportsListenNotify: false,
|
|
486
491
|
supportsRLS: false,
|
|
487
492
|
// SQL Server has OUTER APPLY, not FROM-clause LATERAL: the lateral pick plan
|
package/dist/mysql.js
CHANGED
|
@@ -366,6 +366,11 @@ export const mysqlDialect = {
|
|
|
366
366
|
supportsReturning: false,
|
|
367
367
|
supportsILike: false,
|
|
368
368
|
supportsVector: false,
|
|
369
|
+
// MySQL full-text is `MATCH(col) AGAINST(...)` over a FULLTEXT index: a
|
|
370
|
+
// different surface with different semantics, not the emitted tsvector form.
|
|
371
|
+
supportsFullTextSearch: false,
|
|
372
|
+
// No array column type (a JSON column is not an array column).
|
|
373
|
+
supportsArrayColumns: false,
|
|
369
374
|
supportsListenNotify: false,
|
|
370
375
|
supportsRLS: false,
|
|
371
376
|
// MySQL 8.0.14+ supports LATERAL, but the opt-in lateral pick plan stays
|
package/dist/nested-write.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `client.ts` directly — the transaction handle is passed in via
|
|
12
12
|
* `NestedWriteContext`.
|
|
13
13
|
*/
|
|
14
|
-
import { CircularRelationError, describeTargetForMessage, RelationError, ValidationError } from './errors.js';
|
|
14
|
+
import { CircularRelationError, describeTargetForMessage, NotFoundError, RelationError, ValidationError, } from './errors.js';
|
|
15
15
|
import { normalizeKeyColumns } from './schema.js';
|
|
16
16
|
const MAX_DEPTH = 10;
|
|
17
17
|
const CREATE_ONLY_OPS = new Set(['create', 'connect', 'connectOrCreate']);
|
|
@@ -111,6 +111,152 @@ function pkWhere(tableMeta, row) {
|
|
|
111
111
|
}
|
|
112
112
|
return where;
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* The child-side predicate that ties a hasMany/hasOne relation's rows to THIS
|
|
116
|
+
* parent: `child.foreignKey = parent.referenceKey`. This is the exact
|
|
117
|
+
* correlation the connect/connectOrCreate/set paths already write when they
|
|
118
|
+
* point a child AT the parent, read back here as a filter.
|
|
119
|
+
*
|
|
120
|
+
* Returns `null` when the parent's reference key is null/undefined: SQL
|
|
121
|
+
* equality never matches NULL, so no child can be related and every scoped
|
|
122
|
+
* operation must report not-found rather than run unscoped.
|
|
123
|
+
*/
|
|
124
|
+
function parentCorrelationWhere(ctx, rel, parentRow) {
|
|
125
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
126
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
127
|
+
const childTable = ctx.schema.tables[rel.to];
|
|
128
|
+
const parentTable = ctx.schema.tables[rel.from];
|
|
129
|
+
const where = {};
|
|
130
|
+
for (let i = 0; i < fks.length; i++) {
|
|
131
|
+
const fkField = childTable?.reverseColumnMap[fks[i]] ?? fks[i];
|
|
132
|
+
const refField = parentTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
133
|
+
const value = parentRow[refField];
|
|
134
|
+
if (value === null || value === undefined)
|
|
135
|
+
return null;
|
|
136
|
+
where[fkField] = value;
|
|
137
|
+
}
|
|
138
|
+
return where;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The related-side predicate for a belongsTo relation: the parent holds the
|
|
142
|
+
* foreign key, so the related row this parent points at satisfies
|
|
143
|
+
* `related.referenceKey = parent.foreignKey`. The mirror image of
|
|
144
|
+
* {@link parentCorrelationWhere}, which reads the hasMany/hasOne direction.
|
|
145
|
+
*
|
|
146
|
+
* Returns `null` when the parent's foreign key is null/undefined: the parent
|
|
147
|
+
* points at nothing, so no related row is in scope.
|
|
148
|
+
*/
|
|
149
|
+
function belongsToCorrelationWhere(ctx, rel, parentRow, parentTable) {
|
|
150
|
+
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
151
|
+
const refs = normalizeKeyColumns(rel.referenceKey);
|
|
152
|
+
const parentMeta = ctx.schema.tables[parentTable];
|
|
153
|
+
const relatedTable = ctx.schema.tables[rel.to];
|
|
154
|
+
const where = {};
|
|
155
|
+
for (let i = 0; i < fks.length; i++) {
|
|
156
|
+
const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
|
|
157
|
+
const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
158
|
+
const value = parentRow[fkField];
|
|
159
|
+
if (value === null || value === undefined)
|
|
160
|
+
return null;
|
|
161
|
+
where[refField] = value;
|
|
162
|
+
}
|
|
163
|
+
return where;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* AND the caller-supplied child `where` with the parent correlation so a nested
|
|
167
|
+
* delete/update/disconnect can only ever touch rows that actually belong to the
|
|
168
|
+
* parent being written.
|
|
169
|
+
*
|
|
170
|
+
* The flat merge is used whenever the two predicates name disjoint fields (the
|
|
171
|
+
* overwhelmingly common case: the caller addresses the child by its primary
|
|
172
|
+
* key). It keeps every caller key at the top level, so compound-unique selector
|
|
173
|
+
* expansion still sees them. When the caller's `where` names a correlation
|
|
174
|
+
* field itself, the two are combined with `AND` instead, so neither predicate
|
|
175
|
+
* can silently overwrite the other.
|
|
176
|
+
*/
|
|
177
|
+
function scopeWhereToParent(target, correlation) {
|
|
178
|
+
for (const key of Object.keys(correlation)) {
|
|
179
|
+
if (Object.hasOwn(target, key))
|
|
180
|
+
return { AND: [target, correlation] };
|
|
181
|
+
}
|
|
182
|
+
return { ...target, ...correlation };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The caller's own selector must name at least one bound value.
|
|
186
|
+
*
|
|
187
|
+
* The parent correlation is ANDed onto every nested delete/update/disconnect
|
|
188
|
+
* target, which makes the merged predicate non-empty by construction. That
|
|
189
|
+
* defeats the empty-where guard downstream: `delete: {}` or
|
|
190
|
+
* `delete: { id: req.body.postId }` with an undefined body field would compile
|
|
191
|
+
* to `WHERE user_id = $1` and remove EVERY child of this parent instead of
|
|
192
|
+
* throwing. So the caller's half is checked here, before the merge.
|
|
193
|
+
*
|
|
194
|
+
* `true` is accepted only on a to-one relation, where it means "the single
|
|
195
|
+
* related row" and the correlation alone identifies it. On a to-many it would
|
|
196
|
+
* mean "all of them", which is never what a caller spelled `delete: true` for.
|
|
197
|
+
*/
|
|
198
|
+
function assertTargetSelectsSomething(target, op, relName, rel) {
|
|
199
|
+
const toOne = rel.type === 'hasOne' || rel.type === 'belongsTo';
|
|
200
|
+
if (target === true) {
|
|
201
|
+
if (toOne)
|
|
202
|
+
return;
|
|
203
|
+
throw new ValidationError(`[turbine] Nested ${op} on to-many relation "${relName}" needs a "where" selector: ` +
|
|
204
|
+
`"${op}: true" would ${op} every related "${rel.to}" row.`);
|
|
205
|
+
}
|
|
206
|
+
if (target && typeof target === 'object' && !Array.isArray(target)) {
|
|
207
|
+
const bound = Object.values(target).some((v) => v !== undefined);
|
|
208
|
+
if (bound)
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
throw new ValidationError(`[turbine] Nested ${op} on relation "${relName}" requires a selector with at least one defined value. ` +
|
|
212
|
+
`An empty or all-undefined "where" would ${op} every related "${rel.to}" row of this parent.`);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* many-to-many relations have no nested-write branch on any engine: the junction
|
|
216
|
+
* row would have to be written too, and there is no safe default for what to put
|
|
217
|
+
* in its extra columns. Refusing loudly is the only honest option, because the
|
|
218
|
+
* alternative (falling off the end of the dispatch) drops the write silently and
|
|
219
|
+
* reports success.
|
|
220
|
+
*/
|
|
221
|
+
function manyToManyUnsupported(relName, rel) {
|
|
222
|
+
return new ValidationError(`[turbine] Nested writes are not supported on the many-to-many relation "${relName}" ` +
|
|
223
|
+
`(via the "${rel.through?.table ?? 'junction'}" junction table). Write the junction rows directly ` +
|
|
224
|
+
`(db.${rel.through?.table ?? 'junction'}.create / createMany) inside the same $transaction.`);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The E001 raised when a nested delete/update/disconnect target is not a child
|
|
228
|
+
* of this parent (it belongs to another parent, or does not exist at all).
|
|
229
|
+
* Matches Prisma's behavior: the nested where is scoped to the relation, so a
|
|
230
|
+
* row outside the relation is simply "not found".
|
|
231
|
+
*/
|
|
232
|
+
function notRelatedToParent(op, relName, rel, target) {
|
|
233
|
+
// Object form, not the legacy string one: `err.table` / `err.where` /
|
|
234
|
+
// `err.operation` are a documented part of the NotFoundError contract, and a
|
|
235
|
+
// string-constructed error silently drops all three.
|
|
236
|
+
return new NotFoundError({
|
|
237
|
+
table: rel.to,
|
|
238
|
+
where: target,
|
|
239
|
+
operation: `nested ${op}`,
|
|
240
|
+
message: `[turbine] Nested ${op} on relation "${relName}": no "${rel.to}" record matching ` +
|
|
241
|
+
`${describeTargetForMessage(target)} is related to this parent. Either it does not exist, ` +
|
|
242
|
+
`or it belongs to a different parent (a nested ${op} can only touch this parent's rows).`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/** Re-tag a NotFoundError from a scoped child write as a relation-scoped miss. */
|
|
246
|
+
function rethrowAsNotRelated(err, op, relName, rel, target) {
|
|
247
|
+
if (err instanceof NotFoundError) {
|
|
248
|
+
// Only re-tag a miss on THIS relation's own write. A NotFoundError raised
|
|
249
|
+
// deeper in the tree already names its own relation, and re-tagging it here
|
|
250
|
+
// would rename someone else's failure; keep it, and preserve the original
|
|
251
|
+
// as `cause` in either direction.
|
|
252
|
+
if (err.operation?.startsWith('nested '))
|
|
253
|
+
throw err;
|
|
254
|
+
const tagged = notRelatedToParent(op, relName, rel, target);
|
|
255
|
+
tagged.cause ??= err;
|
|
256
|
+
throw tagged;
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
114
260
|
// ---------------------------------------------------------------------------
|
|
115
261
|
// executeNestedCreate
|
|
116
262
|
// ---------------------------------------------------------------------------
|
|
@@ -148,6 +294,9 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
|
|
|
148
294
|
if (rel.type === 'belongsTo') {
|
|
149
295
|
Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
|
|
150
296
|
}
|
|
297
|
+
else if (rel.type === 'manyToMany') {
|
|
298
|
+
throw manyToManyUnsupported(relName, rel);
|
|
299
|
+
}
|
|
151
300
|
}
|
|
152
301
|
// Insert the parent row (scalars + resolved belongsTo foreign keys)
|
|
153
302
|
const parentRow = (await ctx.tx.table(tableName).create({
|
|
@@ -218,7 +367,7 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
|
|
|
218
367
|
await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
|
|
219
368
|
// disconnect
|
|
220
369
|
if (ops.disconnect !== undefined) {
|
|
221
|
-
await processDisconnect(ctx, rel, ops.disconnect, relName);
|
|
370
|
+
await processDisconnect(ctx, rel, ops.disconnect, relName, parentRow);
|
|
222
371
|
}
|
|
223
372
|
// set
|
|
224
373
|
if (ops.set !== undefined) {
|
|
@@ -226,15 +375,15 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
|
|
|
226
375
|
}
|
|
227
376
|
// delete
|
|
228
377
|
if (ops.delete !== undefined) {
|
|
229
|
-
await processDelete(ctx, rel, ops.delete);
|
|
378
|
+
await processDelete(ctx, rel, ops.delete, relName, parentRow);
|
|
230
379
|
}
|
|
231
380
|
// update
|
|
232
381
|
if (ops.update !== undefined) {
|
|
233
|
-
await processNestedUpdate(ctx, rel, ops.update);
|
|
382
|
+
await processNestedUpdate(ctx, rel, ops.update, relName, parentRow);
|
|
234
383
|
}
|
|
235
384
|
// upsert
|
|
236
385
|
if (ops.upsert !== undefined) {
|
|
237
|
-
await processNestedUpsert(ctx, rel, ops.upsert, parentRow);
|
|
386
|
+
await processNestedUpsert(ctx, rel, ops.upsert, parentRow, relName);
|
|
238
387
|
}
|
|
239
388
|
}
|
|
240
389
|
else if (rel.type === 'belongsTo') {
|
|
@@ -268,6 +417,9 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
|
|
|
268
417
|
});
|
|
269
418
|
}
|
|
270
419
|
}
|
|
420
|
+
else {
|
|
421
|
+
throw manyToManyUnsupported(relName, rel);
|
|
422
|
+
}
|
|
271
423
|
}
|
|
272
424
|
// Final read with all touched relations
|
|
273
425
|
const withClause = {};
|
|
@@ -474,7 +626,7 @@ async function connectOrCreate(ctx, rel, op, parentRow) {
|
|
|
474
626
|
await ctx.tx.table(rel.to).update({ where: op.where, data: updateData });
|
|
475
627
|
}
|
|
476
628
|
}
|
|
477
|
-
async function processDisconnect(ctx, rel, disconnectArg, relName) {
|
|
629
|
+
async function processDisconnect(ctx, rel, disconnectArg, relName, parentRow) {
|
|
478
630
|
const fks = normalizeKeyColumns(rel.foreignKey);
|
|
479
631
|
const childTable = ctx.schema.tables[rel.to];
|
|
480
632
|
if (!childTable)
|
|
@@ -488,13 +640,26 @@ async function processDisconnect(ctx, rel, disconnectArg, relName) {
|
|
|
488
640
|
throw new ValidationError(`[turbine] Cannot disconnect "${relName}": foreign key column(s) ${fks.join(', ')} on "${rel.to}" are NOT NULL. Use delete instead.`);
|
|
489
641
|
}
|
|
490
642
|
const items = toArray(disconnectArg);
|
|
643
|
+
if (items.length === 0)
|
|
644
|
+
return;
|
|
491
645
|
const nullData = {};
|
|
492
646
|
for (const fk of fks) {
|
|
493
647
|
const field = childTable.reverseColumnMap[fk] ?? fk;
|
|
494
648
|
nullData[field] = null;
|
|
495
649
|
}
|
|
650
|
+
// Disconnect nulls the child's FK, so an unscoped target would strip ANOTHER
|
|
651
|
+
// parent's child off that parent. Scope every target to this parent's rows.
|
|
652
|
+
const correlation = parentCorrelationWhere(ctx, rel, parentRow);
|
|
496
653
|
for (const target of items) {
|
|
497
|
-
|
|
654
|
+
assertTargetSelectsSomething(target, 'disconnect', relName, rel);
|
|
655
|
+
if (!correlation)
|
|
656
|
+
throw notRelatedToParent('disconnect', relName, rel, target);
|
|
657
|
+
try {
|
|
658
|
+
await ctx.tx.table(rel.to).update({ where: scopeWhereToParent(target, correlation), data: nullData });
|
|
659
|
+
}
|
|
660
|
+
catch (err) {
|
|
661
|
+
rethrowAsNotRelated(err, 'disconnect', relName, rel, target);
|
|
662
|
+
}
|
|
498
663
|
}
|
|
499
664
|
}
|
|
500
665
|
async function processSet(ctx, rel, setItems, parentRow) {
|
|
@@ -503,12 +668,23 @@ async function processSet(ctx, rel, setItems, parentRow) {
|
|
|
503
668
|
const childTable = ctx.schema.tables[rel.to];
|
|
504
669
|
if (!childTable)
|
|
505
670
|
return;
|
|
506
|
-
// Build parent FK match for finding current children
|
|
671
|
+
// Build parent FK match for finding current children. `set` clears the
|
|
672
|
+
// current children with `allowFullTableScan: true`, which is exactly what
|
|
673
|
+
// disables the empty-where guard downstream, so a null/undefined reference
|
|
674
|
+
// key here would null the FK of EVERY row in the child table. Same guard as
|
|
675
|
+
// parentCorrelationWhere, spelled inline because `set` also needs the
|
|
676
|
+
// (identical) forward direction for the reconnect below.
|
|
507
677
|
const parentWhere = {};
|
|
508
678
|
for (let i = 0; i < fks.length; i++) {
|
|
509
679
|
const fkField = childTable.reverseColumnMap[fks[i]] ?? fks[i];
|
|
510
680
|
const refField = ctx.schema.tables[rel.from]?.reverseColumnMap[refs[i]] ?? refs[i];
|
|
511
|
-
|
|
681
|
+
const value = parentRow[refField];
|
|
682
|
+
if (value === null || value === undefined) {
|
|
683
|
+
throw new ValidationError(`[turbine] Nested set on relation "${rel.name}" cannot run: the parent's reference key ` +
|
|
684
|
+
`"${refField}" is ${value === null ? 'null' : 'missing from the loaded row'}, so no child rows ` +
|
|
685
|
+
`can be correlated to this parent.`);
|
|
686
|
+
}
|
|
687
|
+
parentWhere[fkField] = value;
|
|
512
688
|
}
|
|
513
689
|
// Disconnect all current children
|
|
514
690
|
const nullData = {};
|
|
@@ -535,24 +711,49 @@ async function processSet(ctx, rel, setItems, parentRow) {
|
|
|
535
711
|
// ---------------------------------------------------------------------------
|
|
536
712
|
// update / upsert operations (update-context only)
|
|
537
713
|
// ---------------------------------------------------------------------------
|
|
538
|
-
async function processNestedUpdate(ctx, rel, updateArg) {
|
|
714
|
+
async function processNestedUpdate(ctx, rel, updateArg, relName, parentRow) {
|
|
539
715
|
const items = toArray(updateArg);
|
|
716
|
+
if (items.length === 0)
|
|
717
|
+
return;
|
|
718
|
+
const correlation = parentCorrelationWhere(ctx, rel, parentRow);
|
|
540
719
|
for (const item of items) {
|
|
541
720
|
if (!item.where || !item.data) {
|
|
542
721
|
throw new ValidationError(`[turbine] Nested update on "${rel.name}" requires both "where" and "data" fields.`);
|
|
543
722
|
}
|
|
544
|
-
|
|
723
|
+
assertTargetSelectsSomething(item.where, 'update', relName, rel);
|
|
724
|
+
if (!correlation)
|
|
725
|
+
throw notRelatedToParent('update', relName, rel, item.where);
|
|
726
|
+
try {
|
|
727
|
+
await ctx.tx.table(rel.to).update({ where: scopeWhereToParent(item.where, correlation), data: item.data });
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
rethrowAsNotRelated(err, 'update', relName, rel, item.where);
|
|
731
|
+
}
|
|
545
732
|
}
|
|
546
733
|
}
|
|
547
|
-
async function processNestedUpsert(ctx, rel, upsertArg, parentRow) {
|
|
734
|
+
async function processNestedUpsert(ctx, rel, upsertArg, parentRow, relName) {
|
|
548
735
|
const items = toArray(upsertArg);
|
|
736
|
+
if (items.length === 0)
|
|
737
|
+
return;
|
|
738
|
+
// The upsert's `where` is scoped to the relation, so a row that matches it but
|
|
739
|
+
// belongs to ANOTHER parent is not "existing" here: it is never updated, and
|
|
740
|
+
// the create branch runs instead (any resulting unique-constraint violation is
|
|
741
|
+
// surfaced to the caller rather than silently rewriting a stranger's row).
|
|
742
|
+
const correlation = parentCorrelationWhere(ctx, rel, parentRow);
|
|
549
743
|
for (const item of items) {
|
|
550
744
|
if (!item.where || !item.create || !item.update) {
|
|
551
745
|
throw new ValidationError(`[turbine] Nested upsert on "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
552
746
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
747
|
+
assertTargetSelectsSomething(item.where, 'upsert', relName, rel);
|
|
748
|
+
const scoped = correlation ? scopeWhereToParent(item.where, correlation) : null;
|
|
749
|
+
const existing = scoped ? await ctx.tx.table(rel.to).findUnique({ where: scoped }) : null;
|
|
750
|
+
if (existing && scoped) {
|
|
751
|
+
try {
|
|
752
|
+
await ctx.tx.table(rel.to).update({ where: scoped, data: item.update });
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
rethrowAsNotRelated(err, 'upsert', relName, rel, item.where);
|
|
756
|
+
}
|
|
556
757
|
}
|
|
557
758
|
else {
|
|
558
759
|
const injected = injectForeignKey(item.create, rel, parentRow, ctx.schema);
|
|
@@ -583,9 +784,25 @@ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTabl
|
|
|
583
784
|
if (!item.where || !item.create || !item.update) {
|
|
584
785
|
throw new ValidationError(`[turbine] Nested upsert on belongsTo "${rel.name}" requires "where", "create", and "update" fields.`);
|
|
585
786
|
}
|
|
586
|
-
|
|
787
|
+
// Scope the lookup to the row this parent actually points at. Without the
|
|
788
|
+
// correlation, a `where` naming any other row would update a record with no
|
|
789
|
+
// relationship to the parent being written. A miss falls through to the
|
|
790
|
+
// create branch (and re-points the parent's FK), which is the same choice the
|
|
791
|
+
// hasMany/hasOne nested upsert makes: an upsert whose target is out of the
|
|
792
|
+
// relation creates a row owned by this parent rather than rewriting a
|
|
793
|
+
// stranger's.
|
|
794
|
+
const correlation = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
|
|
795
|
+
// findMany, not findUnique: the scoped where ANDs a non-unique-looking
|
|
796
|
+
// correlation onto the caller's selector. The correlation targets the
|
|
797
|
+
// relation's reference key (unique or the PK by construction), so at most one
|
|
798
|
+
// row can come back.
|
|
799
|
+
const existing = correlation
|
|
800
|
+
? ((await ctx.tx.table(rel.to).findMany({ where: scopeWhereToParent(item.where, correlation) }))[0] ??
|
|
801
|
+
null)
|
|
802
|
+
: null;
|
|
587
803
|
if (existing) {
|
|
588
|
-
|
|
804
|
+
const relatedMeta = ctx.schema.tables[rel.to];
|
|
805
|
+
await ctx.tx.table(rel.to).update({ where: pkWhere(relatedMeta, existing), data: item.update });
|
|
589
806
|
}
|
|
590
807
|
else {
|
|
591
808
|
// Create the related row, then update parent's FK to point at it
|
|
@@ -606,9 +823,22 @@ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTabl
|
|
|
606
823
|
});
|
|
607
824
|
}
|
|
608
825
|
}
|
|
609
|
-
async function processDelete(ctx, rel, deleteArg) {
|
|
826
|
+
async function processDelete(ctx, rel, deleteArg, relName, parentRow) {
|
|
610
827
|
const items = toArray(deleteArg);
|
|
828
|
+
if (items.length === 0)
|
|
829
|
+
return;
|
|
830
|
+
// Without the correlation this deletes ANY row the caller can name: a
|
|
831
|
+
// cross-tenant delete primitive for any endpoint that forwards a client id.
|
|
832
|
+
const correlation = parentCorrelationWhere(ctx, rel, parentRow);
|
|
611
833
|
for (const target of items) {
|
|
612
|
-
|
|
834
|
+
assertTargetSelectsSomething(target, 'delete', relName, rel);
|
|
835
|
+
if (!correlation)
|
|
836
|
+
throw notRelatedToParent('delete', relName, rel, target);
|
|
837
|
+
try {
|
|
838
|
+
await ctx.tx.table(rel.to).delete({ where: scopeWhereToParent(target, correlation) });
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
rethrowAsNotRelated(err, 'delete', relName, rel, target);
|
|
842
|
+
}
|
|
613
843
|
}
|
|
614
844
|
}
|
package/dist/observe.d.ts
CHANGED
|
@@ -102,7 +102,6 @@ export declare class HttpJsonSink implements ObserveSink {
|
|
|
102
102
|
export declare class ObserveEngine {
|
|
103
103
|
private readonly sink;
|
|
104
104
|
private readonly buffer;
|
|
105
|
-
private currentBucket;
|
|
106
105
|
private readonly flushIntervalMs;
|
|
107
106
|
private timer;
|
|
108
107
|
private readonly listener;
|