turbine-orm 0.49.0 → 0.50.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 (157) hide show
  1. package/README.md +122 -39
  2. package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
  3. package/dist/cjs/adapters/index.d.ts +110 -0
  4. package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
  5. package/dist/cjs/cli/config.d.ts +181 -0
  6. package/dist/cjs/cli/config.js +32 -6
  7. package/dist/cjs/cli/destructive.d.ts +38 -0
  8. package/dist/cjs/cli/index.d.ts +359 -0
  9. package/dist/cjs/cli/index.js +228 -56
  10. package/dist/cjs/cli/loader.d.ts +61 -0
  11. package/dist/cjs/cli/mcp.d.ts +42 -0
  12. package/dist/cjs/cli/migrate.d.ts +356 -0
  13. package/dist/cjs/cli/migrate.js +131 -40
  14. package/dist/cjs/cli/observe-ui.d.ts +1 -0
  15. package/dist/cjs/cli/observe-ui.js +14 -5
  16. package/dist/cjs/cli/observe.d.ts +25 -0
  17. package/dist/cjs/cli/observe.js +49 -12
  18. package/dist/cjs/cli/pii-tags.d.ts +53 -0
  19. package/dist/cjs/cli/prisma-report.d.ts +33 -0
  20. package/dist/cjs/cli/prisma-report.js +73 -0
  21. package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
  22. package/dist/cjs/cli/prisma-resolve.js +1 -0
  23. package/dist/cjs/cli/prisma-schema.d.ts +176 -0
  24. package/dist/cjs/cli/prisma-schema.js +82 -4
  25. package/dist/cjs/cli/rate-limit.d.ts +32 -0
  26. package/dist/cjs/cli/rate-limit.js +45 -0
  27. package/dist/cjs/cli/studio-demo.d.ts +43 -0
  28. package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
  29. package/dist/cjs/cli/studio.d.ts +207 -0
  30. package/dist/cjs/cli/studio.js +136 -71
  31. package/dist/cjs/cli/ui.d.ts +73 -0
  32. package/dist/cjs/cli/ui.js +51 -9
  33. package/dist/cjs/client.d.ts +837 -0
  34. package/dist/cjs/client.js +3 -0
  35. package/dist/cjs/dialect.d.ts +516 -0
  36. package/dist/cjs/dialect.js +37 -12
  37. package/dist/cjs/errors.d.ts +370 -0
  38. package/dist/cjs/generate.d.ts +137 -0
  39. package/dist/cjs/generate.js +39 -6
  40. package/dist/cjs/index-advisor.d.ts +153 -0
  41. package/dist/cjs/index-stats.d.ts +384 -0
  42. package/dist/cjs/index.d.ts +55 -0
  43. package/dist/cjs/index.js +7 -2
  44. package/dist/cjs/introspect.d.ts +269 -0
  45. package/dist/cjs/mssql.d.ts +232 -0
  46. package/dist/cjs/mssql.js +6 -0
  47. package/dist/cjs/mysql.d.ts +173 -0
  48. package/dist/cjs/mysql.js +16 -0
  49. package/dist/cjs/nested-write.d.ts +96 -0
  50. package/dist/cjs/nested-write.js +414 -24
  51. package/dist/cjs/observe.d.ts +115 -0
  52. package/dist/cjs/optional-peer-import.d.cts +72 -0
  53. package/dist/cjs/pipeline-submittable.d.ts +93 -0
  54. package/dist/cjs/pipeline.d.ts +71 -0
  55. package/dist/cjs/powdb-introspect.d.ts +84 -0
  56. package/dist/cjs/powdb.d.ts +931 -0
  57. package/dist/cjs/powdb.js +106 -21
  58. package/dist/cjs/powql.d.ts +592 -0
  59. package/dist/cjs/powql.js +42 -6
  60. package/dist/cjs/prisma-compat.d.ts +283 -0
  61. package/dist/cjs/prisma-compat.js +167 -9
  62. package/dist/cjs/query/aggregates.d.ts +92 -0
  63. package/dist/cjs/query/aggregates.js +7 -3
  64. package/dist/cjs/query/batched-loader.d.ts +193 -0
  65. package/dist/cjs/query/builder.d.ts +849 -0
  66. package/dist/cjs/query/builder.js +571 -65
  67. package/dist/cjs/query/compound-unique.d.ts +51 -0
  68. package/dist/cjs/query/deferred.d.ts +223 -0
  69. package/dist/cjs/query/filters.d.ts +201 -0
  70. package/dist/cjs/query/index.d.ts +14 -0
  71. package/dist/cjs/query/index.js +6 -1
  72. package/dist/cjs/query/relations.d.ts +609 -0
  73. package/dist/cjs/query/relations.js +693 -46
  74. package/dist/cjs/query/types.d.ts +1300 -0
  75. package/dist/cjs/query/utils.d.ts +209 -0
  76. package/dist/cjs/query/utils.js +208 -1
  77. package/dist/cjs/query/warn-registry.d.ts +68 -0
  78. package/dist/cjs/query/warn-registry.js +9 -0
  79. package/dist/cjs/query/where-compile.d.ts +139 -0
  80. package/dist/cjs/query/where.d.ts +548 -0
  81. package/dist/cjs/query/where.js +58 -22
  82. package/dist/cjs/query/writes.d.ts +172 -0
  83. package/dist/cjs/query/writes.js +105 -12
  84. package/dist/cjs/realtime.d.ts +70 -0
  85. package/dist/cjs/schema-builder.d.ts +354 -0
  86. package/dist/cjs/schema-metadata.d.ts +83 -0
  87. package/dist/cjs/schema-sql.d.ts +217 -0
  88. package/dist/cjs/schema-sql.js +23 -5
  89. package/dist/cjs/schema.d.ts +356 -0
  90. package/dist/cjs/schema.js +125 -0
  91. package/dist/cjs/seed.d.ts +15 -0
  92. package/dist/cjs/serverless.d.ts +142 -0
  93. package/dist/cjs/sqlite.d.ts +143 -0
  94. package/dist/cjs/sqlite.js +4 -0
  95. package/dist/cjs/typed-sql.d.ts +102 -0
  96. package/dist/cli/config.d.ts +18 -4
  97. package/dist/cli/config.js +31 -6
  98. package/dist/cli/index.d.ts +123 -0
  99. package/dist/cli/index.js +223 -58
  100. package/dist/cli/migrate.d.ts +59 -10
  101. package/dist/cli/migrate.js +128 -41
  102. package/dist/cli/observe-ui.d.ts +1 -1
  103. package/dist/cli/observe-ui.js +14 -5
  104. package/dist/cli/observe.d.ts +7 -1
  105. package/dist/cli/observe.js +48 -12
  106. package/dist/cli/prisma-report.d.ts +14 -0
  107. package/dist/cli/prisma-report.js +72 -0
  108. package/dist/cli/prisma-resolve.d.ts +6 -0
  109. package/dist/cli/prisma-resolve.js +1 -0
  110. package/dist/cli/prisma-schema.d.ts +62 -2
  111. package/dist/cli/prisma-schema.js +81 -4
  112. package/dist/cli/rate-limit.d.ts +32 -0
  113. package/dist/cli/rate-limit.js +40 -0
  114. package/dist/cli/studio.d.ts +5 -5
  115. package/dist/cli/studio.js +135 -70
  116. package/dist/cli/ui.d.ts +1 -1
  117. package/dist/cli/ui.js +51 -9
  118. package/dist/client.d.ts +40 -0
  119. package/dist/client.js +3 -0
  120. package/dist/dialect.d.ts +17 -1
  121. package/dist/dialect.js +37 -12
  122. package/dist/generate.js +40 -7
  123. package/dist/index.d.ts +1 -1
  124. package/dist/index.js +1 -1
  125. package/dist/mssql.js +6 -0
  126. package/dist/mysql.js +16 -0
  127. package/dist/nested-write.d.ts +2 -0
  128. package/dist/nested-write.js +415 -25
  129. package/dist/powdb.d.ts +4 -2
  130. package/dist/powdb.js +106 -21
  131. package/dist/powql.d.ts +5 -0
  132. package/dist/powql.js +42 -6
  133. package/dist/prisma-compat.d.ts +2 -0
  134. package/dist/prisma-compat.js +166 -8
  135. package/dist/query/aggregates.js +7 -3
  136. package/dist/query/builder.d.ts +292 -21
  137. package/dist/query/builder.js +570 -64
  138. package/dist/query/deferred.d.ts +39 -0
  139. package/dist/query/index.d.ts +1 -1
  140. package/dist/query/index.js +1 -1
  141. package/dist/query/relations.d.ts +173 -5
  142. package/dist/query/relations.js +688 -47
  143. package/dist/query/types.d.ts +123 -39
  144. package/dist/query/utils.d.ts +116 -0
  145. package/dist/query/utils.js +198 -0
  146. package/dist/query/warn-registry.d.ts +9 -0
  147. package/dist/query/warn-registry.js +9 -0
  148. package/dist/query/where.d.ts +38 -1
  149. package/dist/query/where.js +58 -23
  150. package/dist/query/writes.d.ts +42 -1
  151. package/dist/query/writes.js +104 -13
  152. package/dist/schema-sql.d.ts +14 -0
  153. package/dist/schema-sql.js +23 -5
  154. package/dist/schema.d.ts +38 -0
  155. package/dist/schema.js +123 -0
  156. package/dist/sqlite.js +4 -0
  157. package/package.json +77 -28
@@ -92,6 +92,21 @@ function injectForeignKey(childData, relation, parentRow, schema) {
92
92
  function toArray(value) {
93
93
  return Array.isArray(value) ? value : [value];
94
94
  }
95
+ /**
96
+ * Stringified comparison key for a key value, matching `keyOf` in
97
+ * query/batched-loader.ts (the loader stitches parent and child rows across two
98
+ * tables the same way).
99
+ *
100
+ * Two rows read from DIFFERENT tables go through different column parsers, so
101
+ * the same logical key can arrive as `1` from one and `'1'` from the other (a
102
+ * bigint junction column read as text next to a numeric target primary key).
103
+ * Strict identity also fails outright for object-valued keys: two `Date`s or two
104
+ * `Buffer`s holding the same value are never `===`, so a `Set` of them
105
+ * de-duplicates nothing. Comparing on the string form makes both work.
106
+ */
107
+ function keyOf(value) {
108
+ return String(value);
109
+ }
95
110
  /**
96
111
  * Validate that all operation keys in a nested write are recognized and
97
112
  * allowed for the current context (create vs update).
@@ -219,16 +234,375 @@ function assertTargetSelectsSomething(target, op, relName, rel) {
219
234
  `An empty or all-undefined "where" would ${op} every related "${rel.to}" row of this parent.`);
220
235
  }
221
236
  /**
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.
237
+ * The many-to-many nested-write operations that DO write junction rows:
238
+ * `connect`, `disconnect` and `set`. Everything else (create, connectOrCreate,
239
+ * delete, update, upsert) would have to write the TARGET row as well, and there
240
+ * is no safe default for the junction's own extra columns, so those still
241
+ * refuse loudly. Falling off the end of the dispatch instead would drop the
242
+ * write silently and report success.
243
+ */
244
+ const M2M_SUPPORTED_OPS = new Set(['connect', 'disconnect', 'set']);
245
+ /**
246
+ * How to reach a junction table's write accessor, spelled per client because the
247
+ * two clients expose it under DIFFERENT keys.
248
+ *
249
+ * On the core client the auto-created property accessors are CAMELCASED
250
+ * (`db.userTags` for `user_tags`, see the `Object.defineProperty` loops in
251
+ * client.ts), so `db["user_tags"]` is `undefined` for every snake_case junction
252
+ * — which is every junction `turbine pull` produces over a hand-written join
253
+ * table. `db.table("user_tags")` takes the raw table name and exists on both
254
+ * TurbineClient and the TransactionClient handed to `$transaction`, so that is
255
+ * the form to recommend.
256
+ *
257
+ * The prisma-compat client has no `table()` method at all; it exposes each
258
+ * junction as a model delegate keyed by the RAW table name (`junctionModels` in
259
+ * prisma-compat.ts), inside `$transaction` too.
260
+ */
261
+ function junctionAccessorHint(junction) {
262
+ return (`db.table("${junction}").create / createMany on the core client (inside $transaction too), ` +
263
+ `or db["${junction}"].create / createMany on the prisma-compat client`);
264
+ }
265
+ /**
266
+ * The refusal for a many-to-many nested operation that is still unsupported.
267
+ * Names the operation, the ones that ARE supported, and a remedy that is
268
+ * actually reachable on the reader's client (see {@link junctionAccessorHint}).
269
+ */
270
+ function manyToManyOpUnsupported(op, relName, rel) {
271
+ const junction = rel.through?.table ?? 'junction';
272
+ return new errors_js_1.ValidationError(`[turbine] Nested "${op}" is not supported on the many-to-many relation "${relName}" ` +
273
+ `(via the "${junction}" junction table). The supported many-to-many nested operations are ` +
274
+ `connect, disconnect and set. To ${op} a "${rel.to}" row itself, write it directly on the ` +
275
+ `"${rel.to}" table inside the same $transaction and link it with a nested connect; to write ` +
276
+ `link rows by hand, use the junction accessor: ${junctionAccessorHint(junction)}.`);
277
+ }
278
+ /** Refuse every op on an m2m relation that the junction-write path does not handle. */
279
+ function assertManyToManyOpsSupported(relName, rel, ops) {
280
+ for (const op of Object.keys(ops)) {
281
+ if (!M2M_SUPPORTED_OPS.has(op))
282
+ throw manyToManyOpUnsupported(op, relName, rel);
283
+ }
284
+ }
285
+ /**
286
+ * Does the junction constrain the (source, target) pair to at most one row?
287
+ * Only a key over EXACTLY the two columns qualifies: a unique over a superset
288
+ * permits duplicate pairs, and a PARTIAL unique index only constrains the rows
289
+ * matching its predicate.
290
+ */
291
+ function junctionPairIsUnique(junctionMeta, sourceCol, targetCol) {
292
+ const covers = (cols) => cols.length === 2 && cols.includes(sourceCol) && cols.includes(targetCol);
293
+ if (covers(junctionMeta.primaryKey))
294
+ return true;
295
+ if ((junctionMeta.uniqueColumns ?? []).some(covers))
296
+ return true;
297
+ return (junctionMeta.indexes ?? []).some((idx) => idx.unique && !idx.partial && !idx.docPath && covers(idx.columns));
298
+ }
299
+ /**
300
+ * Resolve the junction descriptor for a many-to-many nested write.
301
+ *
302
+ * Composite source/target keys are refused rather than guessed: a multi-column
303
+ * link row cannot be addressed with the single-column `in (...)` predicates the
304
+ * batched junction writes below use, and emitting a partially-keyed delete would
305
+ * unlink rows the caller never named.
306
+ */
307
+ function junctionPlan(ctx, rel, relName, parentRow) {
308
+ const through = rel.through;
309
+ if (!through) {
310
+ throw new errors_js_1.ValidationError(`[turbine] manyToMany relation "${relName}" is missing a \`through\` junction descriptor.`);
311
+ }
312
+ const junctionMeta = ctx.schema.tables[through.table];
313
+ if (!junctionMeta) {
314
+ throw new errors_js_1.ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}": junction table ` +
315
+ `"${through.table}" is not present in the schema metadata, so its rows cannot be written. ` +
316
+ `Regenerate the schema (turbine generate) so the junction table is included.`);
317
+ }
318
+ const targetMeta = ctx.schema.tables[rel.to];
319
+ if (!targetMeta) {
320
+ throw new errors_js_1.ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}": unknown target table "${rel.to}".`);
321
+ }
322
+ const sourceKeys = (0, schema_js_1.normalizeKeyColumns)(through.sourceKey);
323
+ const targetKeys = (0, schema_js_1.normalizeKeyColumns)(through.targetKey);
324
+ const refKeys = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
325
+ const targetPk = targetMeta.primaryKey;
326
+ if (sourceKeys.length !== 1 || targetKeys.length !== 1 || refKeys.length !== 1 || targetPk.length !== 1) {
327
+ throw new errors_js_1.ValidationError(`[turbine] Nested writes on the many-to-many relation "${relName}" (via "${through.table}") support ` +
328
+ `single-column junction keys only; this relation links on composite keys ` +
329
+ `(source ${sourceKeys.length}, target ${targetKeys.length}, target primary key ${targetPk.length} column(s)). ` +
330
+ `Write the junction rows directly inside the same $transaction: ${junctionAccessorHint(through.table)}.`);
331
+ }
332
+ // The two junction columns must DIFFER. When they collide (a `through` typo
333
+ // that names one column twice) every predicate and every inserted row this
334
+ // module builds is keyed by ONE object property, so the source key silently
335
+ // overwrites the target key: the disconnect predicate loses its parent scope
336
+ // and deletes other parents' link rows, and the inserted row carries no parent
337
+ // key at all. Introspection can never produce this (it requires two distinct
338
+ // FK columns), but a hand-declared `defineSchema` manyToMany can.
339
+ if (sourceKeys[0] === targetKeys[0]) {
340
+ throw new errors_js_1.ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}" cannot run: the junction ` +
341
+ `"${through.table}" names the same column "${sourceKeys[0]}" as BOTH its sourceKey and its ` +
342
+ `targetKey, so a link row cannot hold the parent key and the target key at once. Fix the ` +
343
+ `relation's \`through\` descriptor to name the two distinct junction columns.`);
344
+ }
345
+ const parentField = ctx.schema.tables[rel.from]?.reverseColumnMap[refKeys[0]] ?? refKeys[0];
346
+ const parentValue = parentRow[parentField];
347
+ if (parentValue === null || parentValue === undefined) {
348
+ throw new errors_js_1.ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}" cannot run: the parent's reference ` +
349
+ `key "${parentField}" is ${parentValue === null ? 'null' : 'missing from the loaded row'}, so no ` +
350
+ `junction row can be correlated to this parent.`);
351
+ }
352
+ return {
353
+ table: through.table,
354
+ sourceField: junctionMeta.reverseColumnMap[sourceKeys[0]] ?? sourceKeys[0],
355
+ targetField: junctionMeta.reverseColumnMap[targetKeys[0]] ?? targetKeys[0],
356
+ parentValue,
357
+ targetKeyField: targetMeta.reverseColumnMap[targetPk[0]] ?? targetPk[0],
358
+ pairIsUnique: junctionPairIsUnique(junctionMeta, sourceKeys[0], targetKeys[0]),
359
+ };
360
+ }
361
+ /** The E003 for a target selector that matched no row. */
362
+ function noTargetRow(op, relName, rel, target) {
363
+ return new errors_js_1.ValidationError(`[turbine] Nested ${op} on the many-to-many relation "${relName}": no "${rel.to}" row found ` +
364
+ `matching ${(0, errors_js_1.describeTargetForMessage)(target)}.`);
365
+ }
366
+ /**
367
+ * The plain primary-key values of `items` when EVERY selector is simple
368
+ * equality on the target's primary key (`{ id: 7 }`), else `null`.
369
+ *
370
+ * This is the overwhelmingly common connect payload, and it is the only shape
371
+ * that can be resolved by one `IN (...)` read: anything else (a secondary
372
+ * unique, a compound-unique selector, an operator object like
373
+ * `{ id: { in: [...] } }`, a multi-key selector) still needs its own
374
+ * `findUnique` to know WHICH row it names.
227
375
  */
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.`);
376
+ function simplePkSelectors(items, targetKeyField) {
377
+ const values = [];
378
+ for (const item of items) {
379
+ if (!item || typeof item !== 'object' || Array.isArray(item))
380
+ return null;
381
+ const keys = Object.keys(item);
382
+ if (keys.length !== 1 || keys[0] !== targetKeyField)
383
+ return null;
384
+ const value = item[targetKeyField];
385
+ if (value === null || value === undefined)
386
+ return null;
387
+ // An operator object (`{ gt: 1 }`, `{ in: [...] }`) is not an equality
388
+ // selector. Date and Buffer keys are objects too; they ARE plain values, but
389
+ // they are rare enough that routing them down the per-selector path costs
390
+ // nothing and keeps this test to one cheap rule.
391
+ if (typeof value === 'object')
392
+ return null;
393
+ values.push(value);
394
+ }
395
+ return values;
396
+ }
397
+ /**
398
+ * Resolve each caller-supplied target selector to the target row's key value,
399
+ * de-duplicated in first-seen order. A selector that matches no row is refused:
400
+ * silently skipping it is exactly the failure mode this path exists to remove.
401
+ *
402
+ * A payload of plain primary-key selectors (see {@link simplePkSelectors}) is
403
+ * resolved with ONE `IN (...)` read instead of a `findUnique` per target, so a
404
+ * 20-target connect costs one round trip rather than twenty. The per-selector
405
+ * path stays for arbitrary unique selectors, and BOTH refuse a target that does
406
+ * not exist, naming it.
407
+ */
408
+ async function resolveJunctionTargets(ctx, rel, relName, plan, op, items) {
409
+ for (const target of items)
410
+ assertTargetSelectsSomething(target, op, relName, rel);
411
+ const pkValues = simplePkSelectors(items, plan.targetKeyField);
412
+ if (pkValues)
413
+ return resolveJunctionTargetsByPk(ctx, rel, relName, plan, op, pkValues);
414
+ const values = [];
415
+ const seen = new Set();
416
+ for (const target of items) {
417
+ const row = (await ctx.tx.table(rel.to).findUnique({ where: target }));
418
+ if (!row)
419
+ throw noTargetRow(op, relName, rel, target);
420
+ const value = row[plan.targetKeyField];
421
+ if (value === null || value === undefined) {
422
+ throw new errors_js_1.ValidationError(`[turbine] Nested ${op} on the many-to-many relation "${relName}": the "${rel.to}" row matching ` +
423
+ `${(0, errors_js_1.describeTargetForMessage)(target)} has no "${plan.targetKeyField}" value to link.`);
424
+ }
425
+ const key = keyOf(value);
426
+ if (seen.has(key))
427
+ continue;
428
+ seen.add(key);
429
+ values.push(value);
430
+ }
431
+ return values;
432
+ }
433
+ /** One-read resolution for a payload of plain primary-key selectors. */
434
+ async function resolveJunctionTargetsByPk(ctx, rel, relName, plan, op, pkValues) {
435
+ const wanted = [];
436
+ const seen = new Set();
437
+ for (const value of pkValues) {
438
+ const key = keyOf(value);
439
+ if (seen.has(key))
440
+ continue;
441
+ seen.add(key);
442
+ wanted.push(value);
443
+ }
444
+ if (wanted.length === 0)
445
+ return [];
446
+ const rows = (await ctx.tx.table(rel.to).findMany({
447
+ where: { [plan.targetKeyField]: { in: wanted } },
448
+ warnOnUnlimited: false,
449
+ }));
450
+ // Keyed by string, so a target primary key read back in a different shape than
451
+ // the caller wrote it (a bigint `1` for a supplied `'1'`) still matches.
452
+ const found = new Map();
453
+ for (const row of rows) {
454
+ const value = row[plan.targetKeyField];
455
+ if (value === null || value === undefined)
456
+ continue;
457
+ found.set(keyOf(value), value);
458
+ }
459
+ // The DB-side value is returned, not the caller's, so the junction insert
460
+ // writes the same shape the per-selector path would.
461
+ return wanted.map((value) => {
462
+ const hit = found.get(keyOf(value));
463
+ if (hit === undefined)
464
+ throw noTargetRow(op, relName, rel, { [plan.targetKeyField]: value });
465
+ return hit;
466
+ });
467
+ }
468
+ /** The junction rows linking this parent to `values`. */
469
+ function junctionRows(plan, values) {
470
+ return values.map((v) => ({ [plan.sourceField]: plan.parentValue, [plan.targetField]: v }));
471
+ }
472
+ /** The `feature` string both refusing engines tag their skipDuplicates E017 with. */
473
+ const SKIP_DUPLICATES_FEATURE = 'createMany({ skipDuplicates: true })';
474
+ /**
475
+ * Per-schema memo of whether the bound engine accepts
476
+ * `createMany({ skipDuplicates })`, so the engines that refuse it pay for the
477
+ * refusal once instead of on every connect.
478
+ *
479
+ * The capability is not reachable from here as a flag — the `Dialect` is private
480
+ * to QueryInterface / TurbineClient and `NestedWriteContext` carries only the
481
+ * schema and the transaction — so it is read from the engine's OWN structured
482
+ * refusal (`UnsupportedFeatureError.feature`) rather than from a hardcoded
483
+ * engine list that would drift. Both refusing engines throw while BUILDING the
484
+ * statement, before anything is written, so the attempt is side-effect-free.
485
+ *
486
+ * Getting the memo wrong is harmless in both directions: a stale `false` only
487
+ * costs the read-then-insert fallback, and a stale `true` is re-detected and
488
+ * corrected by the same catch.
489
+ */
490
+ const skipDuplicatesSupport = new WeakMap();
491
+ /**
492
+ * Insert the link rows with `ON CONFLICT DO NOTHING` semantics. Returns false
493
+ * (having written nothing) when the engine refuses the option, so the caller can
494
+ * fall back.
495
+ */
496
+ async function insertJunctionRowsSkippingDuplicates(ctx, plan, values) {
497
+ if (skipDuplicatesSupport.get(ctx.schema) === false)
498
+ return false;
499
+ try {
500
+ await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, values), skipDuplicates: true });
501
+ skipDuplicatesSupport.set(ctx.schema, true);
502
+ return true;
503
+ }
504
+ catch (err) {
505
+ if (err instanceof errors_js_1.UnsupportedFeatureError && err.feature === SKIP_DUPLICATES_FEATURE) {
506
+ skipDuplicatesSupport.set(ctx.schema, false);
507
+ return false;
508
+ }
509
+ throw err;
510
+ }
511
+ }
512
+ /**
513
+ * `connect`: link this parent to each target, idempotently.
514
+ *
515
+ * Two strategies, because idempotence has to survive CONCURRENCY:
516
+ *
517
+ * 1. Junction constrains the pair AND the engine supports `skipDuplicates`
518
+ * (PostgreSQL, SQLite and MySQL: `ON CONFLICT DO NOTHING` / a no-op
519
+ * `ON DUPLICATE KEY UPDATE`) — one INSERT, no read. The engine resolves the
520
+ * conflict, so two transactions connecting the same pair at the same time
521
+ * both succeed and one link row exists. This is the introspected path:
522
+ * every junction introspection can detect declares the pair unique.
523
+ * 2. Otherwise — read this parent's existing link rows for exactly these targets
524
+ * and insert only the missing ones. Used when the engine refuses the option
525
+ * (SQL Server and PowDB have no single-statement skip-duplicates form and
526
+ * throw E017), and when the junction does NOT constrain the pair, where
527
+ * `ON CONFLICT DO NOTHING` has no constraint to fire on and would let a
528
+ * repeated connect insert a second link row. Read-then-insert is not
529
+ * concurrency-safe (both transactions can read "missing"); on an unconstrained
530
+ * junction nothing available here is, and this at least keeps a serially
531
+ * repeated connect idempotent.
532
+ */
533
+ async function processManyToManyConnect(ctx, rel, relName, plan, items) {
534
+ if (items.length === 0)
535
+ return;
536
+ const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'connect', items);
537
+ if (values.length === 0)
538
+ return;
539
+ if (plan.pairIsUnique && (await insertJunctionRowsSkippingDuplicates(ctx, plan, values)))
540
+ return;
541
+ const existing = (await ctx.tx.table(plan.table).findMany({
542
+ where: { [plan.sourceField]: plan.parentValue, [plan.targetField]: { in: values } },
543
+ // An internal engine read: never lecture the caller about a missing `limit`
544
+ // on a statement they did not write.
545
+ warnOnUnlimited: false,
546
+ }));
547
+ // Compared as strings: the junction column and the target primary key are read
548
+ // through DIFFERENT tables' parsers, so an already-linked pair can arrive as
549
+ // `'1'` here and `1` there, and strict identity would re-insert it.
550
+ const linked = new Set(existing.map((r) => keyOf(r[plan.targetField])));
551
+ const missing = values.filter((v) => !linked.has(keyOf(v)));
552
+ if (missing.length === 0)
553
+ return;
554
+ await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, missing) });
555
+ }
556
+ /**
557
+ * `disconnect`: remove the link rows for exactly the named targets.
558
+ *
559
+ * Scoped by BOTH keys in one statement. A delete scoped by the source key alone
560
+ * would unlink every target of this parent, and one scoped by the target key
561
+ * alone would unlink OTHER parents' rows.
562
+ */
563
+ async function processManyToManyDisconnect(ctx, rel, relName, plan, items) {
564
+ if (items.length === 0)
565
+ return;
566
+ const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'disconnect', items);
567
+ if (values.length === 0)
568
+ return;
569
+ await ctx.tx.table(plan.table).deleteMany({
570
+ where: { [plan.sourceField]: plan.parentValue, [plan.targetField]: { in: values } },
571
+ });
572
+ }
573
+ /**
574
+ * `set`: replace this parent's whole link set, in one transaction.
575
+ *
576
+ * `set: []` clears every link of this parent (Prisma's semantics, and the same
577
+ * choice the hasMany/hasOne `set` path already makes). The clearing delete is
578
+ * always scoped by the source key, so it can never touch another parent's rows.
579
+ */
580
+ async function processManyToManySet(ctx, rel, relName, plan, items) {
581
+ const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'set', items);
582
+ await ctx.tx.table(plan.table).deleteMany({ where: { [plan.sourceField]: plan.parentValue } });
583
+ if (values.length === 0)
584
+ return;
585
+ await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, values) });
586
+ }
587
+ /**
588
+ * Run every supported junction operation for one many-to-many relation, in the
589
+ * order the caller's payload implies: `set` (a full replacement) first, then
590
+ * `disconnect`, then `connect`.
591
+ */
592
+ async function processManyToMany(ctx, rel, relName, ops, parentRow) {
593
+ assertManyToManyOpsSupported(relName, rel, ops);
594
+ if (ops.set === undefined && ops.disconnect === undefined && ops.connect === undefined)
595
+ return;
596
+ const plan = junctionPlan(ctx, rel, relName, parentRow);
597
+ if (ops.set !== undefined) {
598
+ await processManyToManySet(ctx, rel, relName, plan, toArray(ops.set));
599
+ }
600
+ if (ops.disconnect !== undefined) {
601
+ await processManyToManyDisconnect(ctx, rel, relName, plan, toArray(ops.disconnect));
602
+ }
603
+ if (ops.connect !== undefined) {
604
+ await processManyToManyConnect(ctx, rel, relName, plan, toArray(ops.connect));
605
+ }
232
606
  }
233
607
  /**
234
608
  * The E001 raised when a nested delete/update/disconnect target is not a child
@@ -302,7 +676,10 @@ async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
302
676
  Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
303
677
  }
304
678
  else if (rel.type === 'manyToMany') {
305
- throw manyToManyUnsupported(relName, rel);
679
+ // Validated BEFORE the parent insert so an unsupported m2m op refuses
680
+ // without having written anything; the junction rows themselves need the
681
+ // parent's key, so they are written after the insert (below).
682
+ assertManyToManyOpsSupported(relName, rel, ops);
306
683
  }
307
684
  }
308
685
  // Insert the parent row (scalars + resolved belongsTo foreign keys)
@@ -316,6 +693,12 @@ async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
316
693
  if (rel.type === 'hasMany' || rel.type === 'hasOne') {
317
694
  await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
318
695
  }
696
+ else if (rel.type === 'manyToMany') {
697
+ // The junction row carries the parent's key, so it can only be written
698
+ // once the parent row exists. Same transaction, same ordering rule the
699
+ // hasMany path follows.
700
+ await processManyToMany(ctx, rel, relName, ops, parentRow);
701
+ }
319
702
  }
320
703
  // Build the `with` clause for the final read to return the full tree
321
704
  const withClause = {};
@@ -425,7 +808,7 @@ async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path
425
808
  }
426
809
  }
427
810
  else {
428
- throw manyToManyUnsupported(relName, rel);
811
+ await processManyToMany(ctx, rel, relName, ops, parentRow);
429
812
  }
430
813
  }
431
814
  // Final read with all touched relations
@@ -773,18 +1156,22 @@ async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTabl
773
1156
  if (!item.data) {
774
1157
  throw new errors_js_1.ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
775
1158
  }
776
- // Derive where from parent's FK values
777
- const fks = (0, schema_js_1.normalizeKeyColumns)(rel.foreignKey);
778
- const refs = (0, schema_js_1.normalizeKeyColumns)(rel.referenceKey);
779
- const parentMeta = ctx.schema.tables[parentTable];
780
- const relatedTable = ctx.schema.tables[rel.to];
781
- const where = {};
782
- for (let i = 0; i < fks.length; i++) {
783
- const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
784
- const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
785
- where[refField] = parentRow[fkField];
1159
+ // The related row is the one this parent's FK points at. Route through the
1160
+ // shared correlation helper (like every sibling operation) so a NULL parent
1161
+ // FK reports not-found instead of compiling to `refField IS NULL` and
1162
+ // updating EVERY row of the related table with a null reference key.
1163
+ const where = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
1164
+ if (!where) {
1165
+ // Parent FK is NULL: it points at nothing, so nothing is in scope to update.
1166
+ const nullFk = Object.fromEntries((0, schema_js_1.normalizeKeyColumns)(rel.foreignKey).map((c) => [c, null]));
1167
+ throw notRelatedToParent('update', rel.name, rel, nullFk);
1168
+ }
1169
+ try {
1170
+ await ctx.tx.table(rel.to).update({ where, data: item.data });
1171
+ }
1172
+ catch (err) {
1173
+ rethrowAsNotRelated(err, 'update', rel.name, rel, where);
786
1174
  }
787
- await ctx.tx.table(rel.to).update({ where, data: item.data });
788
1175
  }
789
1176
  async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
790
1177
  const item = upsertArg;
@@ -804,8 +1191,11 @@ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTabl
804
1191
  // relation's reference key (unique or the PK by construction), so at most one
805
1192
  // row can come back.
806
1193
  const existing = correlation
807
- ? ((await ctx.tx.table(rel.to).findMany({ where: scopeWhereToParent(item.where, correlation) }))[0] ??
808
- null)
1194
+ ? ((await ctx.tx.table(rel.to).findMany({
1195
+ where: scopeWhereToParent(item.where, correlation),
1196
+ // Internal engine read (see the connect path): no unlimited-findMany warning.
1197
+ warnOnUnlimited: false,
1198
+ }))[0] ?? null)
809
1199
  : null;
810
1200
  if (existing) {
811
1201
  const relatedMeta = ctx.schema.tables[rel.to];
@@ -0,0 +1,115 @@
1
+ /**
2
+ * turbine-orm: Observability module
3
+ *
4
+ * Buffers query metrics in memory (keyed by model:action per minute bucket),
5
+ * then periodically flushes aggregates (count, avg, p50, p95, p99, errors) to a
6
+ * pluggable {@link ObserveSink}. The default sink writes to a dedicated
7
+ * `_turbine_metrics` Postgres table over its own 1-connection pool, so metrics
8
+ * writes never contend with the application pool; alternative sinks (for example
9
+ * {@link HttpJsonSink}) can forward the same aggregates elsewhere.
10
+ *
11
+ * The aggregation privacy posture is deliberate: a batch carries only the
12
+ * model/action identity, the counts, and the latency percentiles. It never
13
+ * carries SQL text or bound parameter values.
14
+ */
15
+ import type { QueryEventListener } from './query/index.js';
16
+ export interface ObserveConfig {
17
+ /**
18
+ * Metrics database connection string for the default Postgres sink. Optional
19
+ * when a {@link ObserveConfig.sink} is supplied; at least one of the two must
20
+ * be present.
21
+ */
22
+ connectionString?: string;
23
+ flushIntervalMs?: number;
24
+ retentionDays?: number;
25
+ /**
26
+ * A custom flush target. When omitted, the engine writes to `_turbine_metrics`
27
+ * via the default Postgres sink (byte-identical to the pre-sink writer).
28
+ */
29
+ sink?: ObserveSink;
30
+ }
31
+ export interface ObserveHandle {
32
+ stop(): Promise<void>;
33
+ }
34
+ /** One per-bucket aggregate row. Identity + numbers only: no SQL, no params. */
35
+ export interface MetricsFlushRow {
36
+ /** Minute bucket the aggregate belongs to. */
37
+ bucket: Date;
38
+ model: string;
39
+ action: string;
40
+ count: number;
41
+ avg: number;
42
+ p50: number;
43
+ p95: number;
44
+ p99: number;
45
+ errors: number;
46
+ }
47
+ /** A batch of aggregate rows handed to a sink on each flush. */
48
+ export interface MetricsFlushBatch {
49
+ rows: MetricsFlushRow[];
50
+ }
51
+ /**
52
+ * A pluggable flush target for the observe engine. `init` runs once at startup
53
+ * (create tables, open connections); `flush` receives each aggregate batch;
54
+ * `stop` tears down on shutdown. A sink must never let a flush error escape into
55
+ * the application: metrics are best-effort by contract.
56
+ */
57
+ export interface ObserveSink {
58
+ init?(): Promise<void>;
59
+ flush(batch: MetricsFlushBatch): Promise<void>;
60
+ stop?(): Promise<void>;
61
+ }
62
+ declare function floorToMinute(date: Date): Date;
63
+ declare function percentile(sorted: number[], p: number): number;
64
+ export interface PgMetricsSinkOptions {
65
+ connectionString: string;
66
+ retentionDays?: number;
67
+ }
68
+ /**
69
+ * The default flush target: upserts each aggregate row into `_turbine_metrics`
70
+ * and prunes rows older than `retentionDays`. The SQL and per-row/retention
71
+ * ordering are byte-identical to the pre-sink `ObserveEngine.flush` writer.
72
+ */
73
+ export declare class PgMetricsSink implements ObserveSink {
74
+ private readonly pool;
75
+ private readonly retentionDays;
76
+ constructor(options: PgMetricsSinkOptions);
77
+ init(): Promise<void>;
78
+ flush(batch: MetricsFlushBatch): Promise<void>;
79
+ stop(): Promise<void>;
80
+ }
81
+ export interface HttpJsonSinkOptions {
82
+ /** Endpoint that receives POSTed JSON batches. */
83
+ url: string;
84
+ /** Extra request headers (for example an auth token). */
85
+ headers?: Record<string, string>;
86
+ /** Override the fetch implementation (defaults to the global `fetch`). */
87
+ fetchFunction?: typeof fetch;
88
+ }
89
+ /**
90
+ * Forwards each aggregate batch to an HTTP endpoint as a JSON POST. Fire-and-
91
+ * forget: a failed request is swallowed and never throws, and there are no
92
+ * retries beyond the engine's next scheduled flush. Aggregates only: the body
93
+ * carries no SQL text and no parameter values.
94
+ */
95
+ export declare class HttpJsonSink implements ObserveSink {
96
+ private readonly url;
97
+ private readonly headers;
98
+ private readonly fetchFn;
99
+ constructor(options: HttpJsonSinkOptions);
100
+ flush(batch: MetricsFlushBatch): Promise<void>;
101
+ }
102
+ export declare class ObserveEngine {
103
+ private readonly sink;
104
+ private readonly buffer;
105
+ private readonly flushIntervalMs;
106
+ private timer;
107
+ private readonly listener;
108
+ private stopped;
109
+ constructor(config: ObserveConfig);
110
+ getListener(): QueryEventListener;
111
+ init(): Promise<void>;
112
+ flush(): Promise<void>;
113
+ stop(): Promise<void>;
114
+ }
115
+ export { floorToMinute, percentile };