@opensaas/stack-core 0.37.0 → 0.39.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +191 -0
- package/CLAUDE.md +42 -3
- package/dist/access/access-filter.d.ts +70 -113
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +183 -210
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +62 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +61 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +91 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -5
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +89 -41
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +223 -215
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +55 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +41 -117
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +236 -272
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +117 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +97 -12
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +99 -45
- package/src/access/index.ts +17 -5
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +3 -11
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +311 -276
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +82 -235
- package/src/query/relationship-options.ts +1 -1
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +727 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- 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 —
|
|
31
|
-
*
|
|
32
|
-
* the
|
|
33
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
93
|
-
*
|
|
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
|
|
121
|
-
*
|
|
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
|
|
145
|
-
*
|
|
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
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
158
|
+
/** The per-operation strategy (see {@link WriteStrategy}). */
|
|
189
159
|
strategy: WriteStrategy
|
|
190
160
|
/**
|
|
191
|
-
* The target resolution computed
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// denied
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
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
|
-
//
|
|
261
|
-
//
|
|
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:
|
|
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 (
|
|
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
|
|
281
|
-
//
|
|
282
|
-
|
|
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
|
|
294
|
-
* write's transaction
|
|
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
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
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
|
|
320
|
-
//
|
|
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
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
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)
|
|
358
|
-
//
|
|
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
|
|
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
|
-
//
|
|
388
|
-
// `beforeOperation`
|
|
389
|
-
//
|
|
390
|
-
//
|
|
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
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
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
|
-
//
|
|
441
|
-
//
|
|
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
|
-
//
|
|
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
|
|
518
|
-
*
|
|
519
|
-
*
|
|
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
|
|
533
|
-
* validate/field-validate before the DB delete, then the after-hooks.
|
|
534
|
-
* the deleted row as-is (no Field Visibility pass)
|
|
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
|
|
619
|
-
*
|
|
620
|
-
*
|
|
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:
|
|
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
|
-
//
|
|
50
|
-
//
|
|
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
|
-
//
|
|
55
|
-
//
|
|
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()`.
|