@opensaas/stack-core 0.12.1 → 0.14.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 +291 -0
- package/README.md +6 -3
- package/dist/access/engine.d.ts +2 -0
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +8 -6
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.js +4 -0
- package/dist/access/engine.test.js.map +1 -1
- package/dist/access/types.d.ts +31 -4
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +12 -10
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +37 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/types.d.ts +341 -82
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +330 -60
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +38 -25
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/hooks/index.d.ts +45 -7
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +10 -4
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/engine.test.ts +4 -0
- package/src/access/engine.ts +10 -7
- package/src/access/types.ts +45 -4
- package/src/config/index.ts +65 -9
- package/src/config/types.ts +402 -91
- package/src/context/index.ts +421 -82
- package/src/context/nested-operations.ts +40 -25
- package/src/hooks/index.ts +66 -14
- package/src/index.ts +11 -0
- package/tests/access.test.ts +28 -28
- package/tests/config.test.ts +20 -3
- package/tests/nested-access-and-hooks.test.ts +8 -3
- package/tests/singleton.test.ts +329 -0
- package/tests/sudo.test.ts +2 -13
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from '../access/index.js'
|
|
10
10
|
import {
|
|
11
11
|
executeResolveInput,
|
|
12
|
-
|
|
12
|
+
executeValidate,
|
|
13
13
|
executeBeforeOperation,
|
|
14
14
|
executeAfterOperation,
|
|
15
15
|
validateFieldRules,
|
|
@@ -27,7 +27,9 @@ import type { FieldConfig } from '../config/types.js'
|
|
|
27
27
|
*/
|
|
28
28
|
async function executeFieldResolveInputHooks(
|
|
29
29
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
-
|
|
30
|
+
inputData: Record<string, any>,
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
|
+
resolvedData: Record<string, any>,
|
|
31
33
|
fields: Record<string, FieldConfig>,
|
|
32
34
|
operation: 'create' | 'update',
|
|
33
35
|
context: AccessContext,
|
|
@@ -35,11 +37,11 @@ async function executeFieldResolveInputHooks(
|
|
|
35
37
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
38
|
item?: any,
|
|
37
39
|
): Promise<Record<string, unknown>> {
|
|
38
|
-
|
|
40
|
+
let result = { ...resolvedData }
|
|
39
41
|
|
|
40
|
-
for (const [
|
|
42
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
41
43
|
// Skip if field not in data
|
|
42
|
-
if (!(
|
|
44
|
+
if (!(fieldKey in result)) continue
|
|
43
45
|
|
|
44
46
|
// Skip if no hooks defined
|
|
45
47
|
if (!fieldConfig.hooks?.resolveInput) continue
|
|
@@ -49,27 +51,102 @@ async function executeFieldResolveInputHooks(
|
|
|
49
51
|
// and we're working with runtime values that match those types
|
|
50
52
|
|
|
51
53
|
const transformedValue = await fieldConfig.hooks.resolveInput({
|
|
52
|
-
inputValue: result[fieldName],
|
|
53
|
-
operation,
|
|
54
|
-
fieldName,
|
|
55
54
|
listKey,
|
|
55
|
+
fieldKey,
|
|
56
|
+
operation,
|
|
57
|
+
inputData,
|
|
56
58
|
item,
|
|
59
|
+
resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
|
|
57
60
|
context,
|
|
58
|
-
})
|
|
61
|
+
} as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
|
|
59
62
|
|
|
60
|
-
|
|
63
|
+
// Create new object with updated field to avoid mutating the passed reference
|
|
64
|
+
result = { ...result, [fieldKey]: transformedValue }
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
return result
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Execute field-level validate hooks
|
|
72
|
+
* Allows fields to perform custom validation after resolveInput but before database write
|
|
73
|
+
*/
|
|
74
|
+
async function executeFieldValidateHooks(
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
inputData: Record<string, any> | undefined,
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
resolvedData: Record<string, any> | undefined,
|
|
79
|
+
fields: Record<string, FieldConfig>,
|
|
80
|
+
operation: 'create' | 'update' | 'delete',
|
|
81
|
+
context: AccessContext,
|
|
82
|
+
listKey: string,
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
item?: any,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const errors: string[] = []
|
|
87
|
+
const fieldErrors: Record<string, string> = {}
|
|
88
|
+
|
|
89
|
+
const addValidationError = (fieldKey: string) => (msg: string) => {
|
|
90
|
+
errors.push(msg)
|
|
91
|
+
fieldErrors[fieldKey] = msg
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
95
|
+
// Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
|
|
96
|
+
const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput
|
|
97
|
+
if (!validateHook) continue
|
|
98
|
+
|
|
99
|
+
// Execute field hook
|
|
100
|
+
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
101
|
+
if (operation === 'delete') {
|
|
102
|
+
await validateHook({
|
|
103
|
+
listKey,
|
|
104
|
+
fieldKey,
|
|
105
|
+
operation: 'delete',
|
|
106
|
+
item,
|
|
107
|
+
context,
|
|
108
|
+
addValidationError: addValidationError(fieldKey),
|
|
109
|
+
} as Parameters<typeof validateHook>[0])
|
|
110
|
+
} else if (operation === 'create') {
|
|
111
|
+
await validateHook({
|
|
112
|
+
listKey,
|
|
113
|
+
fieldKey,
|
|
114
|
+
operation: 'create',
|
|
115
|
+
inputData,
|
|
116
|
+
item: undefined,
|
|
117
|
+
resolvedData,
|
|
118
|
+
context,
|
|
119
|
+
addValidationError: addValidationError(fieldKey),
|
|
120
|
+
} as Parameters<typeof validateHook>[0])
|
|
121
|
+
} else {
|
|
122
|
+
// operation === 'update'
|
|
123
|
+
await validateHook({
|
|
124
|
+
listKey,
|
|
125
|
+
fieldKey,
|
|
126
|
+
operation: 'update',
|
|
127
|
+
inputData,
|
|
128
|
+
item,
|
|
129
|
+
resolvedData,
|
|
130
|
+
context,
|
|
131
|
+
addValidationError: addValidationError(fieldKey),
|
|
132
|
+
} as Parameters<typeof validateHook>[0])
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (errors.length > 0) {
|
|
137
|
+
throw new ValidationError(errors, fieldErrors)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
66
141
|
/**
|
|
67
142
|
* Execute field-level beforeOperation hooks (side effects only)
|
|
68
143
|
* Allows fields to perform side effects before database write
|
|
69
144
|
*/
|
|
70
145
|
async function executeFieldBeforeOperationHooks(
|
|
71
146
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
|
-
|
|
147
|
+
inputData: Record<string, any>,
|
|
148
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
149
|
+
resolvedData: Record<string, any>,
|
|
73
150
|
fields: Record<string, FieldConfig>,
|
|
74
151
|
operation: 'create' | 'update' | 'delete',
|
|
75
152
|
context: AccessContext,
|
|
@@ -77,21 +154,43 @@ async function executeFieldBeforeOperationHooks(
|
|
|
77
154
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
155
|
item?: any,
|
|
79
156
|
): Promise<void> {
|
|
80
|
-
for (const [
|
|
81
|
-
// Skip if
|
|
157
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
158
|
+
// Skip if no hooks defined
|
|
82
159
|
if (!fieldConfig.hooks?.beforeOperation) continue
|
|
83
|
-
if
|
|
160
|
+
// Skip if field not in data (for create/update)
|
|
161
|
+
if (operation !== 'delete' && !(fieldKey in resolvedData)) continue
|
|
84
162
|
|
|
85
163
|
// Execute field hook (side effects only, no return value used)
|
|
86
164
|
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
165
|
+
if (operation === 'delete') {
|
|
166
|
+
await fieldConfig.hooks.beforeOperation({
|
|
167
|
+
listKey,
|
|
168
|
+
fieldKey,
|
|
169
|
+
operation: 'delete',
|
|
170
|
+
item,
|
|
171
|
+
context,
|
|
172
|
+
} as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
|
|
173
|
+
} else if (operation === 'create') {
|
|
174
|
+
await fieldConfig.hooks.beforeOperation({
|
|
175
|
+
listKey,
|
|
176
|
+
fieldKey,
|
|
177
|
+
operation: 'create',
|
|
178
|
+
inputData,
|
|
179
|
+
resolvedData,
|
|
180
|
+
context,
|
|
181
|
+
} as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
|
|
182
|
+
} else {
|
|
183
|
+
// operation === 'update'
|
|
184
|
+
await fieldConfig.hooks.beforeOperation({
|
|
185
|
+
listKey,
|
|
186
|
+
fieldKey,
|
|
187
|
+
operation: 'update',
|
|
188
|
+
inputData,
|
|
189
|
+
item,
|
|
190
|
+
resolvedData,
|
|
191
|
+
context,
|
|
192
|
+
} as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
|
|
193
|
+
}
|
|
95
194
|
}
|
|
96
195
|
}
|
|
97
196
|
|
|
@@ -102,31 +201,51 @@ async function executeFieldBeforeOperationHooks(
|
|
|
102
201
|
async function executeFieldAfterOperationHooks(
|
|
103
202
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
203
|
item: any,
|
|
105
|
-
|
|
204
|
+
inputData: Record<string, unknown> | undefined,
|
|
205
|
+
resolvedData: Record<string, unknown> | undefined,
|
|
106
206
|
fields: Record<string, FieldConfig>,
|
|
107
|
-
operation: 'create' | 'update' | 'delete'
|
|
207
|
+
operation: 'create' | 'update' | 'delete',
|
|
108
208
|
context: AccessContext,
|
|
109
209
|
listKey: string,
|
|
110
210
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
111
211
|
originalItem?: any,
|
|
112
212
|
): Promise<void> {
|
|
113
|
-
for (const [
|
|
213
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
114
214
|
// Skip if no hooks defined
|
|
115
215
|
if (!fieldConfig.hooks?.afterOperation) continue
|
|
116
216
|
|
|
117
|
-
// Get the value from item (for all operations)
|
|
118
|
-
const value = item?.[fieldName]
|
|
119
|
-
|
|
120
217
|
// Execute field hook (side effects only, no return value used)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
})
|
|
218
|
+
if (operation === 'delete') {
|
|
219
|
+
await fieldConfig.hooks.afterOperation({
|
|
220
|
+
listKey,
|
|
221
|
+
fieldKey,
|
|
222
|
+
operation: 'delete',
|
|
223
|
+
originalItem,
|
|
224
|
+
context,
|
|
225
|
+
} as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
|
|
226
|
+
} else if (operation === 'create') {
|
|
227
|
+
await fieldConfig.hooks.afterOperation({
|
|
228
|
+
listKey,
|
|
229
|
+
fieldKey,
|
|
230
|
+
operation: 'create',
|
|
231
|
+
inputData,
|
|
232
|
+
item,
|
|
233
|
+
resolvedData,
|
|
234
|
+
context,
|
|
235
|
+
} as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
|
|
236
|
+
} else {
|
|
237
|
+
// operation === 'update'
|
|
238
|
+
await fieldConfig.hooks.afterOperation({
|
|
239
|
+
listKey,
|
|
240
|
+
fieldKey,
|
|
241
|
+
operation: 'update',
|
|
242
|
+
inputData,
|
|
243
|
+
originalItem,
|
|
244
|
+
item,
|
|
245
|
+
resolvedData,
|
|
246
|
+
context,
|
|
247
|
+
} as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
|
|
248
|
+
}
|
|
130
249
|
}
|
|
131
250
|
}
|
|
132
251
|
|
|
@@ -135,6 +254,49 @@ export type ServerActionProps =
|
|
|
135
254
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
136
255
|
| { listKey: string; action: 'delete'; id: string }
|
|
137
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Check if a list is configured as a singleton
|
|
259
|
+
*/
|
|
260
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
261
|
+
function isSingletonList(listConfig: ListConfig<any>): boolean {
|
|
262
|
+
return !!listConfig.isSingleton
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Check if auto-create is enabled for a singleton list
|
|
267
|
+
* Defaults to true if not explicitly set to false
|
|
268
|
+
*/
|
|
269
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
270
|
+
function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
|
|
271
|
+
if (!listConfig.isSingleton) return false
|
|
272
|
+
if (typeof listConfig.isSingleton === 'boolean') return true
|
|
273
|
+
return listConfig.isSingleton.autoCreate !== false
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Extract default values from field configs
|
|
278
|
+
* Used to auto-create singleton records with sensible defaults
|
|
279
|
+
*/
|
|
280
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
281
|
+
function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
|
|
282
|
+
const data: Record<string, unknown> = {}
|
|
283
|
+
|
|
284
|
+
for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
285
|
+
// Skip virtual fields - they're not stored in database
|
|
286
|
+
if (fieldConfig.virtual) continue
|
|
287
|
+
|
|
288
|
+
// Skip system fields (id, createdAt, updatedAt)
|
|
289
|
+
if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
|
|
290
|
+
|
|
291
|
+
// Add default value if present
|
|
292
|
+
if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
|
|
293
|
+
data[fieldKey] = fieldConfig.defaultValue
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return data
|
|
298
|
+
}
|
|
299
|
+
|
|
138
300
|
/**
|
|
139
301
|
* Parse Prisma error and convert to user-friendly DatabaseError
|
|
140
302
|
*/
|
|
@@ -274,14 +436,23 @@ export function getContext<
|
|
|
274
436
|
for (const [listName, listConfig] of Object.entries(config.lists)) {
|
|
275
437
|
const dbKey = getDbKey(listName)
|
|
276
438
|
|
|
277
|
-
|
|
439
|
+
// Create base operations
|
|
440
|
+
const createOp = createCreate(listName, listConfig, prisma, context, config)
|
|
441
|
+
const operations: Record<string, unknown> = {
|
|
278
442
|
findUnique: createFindUnique(listName, listConfig, prisma, context, config),
|
|
279
443
|
findMany: createFindMany(listName, listConfig, prisma, context, config),
|
|
280
|
-
create:
|
|
444
|
+
create: createOp,
|
|
281
445
|
update: createUpdate(listName, listConfig, prisma, context, config),
|
|
282
446
|
delete: createDelete(listName, listConfig, prisma, context),
|
|
283
447
|
count: createCount(listName, listConfig, prisma, context),
|
|
284
448
|
}
|
|
449
|
+
|
|
450
|
+
// Add get() method for singleton lists
|
|
451
|
+
if (isSingletonList(listConfig)) {
|
|
452
|
+
operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
db[dbKey] = operations
|
|
285
456
|
}
|
|
286
457
|
|
|
287
458
|
// Execute plugin runtime functions and populate context.plugins
|
|
@@ -478,17 +649,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
478
649
|
listName,
|
|
479
650
|
)
|
|
480
651
|
|
|
481
|
-
// Execute field afterOperation hooks (side effects only)
|
|
482
|
-
await executeFieldAfterOperationHooks(
|
|
483
|
-
filtered,
|
|
484
|
-
undefined,
|
|
485
|
-
listConfig.fields,
|
|
486
|
-
'query',
|
|
487
|
-
context,
|
|
488
|
-
listName,
|
|
489
|
-
undefined, // originalItem is undefined for query operations
|
|
490
|
-
)
|
|
491
|
-
|
|
492
652
|
return filtered
|
|
493
653
|
}
|
|
494
654
|
}
|
|
@@ -510,6 +670,14 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
510
670
|
skip?: number
|
|
511
671
|
include?: Record<string, unknown>
|
|
512
672
|
}) => {
|
|
673
|
+
// Check singleton constraint (throw error instead of silently returning empty)
|
|
674
|
+
if (isSingletonList(listConfig)) {
|
|
675
|
+
throw new ValidationError(
|
|
676
|
+
[`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`],
|
|
677
|
+
{},
|
|
678
|
+
)
|
|
679
|
+
}
|
|
680
|
+
|
|
513
681
|
// Check query access (skip if sudo mode)
|
|
514
682
|
let where: Record<string, unknown> | undefined = args?.where
|
|
515
683
|
if (!context._isSudo) {
|
|
@@ -573,21 +741,6 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
573
741
|
),
|
|
574
742
|
)
|
|
575
743
|
|
|
576
|
-
// Execute field afterOperation hooks for each item (side effects only)
|
|
577
|
-
await Promise.all(
|
|
578
|
-
filtered.map((item) =>
|
|
579
|
-
executeFieldAfterOperationHooks(
|
|
580
|
-
item,
|
|
581
|
-
undefined,
|
|
582
|
-
listConfig.fields,
|
|
583
|
-
'query',
|
|
584
|
-
context,
|
|
585
|
-
listName,
|
|
586
|
-
undefined, // originalItem is undefined for query operations
|
|
587
|
-
),
|
|
588
|
-
),
|
|
589
|
-
)
|
|
590
|
-
|
|
591
744
|
return filtered
|
|
592
745
|
}
|
|
593
746
|
}
|
|
@@ -604,6 +757,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
604
757
|
config: OpenSaasConfig,
|
|
605
758
|
) {
|
|
606
759
|
return async (args: { data: Record<string, unknown> }) => {
|
|
760
|
+
// 0. Check singleton constraint (enforce even in sudo mode)
|
|
761
|
+
if (isSingletonList(listConfig)) {
|
|
762
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
763
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
764
|
+
const model = (prisma as any)[getDbKey(listName)]
|
|
765
|
+
const existingCount = await model.count()
|
|
766
|
+
|
|
767
|
+
if (existingCount > 0) {
|
|
768
|
+
throw new ValidationError(
|
|
769
|
+
[`Cannot create: ${listName} is a singleton list with an existing record`],
|
|
770
|
+
{},
|
|
771
|
+
)
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
607
775
|
// 1. Check create access (skip if sudo mode)
|
|
608
776
|
if (!context._isSudo) {
|
|
609
777
|
const createAccess = listConfig.access?.operation?.create
|
|
@@ -619,7 +787,9 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
619
787
|
|
|
620
788
|
// 2. Execute list-level resolveInput hook
|
|
621
789
|
let resolvedData = await executeResolveInput(listConfig.hooks, {
|
|
790
|
+
listKey: listName,
|
|
622
791
|
operation: 'create',
|
|
792
|
+
inputData: args.data,
|
|
623
793
|
resolvedData: args.data,
|
|
624
794
|
item: undefined,
|
|
625
795
|
context,
|
|
@@ -627,6 +797,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
627
797
|
|
|
628
798
|
// 2.5. Execute field-level resolveInput hooks (e.g., hash passwords)
|
|
629
799
|
resolvedData = await executeFieldResolveInputHooks(
|
|
800
|
+
args.data,
|
|
630
801
|
resolvedData,
|
|
631
802
|
listConfig.fields,
|
|
632
803
|
'create',
|
|
@@ -634,14 +805,26 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
634
805
|
listName,
|
|
635
806
|
)
|
|
636
807
|
|
|
637
|
-
// 3. Execute
|
|
638
|
-
await
|
|
808
|
+
// 3. Execute list-level validate hook
|
|
809
|
+
await executeValidate(listConfig.hooks, {
|
|
810
|
+
listKey: listName,
|
|
639
811
|
operation: 'create',
|
|
812
|
+
inputData: args.data,
|
|
640
813
|
resolvedData,
|
|
641
814
|
item: undefined,
|
|
642
815
|
context,
|
|
643
816
|
})
|
|
644
817
|
|
|
818
|
+
// 3.5. Execute field-level validate hooks
|
|
819
|
+
await executeFieldValidateHooks(
|
|
820
|
+
args.data,
|
|
821
|
+
resolvedData,
|
|
822
|
+
listConfig.fields,
|
|
823
|
+
'create',
|
|
824
|
+
context,
|
|
825
|
+
listName,
|
|
826
|
+
)
|
|
827
|
+
|
|
645
828
|
// 4. Field validation (isRequired, length, etc.)
|
|
646
829
|
const validation = validateFieldRules(resolvedData, listConfig.fields, 'create')
|
|
647
830
|
if (validation.errors.length > 0) {
|
|
@@ -652,6 +835,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
652
835
|
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
|
|
653
836
|
session: context.session,
|
|
654
837
|
context: { ...context, _isSudo: context._isSudo },
|
|
838
|
+
inputData: args.data,
|
|
655
839
|
})
|
|
656
840
|
|
|
657
841
|
// 5.5. Process nested relationship operations
|
|
@@ -664,11 +848,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
664
848
|
)
|
|
665
849
|
|
|
666
850
|
// 6. Execute field-level beforeOperation hooks (side effects only)
|
|
667
|
-
await executeFieldBeforeOperationHooks(
|
|
851
|
+
await executeFieldBeforeOperationHooks(
|
|
852
|
+
args.data,
|
|
853
|
+
resolvedData,
|
|
854
|
+
listConfig.fields,
|
|
855
|
+
'create',
|
|
856
|
+
context,
|
|
857
|
+
listName,
|
|
858
|
+
)
|
|
668
859
|
|
|
669
860
|
// 7. Execute list-level beforeOperation hook
|
|
670
861
|
await executeBeforeOperation(listConfig.hooks, {
|
|
862
|
+
listKey: listName,
|
|
671
863
|
operation: 'create',
|
|
864
|
+
inputData: args.data,
|
|
865
|
+
resolvedData,
|
|
672
866
|
context,
|
|
673
867
|
})
|
|
674
868
|
|
|
@@ -682,16 +876,19 @@ function createCreate<TPrisma extends PrismaClientLike>(
|
|
|
682
876
|
|
|
683
877
|
// 9. Execute list-level afterOperation hook
|
|
684
878
|
await executeAfterOperation(listConfig.hooks, {
|
|
879
|
+
listKey: listName,
|
|
685
880
|
operation: 'create',
|
|
881
|
+
inputData: args.data,
|
|
686
882
|
item,
|
|
687
|
-
|
|
883
|
+
resolvedData,
|
|
688
884
|
context,
|
|
689
885
|
})
|
|
690
886
|
|
|
691
887
|
// 10. Execute field-level afterOperation hooks (side effects only)
|
|
692
888
|
await executeFieldAfterOperationHooks(
|
|
693
889
|
item,
|
|
694
|
-
data,
|
|
890
|
+
args.data,
|
|
891
|
+
resolvedData,
|
|
695
892
|
listConfig.fields,
|
|
696
893
|
'create',
|
|
697
894
|
context,
|
|
@@ -768,7 +965,9 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
768
965
|
|
|
769
966
|
// 3. Execute list-level resolveInput hook
|
|
770
967
|
let resolvedData = await executeResolveInput(listConfig.hooks, {
|
|
968
|
+
listKey: listName,
|
|
771
969
|
operation: 'update',
|
|
970
|
+
inputData: args.data,
|
|
772
971
|
resolvedData: args.data,
|
|
773
972
|
item,
|
|
774
973
|
context,
|
|
@@ -776,6 +975,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
776
975
|
|
|
777
976
|
// 3.5. Execute field-level resolveInput hooks (e.g., hash passwords)
|
|
778
977
|
resolvedData = await executeFieldResolveInputHooks(
|
|
978
|
+
args.data,
|
|
779
979
|
resolvedData,
|
|
780
980
|
listConfig.fields,
|
|
781
981
|
'update',
|
|
@@ -784,14 +984,27 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
784
984
|
item,
|
|
785
985
|
)
|
|
786
986
|
|
|
787
|
-
// 4. Execute
|
|
788
|
-
await
|
|
987
|
+
// 4. Execute list-level validate hook
|
|
988
|
+
await executeValidate(listConfig.hooks, {
|
|
989
|
+
listKey: listName,
|
|
789
990
|
operation: 'update',
|
|
991
|
+
inputData: args.data,
|
|
790
992
|
resolvedData,
|
|
791
993
|
item,
|
|
792
994
|
context,
|
|
793
995
|
})
|
|
794
996
|
|
|
997
|
+
// 4.5. Execute field-level validate hooks
|
|
998
|
+
await executeFieldValidateHooks(
|
|
999
|
+
args.data,
|
|
1000
|
+
resolvedData,
|
|
1001
|
+
listConfig.fields,
|
|
1002
|
+
'update',
|
|
1003
|
+
context,
|
|
1004
|
+
listName,
|
|
1005
|
+
item,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
795
1008
|
// 5. Field validation (isRequired, length, etc.)
|
|
796
1009
|
const validation = validateFieldRules(resolvedData, listConfig.fields, 'update')
|
|
797
1010
|
if (validation.errors.length > 0) {
|
|
@@ -803,6 +1016,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
803
1016
|
session: context.session,
|
|
804
1017
|
item,
|
|
805
1018
|
context: { ...context, _isSudo: context._isSudo },
|
|
1019
|
+
inputData: args.data,
|
|
806
1020
|
})
|
|
807
1021
|
|
|
808
1022
|
// 6.5. Process nested relationship operations
|
|
@@ -816,7 +1030,8 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
816
1030
|
|
|
817
1031
|
// 7. Execute field-level beforeOperation hooks (side effects only)
|
|
818
1032
|
await executeFieldBeforeOperationHooks(
|
|
819
|
-
data,
|
|
1033
|
+
args.data,
|
|
1034
|
+
resolvedData,
|
|
820
1035
|
listConfig.fields,
|
|
821
1036
|
'update',
|
|
822
1037
|
context,
|
|
@@ -826,8 +1041,11 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
826
1041
|
|
|
827
1042
|
// 8. Execute list-level beforeOperation hook
|
|
828
1043
|
await executeBeforeOperation(listConfig.hooks, {
|
|
1044
|
+
listKey: listName,
|
|
829
1045
|
operation: 'update',
|
|
1046
|
+
inputData: args.data,
|
|
830
1047
|
item,
|
|
1048
|
+
resolvedData,
|
|
831
1049
|
context,
|
|
832
1050
|
})
|
|
833
1051
|
|
|
@@ -839,16 +1057,20 @@ function createUpdate<TPrisma extends PrismaClientLike>(
|
|
|
839
1057
|
|
|
840
1058
|
// 10. Execute list-level afterOperation hook
|
|
841
1059
|
await executeAfterOperation(listConfig.hooks, {
|
|
1060
|
+
listKey: listName,
|
|
842
1061
|
operation: 'update',
|
|
843
|
-
|
|
1062
|
+
inputData: args.data,
|
|
844
1063
|
originalItem: item, // item is the original item before the update
|
|
1064
|
+
item: updated,
|
|
1065
|
+
resolvedData,
|
|
845
1066
|
context,
|
|
846
1067
|
})
|
|
847
1068
|
|
|
848
1069
|
// 11. Execute field-level afterOperation hooks (side effects only)
|
|
849
1070
|
await executeFieldAfterOperationHooks(
|
|
850
1071
|
updated,
|
|
851
|
-
data,
|
|
1072
|
+
args.data,
|
|
1073
|
+
resolvedData,
|
|
852
1074
|
listConfig.fields,
|
|
853
1075
|
'update',
|
|
854
1076
|
context,
|
|
@@ -885,6 +1107,11 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
885
1107
|
context: AccessContext<TPrisma>,
|
|
886
1108
|
) {
|
|
887
1109
|
return async (args: { where: { id: string } }) => {
|
|
1110
|
+
// 0. Check singleton constraint (enforce even in sudo mode)
|
|
1111
|
+
if (isSingletonList(listConfig)) {
|
|
1112
|
+
throw new ValidationError([`Cannot delete: ${listName} is a singleton list`], {})
|
|
1113
|
+
}
|
|
1114
|
+
|
|
888
1115
|
// 1. Fetch the item to pass to access control and hooks
|
|
889
1116
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
890
1117
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -922,33 +1149,62 @@ function createDelete<TPrisma extends PrismaClientLike>(
|
|
|
922
1149
|
}
|
|
923
1150
|
}
|
|
924
1151
|
|
|
925
|
-
// 3. Execute
|
|
926
|
-
await
|
|
1152
|
+
// 3. Execute list-level validate hook
|
|
1153
|
+
await executeValidate(listConfig.hooks, {
|
|
1154
|
+
listKey: listName,
|
|
1155
|
+
operation: 'delete',
|
|
1156
|
+
item,
|
|
1157
|
+
context,
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
// 3.5. Execute field-level validate hooks
|
|
1161
|
+
await executeFieldValidateHooks(
|
|
1162
|
+
undefined,
|
|
1163
|
+
undefined,
|
|
1164
|
+
listConfig.fields,
|
|
1165
|
+
'delete',
|
|
1166
|
+
context,
|
|
1167
|
+
listName,
|
|
1168
|
+
item,
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
// 4. Execute field-level beforeOperation hooks (side effects only)
|
|
1172
|
+
await executeFieldBeforeOperationHooks(
|
|
1173
|
+
{},
|
|
1174
|
+
{},
|
|
1175
|
+
listConfig.fields,
|
|
1176
|
+
'delete',
|
|
1177
|
+
context,
|
|
1178
|
+
listName,
|
|
1179
|
+
item,
|
|
1180
|
+
)
|
|
927
1181
|
|
|
928
|
-
//
|
|
1182
|
+
// 5. Execute list-level beforeOperation hook
|
|
929
1183
|
await executeBeforeOperation(listConfig.hooks, {
|
|
1184
|
+
listKey: listName,
|
|
930
1185
|
operation: 'delete',
|
|
931
1186
|
item,
|
|
932
1187
|
context,
|
|
933
1188
|
})
|
|
934
1189
|
|
|
935
|
-
//
|
|
1190
|
+
// 6. Execute database delete
|
|
936
1191
|
const deleted = await model.delete({
|
|
937
1192
|
where: args.where,
|
|
938
1193
|
})
|
|
939
1194
|
|
|
940
|
-
//
|
|
1195
|
+
// 7. Execute list-level afterOperation hook
|
|
941
1196
|
await executeAfterOperation(listConfig.hooks, {
|
|
1197
|
+
listKey: listName,
|
|
942
1198
|
operation: 'delete',
|
|
943
|
-
item: deleted,
|
|
944
1199
|
originalItem: item, // item is the original item before deletion
|
|
945
1200
|
context,
|
|
946
1201
|
})
|
|
947
1202
|
|
|
948
|
-
//
|
|
1203
|
+
// 8. Execute field-level afterOperation hooks (side effects only)
|
|
949
1204
|
await executeFieldAfterOperationHooks(
|
|
950
1205
|
deleted,
|
|
951
1206
|
undefined,
|
|
1207
|
+
undefined,
|
|
952
1208
|
listConfig.fields,
|
|
953
1209
|
'delete',
|
|
954
1210
|
context,
|
|
@@ -1003,3 +1259,86 @@ function createCount<TPrisma extends PrismaClientLike>(
|
|
|
1003
1259
|
return count
|
|
1004
1260
|
}
|
|
1005
1261
|
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Create get operation for singleton lists
|
|
1265
|
+
* Returns the single record, or auto-creates it if enabled
|
|
1266
|
+
*/
|
|
1267
|
+
function createGet<TPrisma extends PrismaClientLike>(
|
|
1268
|
+
listName: string,
|
|
1269
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
1270
|
+
listConfig: ListConfig<any>,
|
|
1271
|
+
prisma: TPrisma,
|
|
1272
|
+
context: AccessContext<TPrisma>,
|
|
1273
|
+
config: OpenSaasConfig,
|
|
1274
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1275
|
+
createFn: any,
|
|
1276
|
+
) {
|
|
1277
|
+
return async () => {
|
|
1278
|
+
// First try to find the existing record
|
|
1279
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
1280
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1281
|
+
const model = (prisma as any)[getDbKey(listName)]
|
|
1282
|
+
|
|
1283
|
+
// Check query access (skip if sudo mode)
|
|
1284
|
+
let where: Record<string, unknown> = {}
|
|
1285
|
+
if (!context._isSudo) {
|
|
1286
|
+
const queryAccess = listConfig.access?.operation?.query
|
|
1287
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
1288
|
+
session: context.session,
|
|
1289
|
+
context,
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
if (accessResult === false) {
|
|
1293
|
+
return null
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Merge access filter (for singleton, we don't have a specific where clause)
|
|
1297
|
+
if (accessResult && typeof accessResult === 'object') {
|
|
1298
|
+
where = accessResult
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Build include with access control filters
|
|
1303
|
+
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
1304
|
+
listConfig.fields,
|
|
1305
|
+
{
|
|
1306
|
+
session: context.session,
|
|
1307
|
+
context,
|
|
1308
|
+
},
|
|
1309
|
+
config,
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
// Try to find the record
|
|
1313
|
+
const item = await model.findFirst({
|
|
1314
|
+
where,
|
|
1315
|
+
include: accessControlledInclude,
|
|
1316
|
+
})
|
|
1317
|
+
|
|
1318
|
+
// If record exists, return it
|
|
1319
|
+
if (item) {
|
|
1320
|
+
// Filter readable fields and apply resolveOutput hooks
|
|
1321
|
+
const filtered = await filterReadableFields(
|
|
1322
|
+
item,
|
|
1323
|
+
listConfig.fields,
|
|
1324
|
+
{
|
|
1325
|
+
session: context.session,
|
|
1326
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
1327
|
+
},
|
|
1328
|
+
config,
|
|
1329
|
+
0,
|
|
1330
|
+
listName,
|
|
1331
|
+
)
|
|
1332
|
+
return filtered
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// If no record and auto-create is enabled, create it
|
|
1336
|
+
if (shouldAutoCreate(listConfig)) {
|
|
1337
|
+
const defaultData = getDefaultData(listConfig)
|
|
1338
|
+
return await createFn({ data: defaultData })
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// No record and auto-create is disabled
|
|
1342
|
+
return null
|
|
1343
|
+
}
|
|
1344
|
+
}
|