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
@@ -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
- const wider = list.find((w) => w.indexName !== narrow.indexName && isLeadingPrefix(narrow.columns, w.columns));
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', `SELECT c.relname AS table_name,
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
- (SELECT array_agg(a.attname::text ORDER BY k.ord)
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
@@ -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
- await ctx.tx.table(rel.to).update({ where: target, data: nullData });
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
- parentWhere[fkField] = parentRow[refField];
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
- await ctx.tx.table(rel.to).update({ where: item.where, data: item.data });
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
- const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
554
- if (existing) {
555
- await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
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
- const existing = await ctx.tx.table(rel.to).findUnique({ where: item.where });
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
- await ctx.tx.table(rel.to).update({ where: item.where, data: item.update });
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
- await ctx.tx.table(rel.to).delete({ where: target });
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;