@opensaas/stack-core 0.1.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 +4 -0
- package/README.md +447 -0
- package/dist/access/engine.d.ts +73 -0
- package/dist/access/engine.d.ts.map +1 -0
- package/dist/access/engine.js +244 -0
- package/dist/access/engine.js.map +1 -0
- package/dist/access/field-transforms.d.ts +47 -0
- package/dist/access/field-transforms.d.ts.map +1 -0
- package/dist/access/field-transforms.js +2 -0
- package/dist/access/field-transforms.js.map +1 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/types.d.ts +83 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/types.js +2 -0
- package/dist/access/types.js.map +1 -0
- package/dist/config/index.d.ts +39 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +413 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/context/index.d.ts +31 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +524 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/nested-operations.d.ts +10 -0
- package/dist/context/nested-operations.d.ts.map +1 -0
- package/dist/context/nested-operations.js +261 -0
- package/dist/context/nested-operations.js.map +1 -0
- package/dist/fields/index.d.ts +78 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +381 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/hooks/index.d.ts +58 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +79 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/case-utils.d.ts +49 -0
- package/dist/lib/case-utils.d.ts.map +1 -0
- package/dist/lib/case-utils.js +68 -0
- package/dist/lib/case-utils.js.map +1 -0
- package/dist/lib/case-utils.test.d.ts +2 -0
- package/dist/lib/case-utils.test.d.ts.map +1 -0
- package/dist/lib/case-utils.test.js +101 -0
- package/dist/lib/case-utils.test.js.map +1 -0
- package/dist/utils/password.d.ts +81 -0
- package/dist/utils/password.d.ts.map +1 -0
- package/dist/utils/password.js +132 -0
- package/dist/utils/password.js.map +1 -0
- package/dist/validation/schema.d.ts +17 -0
- package/dist/validation/schema.d.ts.map +1 -0
- package/dist/validation/schema.js +42 -0
- package/dist/validation/schema.js.map +1 -0
- package/dist/validation/schema.test.d.ts +2 -0
- package/dist/validation/schema.test.d.ts.map +1 -0
- package/dist/validation/schema.test.js +143 -0
- package/dist/validation/schema.test.js.map +1 -0
- package/docs/type-distribution-fix.md +136 -0
- package/package.json +48 -0
- package/src/access/engine.ts +360 -0
- package/src/access/field-transforms.ts +99 -0
- package/src/access/index.ts +20 -0
- package/src/access/types.ts +103 -0
- package/src/config/index.ts +71 -0
- package/src/config/types.ts +478 -0
- package/src/context/index.ts +814 -0
- package/src/context/nested-operations.ts +412 -0
- package/src/fields/index.ts +438 -0
- package/src/hooks/index.ts +132 -0
- package/src/index.ts +62 -0
- package/src/lib/case-utils.test.ts +127 -0
- package/src/lib/case-utils.ts +74 -0
- package/src/utils/password.ts +147 -0
- package/src/validation/schema.test.ts +171 -0
- package/src/validation/schema.ts +59 -0
- package/tests/access-relationships.test.ts +613 -0
- package/tests/access.test.ts +499 -0
- package/tests/config.test.ts +195 -0
- package/tests/context.test.ts +248 -0
- package/tests/hooks.test.ts +417 -0
- package/tests/password-type-distribution.test.ts +155 -0
- package/tests/password-types.test.ts +147 -0
- package/tests/password.test.ts +249 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
import type { OpenSaasConfig, ListConfig } from '../config/types.js'
|
|
2
|
+
import type { Session, AccessContext, AccessControlledDB } from '../access/index.js'
|
|
3
|
+
import {
|
|
4
|
+
checkAccess,
|
|
5
|
+
mergeFilters,
|
|
6
|
+
filterReadableFields,
|
|
7
|
+
filterWritableFields,
|
|
8
|
+
buildIncludeWithAccessControl,
|
|
9
|
+
} from '../access/index.js'
|
|
10
|
+
import {
|
|
11
|
+
executeResolveInput,
|
|
12
|
+
executeValidateInput,
|
|
13
|
+
executeBeforeOperation,
|
|
14
|
+
executeAfterOperation,
|
|
15
|
+
validateFieldRules,
|
|
16
|
+
ValidationError,
|
|
17
|
+
} from '../hooks/index.js'
|
|
18
|
+
import { processNestedOperations } from './nested-operations.js'
|
|
19
|
+
import { getDbKey } from '../lib/case-utils.js'
|
|
20
|
+
import type { PrismaClientLike } from '../access/types.js'
|
|
21
|
+
import type { FieldConfig } from '../config/types.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Execute field-level resolveInput hooks
|
|
25
|
+
* Allows fields to transform their input values before database write
|
|
26
|
+
*/
|
|
27
|
+
async function executeFieldResolveInputHooks(
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
data: Record<string, any>,
|
|
30
|
+
fields: Record<string, FieldConfig>,
|
|
31
|
+
operation: 'create' | 'update',
|
|
32
|
+
context: AccessContext,
|
|
33
|
+
listKey: string,
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
item?: any,
|
|
36
|
+
): Promise<Record<string, unknown>> {
|
|
37
|
+
const result = { ...data }
|
|
38
|
+
|
|
39
|
+
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
|
|
40
|
+
// Skip if field not in data
|
|
41
|
+
if (!(fieldName in result)) continue
|
|
42
|
+
|
|
43
|
+
// Skip if no hooks defined
|
|
44
|
+
if (!fieldConfig.hooks?.resolveInput) continue
|
|
45
|
+
|
|
46
|
+
// Execute field hook
|
|
47
|
+
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
48
|
+
// and we're working with runtime values that match those types
|
|
49
|
+
|
|
50
|
+
const transformedValue = await fieldConfig.hooks.resolveInput({
|
|
51
|
+
inputValue: result[fieldName],
|
|
52
|
+
operation,
|
|
53
|
+
fieldName,
|
|
54
|
+
listKey,
|
|
55
|
+
item,
|
|
56
|
+
context,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
result[fieldName] = transformedValue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Execute field-level beforeOperation hooks (side effects only)
|
|
67
|
+
* Allows fields to perform side effects before database write
|
|
68
|
+
*/
|
|
69
|
+
async function executeFieldBeforeOperationHooks(
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
data: Record<string, any>,
|
|
72
|
+
fields: Record<string, FieldConfig>,
|
|
73
|
+
operation: 'create' | 'update' | 'delete',
|
|
74
|
+
context: AccessContext,
|
|
75
|
+
listKey: string,
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
77
|
+
item?: any,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
|
|
80
|
+
// Skip if field not in data (for create/update) or if no hooks defined
|
|
81
|
+
if (!fieldConfig.hooks?.beforeOperation) continue
|
|
82
|
+
if (operation !== 'delete' && !(fieldName in data)) continue
|
|
83
|
+
|
|
84
|
+
// Execute field hook (side effects only, no return value used)
|
|
85
|
+
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
86
|
+
await fieldConfig.hooks.beforeOperation({
|
|
87
|
+
resolvedValue: data[fieldName],
|
|
88
|
+
operation,
|
|
89
|
+
fieldName,
|
|
90
|
+
listKey,
|
|
91
|
+
item,
|
|
92
|
+
context,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Execute field-level afterOperation hooks (side effects only)
|
|
99
|
+
* Allows fields to perform side effects after database operations
|
|
100
|
+
*/
|
|
101
|
+
async function executeFieldAfterOperationHooks(
|
|
102
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
103
|
+
item: any,
|
|
104
|
+
data: Record<string, unknown> | undefined,
|
|
105
|
+
fields: Record<string, FieldConfig>,
|
|
106
|
+
operation: 'create' | 'update' | 'delete' | 'query',
|
|
107
|
+
context: AccessContext,
|
|
108
|
+
listKey: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
|
|
111
|
+
// Skip if no hooks defined
|
|
112
|
+
if (!fieldConfig.hooks?.afterOperation) continue
|
|
113
|
+
|
|
114
|
+
// Get the value from item (for all operations)
|
|
115
|
+
const value = item?.[fieldName]
|
|
116
|
+
|
|
117
|
+
// Execute field hook (side effects only, no return value used)
|
|
118
|
+
await fieldConfig.hooks.afterOperation({
|
|
119
|
+
value,
|
|
120
|
+
operation,
|
|
121
|
+
fieldName,
|
|
122
|
+
listKey,
|
|
123
|
+
item,
|
|
124
|
+
context,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Execute field-level resolveOutput hooks
|
|
131
|
+
* Allows fields to transform their output values after database read
|
|
132
|
+
*/
|
|
133
|
+
function executeFieldResolveOutputHooks(
|
|
134
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
135
|
+
item: Record<string, any> | null,
|
|
136
|
+
fields: Record<string, FieldConfig>,
|
|
137
|
+
context: AccessContext,
|
|
138
|
+
listKey: string,
|
|
139
|
+
): Record<string, unknown> | null {
|
|
140
|
+
if (!item) return null
|
|
141
|
+
|
|
142
|
+
const result = { ...item }
|
|
143
|
+
|
|
144
|
+
for (const [fieldName, fieldConfig] of Object.entries(fields)) {
|
|
145
|
+
// Skip if field not in result
|
|
146
|
+
if (!(fieldName in result)) continue
|
|
147
|
+
|
|
148
|
+
// Skip if no hooks defined
|
|
149
|
+
if (!fieldConfig.hooks?.resolveOutput) continue
|
|
150
|
+
|
|
151
|
+
// Execute field hook
|
|
152
|
+
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
153
|
+
const transformedValue = fieldConfig.hooks.resolveOutput({
|
|
154
|
+
value: result[fieldName],
|
|
155
|
+
operation: 'query',
|
|
156
|
+
fieldName,
|
|
157
|
+
listKey,
|
|
158
|
+
item,
|
|
159
|
+
context,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
result[fieldName] = transformedValue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return result
|
|
166
|
+
}
|
|
167
|
+
export type ServerActionProps =
|
|
168
|
+
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
169
|
+
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
170
|
+
| { listKey: string; action: 'delete'; id: string }
|
|
171
|
+
/**
|
|
172
|
+
* Create an access-controlled context
|
|
173
|
+
*
|
|
174
|
+
* @param config - OpenSaas configuration
|
|
175
|
+
* @param prisma - Your Prisma client instance (pass as generic for type safety)
|
|
176
|
+
* @param session - Current session object (or null if not authenticated)
|
|
177
|
+
*/
|
|
178
|
+
export function getContext<
|
|
179
|
+
TConfig extends OpenSaasConfig,
|
|
180
|
+
TPrisma extends PrismaClientLike = PrismaClientLike,
|
|
181
|
+
>(
|
|
182
|
+
config: TConfig,
|
|
183
|
+
prisma: TPrisma,
|
|
184
|
+
session: Session,
|
|
185
|
+
): {
|
|
186
|
+
db: AccessControlledDB<TPrisma>
|
|
187
|
+
session: Session
|
|
188
|
+
prisma: TPrisma
|
|
189
|
+
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
190
|
+
} {
|
|
191
|
+
// Initialize db object - will be populated with access-controlled operations
|
|
192
|
+
// Type is intentionally broad to allow dynamic model access
|
|
193
|
+
const db: Record<string, unknown> = {}
|
|
194
|
+
|
|
195
|
+
// Create context with db reference (will be populated below)
|
|
196
|
+
const context: AccessContext<TPrisma> = {
|
|
197
|
+
session,
|
|
198
|
+
prisma: prisma as TPrisma,
|
|
199
|
+
db: db as AccessControlledDB<TPrisma>,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Create access-controlled operations for each list
|
|
203
|
+
for (const [listName, listConfig] of Object.entries(config.lists)) {
|
|
204
|
+
const dbKey = getDbKey(listName)
|
|
205
|
+
|
|
206
|
+
db[dbKey] = {
|
|
207
|
+
findUnique: createFindUnique(listName, listConfig, prisma, context, config),
|
|
208
|
+
findMany: createFindMany(listName, listConfig, prisma, context, config),
|
|
209
|
+
create: createCreate(listName, listConfig, prisma, context, config),
|
|
210
|
+
update: createUpdate(listName, listConfig, prisma, context, config),
|
|
211
|
+
delete: createDelete(listName, listConfig, prisma, context),
|
|
212
|
+
count: createCount(listName, listConfig, prisma, context),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Generic server action handler with discriminated union for type safety
|
|
217
|
+
async function serverAction(props: ServerActionProps): Promise<unknown> {
|
|
218
|
+
const dbKey = getDbKey(props.listKey)
|
|
219
|
+
const model = db[dbKey] as {
|
|
220
|
+
create: (args: { data: Record<string, unknown> }) => Promise<unknown>
|
|
221
|
+
update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown>
|
|
222
|
+
delete: (args: { where: { id: string } }) => Promise<unknown>
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (props.action === 'create') {
|
|
226
|
+
return await model.create({ data: props.data })
|
|
227
|
+
} else if (props.action === 'update') {
|
|
228
|
+
return await model.update({
|
|
229
|
+
where: { id: props.id },
|
|
230
|
+
data: props.data,
|
|
231
|
+
})
|
|
232
|
+
} else if (props.action === 'delete') {
|
|
233
|
+
return await model.delete({
|
|
234
|
+
where: { id: props.id },
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
db: db as AccessControlledDB<TPrisma>,
|
|
243
|
+
session,
|
|
244
|
+
prisma,
|
|
245
|
+
serverAction,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Create findUnique operation with access control
|
|
251
|
+
*/
|
|
252
|
+
function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
253
|
+
listName: string,
|
|
254
|
+
listConfig: ListConfig,
|
|
255
|
+
prisma: TPrisma,
|
|
256
|
+
context: AccessContext,
|
|
257
|
+
config: OpenSaasConfig,
|
|
258
|
+
) {
|
|
259
|
+
return async (args: { where: { id: string }; include?: Record<string, unknown> }) => {
|
|
260
|
+
// Check query access
|
|
261
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
262
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
263
|
+
session: context.session,
|
|
264
|
+
context,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
if (accessResult === false) {
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Merge access filter with where clause
|
|
272
|
+
const where = mergeFilters(args.where, accessResult)
|
|
273
|
+
if (where === null) {
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Build include with access control filters
|
|
278
|
+
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
279
|
+
listConfig.fields,
|
|
280
|
+
{
|
|
281
|
+
session: context.session,
|
|
282
|
+
context,
|
|
283
|
+
},
|
|
284
|
+
config,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
// Merge user-provided include with access-controlled include
|
|
288
|
+
const include = args.include || accessControlledInclude
|
|
289
|
+
|
|
290
|
+
// Execute query with optimized includes
|
|
291
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
292
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
293
|
+
const model = (prisma as any)[getDbKey(listName)]
|
|
294
|
+
const item = await model.findFirst({
|
|
295
|
+
where,
|
|
296
|
+
include,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
if (!item) {
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Filter readable fields (now only handles field-level access, not array filtering)
|
|
304
|
+
const filtered = await filterReadableFields(
|
|
305
|
+
item,
|
|
306
|
+
listConfig.fields,
|
|
307
|
+
{
|
|
308
|
+
session: context.session,
|
|
309
|
+
context,
|
|
310
|
+
},
|
|
311
|
+
config,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
// Execute field resolveOutput hooks (e.g., wrap password with HashedPassword)
|
|
315
|
+
const resolved = executeFieldResolveOutputHooks(filtered, listConfig.fields, context, listName)
|
|
316
|
+
|
|
317
|
+
// Execute field afterOperation hooks (side effects only)
|
|
318
|
+
await executeFieldAfterOperationHooks(
|
|
319
|
+
resolved,
|
|
320
|
+
undefined,
|
|
321
|
+
listConfig.fields,
|
|
322
|
+
'query',
|
|
323
|
+
context,
|
|
324
|
+
listName,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return resolved
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Create findMany operation with access control
|
|
333
|
+
*/
|
|
334
|
+
function createFindMany<TPrisma extends PrismaClientLike>(
|
|
335
|
+
listName: string,
|
|
336
|
+
listConfig: ListConfig,
|
|
337
|
+
prisma: TPrisma,
|
|
338
|
+
context: AccessContext,
|
|
339
|
+
config: OpenSaasConfig,
|
|
340
|
+
) {
|
|
341
|
+
return async (args?: {
|
|
342
|
+
where?: Record<string, unknown>
|
|
343
|
+
take?: number
|
|
344
|
+
skip?: number
|
|
345
|
+
include?: Record<string, unknown>
|
|
346
|
+
}) => {
|
|
347
|
+
// Check query access
|
|
348
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
349
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
350
|
+
session: context.session,
|
|
351
|
+
context,
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
if (accessResult === false) {
|
|
355
|
+
return []
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Merge access filter with where clause
|
|
359
|
+
const where = mergeFilters(args?.where, accessResult)
|
|
360
|
+
if (where === null) {
|
|
361
|
+
return []
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Build include with access control filters
|
|
365
|
+
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
366
|
+
listConfig.fields,
|
|
367
|
+
{
|
|
368
|
+
session: context.session,
|
|
369
|
+
context,
|
|
370
|
+
},
|
|
371
|
+
config,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
// Merge user-provided include with access-controlled include
|
|
375
|
+
const include = args?.include || accessControlledInclude
|
|
376
|
+
|
|
377
|
+
// Execute query with optimized includes
|
|
378
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
379
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
380
|
+
const model = (prisma as any)[getDbKey(listName)]
|
|
381
|
+
const items = await model.findMany({
|
|
382
|
+
where,
|
|
383
|
+
take: args?.take,
|
|
384
|
+
skip: args?.skip,
|
|
385
|
+
include,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// Filter readable fields for each item (now only handles field-level access)
|
|
389
|
+
const filtered = await Promise.all(
|
|
390
|
+
items.map((item: Record<string, unknown>) =>
|
|
391
|
+
filterReadableFields(
|
|
392
|
+
item,
|
|
393
|
+
listConfig.fields,
|
|
394
|
+
{
|
|
395
|
+
session: context.session,
|
|
396
|
+
context,
|
|
397
|
+
},
|
|
398
|
+
config,
|
|
399
|
+
),
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
// Execute field resolveOutput hooks for each item
|
|
404
|
+
const resolved = filtered.map((item) =>
|
|
405
|
+
executeFieldResolveOutputHooks(item, listConfig.fields, context, listName),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
// Execute field afterOperation hooks for each item (side effects only)
|
|
409
|
+
await Promise.all(
|
|
410
|
+
resolved.map((item) =>
|
|
411
|
+
executeFieldAfterOperationHooks(
|
|
412
|
+
item,
|
|
413
|
+
undefined,
|
|
414
|
+
listConfig.fields,
|
|
415
|
+
'query',
|
|
416
|
+
context,
|
|
417
|
+
listName,
|
|
418
|
+
),
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
return resolved
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Create create operation with access control and hooks
|
|
428
|
+
*/
|
|
429
|
+
function createCreate<TPrisma extends PrismaClientLike>(
|
|
430
|
+
listName: string,
|
|
431
|
+
listConfig: ListConfig,
|
|
432
|
+
prisma: TPrisma,
|
|
433
|
+
context: AccessContext,
|
|
434
|
+
config: OpenSaasConfig,
|
|
435
|
+
) {
|
|
436
|
+
return async (args: { data: Record<string, unknown> }) => {
|
|
437
|
+
// 1. Check create access
|
|
438
|
+
const createAccess = listConfig.access?.operation?.create
|
|
439
|
+
const accessResult = await checkAccess(createAccess, {
|
|
440
|
+
session: context.session,
|
|
441
|
+
context,
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
if (accessResult === false) {
|
|
445
|
+
return null
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 2. Execute list-level resolveInput hook
|
|
449
|
+
let resolvedData = await executeResolveInput(listConfig.hooks, {
|
|
450
|
+
operation: 'create',
|
|
451
|
+
resolvedData: args.data,
|
|
452
|
+
context,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
// 2.5. Execute field-level resolveInput hooks (e.g., hash passwords)
|
|
456
|
+
resolvedData = await executeFieldResolveInputHooks(
|
|
457
|
+
resolvedData,
|
|
458
|
+
listConfig.fields,
|
|
459
|
+
'create',
|
|
460
|
+
context,
|
|
461
|
+
listName,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
// 3. Execute validateInput hook
|
|
465
|
+
await executeValidateInput(listConfig.hooks, {
|
|
466
|
+
operation: 'create',
|
|
467
|
+
resolvedData,
|
|
468
|
+
context,
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
// 4. Field validation (isRequired, length, etc.)
|
|
472
|
+
const validation = validateFieldRules(resolvedData, listConfig.fields, 'create')
|
|
473
|
+
if (validation.errors.length > 0) {
|
|
474
|
+
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// 5. Filter writable fields (field-level access control)
|
|
478
|
+
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
|
|
479
|
+
session: context.session,
|
|
480
|
+
context,
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// 5.5. Process nested relationship operations
|
|
484
|
+
const data = await processNestedOperations(
|
|
485
|
+
filteredData,
|
|
486
|
+
listConfig.fields,
|
|
487
|
+
config,
|
|
488
|
+
{ ...context, prisma },
|
|
489
|
+
'create',
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
// 6. Execute field-level beforeOperation hooks (side effects only)
|
|
493
|
+
await executeFieldBeforeOperationHooks(data, listConfig.fields, 'create', context, listName)
|
|
494
|
+
|
|
495
|
+
// 7. Execute list-level beforeOperation hook
|
|
496
|
+
await executeBeforeOperation(listConfig.hooks, {
|
|
497
|
+
operation: 'create',
|
|
498
|
+
context,
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
// 8. Execute database create
|
|
502
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
503
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
504
|
+
const model = (prisma as any)[getDbKey(listName)]
|
|
505
|
+
const item = await model.create({
|
|
506
|
+
data,
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
// 9. Execute list-level afterOperation hook
|
|
510
|
+
await executeAfterOperation(listConfig.hooks, {
|
|
511
|
+
operation: 'create',
|
|
512
|
+
item,
|
|
513
|
+
context,
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
// 10. Execute field-level afterOperation hooks (side effects only)
|
|
517
|
+
await executeFieldAfterOperationHooks(
|
|
518
|
+
item,
|
|
519
|
+
data,
|
|
520
|
+
listConfig.fields,
|
|
521
|
+
'create',
|
|
522
|
+
context,
|
|
523
|
+
listName,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
// 11. Filter readable fields
|
|
527
|
+
const filtered = await filterReadableFields(
|
|
528
|
+
item,
|
|
529
|
+
listConfig.fields,
|
|
530
|
+
{
|
|
531
|
+
session: context.session,
|
|
532
|
+
context,
|
|
533
|
+
},
|
|
534
|
+
config,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
// 12. Execute field resolveOutput hooks (e.g., wrap password with HashedPassword)
|
|
538
|
+
const resolved = executeFieldResolveOutputHooks(filtered, listConfig.fields, context, listName)
|
|
539
|
+
|
|
540
|
+
return resolved
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Create update operation with access control and hooks
|
|
546
|
+
*/
|
|
547
|
+
function createUpdate<TPrisma extends PrismaClientLike>(
|
|
548
|
+
listName: string,
|
|
549
|
+
listConfig: ListConfig,
|
|
550
|
+
prisma: TPrisma,
|
|
551
|
+
context: AccessContext,
|
|
552
|
+
config: OpenSaasConfig,
|
|
553
|
+
) {
|
|
554
|
+
return async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
|
555
|
+
// 1. Fetch the item to pass to access control and hooks
|
|
556
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
557
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
558
|
+
const model = (prisma as any)[getDbKey(listName)]
|
|
559
|
+
const item = await model.findUnique({
|
|
560
|
+
where: args.where,
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
if (!item) {
|
|
564
|
+
return null
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 2. Check update access
|
|
568
|
+
const updateAccess = listConfig.access?.operation?.update
|
|
569
|
+
const accessResult = await checkAccess(updateAccess, {
|
|
570
|
+
session: context.session,
|
|
571
|
+
item,
|
|
572
|
+
context,
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
if (accessResult === false) {
|
|
576
|
+
return null
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// If access returns a filter, check if item matches
|
|
580
|
+
if (typeof accessResult === 'object') {
|
|
581
|
+
const matchesFilter = await model.findFirst({
|
|
582
|
+
where: mergeFilters(args.where, accessResult),
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
if (!matchesFilter) {
|
|
586
|
+
return null
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 3. Execute list-level resolveInput hook
|
|
591
|
+
let resolvedData = await executeResolveInput(listConfig.hooks, {
|
|
592
|
+
operation: 'update',
|
|
593
|
+
resolvedData: args.data,
|
|
594
|
+
item,
|
|
595
|
+
context,
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
// 3.5. Execute field-level resolveInput hooks (e.g., hash passwords)
|
|
599
|
+
resolvedData = await executeFieldResolveInputHooks(
|
|
600
|
+
resolvedData,
|
|
601
|
+
listConfig.fields,
|
|
602
|
+
'update',
|
|
603
|
+
context,
|
|
604
|
+
listName,
|
|
605
|
+
item,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
// 4. Execute validateInput hook
|
|
609
|
+
await executeValidateInput(listConfig.hooks, {
|
|
610
|
+
operation: 'update',
|
|
611
|
+
resolvedData,
|
|
612
|
+
item,
|
|
613
|
+
context,
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
// 5. Field validation (isRequired, length, etc.)
|
|
617
|
+
const validation = validateFieldRules(resolvedData, listConfig.fields, 'update')
|
|
618
|
+
if (validation.errors.length > 0) {
|
|
619
|
+
throw new ValidationError(validation.errors, validation.fieldErrors)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// 6. Filter writable fields (field-level access control)
|
|
623
|
+
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'update', {
|
|
624
|
+
session: context.session,
|
|
625
|
+
item,
|
|
626
|
+
context,
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
// 6.5. Process nested relationship operations
|
|
630
|
+
const data = await processNestedOperations(
|
|
631
|
+
filteredData,
|
|
632
|
+
listConfig.fields,
|
|
633
|
+
config,
|
|
634
|
+
{ ...context, prisma },
|
|
635
|
+
'update',
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
// 7. Execute field-level beforeOperation hooks (side effects only)
|
|
639
|
+
await executeFieldBeforeOperationHooks(
|
|
640
|
+
data,
|
|
641
|
+
listConfig.fields,
|
|
642
|
+
'update',
|
|
643
|
+
context,
|
|
644
|
+
listName,
|
|
645
|
+
item,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
// 8. Execute list-level beforeOperation hook
|
|
649
|
+
await executeBeforeOperation(listConfig.hooks, {
|
|
650
|
+
operation: 'update',
|
|
651
|
+
item,
|
|
652
|
+
context,
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
// 9. Execute database update
|
|
656
|
+
const updated = await model.update({
|
|
657
|
+
where: args.where,
|
|
658
|
+
data,
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
// 10. Execute list-level afterOperation hook
|
|
662
|
+
await executeAfterOperation(listConfig.hooks, {
|
|
663
|
+
operation: 'update',
|
|
664
|
+
item: updated,
|
|
665
|
+
context,
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
// 11. Execute field-level afterOperation hooks (side effects only)
|
|
669
|
+
await executeFieldAfterOperationHooks(
|
|
670
|
+
updated,
|
|
671
|
+
data,
|
|
672
|
+
listConfig.fields,
|
|
673
|
+
'update',
|
|
674
|
+
context,
|
|
675
|
+
listName,
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
// 12. Filter readable fields
|
|
679
|
+
const filtered = await filterReadableFields(
|
|
680
|
+
updated,
|
|
681
|
+
listConfig.fields,
|
|
682
|
+
{
|
|
683
|
+
session: context.session,
|
|
684
|
+
context,
|
|
685
|
+
},
|
|
686
|
+
config,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
// 13. Execute field resolveOutput hooks (e.g., wrap password with HashedPassword)
|
|
690
|
+
const resolved = executeFieldResolveOutputHooks(filtered, listConfig.fields, context, listName)
|
|
691
|
+
|
|
692
|
+
return resolved
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Create delete operation with access control and hooks
|
|
698
|
+
*/
|
|
699
|
+
function createDelete<TPrisma extends PrismaClientLike>(
|
|
700
|
+
listName: string,
|
|
701
|
+
listConfig: ListConfig,
|
|
702
|
+
prisma: TPrisma,
|
|
703
|
+
context: AccessContext,
|
|
704
|
+
) {
|
|
705
|
+
return async (args: { where: { id: string } }) => {
|
|
706
|
+
// 1. Fetch the item to pass to access control and hooks
|
|
707
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
708
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
709
|
+
const model = (prisma as any)[getDbKey(listName)]
|
|
710
|
+
const item = await model.findUnique({
|
|
711
|
+
where: args.where,
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
if (!item) {
|
|
715
|
+
return null
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// 2. Check delete access
|
|
719
|
+
const deleteAccess = listConfig.access?.operation?.delete
|
|
720
|
+
const accessResult = await checkAccess(deleteAccess, {
|
|
721
|
+
session: context.session,
|
|
722
|
+
item,
|
|
723
|
+
context,
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
if (accessResult === false) {
|
|
727
|
+
return null
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// If access returns a filter, check if item matches
|
|
731
|
+
if (typeof accessResult === 'object') {
|
|
732
|
+
const matchesFilter = await model.findFirst({
|
|
733
|
+
where: mergeFilters(args.where, accessResult),
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
if (!matchesFilter) {
|
|
737
|
+
return null
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// 3. Execute field-level beforeOperation hooks (side effects only)
|
|
742
|
+
await executeFieldBeforeOperationHooks({}, listConfig.fields, 'delete', context, listName, item)
|
|
743
|
+
|
|
744
|
+
// 4. Execute list-level beforeOperation hook
|
|
745
|
+
await executeBeforeOperation(listConfig.hooks, {
|
|
746
|
+
operation: 'delete',
|
|
747
|
+
item,
|
|
748
|
+
context,
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
// 5. Execute database delete
|
|
752
|
+
const deleted = await model.delete({
|
|
753
|
+
where: args.where,
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
// 6. Execute list-level afterOperation hook
|
|
757
|
+
await executeAfterOperation(listConfig.hooks, {
|
|
758
|
+
operation: 'delete',
|
|
759
|
+
item: deleted,
|
|
760
|
+
context,
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
// 7. Execute field-level afterOperation hooks (side effects only)
|
|
764
|
+
await executeFieldAfterOperationHooks(
|
|
765
|
+
deleted,
|
|
766
|
+
undefined,
|
|
767
|
+
listConfig.fields,
|
|
768
|
+
'delete',
|
|
769
|
+
context,
|
|
770
|
+
listName,
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
return deleted
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Create count operation with access control
|
|
779
|
+
*/
|
|
780
|
+
function createCount<TPrisma extends PrismaClientLike>(
|
|
781
|
+
listName: string,
|
|
782
|
+
listConfig: ListConfig,
|
|
783
|
+
prisma: TPrisma,
|
|
784
|
+
context: AccessContext,
|
|
785
|
+
) {
|
|
786
|
+
return async (args?: { where?: Record<string, unknown> }) => {
|
|
787
|
+
// Check query access
|
|
788
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
789
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
790
|
+
session: context.session,
|
|
791
|
+
context,
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
if (accessResult === false) {
|
|
795
|
+
return 0
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Merge access filter with where clause
|
|
799
|
+
const where = mergeFilters(args?.where, accessResult)
|
|
800
|
+
if (where === null) {
|
|
801
|
+
return 0
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Execute count
|
|
805
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
806
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
807
|
+
const model = (prisma as any)[getDbKey(listName)]
|
|
808
|
+
const count = await model.count({
|
|
809
|
+
where,
|
|
810
|
+
})
|
|
811
|
+
|
|
812
|
+
return count
|
|
813
|
+
}
|
|
814
|
+
}
|