@opensaas/stack-core 0.20.1 → 0.21.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 +72 -0
- package/CLAUDE.md +18 -2
- package/dist/access/access-filter.d.ts +29 -0
- package/dist/access/access-filter.d.ts.map +1 -0
- package/dist/access/access-filter.js +68 -0
- package/dist/access/access-filter.js.map +1 -0
- package/dist/access/engine.d.ts +15 -48
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +14 -280
- package/dist/access/engine.js.map +1 -1
- package/dist/access/field-access.d.ts +44 -0
- package/dist/access/field-access.d.ts.map +1 -0
- package/dist/access/field-access.js +123 -0
- package/dist/access/field-access.js.map +1 -0
- package/dist/access/field-access.test.d.ts +2 -0
- package/dist/access/field-access.test.d.ts.map +1 -0
- package/dist/access/{engine.test.js → field-access.test.js} +2 -2
- package/dist/access/field-access.test.js.map +1 -0
- package/dist/access/field-visibility.d.ts +13 -0
- package/dist/access/field-visibility.d.ts.map +1 -0
- package/dist/access/field-visibility.js +155 -0
- package/dist/access/field-visibility.js.map +1 -0
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +8 -1
- package/dist/access/index.js.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 +45 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +49 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -0
- package/dist/context/hook-pipeline.js +75 -0
- package/dist/context/hook-pipeline.js.map +1 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -462
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +72 -68
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +158 -0
- package/dist/context/write-pipeline.d.ts.map +1 -0
- package/dist/context/write-pipeline.js +306 -0
- package/dist/context/write-pipeline.js.map +1 -0
- package/dist/extend.d.ts +3 -0
- package/dist/extend.d.ts.map +1 -0
- package/dist/extend.js +10 -0
- package/dist/extend.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +213 -2
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +202 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +8 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/internal.js.map +1 -0
- package/dist/validation/field-config.d.ts +55 -0
- package/dist/validation/field-config.d.ts.map +1 -0
- package/dist/validation/field-config.js +100 -0
- package/dist/validation/field-config.js.map +1 -0
- package/dist/validation/field-config.test.d.ts +2 -0
- package/dist/validation/field-config.test.d.ts.map +1 -0
- package/dist/validation/field-config.test.js +159 -0
- package/dist/validation/field-config.test.js.map +1 -0
- package/package.json +11 -3
- package/src/access/access-filter.ts +97 -0
- package/src/access/engine.ts +13 -396
- package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
- package/src/access/field-access.ts +159 -0
- package/src/access/field-visibility.ts +247 -0
- package/src/access/index.ts +7 -4
- package/src/config/index.ts +1 -0
- package/src/config/types.ts +51 -4
- package/src/context/hook-pipeline.ts +160 -0
- package/src/context/index.ts +29 -667
- package/src/context/nested-operations.ts +142 -111
- package/src/context/write-pipeline.ts +543 -0
- package/src/extend.ts +14 -0
- package/src/fields/index.ts +310 -2
- package/src/hooks/index.ts +227 -0
- package/src/index.ts +27 -90
- package/src/internal.ts +49 -0
- package/src/validation/field-config.test.ts +199 -0
- package/src/validation/field-config.ts +145 -0
- package/tests/access-relationships.test.ts +4 -4
- package/tests/access.test.ts +1 -1
- package/tests/field-hooks.test.ts +410 -0
- package/tests/field-types.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +233 -0
- package/tests/nested-operation-registry.test.ts +206 -0
- package/tests/write-pipeline.test.ts +588 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +43 -1
- package/dist/access/engine.test.d.ts +0 -2
- package/dist/access/engine.test.d.ts.map +0 -1
- package/dist/access/engine.test.js.map +0 -1
package/src/context/index.ts
CHANGED
|
@@ -4,261 +4,18 @@ import {
|
|
|
4
4
|
checkAccess,
|
|
5
5
|
mergeFilters,
|
|
6
6
|
filterReadableFields,
|
|
7
|
-
filterWritableFields,
|
|
8
7
|
buildIncludeWithAccessControl,
|
|
9
8
|
} from '../access/index.js'
|
|
10
|
-
import {
|
|
11
|
-
executeResolveInput,
|
|
12
|
-
executeValidate,
|
|
13
|
-
executeBeforeOperation,
|
|
14
|
-
executeAfterOperation,
|
|
15
|
-
validateFieldRules,
|
|
16
|
-
ValidationError,
|
|
17
|
-
DatabaseError,
|
|
18
|
-
} from '../hooks/index.js'
|
|
19
|
-
import { processNestedOperations } from './nested-operations.js'
|
|
9
|
+
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
20
10
|
import { getDbKey } from '../lib/case-utils.js'
|
|
21
11
|
import type { PrismaClientLike } from '../access/types.js'
|
|
22
|
-
import type { FieldConfig } from '../config/types.js'
|
|
23
12
|
import { buildInclude, pickFields, isFragment } from '../query/index.js'
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
-
inputData: Record<string, any>,
|
|
32
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
-
resolvedData: Record<string, any>,
|
|
34
|
-
fields: Record<string, FieldConfig>,
|
|
35
|
-
operation: 'create' | 'update',
|
|
36
|
-
context: AccessContext,
|
|
37
|
-
listKey: string,
|
|
38
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
-
item?: any,
|
|
40
|
-
): Promise<Record<string, unknown>> {
|
|
41
|
-
let result = { ...resolvedData }
|
|
42
|
-
console.log(
|
|
43
|
-
'Executing field resolveInput hooks for list:',
|
|
44
|
-
listKey,
|
|
45
|
-
'operation:',
|
|
46
|
-
operation,
|
|
47
|
-
'inputData:',
|
|
48
|
-
inputData,
|
|
49
|
-
'resolvedData before field hooks:',
|
|
50
|
-
resolvedData,
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
54
|
-
// Skip if field not in data
|
|
55
|
-
if (!(fieldKey in result)) continue
|
|
56
|
-
|
|
57
|
-
// Skip if no hooks defined
|
|
58
|
-
if (!fieldConfig.hooks?.resolveInput) continue
|
|
59
|
-
|
|
60
|
-
// Execute field hook
|
|
61
|
-
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
62
|
-
// and we're working with runtime values that match those types
|
|
63
|
-
|
|
64
|
-
const transformedValue = await fieldConfig.hooks.resolveInput({
|
|
65
|
-
listKey,
|
|
66
|
-
fieldKey,
|
|
67
|
-
operation,
|
|
68
|
-
inputData,
|
|
69
|
-
item,
|
|
70
|
-
resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
|
|
71
|
-
context,
|
|
72
|
-
} as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
|
|
73
|
-
|
|
74
|
-
// Create new object with updated field to avoid mutating the passed reference
|
|
75
|
-
result = { ...result, [fieldKey]: transformedValue }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return result
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Execute field-level validate hooks
|
|
83
|
-
* Allows fields to perform custom validation after resolveInput but before database write
|
|
84
|
-
*/
|
|
85
|
-
async function executeFieldValidateHooks(
|
|
86
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
87
|
-
inputData: Record<string, any> | undefined,
|
|
88
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
89
|
-
resolvedData: Record<string, any> | undefined,
|
|
90
|
-
fields: Record<string, FieldConfig>,
|
|
91
|
-
operation: 'create' | 'update' | 'delete',
|
|
92
|
-
context: AccessContext,
|
|
93
|
-
listKey: string,
|
|
94
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
-
item?: any,
|
|
96
|
-
): Promise<void> {
|
|
97
|
-
const errors: string[] = []
|
|
98
|
-
const fieldErrors: Record<string, string> = {}
|
|
99
|
-
|
|
100
|
-
const addValidationError = (fieldKey: string) => (msg: string) => {
|
|
101
|
-
errors.push(msg)
|
|
102
|
-
fieldErrors[fieldKey] = msg
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
106
|
-
// Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
|
|
107
|
-
const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput
|
|
108
|
-
if (!validateHook) continue
|
|
109
|
-
|
|
110
|
-
// Execute field hook
|
|
111
|
-
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
112
|
-
if (operation === 'delete') {
|
|
113
|
-
await validateHook({
|
|
114
|
-
listKey,
|
|
115
|
-
fieldKey,
|
|
116
|
-
operation: 'delete',
|
|
117
|
-
item,
|
|
118
|
-
context,
|
|
119
|
-
addValidationError: addValidationError(fieldKey),
|
|
120
|
-
} as Parameters<typeof validateHook>[0])
|
|
121
|
-
} else if (operation === 'create') {
|
|
122
|
-
await validateHook({
|
|
123
|
-
listKey,
|
|
124
|
-
fieldKey,
|
|
125
|
-
operation: 'create',
|
|
126
|
-
inputData,
|
|
127
|
-
item: undefined,
|
|
128
|
-
resolvedData,
|
|
129
|
-
context,
|
|
130
|
-
addValidationError: addValidationError(fieldKey),
|
|
131
|
-
} as Parameters<typeof validateHook>[0])
|
|
132
|
-
} else {
|
|
133
|
-
// operation === 'update'
|
|
134
|
-
await validateHook({
|
|
135
|
-
listKey,
|
|
136
|
-
fieldKey,
|
|
137
|
-
operation: 'update',
|
|
138
|
-
inputData,
|
|
139
|
-
item,
|
|
140
|
-
resolvedData,
|
|
141
|
-
context,
|
|
142
|
-
addValidationError: addValidationError(fieldKey),
|
|
143
|
-
} as Parameters<typeof validateHook>[0])
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (errors.length > 0) {
|
|
148
|
-
throw new ValidationError(errors, fieldErrors)
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Execute field-level beforeOperation hooks (side effects only)
|
|
154
|
-
* Allows fields to perform side effects before database write
|
|
155
|
-
*/
|
|
156
|
-
async function executeFieldBeforeOperationHooks(
|
|
157
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
-
inputData: Record<string, any>,
|
|
159
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
160
|
-
resolvedData: Record<string, any>,
|
|
161
|
-
fields: Record<string, FieldConfig>,
|
|
162
|
-
operation: 'create' | 'update' | 'delete',
|
|
163
|
-
context: AccessContext,
|
|
164
|
-
listKey: string,
|
|
165
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
166
|
-
item?: any,
|
|
167
|
-
): Promise<void> {
|
|
168
|
-
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
169
|
-
// Skip if no hooks defined
|
|
170
|
-
if (!fieldConfig.hooks?.beforeOperation) continue
|
|
171
|
-
// Skip if field not in data (for create/update)
|
|
172
|
-
if (operation !== 'delete' && !(fieldKey in resolvedData)) continue
|
|
173
|
-
|
|
174
|
-
// Execute field hook (side effects only, no return value used)
|
|
175
|
-
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
176
|
-
if (operation === 'delete') {
|
|
177
|
-
await fieldConfig.hooks.beforeOperation({
|
|
178
|
-
listKey,
|
|
179
|
-
fieldKey,
|
|
180
|
-
operation: 'delete',
|
|
181
|
-
item,
|
|
182
|
-
context,
|
|
183
|
-
} as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
|
|
184
|
-
} else if (operation === 'create') {
|
|
185
|
-
await fieldConfig.hooks.beforeOperation({
|
|
186
|
-
listKey,
|
|
187
|
-
fieldKey,
|
|
188
|
-
operation: 'create',
|
|
189
|
-
inputData,
|
|
190
|
-
resolvedData,
|
|
191
|
-
context,
|
|
192
|
-
} as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
|
|
193
|
-
} else {
|
|
194
|
-
// operation === 'update'
|
|
195
|
-
await fieldConfig.hooks.beforeOperation({
|
|
196
|
-
listKey,
|
|
197
|
-
fieldKey,
|
|
198
|
-
operation: 'update',
|
|
199
|
-
inputData,
|
|
200
|
-
item,
|
|
201
|
-
resolvedData,
|
|
202
|
-
context,
|
|
203
|
-
} as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Execute field-level afterOperation hooks (side effects only)
|
|
210
|
-
* Allows fields to perform side effects after database operations
|
|
211
|
-
*/
|
|
212
|
-
async function executeFieldAfterOperationHooks(
|
|
213
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
214
|
-
item: any,
|
|
215
|
-
inputData: Record<string, unknown> | undefined,
|
|
216
|
-
resolvedData: Record<string, unknown> | undefined,
|
|
217
|
-
fields: Record<string, FieldConfig>,
|
|
218
|
-
operation: 'create' | 'update' | 'delete',
|
|
219
|
-
context: AccessContext,
|
|
220
|
-
listKey: string,
|
|
221
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
222
|
-
originalItem?: any,
|
|
223
|
-
): Promise<void> {
|
|
224
|
-
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
225
|
-
// Skip if no hooks defined
|
|
226
|
-
if (!fieldConfig.hooks?.afterOperation) continue
|
|
227
|
-
|
|
228
|
-
// Execute field hook (side effects only, no return value used)
|
|
229
|
-
if (operation === 'delete') {
|
|
230
|
-
await fieldConfig.hooks.afterOperation({
|
|
231
|
-
listKey,
|
|
232
|
-
fieldKey,
|
|
233
|
-
operation: 'delete',
|
|
234
|
-
originalItem,
|
|
235
|
-
context,
|
|
236
|
-
} as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
|
|
237
|
-
} else if (operation === 'create') {
|
|
238
|
-
await fieldConfig.hooks.afterOperation({
|
|
239
|
-
listKey,
|
|
240
|
-
fieldKey,
|
|
241
|
-
operation: 'create',
|
|
242
|
-
inputData,
|
|
243
|
-
item,
|
|
244
|
-
resolvedData,
|
|
245
|
-
context,
|
|
246
|
-
} as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
|
|
247
|
-
} else {
|
|
248
|
-
// operation === 'update'
|
|
249
|
-
await fieldConfig.hooks.afterOperation({
|
|
250
|
-
listKey,
|
|
251
|
-
fieldKey,
|
|
252
|
-
operation: 'update',
|
|
253
|
-
inputData,
|
|
254
|
-
originalItem,
|
|
255
|
-
item,
|
|
256
|
-
resolvedData,
|
|
257
|
-
context,
|
|
258
|
-
} as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
13
|
+
import {
|
|
14
|
+
runWritePipeline,
|
|
15
|
+
createWriteStrategy,
|
|
16
|
+
updateWriteStrategy,
|
|
17
|
+
deleteWriteStrategy,
|
|
18
|
+
} from './write-pipeline.js'
|
|
262
19
|
|
|
263
20
|
export type ServerActionProps =
|
|
264
21
|
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
@@ -457,7 +214,7 @@ export function getContext<
|
|
|
457
214
|
findMany: findManyOp,
|
|
458
215
|
create: createOp,
|
|
459
216
|
update: updateOp,
|
|
460
|
-
delete: createDelete(listName, listConfig, prisma, context),
|
|
217
|
+
delete: createDelete(listName, listConfig, prisma, context, config),
|
|
461
218
|
count: createCount(listName, listConfig, prisma, context),
|
|
462
219
|
createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
|
|
463
220
|
updateMany: createUpdateMany(
|
|
@@ -812,163 +569,18 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
812
569
|
context: AccessContext<TPrisma>,
|
|
813
570
|
config: OpenSaasConfig,
|
|
814
571
|
) {
|
|
572
|
+
// Thin adapter over the Write Pipeline: pick the create strategy, run the
|
|
573
|
+
// canonical secured write sequence, return its result.
|
|
815
574
|
return async (args: { data: Record<string, unknown> }) => {
|
|
816
|
-
|
|
817
|
-
if (isSingletonList(listConfig)) {
|
|
818
|
-
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
819
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
820
|
-
const model = (prisma as any)[getDbKey(listName)]
|
|
821
|
-
const existingCount = await model.count()
|
|
822
|
-
|
|
823
|
-
if (existingCount > 0) {
|
|
824
|
-
throw new ValidationError(
|
|
825
|
-
[`Cannot create: ${listName} is a singleton list with an existing record`],
|
|
826
|
-
{},
|
|
827
|
-
)
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// 1. Check create access (skip if sudo mode)
|
|
832
|
-
if (!context._isSudo) {
|
|
833
|
-
const createAccess = listConfig.access?.operation?.create
|
|
834
|
-
const accessResult = await checkAccess(createAccess, {
|
|
835
|
-
session: context.session,
|
|
836
|
-
context,
|
|
837
|
-
})
|
|
838
|
-
|
|
839
|
-
if (accessResult === false) {
|
|
840
|
-
return null
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// 2. Execute list-level resolveInput hook
|
|
845
|
-
let resolvedData = await executeResolveInput(listConfig.hooks, {
|
|
846
|
-
listKey: listName,
|
|
847
|
-
operation: 'create',
|
|
848
|
-
inputData: args.data,
|
|
849
|
-
resolvedData: args.data,
|
|
850
|
-
item: undefined,
|
|
851
|
-
context,
|
|
852
|
-
})
|
|
853
|
-
|
|
854
|
-
// 2.5. Execute field-level resolveInput hooks (e.g., hash passwords)
|
|
855
|
-
resolvedData = await executeFieldResolveInputHooks(
|
|
856
|
-
args.data,
|
|
857
|
-
resolvedData,
|
|
858
|
-
listConfig.fields,
|
|
859
|
-
'create',
|
|
860
|
-
context,
|
|
575
|
+
return runWritePipeline({
|
|
861
576
|
listName,
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
// 3. Execute list-level validate hook
|
|
865
|
-
await executeValidate(listConfig.hooks, {
|
|
866
|
-
listKey: listName,
|
|
867
|
-
operation: 'create',
|
|
868
|
-
inputData: args.data,
|
|
869
|
-
resolvedData,
|
|
870
|
-
item: undefined,
|
|
871
|
-
context,
|
|
872
|
-
})
|
|
873
|
-
|
|
874
|
-
// 3.5. Execute field-level validate hooks
|
|
875
|
-
await executeFieldValidateHooks(
|
|
876
|
-
args.data,
|
|
877
|
-
resolvedData,
|
|
878
|
-
listConfig.fields,
|
|
879
|
-
'create',
|
|
577
|
+
listConfig,
|
|
578
|
+
prisma,
|
|
880
579
|
context,
|
|
881
|
-
listName,
|
|
882
|
-
)
|
|
883
|
-
|
|
884
|
-
// 4. Field validation (isRequired, length, etc.)
|
|
885
|
-
const validation = validateFieldRules(resolvedData, listConfig.fields, 'create')
|
|
886
|
-
if (validation.errors.length > 0) {
|
|
887
|
-
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// 5. Filter writable fields (field-level access control, skip if sudo mode)
|
|
891
|
-
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
|
|
892
|
-
session: context.session,
|
|
893
|
-
context: { ...context, _isSudo: context._isSudo },
|
|
894
|
-
inputData: args.data,
|
|
895
|
-
})
|
|
896
|
-
|
|
897
|
-
// 5.5. Process nested relationship operations
|
|
898
|
-
const data = await processNestedOperations(
|
|
899
|
-
filteredData,
|
|
900
|
-
listConfig.fields,
|
|
901
580
|
config,
|
|
902
|
-
{ ...context, prisma },
|
|
903
|
-
'create',
|
|
904
|
-
)
|
|
905
|
-
|
|
906
|
-
// 6. Execute field-level beforeOperation hooks (side effects only)
|
|
907
|
-
await executeFieldBeforeOperationHooks(
|
|
908
|
-
args.data,
|
|
909
|
-
resolvedData,
|
|
910
|
-
listConfig.fields,
|
|
911
|
-
'create',
|
|
912
|
-
context,
|
|
913
|
-
listName,
|
|
914
|
-
)
|
|
915
|
-
|
|
916
|
-
// 7. Execute list-level beforeOperation hook
|
|
917
|
-
await executeBeforeOperation(listConfig.hooks, {
|
|
918
|
-
listKey: listName,
|
|
919
|
-
operation: 'create',
|
|
920
581
|
inputData: args.data,
|
|
921
|
-
|
|
922
|
-
context,
|
|
582
|
+
strategy: createWriteStrategy(listName, listConfig, context),
|
|
923
583
|
})
|
|
924
|
-
|
|
925
|
-
// 8. Execute database create
|
|
926
|
-
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
927
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
928
|
-
const model = (prisma as any)[getDbKey(listName)]
|
|
929
|
-
// Singleton lists use Int @id with value always 1 (matching Keystone 6 behaviour)
|
|
930
|
-
const createData = isSingletonList(listConfig) ? { id: 1, ...data } : data
|
|
931
|
-
const item = await model.create({
|
|
932
|
-
data: createData,
|
|
933
|
-
})
|
|
934
|
-
|
|
935
|
-
// 9. Execute list-level afterOperation hook
|
|
936
|
-
await executeAfterOperation(listConfig.hooks, {
|
|
937
|
-
listKey: listName,
|
|
938
|
-
operation: 'create',
|
|
939
|
-
inputData: args.data,
|
|
940
|
-
item,
|
|
941
|
-
resolvedData,
|
|
942
|
-
context,
|
|
943
|
-
})
|
|
944
|
-
|
|
945
|
-
// 10. Execute field-level afterOperation hooks (side effects only)
|
|
946
|
-
await executeFieldAfterOperationHooks(
|
|
947
|
-
item,
|
|
948
|
-
args.data,
|
|
949
|
-
resolvedData,
|
|
950
|
-
listConfig.fields,
|
|
951
|
-
'create',
|
|
952
|
-
context,
|
|
953
|
-
listName,
|
|
954
|
-
undefined, // originalItem is undefined for create operations
|
|
955
|
-
)
|
|
956
|
-
|
|
957
|
-
// 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
958
|
-
// Pass sudo flag through context to skip field-level access checks
|
|
959
|
-
const filtered = await filterReadableFields(
|
|
960
|
-
item,
|
|
961
|
-
listConfig.fields,
|
|
962
|
-
{
|
|
963
|
-
session: context.session,
|
|
964
|
-
context: { ...context, _isSudo: context._isSudo },
|
|
965
|
-
},
|
|
966
|
-
config,
|
|
967
|
-
0,
|
|
968
|
-
listName,
|
|
969
|
-
)
|
|
970
|
-
|
|
971
|
-
return filtered
|
|
972
584
|
}
|
|
973
585
|
}
|
|
974
586
|
|
|
@@ -1009,174 +621,18 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
1009
621
|
context: AccessContext<TPrisma>,
|
|
1010
622
|
config: OpenSaasConfig,
|
|
1011
623
|
) {
|
|
624
|
+
// Thin adapter over the Write Pipeline: pick the update strategy, run the
|
|
625
|
+
// canonical secured write sequence, return its result.
|
|
1012
626
|
return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
1013
|
-
|
|
1014
|
-
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1015
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1016
|
-
const model = (prisma as any)[getDbKey(listName)]
|
|
1017
|
-
const item = await model.findUnique({
|
|
1018
|
-
where: args.where,
|
|
1019
|
-
})
|
|
1020
|
-
|
|
1021
|
-
if (!item) {
|
|
1022
|
-
return null
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// 2. Check update access (skip if sudo mode)
|
|
1026
|
-
if (!context._isSudo) {
|
|
1027
|
-
const updateAccess = listConfig.access?.operation?.update
|
|
1028
|
-
const accessResult = await checkAccess(updateAccess, {
|
|
1029
|
-
session: context.session,
|
|
1030
|
-
item,
|
|
1031
|
-
context,
|
|
1032
|
-
})
|
|
1033
|
-
|
|
1034
|
-
if (accessResult === false) {
|
|
1035
|
-
return null
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
// If access returns a filter, check if item matches
|
|
1039
|
-
if (typeof accessResult === 'object') {
|
|
1040
|
-
const matchesFilter = await model.findFirst({
|
|
1041
|
-
where: mergeFilters(args.where, accessResult),
|
|
1042
|
-
})
|
|
1043
|
-
|
|
1044
|
-
if (!matchesFilter) {
|
|
1045
|
-
return null
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
// 3. Execute list-level resolveInput hook
|
|
1051
|
-
let resolvedData = await executeResolveInput(listConfig.hooks, {
|
|
1052
|
-
listKey: listName,
|
|
1053
|
-
operation: 'update',
|
|
1054
|
-
inputData: args.data,
|
|
1055
|
-
resolvedData: args.data,
|
|
1056
|
-
item,
|
|
1057
|
-
context,
|
|
1058
|
-
})
|
|
1059
|
-
|
|
1060
|
-
// 3.5. Execute field-level resolveInput hooks (e.g., hash passwords)
|
|
1061
|
-
resolvedData = await executeFieldResolveInputHooks(
|
|
1062
|
-
args.data,
|
|
1063
|
-
resolvedData,
|
|
1064
|
-
listConfig.fields,
|
|
1065
|
-
'update',
|
|
1066
|
-
context,
|
|
627
|
+
return runWritePipeline({
|
|
1067
628
|
listName,
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
// 4. Execute list-level validate hook
|
|
1072
|
-
await executeValidate(listConfig.hooks, {
|
|
1073
|
-
listKey: listName,
|
|
1074
|
-
operation: 'update',
|
|
1075
|
-
inputData: args.data,
|
|
1076
|
-
resolvedData,
|
|
1077
|
-
item,
|
|
629
|
+
listConfig,
|
|
630
|
+
prisma,
|
|
1078
631
|
context,
|
|
1079
|
-
})
|
|
1080
|
-
|
|
1081
|
-
// 4.5. Execute field-level validate hooks
|
|
1082
|
-
await executeFieldValidateHooks(
|
|
1083
|
-
args.data,
|
|
1084
|
-
resolvedData,
|
|
1085
|
-
listConfig.fields,
|
|
1086
|
-
'update',
|
|
1087
|
-
context,
|
|
1088
|
-
listName,
|
|
1089
|
-
item,
|
|
1090
|
-
)
|
|
1091
|
-
|
|
1092
|
-
// 5. Field validation (isRequired, length, etc.)
|
|
1093
|
-
const validation = validateFieldRules(resolvedData, listConfig.fields, 'update')
|
|
1094
|
-
if (validation.errors.length > 0) {
|
|
1095
|
-
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
// 6. Filter writable fields (field-level access control, skip if sudo mode)
|
|
1099
|
-
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
|
|
1100
|
-
session: context.session,
|
|
1101
|
-
item,
|
|
1102
|
-
context: { ...context, _isSudo: context._isSudo },
|
|
1103
|
-
inputData: args.data,
|
|
1104
|
-
})
|
|
1105
|
-
|
|
1106
|
-
// 6.5. Process nested relationship operations
|
|
1107
|
-
const data = await processNestedOperations(
|
|
1108
|
-
filteredData,
|
|
1109
|
-
listConfig.fields,
|
|
1110
632
|
config,
|
|
1111
|
-
{ ...context, prisma },
|
|
1112
|
-
'update',
|
|
1113
|
-
)
|
|
1114
|
-
|
|
1115
|
-
// 7. Execute field-level beforeOperation hooks (side effects only)
|
|
1116
|
-
await executeFieldBeforeOperationHooks(
|
|
1117
|
-
args.data,
|
|
1118
|
-
resolvedData,
|
|
1119
|
-
listConfig.fields,
|
|
1120
|
-
'update',
|
|
1121
|
-
context,
|
|
1122
|
-
listName,
|
|
1123
|
-
item,
|
|
1124
|
-
)
|
|
1125
|
-
|
|
1126
|
-
// 8. Execute list-level beforeOperation hook
|
|
1127
|
-
await executeBeforeOperation(listConfig.hooks, {
|
|
1128
|
-
listKey: listName,
|
|
1129
|
-
operation: 'update',
|
|
1130
633
|
inputData: args.data,
|
|
1131
|
-
|
|
1132
|
-
resolvedData,
|
|
1133
|
-
context,
|
|
634
|
+
strategy: updateWriteStrategy(listConfig, context, args.where),
|
|
1134
635
|
})
|
|
1135
|
-
|
|
1136
|
-
// 9. Execute database update
|
|
1137
|
-
const updated = await model.update({
|
|
1138
|
-
where: args.where,
|
|
1139
|
-
data,
|
|
1140
|
-
})
|
|
1141
|
-
|
|
1142
|
-
// 10. Execute list-level afterOperation hook
|
|
1143
|
-
await executeAfterOperation(listConfig.hooks, {
|
|
1144
|
-
listKey: listName,
|
|
1145
|
-
operation: 'update',
|
|
1146
|
-
inputData: args.data,
|
|
1147
|
-
originalItem: item, // item is the original item before the update
|
|
1148
|
-
item: updated,
|
|
1149
|
-
resolvedData,
|
|
1150
|
-
context,
|
|
1151
|
-
})
|
|
1152
|
-
|
|
1153
|
-
// 11. Execute field-level afterOperation hooks (side effects only)
|
|
1154
|
-
await executeFieldAfterOperationHooks(
|
|
1155
|
-
updated,
|
|
1156
|
-
args.data,
|
|
1157
|
-
resolvedData,
|
|
1158
|
-
listConfig.fields,
|
|
1159
|
-
'update',
|
|
1160
|
-
context,
|
|
1161
|
-
listName,
|
|
1162
|
-
item, // item is the original item before the update
|
|
1163
|
-
)
|
|
1164
|
-
|
|
1165
|
-
// 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
1166
|
-
// Pass sudo flag through context to skip field-level access checks
|
|
1167
|
-
const filtered = await filterReadableFields(
|
|
1168
|
-
updated,
|
|
1169
|
-
listConfig.fields,
|
|
1170
|
-
{
|
|
1171
|
-
session: context.session,
|
|
1172
|
-
context: { ...context, _isSudo: context._isSudo },
|
|
1173
|
-
},
|
|
1174
|
-
config,
|
|
1175
|
-
0,
|
|
1176
|
-
listName,
|
|
1177
|
-
)
|
|
1178
|
-
|
|
1179
|
-
return filtered
|
|
1180
636
|
}
|
|
1181
637
|
}
|
|
1182
638
|
|
|
@@ -1220,114 +676,20 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
1220
676
|
listConfig: ListConfig<any>,
|
|
1221
677
|
prisma: TPrisma,
|
|
1222
678
|
context: AccessContext<TPrisma>,
|
|
679
|
+
config: OpenSaasConfig,
|
|
1223
680
|
) {
|
|
681
|
+
// Thin adapter over the Write Pipeline: pick the delete strategy, run the
|
|
682
|
+
// canonical secured write sequence, return its result.
|
|
1224
683
|
return async (args: { where: { id: string } }) => {
|
|
1225
|
-
|
|
1226
|
-
if (isSingletonList(listConfig)) {
|
|
1227
|
-
throw new ValidationError([`Cannot delete: ${listName} is a singleton list`], {})
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
// 1. Fetch the item to pass to access control and hooks
|
|
1231
|
-
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1232
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1233
|
-
const model = (prisma as any)[getDbKey(listName)]
|
|
1234
|
-
const item = await model.findUnique({
|
|
1235
|
-
where: args.where,
|
|
1236
|
-
})
|
|
1237
|
-
|
|
1238
|
-
if (!item) {
|
|
1239
|
-
return null
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
// 2. Check delete access (skip if sudo mode)
|
|
1243
|
-
if (!context._isSudo) {
|
|
1244
|
-
const deleteAccess = listConfig.access?.operation?.delete
|
|
1245
|
-
const accessResult = await checkAccess(deleteAccess, {
|
|
1246
|
-
session: context.session,
|
|
1247
|
-
item,
|
|
1248
|
-
context,
|
|
1249
|
-
})
|
|
1250
|
-
|
|
1251
|
-
if (accessResult === false) {
|
|
1252
|
-
return null
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
// If access returns a filter, check if item matches
|
|
1256
|
-
if (typeof accessResult === 'object') {
|
|
1257
|
-
const matchesFilter = await model.findFirst({
|
|
1258
|
-
where: mergeFilters(args.where, accessResult),
|
|
1259
|
-
})
|
|
1260
|
-
|
|
1261
|
-
if (!matchesFilter) {
|
|
1262
|
-
return null
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
// 3. Execute list-level validate hook
|
|
1268
|
-
await executeValidate(listConfig.hooks, {
|
|
1269
|
-
listKey: listName,
|
|
1270
|
-
operation: 'delete',
|
|
1271
|
-
item,
|
|
1272
|
-
context,
|
|
1273
|
-
})
|
|
1274
|
-
|
|
1275
|
-
// 3.5. Execute field-level validate hooks
|
|
1276
|
-
await executeFieldValidateHooks(
|
|
1277
|
-
undefined,
|
|
1278
|
-
undefined,
|
|
1279
|
-
listConfig.fields,
|
|
1280
|
-
'delete',
|
|
1281
|
-
context,
|
|
684
|
+
return runWritePipeline({
|
|
1282
685
|
listName,
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
// 4. Execute field-level beforeOperation hooks (side effects only)
|
|
1287
|
-
await executeFieldBeforeOperationHooks(
|
|
1288
|
-
{},
|
|
1289
|
-
{},
|
|
1290
|
-
listConfig.fields,
|
|
1291
|
-
'delete',
|
|
1292
|
-
context,
|
|
1293
|
-
listName,
|
|
1294
|
-
item,
|
|
1295
|
-
)
|
|
1296
|
-
|
|
1297
|
-
// 5. Execute list-level beforeOperation hook
|
|
1298
|
-
await executeBeforeOperation(listConfig.hooks, {
|
|
1299
|
-
listKey: listName,
|
|
1300
|
-
operation: 'delete',
|
|
1301
|
-
item,
|
|
1302
|
-
context,
|
|
1303
|
-
})
|
|
1304
|
-
|
|
1305
|
-
// 6. Execute database delete
|
|
1306
|
-
const deleted = await model.delete({
|
|
1307
|
-
where: args.where,
|
|
1308
|
-
})
|
|
1309
|
-
|
|
1310
|
-
// 7. Execute list-level afterOperation hook
|
|
1311
|
-
await executeAfterOperation(listConfig.hooks, {
|
|
1312
|
-
listKey: listName,
|
|
1313
|
-
operation: 'delete',
|
|
1314
|
-
originalItem: item, // item is the original item before deletion
|
|
686
|
+
listConfig,
|
|
687
|
+
prisma,
|
|
1315
688
|
context,
|
|
689
|
+
config,
|
|
690
|
+
inputData: undefined,
|
|
691
|
+
strategy: deleteWriteStrategy(listName, listConfig, context, args.where),
|
|
1316
692
|
})
|
|
1317
|
-
|
|
1318
|
-
// 8. Execute field-level afterOperation hooks (side effects only)
|
|
1319
|
-
await executeFieldAfterOperationHooks(
|
|
1320
|
-
deleted,
|
|
1321
|
-
undefined,
|
|
1322
|
-
undefined,
|
|
1323
|
-
listConfig.fields,
|
|
1324
|
-
'delete',
|
|
1325
|
-
context,
|
|
1326
|
-
listName,
|
|
1327
|
-
item, // item is the original item before deletion
|
|
1328
|
-
)
|
|
1329
|
-
|
|
1330
|
-
return deleted
|
|
1331
693
|
}
|
|
1332
694
|
}
|
|
1333
695
|
|