@opensaas/stack-core 0.23.0 → 0.25.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 +256 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +378 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +153 -26
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +552 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/index.ts +2 -0
- package/src/config/types.ts +426 -0
- package/src/context/index.ts +207 -37
- package/src/context/nested-operations.ts +969 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/config.test.ts +30 -0
- package/tests/context.test.ts +481 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
2
|
+
import type { AccessContext, PrismaClientLike } from '../access/types.js'
|
|
3
|
+
import { getRelatedListConfig } from '../access/index.js'
|
|
4
|
+
import {
|
|
5
|
+
executeBeforeTransaction,
|
|
6
|
+
executeAfterTransaction,
|
|
7
|
+
executeFieldBeforeTransactionHooks,
|
|
8
|
+
executeFieldAfterTransactionHooks,
|
|
9
|
+
type TransactionOutcome,
|
|
10
|
+
} from '../hooks/index.js'
|
|
11
|
+
import type { WriteOperation } from './write-pipeline.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
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.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
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).
|
|
44
|
+
*/
|
|
45
|
+
export interface InvolvedList {
|
|
46
|
+
listKey: string
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
48
|
+
listConfig: ListConfig<any>
|
|
49
|
+
operation: WriteOperation
|
|
50
|
+
/** Whether this is the top-level write target (the only list with a reliable persisted row). */
|
|
51
|
+
isTopLevel: boolean
|
|
52
|
+
/** The input payload for this involvement (create/update); `undefined` for delete. */
|
|
53
|
+
inputData: Record<string, unknown> | undefined
|
|
54
|
+
/** The existing row for the TOP-LEVEL update/delete target; `undefined` otherwise. */
|
|
55
|
+
originalItem: Record<string, unknown> | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Max nesting depth walked when enumerating involved lists (matches nested-operations). */
|
|
59
|
+
const MAX_DEPTH = 5
|
|
60
|
+
|
|
61
|
+
/** Nested-op kinds whose payloads imply an involved list + operation. */
|
|
62
|
+
const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperation }> = [
|
|
63
|
+
{ kind: 'create', operation: 'create' },
|
|
64
|
+
{ kind: 'update', operation: 'update' },
|
|
65
|
+
{ kind: 'delete', operation: 'delete' },
|
|
66
|
+
// connectOrCreate's create branch may create; treat as a possible create involvement.
|
|
67
|
+
{ kind: 'connectOrCreate', operation: 'create' },
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
|
|
71
|
+
return fieldConfig?.type === 'relationship'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function asRecordArray(value: unknown): Array<Record<string, unknown>> {
|
|
75
|
+
if (value == null) return []
|
|
76
|
+
if (Array.isArray(value)) return value.filter((v) => v && typeof v === 'object')
|
|
77
|
+
if (typeof value === 'object') return [value as Record<string, unknown>]
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extract the create/update payload from a nested-op entry so nested
|
|
83
|
+
* `beforeTransaction` receives meaningful `inputData`.
|
|
84
|
+
*
|
|
85
|
+
* - `create`: the entry itself is the create data.
|
|
86
|
+
* - `update`: the entry's `data`.
|
|
87
|
+
* - `connectOrCreate`: the entry's `create`.
|
|
88
|
+
* - `delete`: no input payload.
|
|
89
|
+
*/
|
|
90
|
+
function nestedInputData(
|
|
91
|
+
kind: string,
|
|
92
|
+
entry: Record<string, unknown>,
|
|
93
|
+
): Record<string, unknown> | undefined {
|
|
94
|
+
if (kind === 'update') {
|
|
95
|
+
const data = entry.data
|
|
96
|
+
return data && typeof data === 'object' ? (data as Record<string, unknown>) : undefined
|
|
97
|
+
}
|
|
98
|
+
if (kind === 'connectOrCreate') {
|
|
99
|
+
const create = entry.create
|
|
100
|
+
return create && typeof create === 'object' ? (create as Record<string, unknown>) : undefined
|
|
101
|
+
}
|
|
102
|
+
if (kind === 'delete') return undefined
|
|
103
|
+
// create
|
|
104
|
+
return entry
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Recursively walk a write payload's relationship fields, appending one
|
|
109
|
+
* {@link InvolvedList} per nested create/update/delete involvement. De-dups by
|
|
110
|
+
* (listKey, operation) so a list with many nested records of the same operation
|
|
111
|
+
* fires its transaction-boundary bracket once (these hooks are a per-LIST
|
|
112
|
+
* compensation bracket, not per-record).
|
|
113
|
+
*/
|
|
114
|
+
function walkNested(
|
|
115
|
+
data: Record<string, unknown> | undefined,
|
|
116
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
117
|
+
config: OpenSaasConfig,
|
|
118
|
+
out: InvolvedList[],
|
|
119
|
+
seen: Set<string>,
|
|
120
|
+
depth: number,
|
|
121
|
+
): void {
|
|
122
|
+
if (!data || depth >= MAX_DEPTH) return
|
|
123
|
+
|
|
124
|
+
for (const [fieldName, value] of Object.entries(data)) {
|
|
125
|
+
const fieldConfig = fieldConfigs[fieldName]
|
|
126
|
+
if (!isRelationshipField(fieldConfig) || value == null || typeof value !== 'object') continue
|
|
127
|
+
|
|
128
|
+
const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
|
|
129
|
+
const related = getRelatedListConfig(relationshipField.ref, config)
|
|
130
|
+
if (!related) continue
|
|
131
|
+
const { listName: relatedListName, listConfig: relatedListConfig } = related
|
|
132
|
+
|
|
133
|
+
const valueRecord = value as Record<string, unknown>
|
|
134
|
+
for (const { kind, operation } of NESTED_OP_OPERATIONS) {
|
|
135
|
+
const opValue = valueRecord[kind]
|
|
136
|
+
if (opValue === undefined) continue
|
|
137
|
+
|
|
138
|
+
const entries = asRecordArray(opValue)
|
|
139
|
+
// Record the involvement once per (list, operation).
|
|
140
|
+
const dedupeKey = `${relatedListName}:${operation}`
|
|
141
|
+
if (!seen.has(dedupeKey)) {
|
|
142
|
+
seen.add(dedupeKey)
|
|
143
|
+
out.push({
|
|
144
|
+
listKey: relatedListName,
|
|
145
|
+
listConfig: relatedListConfig,
|
|
146
|
+
operation,
|
|
147
|
+
isTopLevel: false,
|
|
148
|
+
inputData: entries.length > 0 ? nestedInputData(kind, entries[0]) : undefined,
|
|
149
|
+
originalItem: undefined,
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Recurse into each nested entry's own relationship payload.
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
const childData = nestedInputData(kind, entry)
|
|
156
|
+
walkNested(childData, relatedListConfig.fields, config, out, seen, depth + 1)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Enumerate the lists involved in a write — the top-level list plus every
|
|
164
|
+
* nested create/update/delete target reachable from the input tree — WITHOUT
|
|
165
|
+
* any DB reads. The top-level list is always first.
|
|
166
|
+
*/
|
|
167
|
+
export function enumerateInvolvedLists(args: {
|
|
168
|
+
listName: string
|
|
169
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
170
|
+
listConfig: ListConfig<any>
|
|
171
|
+
operation: WriteOperation
|
|
172
|
+
inputData: Record<string, unknown> | undefined
|
|
173
|
+
/** Top-level existing row (update/delete), resolved before the transaction by the caller. */
|
|
174
|
+
topLevelOriginalItem: Record<string, unknown> | undefined
|
|
175
|
+
config: OpenSaasConfig
|
|
176
|
+
}): InvolvedList[] {
|
|
177
|
+
const { listName, listConfig, operation, inputData, topLevelOriginalItem, config } = args
|
|
178
|
+
|
|
179
|
+
const out: InvolvedList[] = [
|
|
180
|
+
{
|
|
181
|
+
listKey: listName,
|
|
182
|
+
listConfig,
|
|
183
|
+
operation,
|
|
184
|
+
isTopLevel: true,
|
|
185
|
+
inputData,
|
|
186
|
+
originalItem: topLevelOriginalItem,
|
|
187
|
+
},
|
|
188
|
+
]
|
|
189
|
+
const seen = new Set<string>([`${listName}:${operation}`])
|
|
190
|
+
|
|
191
|
+
// Delete has no nested payload to walk (inputData is undefined).
|
|
192
|
+
walkNested(inputData, listConfig.fields, config, out, seen, 0)
|
|
193
|
+
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Run the list- and field-level `beforeTransaction` hooks for one involved list.
|
|
199
|
+
* A throw propagates to the caller (which aborts the write).
|
|
200
|
+
*/
|
|
201
|
+
async function runBeforeTransactionForList<TPrisma extends PrismaClientLike>(
|
|
202
|
+
involved: InvolvedList,
|
|
203
|
+
context: AccessContext<TPrisma>,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
const { listKey, listConfig, operation, inputData, originalItem } = involved
|
|
206
|
+
|
|
207
|
+
if (operation === 'create') {
|
|
208
|
+
await executeBeforeTransaction(listConfig.hooks, {
|
|
209
|
+
listKey,
|
|
210
|
+
operation: 'create',
|
|
211
|
+
inputData: inputData ?? {},
|
|
212
|
+
context,
|
|
213
|
+
})
|
|
214
|
+
} else if (operation === 'update') {
|
|
215
|
+
await executeBeforeTransaction(listConfig.hooks, {
|
|
216
|
+
listKey,
|
|
217
|
+
operation: 'update',
|
|
218
|
+
inputData: inputData ?? {},
|
|
219
|
+
item: originalItem,
|
|
220
|
+
context,
|
|
221
|
+
})
|
|
222
|
+
} else {
|
|
223
|
+
await executeBeforeTransaction(listConfig.hooks, {
|
|
224
|
+
listKey,
|
|
225
|
+
operation: 'delete',
|
|
226
|
+
item: originalItem,
|
|
227
|
+
context,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
await executeFieldBeforeTransactionHooks(
|
|
232
|
+
inputData,
|
|
233
|
+
listConfig.fields,
|
|
234
|
+
operation,
|
|
235
|
+
context,
|
|
236
|
+
listKey,
|
|
237
|
+
originalItem,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run the list- and field-level `afterTransaction` hooks for one involved list
|
|
243
|
+
* with the settled {@link TransactionOutcome}. Collects (does not throw) any
|
|
244
|
+
* errors so the caller can keep running the remaining lists' compensators.
|
|
245
|
+
*/
|
|
246
|
+
async function runAfterTransactionForList<TPrisma extends PrismaClientLike>(
|
|
247
|
+
involved: InvolvedList,
|
|
248
|
+
outcome: TransactionOutcome,
|
|
249
|
+
context: AccessContext<TPrisma>,
|
|
250
|
+
errors: unknown[],
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
const { listKey, listConfig, operation, isTopLevel, inputData, originalItem } = involved
|
|
253
|
+
|
|
254
|
+
// On commit, the persisted row (`outcome.item`) is the TOP-LEVEL row. We only
|
|
255
|
+
// surface `item`/`originalItem` for the top-level list — handing the top-level
|
|
256
|
+
// row to a nested list's hook (whose type is the nested list's own item) would
|
|
257
|
+
// be unsound, since the hook would silently read the wrong record. For nested
|
|
258
|
+
// lists we pass `undefined`; per-record nested compensation must use the
|
|
259
|
+
// in-transaction `afterOperation`, which already receives the correct nested row.
|
|
260
|
+
try {
|
|
261
|
+
if (outcome.status === 'committed') {
|
|
262
|
+
// The persisted row is surfaced only for the top-level list (see above).
|
|
263
|
+
const committedItem = isTopLevel ? outcome.item : undefined
|
|
264
|
+
if (operation === 'create') {
|
|
265
|
+
await executeAfterTransaction(listConfig.hooks, {
|
|
266
|
+
listKey,
|
|
267
|
+
operation: 'create',
|
|
268
|
+
status: 'committed',
|
|
269
|
+
inputData: inputData ?? {},
|
|
270
|
+
item: committedItem,
|
|
271
|
+
context,
|
|
272
|
+
})
|
|
273
|
+
} else if (operation === 'update') {
|
|
274
|
+
await executeAfterTransaction(listConfig.hooks, {
|
|
275
|
+
listKey,
|
|
276
|
+
operation: 'update',
|
|
277
|
+
status: 'committed',
|
|
278
|
+
inputData: inputData ?? {},
|
|
279
|
+
originalItem: isTopLevel ? originalItem : undefined,
|
|
280
|
+
item: committedItem,
|
|
281
|
+
context,
|
|
282
|
+
})
|
|
283
|
+
} else {
|
|
284
|
+
await executeAfterTransaction(listConfig.hooks, {
|
|
285
|
+
listKey,
|
|
286
|
+
operation: 'delete',
|
|
287
|
+
status: 'committed',
|
|
288
|
+
originalItem: isTopLevel ? originalItem : undefined,
|
|
289
|
+
context,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
// rolled-back: no persisted item.
|
|
294
|
+
if (operation === 'create') {
|
|
295
|
+
await executeAfterTransaction(listConfig.hooks, {
|
|
296
|
+
listKey,
|
|
297
|
+
operation: 'create',
|
|
298
|
+
status: 'rolled-back',
|
|
299
|
+
inputData: inputData ?? {},
|
|
300
|
+
error: outcome.error,
|
|
301
|
+
context,
|
|
302
|
+
})
|
|
303
|
+
} else if (operation === 'update') {
|
|
304
|
+
await executeAfterTransaction(listConfig.hooks, {
|
|
305
|
+
listKey,
|
|
306
|
+
operation: 'update',
|
|
307
|
+
status: 'rolled-back',
|
|
308
|
+
inputData: inputData ?? {},
|
|
309
|
+
originalItem,
|
|
310
|
+
error: outcome.error,
|
|
311
|
+
context,
|
|
312
|
+
})
|
|
313
|
+
} else {
|
|
314
|
+
await executeAfterTransaction(listConfig.hooks, {
|
|
315
|
+
listKey,
|
|
316
|
+
operation: 'delete',
|
|
317
|
+
status: 'rolled-back',
|
|
318
|
+
originalItem,
|
|
319
|
+
error: outcome.error,
|
|
320
|
+
context,
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch (err) {
|
|
325
|
+
// A throwing afterTransaction must NOT stop the remaining compensators.
|
|
326
|
+
errors.push(err)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
await executeFieldAfterTransactionHooks(
|
|
331
|
+
outcome,
|
|
332
|
+
inputData,
|
|
333
|
+
listConfig.fields,
|
|
334
|
+
operation,
|
|
335
|
+
context,
|
|
336
|
+
listKey,
|
|
337
|
+
isTopLevel,
|
|
338
|
+
originalItem,
|
|
339
|
+
)
|
|
340
|
+
} catch (err) {
|
|
341
|
+
errors.push(err)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Aggregated error surfaced when one or more `afterTransaction` hooks throw.
|
|
347
|
+
* The DB state is already final; all compensators still ran.
|
|
348
|
+
*/
|
|
349
|
+
export class AfterTransactionError extends Error {
|
|
350
|
+
public errors: unknown[]
|
|
351
|
+
constructor(errors: unknown[]) {
|
|
352
|
+
super(
|
|
353
|
+
`afterTransaction hook(s) failed: ${errors
|
|
354
|
+
.map((e) => (e instanceof Error ? e.message : String(e)))
|
|
355
|
+
.join('; ')}`,
|
|
356
|
+
)
|
|
357
|
+
this.name = 'AfterTransactionError'
|
|
358
|
+
this.errors = errors
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Bracket a write's transaction with the transaction-boundary hooks (#590).
|
|
364
|
+
*
|
|
365
|
+
* Sequence:
|
|
366
|
+
* 1. Run every involved list's `beforeTransaction` in order, tracking which
|
|
367
|
+
* ran. A throw aborts: the transaction is NEVER opened; `afterTransaction`
|
|
368
|
+
* fires (status `rolled-back`, with the throw as `error`) ONLY for the lists
|
|
369
|
+
* whose `beforeTransaction` already ran (symmetric bracket), and the throw
|
|
370
|
+
* is then re-surfaced.
|
|
371
|
+
* 2. Otherwise open the transaction via `runTransaction` (the existing #569
|
|
372
|
+
* machinery). On settle (commit or rollback) run `afterTransaction` for
|
|
373
|
+
* EVERY involved list (all of their `beforeTransaction` ran) with the
|
|
374
|
+
* outcome.
|
|
375
|
+
* 3. If any `afterTransaction` throws, the rest still run; the collected
|
|
376
|
+
* errors are surfaced afterward as an {@link AfterTransactionError}.
|
|
377
|
+
*
|
|
378
|
+
* Sudo does not affect these hooks — they always run; sudo only bypasses access.
|
|
379
|
+
*/
|
|
380
|
+
export async function runWithTransactionBoundary<TPrisma extends PrismaClientLike>(args: {
|
|
381
|
+
involvedLists: InvolvedList[]
|
|
382
|
+
context: AccessContext<TPrisma>
|
|
383
|
+
runTransaction: () => Promise<Record<string, unknown> | null>
|
|
384
|
+
}): Promise<Record<string, unknown> | null> {
|
|
385
|
+
const { involvedLists, context, runTransaction } = args
|
|
386
|
+
|
|
387
|
+
// Lists whose beforeTransaction ran (in order), for the symmetric bracket. A
|
|
388
|
+
// list is marked as "ran" the moment its beforeTransaction BEGINS, so even a
|
|
389
|
+
// list whose beforeTransaction throws gets its afterTransaction (it may have
|
|
390
|
+
// taken a partial external action that needs compensating).
|
|
391
|
+
const ran: InvolvedList[] = []
|
|
392
|
+
|
|
393
|
+
let beforeError: unknown
|
|
394
|
+
for (const involved of involvedLists) {
|
|
395
|
+
ran.push(involved)
|
|
396
|
+
try {
|
|
397
|
+
await runBeforeTransactionForList(involved, context)
|
|
398
|
+
} catch (err) {
|
|
399
|
+
beforeError = err
|
|
400
|
+
break
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// beforeTransaction threw → abort: never open the transaction, compensate the
|
|
405
|
+
// lists whose beforeTransaction ran, then surface the original error.
|
|
406
|
+
if (beforeError !== undefined) {
|
|
407
|
+
const outcome: TransactionOutcome = { status: 'rolled-back', error: beforeError }
|
|
408
|
+
const afterErrors: unknown[] = []
|
|
409
|
+
for (const involved of ran) {
|
|
410
|
+
await runAfterTransactionForList(involved, outcome, context, afterErrors)
|
|
411
|
+
}
|
|
412
|
+
throw beforeError
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Open the transaction and capture the settle outcome.
|
|
416
|
+
let outcome: TransactionOutcome
|
|
417
|
+
let result: Record<string, unknown> | null = null
|
|
418
|
+
let txError: unknown
|
|
419
|
+
try {
|
|
420
|
+
result = await runTransaction()
|
|
421
|
+
outcome = { status: 'committed', item: result ?? {} }
|
|
422
|
+
} catch (err) {
|
|
423
|
+
txError = err
|
|
424
|
+
outcome = { status: 'rolled-back', error: err }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// afterTransaction always runs for every list whose beforeTransaction ran
|
|
428
|
+
// (here: all involved lists). All compensators run even if one throws.
|
|
429
|
+
const afterErrors: unknown[] = []
|
|
430
|
+
for (const involved of ran) {
|
|
431
|
+
await runAfterTransactionForList(involved, outcome, context, afterErrors)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Surface errors: the transaction's own error takes precedence (the write
|
|
435
|
+
// failed); otherwise any afterTransaction errors.
|
|
436
|
+
if (txError !== undefined) throw txError
|
|
437
|
+
if (afterErrors.length > 0) throw new AfterTransactionError(afterErrors)
|
|
438
|
+
|
|
439
|
+
return result
|
|
440
|
+
}
|