@opensaas/stack-core 0.38.0 → 0.39.1

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 (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/CLAUDE.md +21 -0
  4. package/dist/access/access-filter.d.ts +46 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +113 -4
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/declared-dependencies.d.ts.map +1 -1
  9. package/dist/access/declared-dependencies.js +4 -9
  10. package/dist/access/declared-dependencies.js.map +1 -1
  11. package/dist/access/engine.d.ts +0 -20
  12. package/dist/access/engine.d.ts.map +1 -1
  13. package/dist/access/engine.js +0 -25
  14. package/dist/access/engine.js.map +1 -1
  15. package/dist/access/errors.d.ts +49 -0
  16. package/dist/access/errors.d.ts.map +1 -1
  17. package/dist/access/errors.js +75 -0
  18. package/dist/access/errors.js.map +1 -1
  19. package/dist/access/field-access.d.ts +39 -1
  20. package/dist/access/field-access.d.ts.map +1 -1
  21. package/dist/access/field-access.js +108 -42
  22. package/dist/access/field-access.js.map +1 -1
  23. package/dist/access/field-access.test.js +229 -2
  24. package/dist/access/field-access.test.js.map +1 -1
  25. package/dist/access/field-transforms.d.ts +0 -27
  26. package/dist/access/field-transforms.d.ts.map +1 -1
  27. package/dist/access/field-visibility.d.ts +0 -4
  28. package/dist/access/field-visibility.d.ts.map +1 -1
  29. package/dist/access/field-visibility.js +13 -39
  30. package/dist/access/field-visibility.js.map +1 -1
  31. package/dist/access/index.d.ts +6 -2
  32. package/dist/access/index.d.ts.map +1 -1
  33. package/dist/access/index.js +12 -2
  34. package/dist/access/index.js.map +1 -1
  35. package/dist/access/query-validation.d.ts +121 -0
  36. package/dist/access/query-validation.d.ts.map +1 -0
  37. package/dist/access/query-validation.js +292 -0
  38. package/dist/access/query-validation.js.map +1 -0
  39. package/dist/access/relationship-count.d.ts.map +1 -1
  40. package/dist/access/relationship-count.js +2 -10
  41. package/dist/access/relationship-count.js.map +1 -1
  42. package/dist/access/relationship-label-filter.d.ts +23 -23
  43. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  44. package/dist/access/relationship-label-filter.js +5 -76
  45. package/dist/access/relationship-label-filter.js.map +1 -1
  46. package/dist/access/relationship-label-filter.test.js +19 -56
  47. package/dist/access/relationship-label-filter.test.js.map +1 -1
  48. package/dist/access/transaction-registry.d.ts +45 -0
  49. package/dist/access/transaction-registry.d.ts.map +1 -0
  50. package/dist/access/transaction-registry.js +37 -0
  51. package/dist/access/transaction-registry.js.map +1 -0
  52. package/dist/access/types.d.ts +55 -59
  53. package/dist/access/types.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +9 -7
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/index.js +10 -18
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/label.d.ts +6 -13
  59. package/dist/config/label.d.ts.map +1 -1
  60. package/dist/config/label.js +6 -13
  61. package/dist/config/label.js.map +1 -1
  62. package/dist/config/nav-count.d.ts +11 -19
  63. package/dist/config/nav-count.d.ts.map +1 -1
  64. package/dist/config/nav-count.js +11 -19
  65. package/dist/config/nav-count.js.map +1 -1
  66. package/dist/config/plugin-engine.d.ts +0 -10
  67. package/dist/config/plugin-engine.d.ts.map +1 -1
  68. package/dist/config/plugin-engine.js +1 -37
  69. package/dist/config/plugin-engine.js.map +1 -1
  70. package/dist/config/types.d.ts +94 -109
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/apply-defaults.d.ts +8 -31
  73. package/dist/context/apply-defaults.d.ts.map +1 -1
  74. package/dist/context/apply-defaults.js +12 -41
  75. package/dist/context/apply-defaults.js.map +1 -1
  76. package/dist/context/hook-pipeline.d.ts +4 -29
  77. package/dist/context/hook-pipeline.d.ts.map +1 -1
  78. package/dist/context/hook-pipeline.js +8 -41
  79. package/dist/context/hook-pipeline.js.map +1 -1
  80. package/dist/context/index.d.ts +4 -15
  81. package/dist/context/index.d.ts.map +1 -1
  82. package/dist/context/index.js +190 -193
  83. package/dist/context/index.js.map +1 -1
  84. package/dist/context/nested-operations.d.ts +7 -26
  85. package/dist/context/nested-operations.d.ts.map +1 -1
  86. package/dist/context/nested-operations.js +73 -171
  87. package/dist/context/nested-operations.js.map +1 -1
  88. package/dist/context/transaction-boundary.d.ts +36 -47
  89. package/dist/context/transaction-boundary.d.ts.map +1 -1
  90. package/dist/context/transaction-boundary.js +92 -79
  91. package/dist/context/transaction-boundary.js.map +1 -1
  92. package/dist/context/write-pipeline.d.ts +25 -64
  93. package/dist/context/write-pipeline.d.ts.map +1 -1
  94. package/dist/context/write-pipeline.js +81 -111
  95. package/dist/context/write-pipeline.js.map +1 -1
  96. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  97. package/dist/fields/format-prisma-default.js +4 -7
  98. package/dist/fields/format-prisma-default.js.map +1 -1
  99. package/dist/fields/index.d.ts +17 -2
  100. package/dist/fields/index.d.ts.map +1 -1
  101. package/dist/fields/index.js +136 -147
  102. package/dist/fields/index.js.map +1 -1
  103. package/dist/filter/collect.d.ts +26 -3
  104. package/dist/filter/collect.d.ts.map +1 -1
  105. package/dist/filter/collect.js +30 -12
  106. package/dist/filter/collect.js.map +1 -1
  107. package/dist/filter/filter.test.js +98 -15
  108. package/dist/filter/filter.test.js.map +1 -1
  109. package/dist/filter/index.d.ts +1 -0
  110. package/dist/filter/index.d.ts.map +1 -1
  111. package/dist/filter/index.js.map +1 -1
  112. package/dist/filter/map.d.ts.map +1 -1
  113. package/dist/filter/map.js +0 -5
  114. package/dist/filter/map.js.map +1 -1
  115. package/dist/filter/parse.d.ts.map +1 -1
  116. package/dist/filter/parse.js +2 -9
  117. package/dist/filter/parse.js.map +1 -1
  118. package/dist/filter/serialize.d.ts.map +1 -1
  119. package/dist/filter/serialize.js +2 -4
  120. package/dist/filter/serialize.js.map +1 -1
  121. package/dist/hooks/index.d.ts +4 -42
  122. package/dist/hooks/index.d.ts.map +1 -1
  123. package/dist/hooks/index.js +5 -59
  124. package/dist/hooks/index.js.map +1 -1
  125. package/dist/index.d.ts +3 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +20 -4
  128. package/dist/index.js.map +1 -1
  129. package/dist/internal.d.ts +1 -0
  130. package/dist/internal.d.ts.map +1 -1
  131. package/dist/internal.js +4 -0
  132. package/dist/internal.js.map +1 -1
  133. package/dist/lib/case-utils.d.ts +0 -32
  134. package/dist/lib/case-utils.d.ts.map +1 -1
  135. package/dist/lib/case-utils.js +0 -32
  136. package/dist/lib/case-utils.js.map +1 -1
  137. package/dist/mcp/handler.d.ts +0 -4
  138. package/dist/mcp/handler.d.ts.map +1 -1
  139. package/dist/mcp/handler.js +12 -69
  140. package/dist/mcp/handler.js.map +1 -1
  141. package/dist/query/index.d.ts +26 -227
  142. package/dist/query/index.d.ts.map +1 -1
  143. package/dist/query/index.js +14 -117
  144. package/dist/query/index.js.map +1 -1
  145. package/dist/utils/password.d.ts +0 -68
  146. package/dist/utils/password.d.ts.map +1 -1
  147. package/dist/utils/password.js +7 -76
  148. package/dist/utils/password.js.map +1 -1
  149. package/dist/validation/field-config.d.ts.map +1 -1
  150. package/dist/validation/field-config.js +0 -6
  151. package/dist/validation/field-config.js.map +1 -1
  152. package/dist/validation/schema.d.ts +0 -7
  153. package/dist/validation/schema.d.ts.map +1 -1
  154. package/dist/validation/schema.js +0 -10
  155. package/dist/validation/schema.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/access/access-filter.ts +152 -5
  158. package/src/access/declared-dependencies.ts +4 -9
  159. package/src/access/engine.ts +0 -25
  160. package/src/access/errors.ts +81 -0
  161. package/src/access/field-access.test.ts +312 -2
  162. package/src/access/field-access.ts +118 -42
  163. package/src/access/field-transforms.ts +8 -39
  164. package/src/access/field-visibility.ts +16 -47
  165. package/src/access/index.ts +20 -2
  166. package/src/access/query-validation.ts +359 -0
  167. package/src/access/relationship-count.ts +2 -10
  168. package/src/access/relationship-label-filter.test.ts +19 -68
  169. package/src/access/relationship-label-filter.ts +27 -104
  170. package/src/access/transaction-registry.ts +58 -0
  171. package/src/access/types.ts +83 -86
  172. package/src/config/index.ts +10 -22
  173. package/src/config/label.ts +6 -13
  174. package/src/config/nav-count.ts +11 -19
  175. package/src/config/plugin-engine.ts +1 -37
  176. package/src/config/types.ts +95 -110
  177. package/src/context/apply-defaults.ts +12 -44
  178. package/src/context/hook-pipeline.ts +12 -67
  179. package/src/context/index.ts +263 -247
  180. package/src/context/nested-operations.ts +94 -226
  181. package/src/context/transaction-boundary.ts +113 -109
  182. package/src/context/write-pipeline.ts +99 -155
  183. package/src/fields/format-prisma-default.ts +4 -7
  184. package/src/fields/index.ts +155 -147
  185. package/src/filter/collect.ts +48 -12
  186. package/src/filter/filter.test.ts +153 -15
  187. package/src/filter/index.ts +1 -0
  188. package/src/filter/map.ts +0 -5
  189. package/src/filter/parse.ts +2 -9
  190. package/src/filter/serialize.ts +2 -4
  191. package/src/hooks/index.ts +5 -59
  192. package/src/index.ts +23 -4
  193. package/src/internal.ts +5 -0
  194. package/src/lib/case-utils.ts +0 -32
  195. package/src/mcp/handler.ts +13 -71
  196. package/src/query/index.ts +29 -235
  197. package/src/utils/password.ts +7 -76
  198. package/src/validation/field-config.ts +0 -6
  199. package/src/validation/schema.ts +0 -10
  200. package/tests/context.test.ts +700 -1
  201. package/tests/field-types.test.ts +222 -0
  202. package/tests/mcp-handler.test.ts +34 -0
  203. package/tests/transaction-boundary-outermost.test.ts +530 -0
  204. package/tsconfig.tsbuildinfo +1 -1
@@ -9,53 +9,36 @@ import {
9
9
  type TransactionOutcome,
10
10
  } from '../hooks/index.js'
11
11
  import type { WriteOperation } from './write-pipeline.js'
12
+ import type {
13
+ TransactionRegistry,
14
+ TransactionSettleOutcome,
15
+ } from '../access/transaction-registry.js'
12
16
 
13
17
  /**
14
- * Transaction-boundary hooks (#590 / ADR-0010).
15
- *
16
- * `beforeTransaction`/`afterTransaction` run OUTSIDE the write's `$transaction`
17
- * `beforeTransaction` before it opens, `afterTransaction` after it settles —
18
- * for non-transactional side effects (e.g. external API calls) that must not
19
- * hold a DB transaction open and cannot be rolled back. The pair forms a
20
- * compensation bracket around the atomic write described by ADR-0010.
21
- *
22
- * This module:
23
- * 1. Enumerates the lists involved in a write up front, BY WALKING THE INPUT
24
- * TREE only (no DB reads), so the bracket can run per involved list before
25
- * the transaction opens (mirroring how in-transaction before/afterOperation
26
- * fire per record, but at list granularity).
27
- * 2. Runs all `beforeTransaction` hooks, tracking exactly which involved lists'
28
- * `beforeTransaction` ran, then — after the caller settles the transaction —
29
- * runs `afterTransaction` ONLY for those lists (the symmetric-bracket
30
- * "always-run" rule), surfacing any hook errors afterward.
18
+ * Transaction-boundary hooks (#590 / ADR-0010): `beforeTransaction`/`afterTransaction`
19
+ * bracket a write's `$transaction` from the outside, for non-transactional side
20
+ * effects. See ADR-0010 for the bracket's design and ADR-0028 for how a joined
21
+ * write's `afterTransaction` defers to the transaction owner.
31
22
  */
32
23
 
33
24
  /**
34
- * One list involved in a write, with the data the transaction-boundary hooks
35
- * receive. Enumerated purely from the input tree (no DB reads).
36
- *
37
- * The persisted/pre-write rows (`item`/`originalItem`) are surfaced to
38
- * `afterTransaction` ONLY for the TOP-LEVEL record (`isTopLevel`). For nested
39
- * lists the per-record persisted row is not reliably recoverable outside the
40
- * transaction, so they are passed as `undefined` rather than mis-handing the
41
- * top-level row as if it were the nested row. `originalItem` here is therefore
42
- * populated only for the top-level update/delete target (the pipeline resolves
43
- * it before the transaction opens).
25
+ * One list involved in a write, enumerated purely from the input tree (no DB
26
+ * reads). `item`/`originalItem` are populated only for the top-level record
27
+ * (`isTopLevel`) — a nested list's persisted row isn't reliably recoverable
28
+ * outside the transaction, so handing it the top-level row instead would be
29
+ * silently wrong.
44
30
  */
45
31
  export interface InvolvedList {
46
32
  listKey: string
47
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
48
34
  listConfig: ListConfig<any>
49
35
  operation: WriteOperation
50
- /** Whether this is the top-level write target (the only list with a reliable persisted row). */
51
36
  isTopLevel: boolean
52
- /** The input payload for this involvement (create/update); `undefined` for delete. */
37
+ /** `undefined` for delete, which has no input payload. */
53
38
  inputData: Record<string, unknown> | undefined
54
- /** The existing row for the TOP-LEVEL update/delete target; `undefined` otherwise. */
55
39
  originalItem: Record<string, unknown> | undefined
56
40
  }
57
41
 
58
- /** Nested-op kinds whose payloads imply an involved list + operation. */
59
42
  const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperation }> = [
60
43
  { kind: 'create', operation: 'create' },
61
44
  { kind: 'update', operation: 'update' },
@@ -64,7 +47,6 @@ const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperat
64
47
  { kind: 'connectOrCreate', operation: 'create' },
65
48
  ]
66
49
 
67
- /** Distinct dedupe-key operations `NESTED_OP_OPERATIONS` can produce (create/update/delete). */
68
50
  const DISTINCT_OPERATION_COUNT = new Set(NESTED_OP_OPERATIONS.map((o) => o.operation)).size
69
51
 
70
52
  function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
@@ -72,17 +54,12 @@ function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
72
54
  }
73
55
 
74
56
  /**
75
- * The number of distinct (listKey, operation) involvement pairs the walk
76
- * could ever record starting from `startListName`computed from the
77
- * CONFIG's relationship graph (not the payload), so it bounds the walk by
78
- * what the schema can reach rather than by an arbitrary depth.
79
- *
80
- * Used as the saturation bound: once `walkNested` has recorded this many
81
- * pairs, no further pair can be new, so it stops descending. This replaces
82
- * the old depth cap as the cost bound (#835) — a payload nesting the config's
83
- * lists more deeply than any previous cap no longer loses their
84
- * transaction-boundary hooks, while a payload that repeats the same few
85
- * lists still terminates promptly instead of walking every entry.
57
+ * Distinct (listKey, operation) pairs reachable from `startListName` via the
58
+ * CONFIG's relationship graph (not the payload)the saturation bound
59
+ * `walkNested` stops at once it has recorded this many. Bounding by reachable
60
+ * pairs rather than a depth cap (#835) avoids losing hooks for payloads
61
+ * nested deeper than any fixed cap, while still terminating promptly on
62
+ * payloads that repeat the same few lists.
86
63
  */
87
64
  function countReachableInvolvementPairs(
88
65
  startListName: string,
@@ -114,15 +91,7 @@ function asRecordArray(value: unknown): Array<Record<string, unknown>> {
114
91
  return []
115
92
  }
116
93
 
117
- /**
118
- * Extract the create/update payload from a nested-op entry so nested
119
- * `beforeTransaction` receives meaningful `inputData`.
120
- *
121
- * - `create`: the entry itself is the create data.
122
- * - `update`: the entry's `data`.
123
- * - `connectOrCreate`: the entry's `create`.
124
- * - `delete`: no input payload.
125
- */
94
+ /** Extract a nested-op entry's create/update payload so its `beforeTransaction` receives meaningful `inputData`. */
126
95
  function nestedInputData(
127
96
  kind: string,
128
97
  entry: Record<string, unknown>,
@@ -142,10 +111,10 @@ function nestedInputData(
142
111
 
143
112
  /**
144
113
  * Recursively walk a write payload's relationship fields, appending one
145
- * {@link InvolvedList} per nested create/update/delete involvement. De-dups by
146
- * (listKey, operation) so a list with many nested records of the same operation
147
- * fires its transaction-boundary bracket once (these hooks are a per-LIST
148
- * compensation bracket, not per-record).
114
+ * {@link InvolvedList} per nested create/update/delete involvement. De-dups
115
+ * by (listKey, operation): these hooks are a per-LIST compensation bracket,
116
+ * not per-record, so a list with many nested records of the same operation
117
+ * fires its bracket once.
149
118
  */
150
119
  function walkNested(
151
120
  data: Record<string, unknown> | undefined,
@@ -155,8 +124,7 @@ function walkNested(
155
124
  seen: Set<string>,
156
125
  maxPairs: number,
157
126
  ): void {
158
- // Every reachable pair is already recorded no further recursion can add
159
- // anything new, so stop instead of re-walking the rest of the payload.
127
+ // Saturated: no further pair can be new (see countReachableInvolvementPairs).
160
128
  if (!data || seen.size >= maxPairs) return
161
129
 
162
130
  for (const [fieldName, value] of Object.entries(data)) {
@@ -174,7 +142,6 @@ function walkNested(
174
142
  if (opValue === undefined) continue
175
143
 
176
144
  const entries = asRecordArray(opValue)
177
- // Record the involvement once per (list, operation).
178
145
  const dedupeKey = `${relatedListName}:${operation}`
179
146
  if (!seen.has(dedupeKey)) {
180
147
  seen.add(dedupeKey)
@@ -190,7 +157,6 @@ function walkNested(
190
157
 
191
158
  if (seen.size >= maxPairs) return
192
159
 
193
- // Recurse into each nested entry's own relationship payload.
194
160
  for (const entry of entries) {
195
161
  const childData = nestedInputData(kind, entry)
196
162
  walkNested(childData, relatedListConfig.fields, config, out, seen, maxPairs)
@@ -201,8 +167,8 @@ function walkNested(
201
167
 
202
168
  /**
203
169
  * Enumerate the lists involved in a write — the top-level list plus every
204
- * nested create/update/delete target reachable from the input tree WITHOUT
205
- * any DB reads. The top-level list is always first.
170
+ * nested create/update/delete target without DB reads. The top-level list
171
+ * is always first.
206
172
  */
207
173
  export function enumerateInvolvedLists(args: {
208
174
  listName: string
@@ -229,16 +195,12 @@ export function enumerateInvolvedLists(args: {
229
195
  const seen = new Set<string>([`${listName}:${operation}`])
230
196
  const maxPairs = countReachableInvolvementPairs(listName, listConfig, config)
231
197
 
232
- // Delete has no nested payload to walk (inputData is undefined).
233
198
  walkNested(inputData, listConfig.fields, config, out, seen, maxPairs)
234
199
 
235
200
  return out
236
201
  }
237
202
 
238
- /**
239
- * Run the list- and field-level `beforeTransaction` hooks for one involved list.
240
- * A throw propagates to the caller (which aborts the write).
241
- */
203
+ /** Runs one involved list's `beforeTransaction` hooks. A throw propagates to the caller (which aborts the write). */
242
204
  async function runBeforeTransactionForList<TPrisma extends PrismaClientLike>(
243
205
  involved: InvolvedList,
244
206
  context: AccessContext<TPrisma>,
@@ -280,11 +242,13 @@ async function runBeforeTransactionForList<TPrisma extends PrismaClientLike>(
280
242
  }
281
243
 
282
244
  /**
283
- * Run the list- and field-level `afterTransaction` hooks for one involved list
284
- * with the settled {@link TransactionOutcome}. Collects (does not throw) any
285
- * errors so the caller can keep running the remaining lists' compensators.
245
+ * Runs one involved list's `afterTransaction` hooks against the settled
246
+ * {@link TransactionOutcome}, collecting rather than throwing errors so the
247
+ * caller can keep running the remaining lists' compensators. Exported for
248
+ * {@link TransactionRegistry} to reuse when draining a deferred, joined
249
+ * write's bracket (ADR-0028).
286
250
  */
287
- async function runAfterTransactionForList<TPrisma extends PrismaClientLike>(
251
+ export async function runAfterTransactionForList<TPrisma extends PrismaClientLike>(
288
252
  involved: InvolvedList,
289
253
  outcome: TransactionOutcome,
290
254
  context: AccessContext<TPrisma>,
@@ -292,15 +256,13 @@ async function runAfterTransactionForList<TPrisma extends PrismaClientLike>(
292
256
  ): Promise<void> {
293
257
  const { listKey, listConfig, operation, isTopLevel, inputData, originalItem } = involved
294
258
 
295
- // On commit, the persisted row (`outcome.item`) is the TOP-LEVEL row. We only
296
- // surface `item`/`originalItem` for the top-level list handing the top-level
297
- // row to a nested list's hook (whose type is the nested list's own item) would
298
- // be unsound, since the hook would silently read the wrong record. For nested
299
- // lists we pass `undefined`; per-record nested compensation must use the
300
- // in-transaction `afterOperation`, which already receives the correct nested row.
259
+ // The persisted row (`outcome.item`) is the TOP-LEVEL row only. Handing it to
260
+ // a nested list's hook would silently mis-type as that list's own item for
261
+ // nested lists `item`/`originalItem` stay `undefined`; per-record nested
262
+ // compensation belongs in the in-transaction `afterOperation`, which gets the
263
+ // correct row.
301
264
  try {
302
265
  if (outcome.status === 'committed') {
303
- // The persisted row is surfaced only for the top-level list (see above).
304
266
  const committedItem = isTopLevel ? outcome.item : undefined
305
267
  if (operation === 'create') {
306
268
  await executeAfterTransaction(listConfig.hooks, {
@@ -331,7 +293,6 @@ async function runAfterTransactionForList<TPrisma extends PrismaClientLike>(
331
293
  })
332
294
  }
333
295
  } else {
334
- // rolled-back: no persisted item.
335
296
  if (operation === 'create') {
336
297
  await executeAfterTransaction(listConfig.hooks, {
337
298
  listKey,
@@ -401,34 +362,47 @@ export class AfterTransactionError extends Error {
401
362
  }
402
363
 
403
364
  /**
404
- * Bracket a write's transaction with the transaction-boundary hooks (#590).
405
- *
406
- * Sequence:
407
- * 1. Run every involved list's `beforeTransaction` in order, tracking which
408
- * ran. A throw aborts: the transaction is NEVER opened; `afterTransaction`
409
- * fires (status `rolled-back`, with the throw as `error`) ONLY for the lists
410
- * whose `beforeTransaction` already ran (symmetric bracket), and the throw
411
- * is then re-surfaced.
412
- * 2. Otherwise open the transaction via `runTransaction` (the existing #569
413
- * machinery). On settle (commit or rollback) run `afterTransaction` for
414
- * EVERY involved list (all of their `beforeTransaction` ran) with the
415
- * outcome.
416
- * 3. If any `afterTransaction` throws, the rest still run; the collected
417
- * errors are surfaced afterward as an {@link AfterTransactionError}.
365
+ * Resolves a joined write's final outcome once the owner's settle is known
366
+ * (ADR-0028): the write's own error always wins; otherwise committed iff the
367
+ * owner's transaction also committed.
368
+ */
369
+ function resolveDeferredOutcome(
370
+ writeOutcome: TransactionOutcome,
371
+ settle: TransactionSettleOutcome,
372
+ ): TransactionOutcome {
373
+ if (writeOutcome.status === 'rolled-back') return writeOutcome
374
+ if (settle.status === 'committed') return writeOutcome
375
+ return { status: 'rolled-back', error: settle.error }
376
+ }
377
+
378
+ /**
379
+ * Brackets a write's transaction with the transaction-boundary hooks (#590,
380
+ * ADR-0028 / #899): runs every involved list's `beforeTransaction` eagerly —
381
+ * a throw here aborts before `runTransaction` (#569) ever opens the write's
382
+ * transaction — then routes `afterTransaction` by ownership: deferred onto
383
+ * `args.joinedOwner`'s {@link TransactionRegistry} for a joined write, run
384
+ * eagerly (draining `args.ownedRegistry`) for the write that opened the
385
+ * transaction, or run eagerly with neither set. See ADR-0028 for why. A
386
+ * joined write's `afterTransaction` errors therefore surface as an
387
+ * {@link AfterTransactionError} from the OWNER's promise, not this write's.
418
388
  *
419
- * Sudo does not affect these hooks they always run; sudo only bypasses access.
389
+ * Sudo bypasses access control onlynever these hooks.
420
390
  */
421
391
  export async function runWithTransactionBoundary<TPrisma extends PrismaClientLike>(args: {
422
392
  involvedLists: InvolvedList[]
423
393
  context: AccessContext<TPrisma>
394
+ /** Set when this write is nested in a transaction it did not open (ADR-0028). */
395
+ joinedOwner?: TransactionRegistry
396
+ /** Set when this write just opened the transaction joined writes below it share. */
397
+ ownedRegistry?: TransactionRegistry
424
398
  runTransaction: () => Promise<Record<string, unknown> | null>
425
399
  }): Promise<Record<string, unknown> | null> {
426
- const { involvedLists, context, runTransaction } = args
400
+ const { involvedLists, context, joinedOwner, ownedRegistry, runTransaction } = args
427
401
 
428
- // Lists whose beforeTransaction ran (in order), for the symmetric bracket. A
429
- // list is marked as "ran" the moment its beforeTransaction BEGINS, so even a
430
- // list whose beforeTransaction throws gets its afterTransaction (it may have
431
- // taken a partial external action that needs compensating).
402
+ // A list counts as "ran" the moment its beforeTransaction BEGINS (pushed
403
+ // before the try below), not on success so a list whose beforeTransaction
404
+ // itself throws still gets its afterTransaction, in case it took a partial
405
+ // external action that needs compensating.
432
406
  const ran: InvolvedList[] = []
433
407
 
434
408
  let beforeError: unknown
@@ -442,18 +416,29 @@ export async function runWithTransactionBoundary<TPrisma extends PrismaClientLik
442
416
  }
443
417
  }
444
418
 
445
- // beforeTransaction threw → abort: never open the transaction, compensate the
446
- // lists whose beforeTransaction ran, then surface the original error.
419
+ // Abort path: never open the transaction; compensate the lists that ran,
420
+ // then rethrow the original error.
447
421
  if (beforeError !== undefined) {
448
422
  const outcome: TransactionOutcome = { status: 'rolled-back', error: beforeError }
449
- const afterErrors: unknown[] = []
450
- for (const involved of ran) {
451
- await runAfterTransactionForList(involved, outcome, context, afterErrors)
423
+ if (joinedOwner) {
424
+ // Discards any afterTransaction errors on this path — only beforeError
425
+ // propagates, matching the eager branch below.
426
+ joinedOwner.enqueue(async (_settle, _errors) => {
427
+ const discarded: unknown[] = []
428
+ for (const involved of ran) {
429
+ await runAfterTransactionForList(involved, outcome, context, discarded)
430
+ }
431
+ })
432
+ } else {
433
+ const afterErrors: unknown[] = []
434
+ for (const involved of ran) {
435
+ await runAfterTransactionForList(involved, outcome, context, afterErrors)
436
+ }
452
437
  }
453
438
  throw beforeError
454
439
  }
455
440
 
456
- // Open the transaction and capture the settle outcome.
441
+ // Open (or join) the transaction and capture this write's own settle outcome.
457
442
  let outcome: TransactionOutcome
458
443
  let result: Record<string, unknown> | null = null
459
444
  let txError: unknown
@@ -465,15 +450,34 @@ export async function runWithTransactionBoundary<TPrisma extends PrismaClientLik
465
450
  outcome = { status: 'rolled-back', error: err }
466
451
  }
467
452
 
468
- // afterTransaction always runs for every list whose beforeTransaction ran
469
- // (here: all involved lists). All compensators run even if one throws.
453
+ if (joinedOwner) {
454
+ // Deferred (ADR-0028) only afterTransaction waits; this write's own
455
+ // result/throw is returned/thrown normally below.
456
+ joinedOwner.enqueue(async (settle, errors) => {
457
+ const finalOutcome = resolveDeferredOutcome(outcome, settle)
458
+ for (const involved of ran) {
459
+ await runAfterTransactionForList(involved, finalOutcome, context, errors)
460
+ }
461
+ })
462
+ if (txError !== undefined) throw txError
463
+ return result
464
+ }
465
+
466
+ // All compensators run even if one throws.
470
467
  const afterErrors: unknown[] = []
471
468
  for (const involved of ran) {
472
469
  await runAfterTransactionForList(involved, outcome, context, afterErrors)
473
470
  }
474
471
 
475
- // Surface errors: the transaction's own error takes precedence (the write
476
- // failed); otherwise any afterTransaction errors.
472
+ // Owner: drain joined writes' deferred brackets with this write's own settle
473
+ // outcome (ADR-0028).
474
+ if (ownedRegistry) {
475
+ const settle: TransactionSettleOutcome =
476
+ txError !== undefined ? { status: 'rolled-back', error: txError } : { status: 'committed' }
477
+ await ownedRegistry.drain(settle, afterErrors)
478
+ }
479
+
480
+ // Transaction error takes precedence over afterTransaction errors (ADR-0028).
477
481
  if (txError !== undefined) throw txError
478
482
  if (afterErrors.length > 0) throw new AfterTransactionError(afterErrors)
479
483