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
@@ -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, NotFoundError, RelationError, ValidationError, } from './errors.js';
14
+ import { CircularRelationError, describeTargetForMessage, NotFoundError, RelationError, UnsupportedFeatureError, 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']);
@@ -85,6 +85,21 @@ export function injectForeignKey(childData, relation, parentRow, schema) {
85
85
  function toArray(value) {
86
86
  return Array.isArray(value) ? value : [value];
87
87
  }
88
+ /**
89
+ * Stringified comparison key for a key value, matching `keyOf` in
90
+ * query/batched-loader.ts (the loader stitches parent and child rows across two
91
+ * tables the same way).
92
+ *
93
+ * Two rows read from DIFFERENT tables go through different column parsers, so
94
+ * the same logical key can arrive as `1` from one and `'1'` from the other (a
95
+ * bigint junction column read as text next to a numeric target primary key).
96
+ * Strict identity also fails outright for object-valued keys: two `Date`s or two
97
+ * `Buffer`s holding the same value are never `===`, so a `Set` of them
98
+ * de-duplicates nothing. Comparing on the string form makes both work.
99
+ */
100
+ function keyOf(value) {
101
+ return String(value);
102
+ }
88
103
  /**
89
104
  * Validate that all operation keys in a nested write are recognized and
90
105
  * allowed for the current context (create vs update).
@@ -212,16 +227,375 @@ function assertTargetSelectsSomething(target, op, relName, rel) {
212
227
  `An empty or all-undefined "where" would ${op} every related "${rel.to}" row of this parent.`);
213
228
  }
214
229
  /**
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.
230
+ * The many-to-many nested-write operations that DO write junction rows:
231
+ * `connect`, `disconnect` and `set`. Everything else (create, connectOrCreate,
232
+ * delete, update, upsert) would have to write the TARGET row as well, and there
233
+ * is no safe default for the junction's own extra columns, so those still
234
+ * refuse loudly. Falling off the end of the dispatch instead would drop the
235
+ * write silently and report success.
236
+ */
237
+ const M2M_SUPPORTED_OPS = new Set(['connect', 'disconnect', 'set']);
238
+ /**
239
+ * How to reach a junction table's write accessor, spelled per client because the
240
+ * two clients expose it under DIFFERENT keys.
241
+ *
242
+ * On the core client the auto-created property accessors are CAMELCASED
243
+ * (`db.userTags` for `user_tags`, see the `Object.defineProperty` loops in
244
+ * client.ts), so `db["user_tags"]` is `undefined` for every snake_case junction
245
+ * — which is every junction `turbine pull` produces over a hand-written join
246
+ * table. `db.table("user_tags")` takes the raw table name and exists on both
247
+ * TurbineClient and the TransactionClient handed to `$transaction`, so that is
248
+ * the form to recommend.
249
+ *
250
+ * The prisma-compat client has no `table()` method at all; it exposes each
251
+ * junction as a model delegate keyed by the RAW table name (`junctionModels` in
252
+ * prisma-compat.ts), inside `$transaction` too.
253
+ */
254
+ function junctionAccessorHint(junction) {
255
+ return (`db.table("${junction}").create / createMany on the core client (inside $transaction too), ` +
256
+ `or db["${junction}"].create / createMany on the prisma-compat client`);
257
+ }
258
+ /**
259
+ * The refusal for a many-to-many nested operation that is still unsupported.
260
+ * Names the operation, the ones that ARE supported, and a remedy that is
261
+ * actually reachable on the reader's client (see {@link junctionAccessorHint}).
262
+ */
263
+ function manyToManyOpUnsupported(op, relName, rel) {
264
+ const junction = rel.through?.table ?? 'junction';
265
+ return new ValidationError(`[turbine] Nested "${op}" is not supported on the many-to-many relation "${relName}" ` +
266
+ `(via the "${junction}" junction table). The supported many-to-many nested operations are ` +
267
+ `connect, disconnect and set. To ${op} a "${rel.to}" row itself, write it directly on the ` +
268
+ `"${rel.to}" table inside the same $transaction and link it with a nested connect; to write ` +
269
+ `link rows by hand, use the junction accessor: ${junctionAccessorHint(junction)}.`);
270
+ }
271
+ /** Refuse every op on an m2m relation that the junction-write path does not handle. */
272
+ function assertManyToManyOpsSupported(relName, rel, ops) {
273
+ for (const op of Object.keys(ops)) {
274
+ if (!M2M_SUPPORTED_OPS.has(op))
275
+ throw manyToManyOpUnsupported(op, relName, rel);
276
+ }
277
+ }
278
+ /**
279
+ * Does the junction constrain the (source, target) pair to at most one row?
280
+ * Only a key over EXACTLY the two columns qualifies: a unique over a superset
281
+ * permits duplicate pairs, and a PARTIAL unique index only constrains the rows
282
+ * matching its predicate.
283
+ */
284
+ function junctionPairIsUnique(junctionMeta, sourceCol, targetCol) {
285
+ const covers = (cols) => cols.length === 2 && cols.includes(sourceCol) && cols.includes(targetCol);
286
+ if (covers(junctionMeta.primaryKey))
287
+ return true;
288
+ if ((junctionMeta.uniqueColumns ?? []).some(covers))
289
+ return true;
290
+ return (junctionMeta.indexes ?? []).some((idx) => idx.unique && !idx.partial && !idx.docPath && covers(idx.columns));
291
+ }
292
+ /**
293
+ * Resolve the junction descriptor for a many-to-many nested write.
294
+ *
295
+ * Composite source/target keys are refused rather than guessed: a multi-column
296
+ * link row cannot be addressed with the single-column `in (...)` predicates the
297
+ * batched junction writes below use, and emitting a partially-keyed delete would
298
+ * unlink rows the caller never named.
299
+ */
300
+ function junctionPlan(ctx, rel, relName, parentRow) {
301
+ const through = rel.through;
302
+ if (!through) {
303
+ throw new ValidationError(`[turbine] manyToMany relation "${relName}" is missing a \`through\` junction descriptor.`);
304
+ }
305
+ const junctionMeta = ctx.schema.tables[through.table];
306
+ if (!junctionMeta) {
307
+ throw new ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}": junction table ` +
308
+ `"${through.table}" is not present in the schema metadata, so its rows cannot be written. ` +
309
+ `Regenerate the schema (turbine generate) so the junction table is included.`);
310
+ }
311
+ const targetMeta = ctx.schema.tables[rel.to];
312
+ if (!targetMeta) {
313
+ throw new ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}": unknown target table "${rel.to}".`);
314
+ }
315
+ const sourceKeys = normalizeKeyColumns(through.sourceKey);
316
+ const targetKeys = normalizeKeyColumns(through.targetKey);
317
+ const refKeys = normalizeKeyColumns(rel.referenceKey);
318
+ const targetPk = targetMeta.primaryKey;
319
+ if (sourceKeys.length !== 1 || targetKeys.length !== 1 || refKeys.length !== 1 || targetPk.length !== 1) {
320
+ throw new ValidationError(`[turbine] Nested writes on the many-to-many relation "${relName}" (via "${through.table}") support ` +
321
+ `single-column junction keys only; this relation links on composite keys ` +
322
+ `(source ${sourceKeys.length}, target ${targetKeys.length}, target primary key ${targetPk.length} column(s)). ` +
323
+ `Write the junction rows directly inside the same $transaction: ${junctionAccessorHint(through.table)}.`);
324
+ }
325
+ // The two junction columns must DIFFER. When they collide (a `through` typo
326
+ // that names one column twice) every predicate and every inserted row this
327
+ // module builds is keyed by ONE object property, so the source key silently
328
+ // overwrites the target key: the disconnect predicate loses its parent scope
329
+ // and deletes other parents' link rows, and the inserted row carries no parent
330
+ // key at all. Introspection can never produce this (it requires two distinct
331
+ // FK columns), but a hand-declared `defineSchema` manyToMany can.
332
+ if (sourceKeys[0] === targetKeys[0]) {
333
+ throw new ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}" cannot run: the junction ` +
334
+ `"${through.table}" names the same column "${sourceKeys[0]}" as BOTH its sourceKey and its ` +
335
+ `targetKey, so a link row cannot hold the parent key and the target key at once. Fix the ` +
336
+ `relation's \`through\` descriptor to name the two distinct junction columns.`);
337
+ }
338
+ const parentField = ctx.schema.tables[rel.from]?.reverseColumnMap[refKeys[0]] ?? refKeys[0];
339
+ const parentValue = parentRow[parentField];
340
+ if (parentValue === null || parentValue === undefined) {
341
+ throw new ValidationError(`[turbine] Nested write on the many-to-many relation "${relName}" cannot run: the parent's reference ` +
342
+ `key "${parentField}" is ${parentValue === null ? 'null' : 'missing from the loaded row'}, so no ` +
343
+ `junction row can be correlated to this parent.`);
344
+ }
345
+ return {
346
+ table: through.table,
347
+ sourceField: junctionMeta.reverseColumnMap[sourceKeys[0]] ?? sourceKeys[0],
348
+ targetField: junctionMeta.reverseColumnMap[targetKeys[0]] ?? targetKeys[0],
349
+ parentValue,
350
+ targetKeyField: targetMeta.reverseColumnMap[targetPk[0]] ?? targetPk[0],
351
+ pairIsUnique: junctionPairIsUnique(junctionMeta, sourceKeys[0], targetKeys[0]),
352
+ };
353
+ }
354
+ /** The E003 for a target selector that matched no row. */
355
+ function noTargetRow(op, relName, rel, target) {
356
+ return new ValidationError(`[turbine] Nested ${op} on the many-to-many relation "${relName}": no "${rel.to}" row found ` +
357
+ `matching ${describeTargetForMessage(target)}.`);
358
+ }
359
+ /**
360
+ * The plain primary-key values of `items` when EVERY selector is simple
361
+ * equality on the target's primary key (`{ id: 7 }`), else `null`.
362
+ *
363
+ * This is the overwhelmingly common connect payload, and it is the only shape
364
+ * that can be resolved by one `IN (...)` read: anything else (a secondary
365
+ * unique, a compound-unique selector, an operator object like
366
+ * `{ id: { in: [...] } }`, a multi-key selector) still needs its own
367
+ * `findUnique` to know WHICH row it names.
220
368
  */
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.`);
369
+ function simplePkSelectors(items, targetKeyField) {
370
+ const values = [];
371
+ for (const item of items) {
372
+ if (!item || typeof item !== 'object' || Array.isArray(item))
373
+ return null;
374
+ const keys = Object.keys(item);
375
+ if (keys.length !== 1 || keys[0] !== targetKeyField)
376
+ return null;
377
+ const value = item[targetKeyField];
378
+ if (value === null || value === undefined)
379
+ return null;
380
+ // An operator object (`{ gt: 1 }`, `{ in: [...] }`) is not an equality
381
+ // selector. Date and Buffer keys are objects too; they ARE plain values, but
382
+ // they are rare enough that routing them down the per-selector path costs
383
+ // nothing and keeps this test to one cheap rule.
384
+ if (typeof value === 'object')
385
+ return null;
386
+ values.push(value);
387
+ }
388
+ return values;
389
+ }
390
+ /**
391
+ * Resolve each caller-supplied target selector to the target row's key value,
392
+ * de-duplicated in first-seen order. A selector that matches no row is refused:
393
+ * silently skipping it is exactly the failure mode this path exists to remove.
394
+ *
395
+ * A payload of plain primary-key selectors (see {@link simplePkSelectors}) is
396
+ * resolved with ONE `IN (...)` read instead of a `findUnique` per target, so a
397
+ * 20-target connect costs one round trip rather than twenty. The per-selector
398
+ * path stays for arbitrary unique selectors, and BOTH refuse a target that does
399
+ * not exist, naming it.
400
+ */
401
+ async function resolveJunctionTargets(ctx, rel, relName, plan, op, items) {
402
+ for (const target of items)
403
+ assertTargetSelectsSomething(target, op, relName, rel);
404
+ const pkValues = simplePkSelectors(items, plan.targetKeyField);
405
+ if (pkValues)
406
+ return resolveJunctionTargetsByPk(ctx, rel, relName, plan, op, pkValues);
407
+ const values = [];
408
+ const seen = new Set();
409
+ for (const target of items) {
410
+ const row = (await ctx.tx.table(rel.to).findUnique({ where: target }));
411
+ if (!row)
412
+ throw noTargetRow(op, relName, rel, target);
413
+ const value = row[plan.targetKeyField];
414
+ if (value === null || value === undefined) {
415
+ throw new ValidationError(`[turbine] Nested ${op} on the many-to-many relation "${relName}": the "${rel.to}" row matching ` +
416
+ `${describeTargetForMessage(target)} has no "${plan.targetKeyField}" value to link.`);
417
+ }
418
+ const key = keyOf(value);
419
+ if (seen.has(key))
420
+ continue;
421
+ seen.add(key);
422
+ values.push(value);
423
+ }
424
+ return values;
425
+ }
426
+ /** One-read resolution for a payload of plain primary-key selectors. */
427
+ async function resolveJunctionTargetsByPk(ctx, rel, relName, plan, op, pkValues) {
428
+ const wanted = [];
429
+ const seen = new Set();
430
+ for (const value of pkValues) {
431
+ const key = keyOf(value);
432
+ if (seen.has(key))
433
+ continue;
434
+ seen.add(key);
435
+ wanted.push(value);
436
+ }
437
+ if (wanted.length === 0)
438
+ return [];
439
+ const rows = (await ctx.tx.table(rel.to).findMany({
440
+ where: { [plan.targetKeyField]: { in: wanted } },
441
+ warnOnUnlimited: false,
442
+ }));
443
+ // Keyed by string, so a target primary key read back in a different shape than
444
+ // the caller wrote it (a bigint `1` for a supplied `'1'`) still matches.
445
+ const found = new Map();
446
+ for (const row of rows) {
447
+ const value = row[plan.targetKeyField];
448
+ if (value === null || value === undefined)
449
+ continue;
450
+ found.set(keyOf(value), value);
451
+ }
452
+ // The DB-side value is returned, not the caller's, so the junction insert
453
+ // writes the same shape the per-selector path would.
454
+ return wanted.map((value) => {
455
+ const hit = found.get(keyOf(value));
456
+ if (hit === undefined)
457
+ throw noTargetRow(op, relName, rel, { [plan.targetKeyField]: value });
458
+ return hit;
459
+ });
460
+ }
461
+ /** The junction rows linking this parent to `values`. */
462
+ function junctionRows(plan, values) {
463
+ return values.map((v) => ({ [plan.sourceField]: plan.parentValue, [plan.targetField]: v }));
464
+ }
465
+ /** The `feature` string both refusing engines tag their skipDuplicates E017 with. */
466
+ const SKIP_DUPLICATES_FEATURE = 'createMany({ skipDuplicates: true })';
467
+ /**
468
+ * Per-schema memo of whether the bound engine accepts
469
+ * `createMany({ skipDuplicates })`, so the engines that refuse it pay for the
470
+ * refusal once instead of on every connect.
471
+ *
472
+ * The capability is not reachable from here as a flag — the `Dialect` is private
473
+ * to QueryInterface / TurbineClient and `NestedWriteContext` carries only the
474
+ * schema and the transaction — so it is read from the engine's OWN structured
475
+ * refusal (`UnsupportedFeatureError.feature`) rather than from a hardcoded
476
+ * engine list that would drift. Both refusing engines throw while BUILDING the
477
+ * statement, before anything is written, so the attempt is side-effect-free.
478
+ *
479
+ * Getting the memo wrong is harmless in both directions: a stale `false` only
480
+ * costs the read-then-insert fallback, and a stale `true` is re-detected and
481
+ * corrected by the same catch.
482
+ */
483
+ const skipDuplicatesSupport = new WeakMap();
484
+ /**
485
+ * Insert the link rows with `ON CONFLICT DO NOTHING` semantics. Returns false
486
+ * (having written nothing) when the engine refuses the option, so the caller can
487
+ * fall back.
488
+ */
489
+ async function insertJunctionRowsSkippingDuplicates(ctx, plan, values) {
490
+ if (skipDuplicatesSupport.get(ctx.schema) === false)
491
+ return false;
492
+ try {
493
+ await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, values), skipDuplicates: true });
494
+ skipDuplicatesSupport.set(ctx.schema, true);
495
+ return true;
496
+ }
497
+ catch (err) {
498
+ if (err instanceof UnsupportedFeatureError && err.feature === SKIP_DUPLICATES_FEATURE) {
499
+ skipDuplicatesSupport.set(ctx.schema, false);
500
+ return false;
501
+ }
502
+ throw err;
503
+ }
504
+ }
505
+ /**
506
+ * `connect`: link this parent to each target, idempotently.
507
+ *
508
+ * Two strategies, because idempotence has to survive CONCURRENCY:
509
+ *
510
+ * 1. Junction constrains the pair AND the engine supports `skipDuplicates`
511
+ * (PostgreSQL, SQLite and MySQL: `ON CONFLICT DO NOTHING` / a no-op
512
+ * `ON DUPLICATE KEY UPDATE`) — one INSERT, no read. The engine resolves the
513
+ * conflict, so two transactions connecting the same pair at the same time
514
+ * both succeed and one link row exists. This is the introspected path:
515
+ * every junction introspection can detect declares the pair unique.
516
+ * 2. Otherwise — read this parent's existing link rows for exactly these targets
517
+ * and insert only the missing ones. Used when the engine refuses the option
518
+ * (SQL Server and PowDB have no single-statement skip-duplicates form and
519
+ * throw E017), and when the junction does NOT constrain the pair, where
520
+ * `ON CONFLICT DO NOTHING` has no constraint to fire on and would let a
521
+ * repeated connect insert a second link row. Read-then-insert is not
522
+ * concurrency-safe (both transactions can read "missing"); on an unconstrained
523
+ * junction nothing available here is, and this at least keeps a serially
524
+ * repeated connect idempotent.
525
+ */
526
+ async function processManyToManyConnect(ctx, rel, relName, plan, items) {
527
+ if (items.length === 0)
528
+ return;
529
+ const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'connect', items);
530
+ if (values.length === 0)
531
+ return;
532
+ if (plan.pairIsUnique && (await insertJunctionRowsSkippingDuplicates(ctx, plan, values)))
533
+ return;
534
+ const existing = (await ctx.tx.table(plan.table).findMany({
535
+ where: { [plan.sourceField]: plan.parentValue, [plan.targetField]: { in: values } },
536
+ // An internal engine read: never lecture the caller about a missing `limit`
537
+ // on a statement they did not write.
538
+ warnOnUnlimited: false,
539
+ }));
540
+ // Compared as strings: the junction column and the target primary key are read
541
+ // through DIFFERENT tables' parsers, so an already-linked pair can arrive as
542
+ // `'1'` here and `1` there, and strict identity would re-insert it.
543
+ const linked = new Set(existing.map((r) => keyOf(r[plan.targetField])));
544
+ const missing = values.filter((v) => !linked.has(keyOf(v)));
545
+ if (missing.length === 0)
546
+ return;
547
+ await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, missing) });
548
+ }
549
+ /**
550
+ * `disconnect`: remove the link rows for exactly the named targets.
551
+ *
552
+ * Scoped by BOTH keys in one statement. A delete scoped by the source key alone
553
+ * would unlink every target of this parent, and one scoped by the target key
554
+ * alone would unlink OTHER parents' rows.
555
+ */
556
+ async function processManyToManyDisconnect(ctx, rel, relName, plan, items) {
557
+ if (items.length === 0)
558
+ return;
559
+ const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'disconnect', items);
560
+ if (values.length === 0)
561
+ return;
562
+ await ctx.tx.table(plan.table).deleteMany({
563
+ where: { [plan.sourceField]: plan.parentValue, [plan.targetField]: { in: values } },
564
+ });
565
+ }
566
+ /**
567
+ * `set`: replace this parent's whole link set, in one transaction.
568
+ *
569
+ * `set: []` clears every link of this parent (Prisma's semantics, and the same
570
+ * choice the hasMany/hasOne `set` path already makes). The clearing delete is
571
+ * always scoped by the source key, so it can never touch another parent's rows.
572
+ */
573
+ async function processManyToManySet(ctx, rel, relName, plan, items) {
574
+ const values = await resolveJunctionTargets(ctx, rel, relName, plan, 'set', items);
575
+ await ctx.tx.table(plan.table).deleteMany({ where: { [plan.sourceField]: plan.parentValue } });
576
+ if (values.length === 0)
577
+ return;
578
+ await ctx.tx.table(plan.table).createMany({ data: junctionRows(plan, values) });
579
+ }
580
+ /**
581
+ * Run every supported junction operation for one many-to-many relation, in the
582
+ * order the caller's payload implies: `set` (a full replacement) first, then
583
+ * `disconnect`, then `connect`.
584
+ */
585
+ async function processManyToMany(ctx, rel, relName, ops, parentRow) {
586
+ assertManyToManyOpsSupported(relName, rel, ops);
587
+ if (ops.set === undefined && ops.disconnect === undefined && ops.connect === undefined)
588
+ return;
589
+ const plan = junctionPlan(ctx, rel, relName, parentRow);
590
+ if (ops.set !== undefined) {
591
+ await processManyToManySet(ctx, rel, relName, plan, toArray(ops.set));
592
+ }
593
+ if (ops.disconnect !== undefined) {
594
+ await processManyToManyDisconnect(ctx, rel, relName, plan, toArray(ops.disconnect));
595
+ }
596
+ if (ops.connect !== undefined) {
597
+ await processManyToManyConnect(ctx, rel, relName, plan, toArray(ops.connect));
598
+ }
225
599
  }
226
600
  /**
227
601
  * The E001 raised when a nested delete/update/disconnect target is not a child
@@ -295,7 +669,10 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
295
669
  Object.assign(belongsToFks, await resolveBelongsToForCreate(ctx, rel, ops, tableName, depth, path, relName));
296
670
  }
297
671
  else if (rel.type === 'manyToMany') {
298
- throw manyToManyUnsupported(relName, rel);
672
+ // Validated BEFORE the parent insert so an unsupported m2m op refuses
673
+ // without having written anything; the junction rows themselves need the
674
+ // parent's key, so they are written after the insert (below).
675
+ assertManyToManyOpsSupported(relName, rel, ops);
299
676
  }
300
677
  }
301
678
  // Insert the parent row (scalars + resolved belongsTo foreign keys)
@@ -309,6 +686,12 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
309
686
  if (rel.type === 'hasMany' || rel.type === 'hasOne') {
310
687
  await processHasManyCreate(ctx, rel, ops, parentRow, depth, path, relName);
311
688
  }
689
+ else if (rel.type === 'manyToMany') {
690
+ // The junction row carries the parent's key, so it can only be written
691
+ // once the parent row exists. Same transaction, same ordering rule the
692
+ // hasMany path follows.
693
+ await processManyToMany(ctx, rel, relName, ops, parentRow);
694
+ }
312
695
  }
313
696
  // Build the `with` clause for the final read to return the full tree
314
697
  const withClause = {};
@@ -418,7 +801,7 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
418
801
  }
419
802
  }
420
803
  else {
421
- throw manyToManyUnsupported(relName, rel);
804
+ await processManyToMany(ctx, rel, relName, ops, parentRow);
422
805
  }
423
806
  }
424
807
  // Final read with all touched relations
@@ -766,18 +1149,22 @@ async function processBelongsToUpdate(ctx, rel, updateArg, parentRow, parentTabl
766
1149
  if (!item.data) {
767
1150
  throw new ValidationError(`[turbine] Nested update on belongsTo "${rel.name}" requires a "data" field.`);
768
1151
  }
769
- // Derive where from parent's FK values
770
- const fks = normalizeKeyColumns(rel.foreignKey);
771
- const refs = normalizeKeyColumns(rel.referenceKey);
772
- const parentMeta = ctx.schema.tables[parentTable];
773
- const relatedTable = ctx.schema.tables[rel.to];
774
- const where = {};
775
- for (let i = 0; i < fks.length; i++) {
776
- const fkField = parentMeta?.reverseColumnMap[fks[i]] ?? fks[i];
777
- const refField = relatedTable?.reverseColumnMap[refs[i]] ?? refs[i];
778
- where[refField] = parentRow[fkField];
1152
+ // The related row is the one this parent's FK points at. Route through the
1153
+ // shared correlation helper (like every sibling operation) so a NULL parent
1154
+ // FK reports not-found instead of compiling to `refField IS NULL` and
1155
+ // updating EVERY row of the related table with a null reference key.
1156
+ const where = belongsToCorrelationWhere(ctx, rel, parentRow, parentTable);
1157
+ if (!where) {
1158
+ // Parent FK is NULL: it points at nothing, so nothing is in scope to update.
1159
+ const nullFk = Object.fromEntries(normalizeKeyColumns(rel.foreignKey).map((c) => [c, null]));
1160
+ throw notRelatedToParent('update', rel.name, rel, nullFk);
1161
+ }
1162
+ try {
1163
+ await ctx.tx.table(rel.to).update({ where, data: item.data });
1164
+ }
1165
+ catch (err) {
1166
+ rethrowAsNotRelated(err, 'update', rel.name, rel, where);
779
1167
  }
780
- await ctx.tx.table(rel.to).update({ where, data: item.data });
781
1168
  }
782
1169
  async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTable) {
783
1170
  const item = upsertArg;
@@ -797,8 +1184,11 @@ async function processBelongsToUpsert(ctx, rel, upsertArg, parentRow, parentTabl
797
1184
  // relation's reference key (unique or the PK by construction), so at most one
798
1185
  // row can come back.
799
1186
  const existing = correlation
800
- ? ((await ctx.tx.table(rel.to).findMany({ where: scopeWhereToParent(item.where, correlation) }))[0] ??
801
- null)
1187
+ ? ((await ctx.tx.table(rel.to).findMany({
1188
+ where: scopeWhereToParent(item.where, correlation),
1189
+ // Internal engine read (see the connect path): no unlimited-findMany warning.
1190
+ warnOnUnlimited: false,
1191
+ }))[0] ?? null)
802
1192
  : null;
803
1193
  if (existing) {
804
1194
  const relatedMeta = ctx.schema.tables[rel.to];
package/dist/powdb.d.ts CHANGED
@@ -104,7 +104,9 @@ export declare class PowdbFloatParam {
104
104
  */
105
105
  export declare class PowdbJsonParam {
106
106
  readonly value: unknown;
107
- constructor(value: unknown);
107
+ readonly column?: string | undefined;
108
+ /** `column` is diagnostic only: it names the target column when serialization fails. */
109
+ constructor(value: unknown, column?: string | undefined);
108
110
  }
109
111
  /** The four shapes a PowQL result takes over the legacy string wire. */
110
112
  type PowdbResult = {
@@ -695,7 +697,7 @@ interface EmbeddedModule {
695
697
  * quotes, backslashes, `$N`, `"); drop … --`, raw CR, and emoji all round-trip
696
698
  * as data and cannot break out of the literal or inject a second statement.
697
699
  */
698
- export declare function encodePowqlLiteral(value: unknown): string;
700
+ export declare function encodePowqlLiteral(value: unknown, position?: string): string;
699
701
  /**
700
702
  * The newest PowDB engine LINE (major.minor) whose lexer escape handling
701
703
  * {@link encodePowqlString} is VERIFIED against by reading