@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
@@ -19,6 +19,7 @@ import { hookPipeline } from './hook-pipeline.js'
19
19
  import { processNestedOperations, runAfterTasks } from './nested-operations.js'
20
20
  import type { AfterTask } from './nested-operations.js'
21
21
  import { enumerateInvolvedLists, runWithTransactionBoundary } from './transaction-boundary.js'
22
+ import { TransactionRegistry } from '../access/transaction-registry.js'
22
23
  import { getDbKey } from '../lib/case-utils.js'
23
24
  // NOTE: `index.ts` imports from this module too — this is an intentional cyclic
24
25
  // dependency. It is safe because `buildDbDelegate` is only INVOKED at write
@@ -27,30 +28,18 @@ import { getDbKey } from '../lib/case-utils.js'
27
28
  import { buildDbDelegate } from './index.js'
28
29
 
29
30
  /**
30
- * Write Pipeline — the single module that runs the canonical, secured write
31
- * sequence for one create/update/delete. It owns the phase order in one place;
32
- * the per-operation differences (target resolution + access, which input phases
33
- * run, the DB verb and returned row) are supplied by a {@link WriteStrategy}.
34
- *
35
- * The phase order is the framework's single most important invariant. See the
36
- * "Write Pipeline" glossary term in CONTEXT.md and the hooks ordering in
37
- * CLAUDE.md. Reads (findUnique/findMany) and the two-phase read model
38
- * (ADR-0001) are intentionally out of scope here.
31
+ * Write Pipeline — runs the canonical, secured write sequence for one
32
+ * create/update/delete; see the "Write Pipeline" glossary entry in CONTEXT.md
33
+ * for the phase order and the hooks ordering in CLAUDE.md. Reads are out of
34
+ * scope (ADR-0001).
39
35
  */
40
36
 
41
- /**
42
- * The write operations the pipeline can run.
43
- */
44
37
  export type WriteOperation = 'create' | 'update' | 'delete'
45
38
 
46
39
  /**
47
- * Result of resolving a write target (axis 1).
48
- *
49
- * - `{ status: 'ok', originalItem }` — proceed. `originalItem` is the existing
50
- * row for update/delete, or `undefined` for create.
51
- * - `{ status: 'denied' }` — access denied, missing target, or filter
52
- * non-match. The pipeline short-circuits to `null` (silent failure) BEFORE
53
- * any input phases, before-hooks, or the DB call.
40
+ * Result of resolving a write target (axis 1). `denied` covers access denial,
41
+ * a missing target, or a filter non-match alike, and short-circuits to `null`
42
+ * before any hooks or the DB call.
54
43
  */
55
44
  export type TargetResolution =
56
45
  { status: 'ok'; originalItem: Record<string, unknown> | undefined } | { status: 'denied' }
@@ -77,21 +66,14 @@ export interface PrismaModel {
77
66
 
78
67
  /**
79
68
  * Per-operation strategy. Supplies the three axes on which create/update/delete
80
- * genuinely differ; the pipeline owns the shared phase order around them.
81
- *
82
- * 1. `resolveTarget` — fetch the target row (if any) + operation-level access.
83
- * 2. `runInputPhases` — whether the resolveInput → validate-hooks → field
84
- * rules → filter-writable → nested-ops span runs (create & update: yes;
85
- * delete: no).
86
- * 3. `persist` — the DB verb; returns the row passed through Field Visibility.
69
+ * differ; the pipeline owns the shared phase order around them.
87
70
  */
88
71
  export interface WriteStrategy {
89
72
  operation: WriteOperation
90
73
 
91
74
  /**
92
- * Axis 1: resolve the target row and check operation-level access. Receives
93
- * the dynamically-resolved Prisma model so it can fetch rows and perform
94
- * filter re-checks. Implementations must honour `context._isSudo`.
75
+ * Axis 1: resolve the target row and check operation-level access.
76
+ * Implementations must honour `context._isSudo`.
95
77
  */
96
78
  resolveTarget(model: PrismaModel): Promise<TargetResolution>
97
79
 
@@ -117,8 +99,8 @@ export interface WriteStrategy {
117
99
 
118
100
  /**
119
101
  * Resolve the dynamic Prisma model for a list. Model names are generated at
120
- * runtime from list keys, which is the one place a cast is unavoidable — it is
121
- * kept localized here (mirroring the existing pattern in `context/index.ts`).
102
+ * runtime, so the cast is unavoidable — kept localized here (mirrors
103
+ * `context/index.ts`).
122
104
  */
123
105
  function getModel<TPrisma extends PrismaClientLike>(
124
106
  prisma: TPrisma,
@@ -129,11 +111,8 @@ function getModel<TPrisma extends PrismaClientLike>(
129
111
  }
130
112
 
131
113
  /**
132
- * Minimal shape of a Prisma interactive-transaction-capable client.
133
- *
134
- * The transaction client `tx` is dynamically typed exactly like the model
135
- * surface above (model names are generated at runtime), so the cast is kept
136
- * localized and commented per the house rules.
114
+ * Minimal shape of a Prisma interactive-transaction-capable client. `tx` is
115
+ * dynamically typed like the model surface above (names generated at runtime).
137
116
  */
138
117
  interface TransactionCapable {
139
118
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- $transaction callback receives a dynamically-typed tx client
@@ -141,15 +120,12 @@ interface TransactionCapable {
141
120
  }
142
121
 
143
122
  /**
144
- * Run `fn` inside ONE interactive transaction (ADR-0010: every write is
145
- * transactional, so the hook contract does not depend on whether a write
146
- * happened to be nested). The transaction client `tx` is passed to `fn` and
147
- * used as the persistence target for the parent + all nested writes, so they
148
- * are atomic and a throwing hook rolls the whole write back.
123
+ * Run `fn` inside ONE interactive transaction, used as the persistence target
124
+ * for the parent and all nested writes (ADR-0010).
149
125
  *
150
- * If the client does not expose `$transaction` (e.g. a test mock), `fn` runs
151
- * directly against the client — the hook ordering and arguments are identical;
152
- * only the rollback guarantee is provided by the real transaction.
126
+ * Without `$transaction` (e.g. a test mock), `fn` runs directly against the
127
+ * client — hook ordering and arguments are identical, but only a real
128
+ * transaction provides the rollback guarantee.
153
129
  */
154
130
  async function runInTransaction<TPrisma extends PrismaClientLike>(
155
131
  prisma: TPrisma,
@@ -165,17 +141,11 @@ async function runInTransaction<TPrisma extends PrismaClientLike>(
165
141
  return fn(prisma)
166
142
  }
167
143
 
168
- /**
169
- * Check if a list is configured as a singleton.
170
- */
171
144
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
172
145
  function isSingletonList(listConfig: ListConfig<any>): boolean {
173
146
  return !!listConfig.isSingleton
174
147
  }
175
148
 
176
- /**
177
- * Arguments shared by every write pipeline run.
178
- */
179
149
  export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
180
150
  listName: string
181
151
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
@@ -185,45 +155,28 @@ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
185
155
  config: OpenSaasConfig
186
156
  /** The original input data for the write (create/update). `undefined` for delete. */
187
157
  inputData: Record<string, unknown> | undefined
188
- /** The per-operation strategy supplying the three variation axes. */
158
+ /** The per-operation strategy (see {@link WriteStrategy}). */
189
159
  strategy: WriteStrategy
190
160
  /**
191
- * The target resolution computed ONCE before the transaction opened (#590).
192
- *
193
- * The transaction-boundary bracket resolves the top-level target + access
194
- * before opening the transaction (both to gate silent-failures without firing
195
- * boundary hooks, and to supply `originalItem` to `beforeTransaction`). To
196
- * avoid a second resolution inside the transaction, the in-transaction body
197
- * reuses this result instead of calling `strategy.resolveTarget` again.
161
+ * The target resolution computed once before the transaction opens, and
162
+ * reused inside it rather than resolved a second time — see the
163
+ * pre-transaction gate in {@link runWritePipeline}.
198
164
  */
199
165
  preResolvedTarget?: TargetResolution
200
166
  }
201
167
 
202
168
  /**
203
- * Run the canonical secured write sequence once.
204
- *
205
- * Phase order (owned here, in one place):
206
- * resolve target + operation-level access
207
- * → list/field `resolveInput`
208
- * → list/field `validate`
209
- * → built-in field rules (`validateFieldRules`)
210
- * → filter writable fields
211
- * → nested operations
212
- * → list/field `beforeOperation`
213
- * → DB
214
- * → list/field `afterOperation`
215
- * → `filterReadableFields` (Field Visibility)
169
+ * Run the canonical secured write sequence once. Phase order matches the
170
+ * "Write Pipeline" glossary entry in CONTEXT.md.
216
171
  *
217
172
  * Contract preserved exactly:
218
173
  * - missing target / access denied / filter non-match → `null` (silent),
219
174
  * BEFORE the DB call and BEFORE `beforeOperation`.
220
175
  * - validation failure → THROW `ValidationError` (never silent).
221
- * - sudo mode skips access checks and writable-field filtering (the strategy
222
- * and `filterWritableFields` both honour `context._isSudo`).
176
+ * - sudo mode skips access checks and writable-field filtering.
223
177
  * - `afterOperation` receives `originalItem` for update/delete (undefined for
224
178
  * create).
225
- * - delete returns the deleted row as-is (no Field Visibility pass), matching
226
- * current behaviour.
179
+ * - delete returns the deleted row as-is (no Field Visibility pass).
227
180
  */
228
181
  export async function runWritePipeline<TPrisma extends PrismaClientLike>(
229
182
  args: WritePipelineArgs<TPrisma>,
@@ -231,16 +184,12 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
231
184
  const { prisma, listName, listConfig, context, config, inputData, strategy } = args
232
185
 
233
186
  // ── Pre-transaction access gate (#590) ──────────────────────────────────────
234
- // Resolve the TOP-LEVEL target + operation-level access OUTSIDE the
235
- // transaction first, using the NON-transactional client. A
236
- // denied/missing/filter-non-match target short-circuits to `null` (silent
237
- // failure) WITHOUT firing any transaction-boundary hooks a denied write
238
- // opens no transaction and takes no external action, so it must not run
239
- // beforeTransaction/afterTransaction. This resolution also yields the
240
- // top-level `originalItem` the boundary hooks receive for update/delete.
241
- // The result is passed into the transaction as `preResolvedTarget` and REUSED
242
- // there (the in-transaction resolveTarget does NOT re-run), so the target is
243
- // read exactly once — #569's resolveTarget call-count semantics are preserved.
187
+ // Resolves the top-level target + access OUTSIDE the transaction so a denied
188
+ // write short-circuits to `null` WITHOUT firing beforeTransaction/
189
+ // afterTransaction — a denied write takes no external action, so the
190
+ // boundary hooks must not run. The result feeds `preResolvedTarget` and is
191
+ // REUSED inside the transaction rather than re-resolved, keeping the target
192
+ // read exactly once (#569).
244
193
  const gate = await strategy.resolveTarget(getModel(prisma, listName))
245
194
  if (gate.status === 'denied') {
246
195
  return null
@@ -256,33 +205,36 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
256
205
  config,
257
206
  })
258
207
 
208
+ // ── Transaction ownership for the transaction-boundary hooks (ADR-0028) ────
209
+ // A context carrying `_transactionOwner` is JOINING an enclosing transaction
210
+ // it did not open — defer to that owner even if `prisma` here still exposes
211
+ // `$transaction`. Otherwise this write becomes the owner: the registry every
212
+ // joined write below it enqueues into.
213
+ const existingOwner = context._transactionOwner
214
+ const opensOwnTransaction =
215
+ !existingOwner && typeof (prisma as TransactionCapable).$transaction === 'function'
216
+ const ownedRegistry = opensOwnTransaction ? new TransactionRegistry() : undefined
217
+ const transactionOwnerForBody = existingOwner ?? ownedRegistry
218
+
259
219
  // ── Bracket the transaction with beforeTransaction/afterTransaction (#590) ──
260
- // beforeTransaction runs before the transaction opens; afterTransaction runs
261
- // after it settles (commit or rollback), per the symmetric-bracket rule.
220
+ // afterTransaction fires when the transaction settles, deferred to the owner
221
+ // for a joined write (ADR-0028, symmetric-bracket rule).
262
222
  return runWithTransactionBoundary({
263
223
  involvedLists,
264
224
  context,
225
+ joinedOwner: existingOwner,
226
+ ownedRegistry,
265
227
  runTransaction: () =>
266
- // ADR-0010: every write runs inside ONE interactive transaction. The
267
- // parent and ALL nested writes share this transaction's client `tx` as
268
- // their persistence target, so they are atomic and a throwing
269
- // `beforeOperation`/`afterOperation` (or validation) rolls the whole write
270
- // back. `runWriteInTransaction` resolves the target row, runs the full
271
- // hook pipeline, persists, and runs nested + own `afterOperation` — all
272
- // against `tx`.
228
+ // ADR-0010: parent + nested writes share `tx` as their persistence target.
273
229
  runInTransaction(prisma, (tx) =>
274
230
  runWriteInTransaction({
275
231
  ...args,
276
232
  prisma: tx,
277
- // Reuse the pre-transaction target resolution (computed above) so the
278
- // target is read exactly once (#569 call-count semantics preserved).
233
+ // Reuse the pre-transaction target resolution (#569 call-count semantics).
279
234
  preResolvedTarget: gate,
280
- // ADR-0010 atomicity: hooks that write via `context.db` must hit the
281
- // SAME transaction, or those writes would commit independently and
282
- // survive a rollback. Rebind the context's `db` (and `prisma`) to the
283
- // transaction client `tx` so before/afterOperation `context.db` writes
284
- // participate in — and roll back with — this write's transaction.
285
- context: bindContextToTransaction(args, tx),
235
+ // Rebind context.db/prisma to `tx` (ADR-0010 atomicity) and carry the
236
+ // transaction owner (ADR-0028) see bindContextToTransaction below.
237
+ context: bindContextToTransaction(args, tx, transactionOwnerForBody),
286
238
  }),
287
239
  ),
288
240
  })
@@ -290,21 +242,24 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
290
242
 
291
243
  /**
292
244
  * Build an {@link AccessContext} whose `db`/`prisma` target the transaction
293
- * client `tx`, so any `context.db` write a hook performs runs inside this
294
- * write's transaction and rolls back with it (ADR-0010).
245
+ * client `tx`, so a `context.db` write a hook performs runs inside — and rolls
246
+ * back with — this write's transaction (ADR-0010).
295
247
  *
296
248
  * The access-controlled `db` delegates capture their Prisma client at
297
- * construction, so the request-time `context.db` is bound to the ORIGINAL
298
- * client. We rebuild the delegates against `tx` via {@link buildDbDelegate},
299
- * reusing the request context's `session`, `storage`, `plugins`, `_isSudo`, and
300
- * the current `_resolveOutputChain` value (carried through unchanged, so a
301
- * write issued from inside a `resolveOutput` hook keeps that hook's chain).
302
- * Plugin runtimes are NOT re-executed; the existing `plugins` object is
303
- * reused as-is.
249
+ * construction, so swapping `context.prisma` alone would not rebind `db` — we
250
+ * rebuild the delegates against `tx` via {@link buildDbDelegate}, reusing the
251
+ * request context's `session`, `storage`, `plugins`, `_isSudo`, and
252
+ * `_resolveOutputChain` as-is (so a write from inside a `resolveOutput` hook
253
+ * keeps that hook's chain). Plugin runtimes are NOT re-executed.
254
+ *
255
+ * `transactionOwner` (ADR-0028) is carried onto the rebuilt context so a hook's
256
+ * own `context.db` write defers its transaction-boundary bracket to that owner
257
+ * instead of firing eagerly.
304
258
  */
305
259
  function bindContextToTransaction<TPrisma extends PrismaClientLike>(
306
260
  args: WritePipelineArgs<TPrisma>,
307
261
  tx: TPrisma,
262
+ transactionOwner: TransactionRegistry | undefined,
308
263
  ): AccessContext<TPrisma> {
309
264
  const { context, config } = args
310
265
  const txContext: AccessContext<TPrisma> = {
@@ -315,9 +270,10 @@ function bindContextToTransaction<TPrisma extends PrismaClientLike>(
315
270
  plugins: context.plugins,
316
271
  _isSudo: context._isSudo,
317
272
  _resolveOutputChain: context._resolveOutputChain,
273
+ _transactionOwner: transactionOwner,
318
274
  }
319
- // Rebuild the db delegate against `tx`, pointing back at `txContext` so hooks
320
- // reached through it also see the transactional context.
275
+ // Rebuild `db` against `tx`, referencing `txContext` itself so hooks reached
276
+ // through it see the transactional context.
321
277
  txContext.db = buildDbDelegate(config, tx, txContext)
322
278
  return txContext
323
279
  }
@@ -336,12 +292,9 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
336
292
  const model = getModel(tx, listName)
337
293
 
338
294
  // ── Phase 1: resolve target + operation-level access ──────────────────────
339
- // Short-circuits to `null` (silent failure) for missing target, denied
340
- // access, or filter non-match before any hook side effects or the DB call.
341
- // The transaction-boundary bracket (#590) already resolved this once before
342
- // opening the transaction; reuse that result rather than reading the target
343
- // twice. (When invoked without the bracket — e.g. a direct unit test — fall
344
- // back to resolving here against the tx model.)
295
+ // Reuses `preResolvedTarget` from the pre-transaction gate rather than
296
+ // reading the target twice; falls back to resolving here when invoked
297
+ // directly (e.g. a unit test) without that bracket.
345
298
  const resolution = args.preResolvedTarget ?? (await strategy.resolveTarget(model))
346
299
  if (resolution.status === 'denied') {
347
300
  return null
@@ -349,22 +302,18 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
349
302
  const originalItem = resolution.originalItem
350
303
 
351
304
  // ── Delete path: skip input phases, run only validate/field-validate ────────
352
- // (matches current delete behaviour exactly).
353
305
  if (!strategy.runInputPhases) {
354
306
  return runDeletePath({ listName, listConfig, context, originalItem, model, strategy })
355
307
  }
356
308
 
357
- // Only create/update reach here (delete short-circuited above). Narrow the
358
- // operation so the field-hook helpers receive a 'create' | 'update' value.
309
+ // Only create/update reach here (delete short-circuited above); narrow so
310
+ // field-hook helpers receive a 'create' | 'update' value.
359
311
  const writeOp: 'create' | 'update' = operation === 'create' ? 'create' : 'update'
360
312
 
361
- // `inputData` is always present for create/update (the operations that run
362
- // input phases). Default to {} only as a defensive measure.
313
+ // `inputData` is always present here; `?? {}` is only a defensive fallback.
363
314
  const input = inputData ?? {}
364
315
 
365
- // ── Phases 2–4: transform + validate span (Hook Pipeline) ──────────────────
366
- // The Hook Pipeline owns the list/field `resolveInput` → list/field `validate`
367
- // → built-in field rules span and the `resolvedData` threading through it. It
316
+ // ── Phases 2–4: transform + validate span (Hook Pipeline glossary, CONTEXT.md) ──
368
317
  // THROWS `ValidationError` on any validation failure (never silent).
369
318
  const { resolvedData } = await hookPipeline.run({
370
319
  operation: writeOp,
@@ -384,11 +333,10 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
384
333
  })
385
334
 
386
335
  // ── Phase 5.5: process nested relationship operations ───────────────────────
387
- // This runs each nested record's resolveInput/validate/field-rules AND its
388
- // `beforeOperation` (inside this transaction), returning the transformed
389
- // payload plus deferred `afterOperation` tasks and the relation fields to
390
- // `include` so those tasks can recover their persisted `item`. All nested DB
391
- // reads/persistence go through `tx`.
336
+ // Runs each nested record's resolveInput/validate/field-rules AND its
337
+ // `beforeOperation` inside this transaction, returning deferred
338
+ // `afterOperation` tasks plus the relation fields to `include` so those tasks
339
+ // can recover their persisted `item`.
392
340
  const { data, afterTasks, includeFields } = await processNestedOperations(
393
341
  filteredData,
394
342
  listConfig.fields,
@@ -397,10 +345,9 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
397
345
  writeOp,
398
346
  listName,
399
347
  originalItem,
400
- // Pass the enclosing write's `inputData` (the SAME value the Phase-5
401
- // `filterWritableFields` call above uses) so the connect-site owning-field
402
- // gate evaluates item-/inputData-dependent field rules identically to Phase 5
403
- // and the two cannot diverge into a spurious connect denial (#588 finding).
348
+ // Same `inputData` Phase 5's filterWritableFields used, so the connect-site
349
+ // owning-field gate evaluates identically and can't diverge into a
350
+ // spurious connect denial (#588).
404
351
  input,
405
352
  )
406
353
 
@@ -437,8 +384,8 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
437
384
  )
438
385
 
439
386
  // ── Phase 8: DB write ───────────────────────────────────────────────────────
440
- // Ask the DB to return the nested relations that have deferred
441
- // `afterOperation` tasks so they can recover their persisted `item`.
387
+ // `include` returns the nested relations with deferred `afterOperation`
388
+ // tasks so they can recover their persisted `item`.
442
389
  const include = buildIncludeFromFields(includeFields)
443
390
  const item = await strategy.persist(model, data, include)
444
391
 
@@ -458,7 +405,7 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
458
405
  listKey: listName,
459
406
  operation: 'update',
460
407
  inputData: input,
461
- // originalItem is the row before the update
408
+ // Non-null for update (fetched in Phase 1); cast narrows the type.
462
409
  originalItem: originalItem as Record<string, unknown>,
463
410
  item,
464
411
  resolvedData,
@@ -514,11 +461,9 @@ function buildIncludeFromFields(includeFields: Set<string>): Record<string, unkn
514
461
 
515
462
  /**
516
463
  * Run the deferred nested `afterOperation` tasks against the persisted parent
517
- * row. The persisted parent is always a record here; the `?? {}` is only a
518
- * type-narrowing guard. Each task recovers its OWN nested row from `item` by
519
- * id-diff (create) or known id (update); if a created row cannot be recovered
520
- * the task THROWS rather than firing `afterOperation` with a fabricated item
521
- * (see `recoverCreatedRows`/the create task in `nested-operations.ts`).
464
+ * row (`?? {}` is only a type-narrowing guard parent is always a record
465
+ * here). Each task recovers its own nested row by id-diff/known id and THROWS
466
+ * if it can't, rather than firing with a fabricated item (ADR-0010).
522
467
  */
523
468
  async function runNestedAfterTasks(
524
469
  afterTasks: AfterTask[],
@@ -529,10 +474,9 @@ async function runNestedAfterTasks(
529
474
  }
530
475
 
531
476
  /**
532
- * The delete tail of the pipeline: skips the input-shaping phases and runs only
533
- * validate/field-validate before the DB delete, then the after-hooks. Returns
534
- * the deleted row as-is (no Field Visibility pass) — matching current delete
535
- * behaviour exactly.
477
+ * The delete tail of the pipeline: skips the input-shaping phases and runs
478
+ * only validate/field-validate before the DB delete, then the after-hooks.
479
+ * Returns the deleted row as-is (no Field Visibility pass).
536
480
  */
537
481
  async function runDeletePath(args: {
538
482
  listName: string
@@ -613,11 +557,11 @@ async function runDeletePath(args: {
613
557
  // ── Per-operation strategies ──────────────────────────────────────────────────
614
558
 
615
559
  /**
616
- * Create strategy.
560
+ * Create strategy for {@link WriteStrategy}.
617
561
  *
618
- * Axis 1: checks `create` access with NO existing row. Enforces the
619
- * singleton-create constraint even under sudo. On create, an access result of
620
- * `true` OR a filter object both proceed — there is no filter re-check.
562
+ * Axis 1: checks `create` access with no existing row; a filter result
563
+ * proceeds with no re-check (unlike update/delete). Enforces the
564
+ * singleton-create constraint even under sudo.
621
565
  * Axis 2: runs all input phases.
622
566
  * Axis 3: `model.create({ data })`, prepending `id: 1` for singleton lists.
623
567
  */
@@ -709,7 +653,7 @@ function resolveExistingTarget(
709
653
  }
710
654
 
711
655
  /**
712
- * Update strategy.
656
+ * Update strategy for {@link WriteStrategy}.
713
657
  *
714
658
  * Axis 1: fetch row, check `update` access, re-check filter results.
715
659
  * Axis 2: runs all input phases.
@@ -732,11 +676,11 @@ export function updateWriteStrategy(
732
676
  }
733
677
 
734
678
  /**
735
- * Delete strategy.
679
+ * Delete strategy for {@link WriteStrategy}.
736
680
  *
737
681
  * Axis 1: enforce singleton constraint (even under sudo), fetch row, check
738
682
  * `delete` access, re-check filter results.
739
- * Axis 2: SKIPS input phases (runs only validate/field-validate).
683
+ * Axis 2: skips input phases (runs only validate/field-validate).
740
684
  * Axis 3: `model.delete({ where })`; afterOperation gets `originalItem`.
741
685
  */
742
686
  export function deleteWriteStrategy(
@@ -42,19 +42,16 @@ export function formatPrismaDefault(
42
42
 
43
43
  switch (fieldType) {
44
44
  case 'integer':
45
- // Bare numeric literal — Prisma expects no quotes for Int defaults.
46
45
  return String(value)
47
46
 
48
47
  case 'text':
49
- // Double-quoted string literal. The value is escaped via JSON.stringify so
50
- // embedded quotes/backslashes are handled correctly.
48
+ // JSON.stringify (not a template literal) so embedded quotes/backslashes
49
+ // in the value are escaped correctly.
51
50
  return JSON.stringify(String(value))
52
51
 
53
52
  case 'json': {
54
- // Keystone's JSON-literal form: canonical, space-free JSON.stringify of the
55
- // value, then wrap the whole serialised string in escaped double quotes so
56
- // Prisma stores the JSON text as the column default. The outer
57
- // JSON.stringify produces the escaped, double-quoted wrapper.
53
+ // Double JSON.stringify: once to canonicalize the value as JSON text,
54
+ // again to wrap that text in an escaped, double-quoted Prisma literal.
58
55
  const serialised = JSON.stringify(value)
59
56
  // JSON.stringify can return undefined for unserialisable values (e.g. a
60
57
  // function). Treat that as "no default" rather than emitting `@default()`.