@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/dist/context/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { checkAccess, mergeFilters, filterReadableFields, filterWritableFields, buildIncludeWithAccessControl, } from '../access/index.js';
|
|
2
|
-
import { executeResolveInput,
|
|
2
|
+
import { executeResolveInput, executeValidate, executeBeforeOperation, executeAfterOperation, validateFieldRules, ValidationError, DatabaseError, } from '../hooks/index.js';
|
|
3
3
|
import { processNestedOperations } from './nested-operations.js';
|
|
4
4
|
import { getDbKey } from '../lib/case-utils.js';
|
|
5
5
|
/**
|
|
@@ -8,13 +8,15 @@ import { getDbKey } from '../lib/case-utils.js';
|
|
|
8
8
|
*/
|
|
9
9
|
async function executeFieldResolveInputHooks(
|
|
10
10
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
-
|
|
11
|
+
inputData,
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
resolvedData, fields, operation, context, listKey,
|
|
12
14
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
15
|
item) {
|
|
14
|
-
|
|
15
|
-
for (const [
|
|
16
|
+
let result = { ...resolvedData };
|
|
17
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
16
18
|
// Skip if field not in data
|
|
17
|
-
if (!(
|
|
19
|
+
if (!(fieldKey in result))
|
|
18
20
|
continue;
|
|
19
21
|
// Skip if no hooks defined
|
|
20
22
|
if (!fieldConfig.hooks?.resolveInput)
|
|
@@ -23,42 +25,134 @@ item) {
|
|
|
23
25
|
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
24
26
|
// and we're working with runtime values that match those types
|
|
25
27
|
const transformedValue = await fieldConfig.hooks.resolveInput({
|
|
26
|
-
inputValue: result[fieldName],
|
|
27
|
-
operation,
|
|
28
|
-
fieldName,
|
|
29
28
|
listKey,
|
|
29
|
+
fieldKey,
|
|
30
|
+
operation,
|
|
31
|
+
inputData,
|
|
30
32
|
item,
|
|
33
|
+
resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
|
|
31
34
|
context,
|
|
32
35
|
});
|
|
33
|
-
|
|
36
|
+
// Create new object with updated field to avoid mutating the passed reference
|
|
37
|
+
result = { ...result, [fieldKey]: transformedValue };
|
|
34
38
|
}
|
|
35
39
|
return result;
|
|
36
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Execute field-level validate hooks
|
|
43
|
+
* Allows fields to perform custom validation after resolveInput but before database write
|
|
44
|
+
*/
|
|
45
|
+
async function executeFieldValidateHooks(
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
|
+
inputData,
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
resolvedData, fields, operation, context, listKey,
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
51
|
+
item) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
const fieldErrors = {};
|
|
54
|
+
const addValidationError = (fieldKey) => (msg) => {
|
|
55
|
+
errors.push(msg);
|
|
56
|
+
fieldErrors[fieldKey] = msg;
|
|
57
|
+
};
|
|
58
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
59
|
+
// Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
|
|
60
|
+
const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput;
|
|
61
|
+
if (!validateHook)
|
|
62
|
+
continue;
|
|
63
|
+
// Execute field hook
|
|
64
|
+
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
65
|
+
if (operation === 'delete') {
|
|
66
|
+
await validateHook({
|
|
67
|
+
listKey,
|
|
68
|
+
fieldKey,
|
|
69
|
+
operation: 'delete',
|
|
70
|
+
item,
|
|
71
|
+
context,
|
|
72
|
+
addValidationError: addValidationError(fieldKey),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else if (operation === 'create') {
|
|
76
|
+
await validateHook({
|
|
77
|
+
listKey,
|
|
78
|
+
fieldKey,
|
|
79
|
+
operation: 'create',
|
|
80
|
+
inputData,
|
|
81
|
+
item: undefined,
|
|
82
|
+
resolvedData,
|
|
83
|
+
context,
|
|
84
|
+
addValidationError: addValidationError(fieldKey),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
// operation === 'update'
|
|
89
|
+
await validateHook({
|
|
90
|
+
listKey,
|
|
91
|
+
fieldKey,
|
|
92
|
+
operation: 'update',
|
|
93
|
+
inputData,
|
|
94
|
+
item,
|
|
95
|
+
resolvedData,
|
|
96
|
+
context,
|
|
97
|
+
addValidationError: addValidationError(fieldKey),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (errors.length > 0) {
|
|
102
|
+
throw new ValidationError(errors, fieldErrors);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
37
105
|
/**
|
|
38
106
|
* Execute field-level beforeOperation hooks (side effects only)
|
|
39
107
|
* Allows fields to perform side effects before database write
|
|
40
108
|
*/
|
|
41
109
|
async function executeFieldBeforeOperationHooks(
|
|
42
110
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
-
|
|
111
|
+
inputData,
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
113
|
+
resolvedData, fields, operation, context, listKey,
|
|
44
114
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
115
|
item) {
|
|
46
|
-
for (const [
|
|
47
|
-
// Skip if
|
|
116
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
117
|
+
// Skip if no hooks defined
|
|
48
118
|
if (!fieldConfig.hooks?.beforeOperation)
|
|
49
119
|
continue;
|
|
50
|
-
if
|
|
120
|
+
// Skip if field not in data (for create/update)
|
|
121
|
+
if (operation !== 'delete' && !(fieldKey in resolvedData))
|
|
51
122
|
continue;
|
|
52
123
|
// Execute field hook (side effects only, no return value used)
|
|
53
124
|
// Type assertion is safe here because hooks are typed correctly in field definitions
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
125
|
+
if (operation === 'delete') {
|
|
126
|
+
await fieldConfig.hooks.beforeOperation({
|
|
127
|
+
listKey,
|
|
128
|
+
fieldKey,
|
|
129
|
+
operation: 'delete',
|
|
130
|
+
item,
|
|
131
|
+
context,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
else if (operation === 'create') {
|
|
135
|
+
await fieldConfig.hooks.beforeOperation({
|
|
136
|
+
listKey,
|
|
137
|
+
fieldKey,
|
|
138
|
+
operation: 'create',
|
|
139
|
+
inputData,
|
|
140
|
+
resolvedData,
|
|
141
|
+
context,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
// operation === 'update'
|
|
146
|
+
await fieldConfig.hooks.beforeOperation({
|
|
147
|
+
listKey,
|
|
148
|
+
fieldKey,
|
|
149
|
+
operation: 'update',
|
|
150
|
+
inputData,
|
|
151
|
+
item,
|
|
152
|
+
resolvedData,
|
|
153
|
+
context,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
62
156
|
}
|
|
63
157
|
}
|
|
64
158
|
/**
|
|
@@ -67,26 +161,88 @@ item) {
|
|
|
67
161
|
*/
|
|
68
162
|
async function executeFieldAfterOperationHooks(
|
|
69
163
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
-
item,
|
|
164
|
+
item, inputData, resolvedData, fields, operation, context, listKey,
|
|
71
165
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
72
166
|
originalItem) {
|
|
73
|
-
for (const [
|
|
167
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
|
|
74
168
|
// Skip if no hooks defined
|
|
75
169
|
if (!fieldConfig.hooks?.afterOperation)
|
|
76
170
|
continue;
|
|
77
|
-
// Get the value from item (for all operations)
|
|
78
|
-
const value = item?.[fieldName];
|
|
79
171
|
// Execute field hook (side effects only, no return value used)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
172
|
+
if (operation === 'delete') {
|
|
173
|
+
await fieldConfig.hooks.afterOperation({
|
|
174
|
+
listKey,
|
|
175
|
+
fieldKey,
|
|
176
|
+
operation: 'delete',
|
|
177
|
+
originalItem,
|
|
178
|
+
context,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
else if (operation === 'create') {
|
|
182
|
+
await fieldConfig.hooks.afterOperation({
|
|
183
|
+
listKey,
|
|
184
|
+
fieldKey,
|
|
185
|
+
operation: 'create',
|
|
186
|
+
inputData,
|
|
187
|
+
item,
|
|
188
|
+
resolvedData,
|
|
189
|
+
context,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
// operation === 'update'
|
|
194
|
+
await fieldConfig.hooks.afterOperation({
|
|
195
|
+
listKey,
|
|
196
|
+
fieldKey,
|
|
197
|
+
operation: 'update',
|
|
198
|
+
inputData,
|
|
199
|
+
originalItem,
|
|
200
|
+
item,
|
|
201
|
+
resolvedData,
|
|
202
|
+
context,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Check if a list is configured as a singleton
|
|
209
|
+
*/
|
|
210
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
211
|
+
function isSingletonList(listConfig) {
|
|
212
|
+
return !!listConfig.isSingleton;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Check if auto-create is enabled for a singleton list
|
|
216
|
+
* Defaults to true if not explicitly set to false
|
|
217
|
+
*/
|
|
218
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
219
|
+
function shouldAutoCreate(listConfig) {
|
|
220
|
+
if (!listConfig.isSingleton)
|
|
221
|
+
return false;
|
|
222
|
+
if (typeof listConfig.isSingleton === 'boolean')
|
|
223
|
+
return true;
|
|
224
|
+
return listConfig.isSingleton.autoCreate !== false;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Extract default values from field configs
|
|
228
|
+
* Used to auto-create singleton records with sensible defaults
|
|
229
|
+
*/
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
231
|
+
function getDefaultData(listConfig) {
|
|
232
|
+
const data = {};
|
|
233
|
+
for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
234
|
+
// Skip virtual fields - they're not stored in database
|
|
235
|
+
if (fieldConfig.virtual)
|
|
236
|
+
continue;
|
|
237
|
+
// Skip system fields (id, createdAt, updatedAt)
|
|
238
|
+
if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt')
|
|
239
|
+
continue;
|
|
240
|
+
// Add default value if present
|
|
241
|
+
if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
|
|
242
|
+
data[fieldKey] = fieldConfig.defaultValue;
|
|
243
|
+
}
|
|
89
244
|
}
|
|
245
|
+
return data;
|
|
90
246
|
}
|
|
91
247
|
/**
|
|
92
248
|
* Parse Prisma error and convert to user-friendly DatabaseError
|
|
@@ -171,14 +327,21 @@ export function getContext(config, prisma, session, storage, _isSudo = false) {
|
|
|
171
327
|
// Create access-controlled operations for each list
|
|
172
328
|
for (const [listName, listConfig] of Object.entries(config.lists)) {
|
|
173
329
|
const dbKey = getDbKey(listName);
|
|
174
|
-
|
|
330
|
+
// Create base operations
|
|
331
|
+
const createOp = createCreate(listName, listConfig, prisma, context, config);
|
|
332
|
+
const operations = {
|
|
175
333
|
findUnique: createFindUnique(listName, listConfig, prisma, context, config),
|
|
176
334
|
findMany: createFindMany(listName, listConfig, prisma, context, config),
|
|
177
|
-
create:
|
|
335
|
+
create: createOp,
|
|
178
336
|
update: createUpdate(listName, listConfig, prisma, context, config),
|
|
179
337
|
delete: createDelete(listName, listConfig, prisma, context),
|
|
180
338
|
count: createCount(listName, listConfig, prisma, context),
|
|
181
339
|
};
|
|
340
|
+
// Add get() method for singleton lists
|
|
341
|
+
if (isSingletonList(listConfig)) {
|
|
342
|
+
operations.get = createGet(listName, listConfig, prisma, context, config, createOp);
|
|
343
|
+
}
|
|
344
|
+
db[dbKey] = operations;
|
|
182
345
|
}
|
|
183
346
|
// Execute plugin runtime functions and populate context.plugins
|
|
184
347
|
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
@@ -332,8 +495,6 @@ listConfig, prisma, context, config) {
|
|
|
332
495
|
session: context.session,
|
|
333
496
|
context: { ...context, _isSudo: context._isSudo },
|
|
334
497
|
}, config, 0, listName);
|
|
335
|
-
// Execute field afterOperation hooks (side effects only)
|
|
336
|
-
await executeFieldAfterOperationHooks(filtered, undefined, listConfig.fields, 'query', context, listName, undefined);
|
|
337
498
|
return filtered;
|
|
338
499
|
};
|
|
339
500
|
}
|
|
@@ -344,6 +505,10 @@ function createFindMany(listName,
|
|
|
344
505
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
345
506
|
listConfig, prisma, context, config) {
|
|
346
507
|
return async (args) => {
|
|
508
|
+
// Check singleton constraint (throw error instead of silently returning empty)
|
|
509
|
+
if (isSingletonList(listConfig)) {
|
|
510
|
+
throw new ValidationError([`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`], {});
|
|
511
|
+
}
|
|
347
512
|
// Check query access (skip if sudo mode)
|
|
348
513
|
let where = args?.where;
|
|
349
514
|
if (!context._isSudo) {
|
|
@@ -385,8 +550,6 @@ listConfig, prisma, context, config) {
|
|
|
385
550
|
session: context.session,
|
|
386
551
|
context: { ...context, _isSudo: context._isSudo },
|
|
387
552
|
}, config, 0, listName)));
|
|
388
|
-
// Execute field afterOperation hooks for each item (side effects only)
|
|
389
|
-
await Promise.all(filtered.map((item) => executeFieldAfterOperationHooks(item, undefined, listConfig.fields, 'query', context, listName, undefined)));
|
|
390
553
|
return filtered;
|
|
391
554
|
};
|
|
392
555
|
}
|
|
@@ -397,6 +560,16 @@ function createCreate(listName,
|
|
|
397
560
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
398
561
|
listConfig, prisma, context, config) {
|
|
399
562
|
return async (args) => {
|
|
563
|
+
// 0. Check singleton constraint (enforce even in sudo mode)
|
|
564
|
+
if (isSingletonList(listConfig)) {
|
|
565
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
566
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
567
|
+
const model = prisma[getDbKey(listName)];
|
|
568
|
+
const existingCount = await model.count();
|
|
569
|
+
if (existingCount > 0) {
|
|
570
|
+
throw new ValidationError([`Cannot create: ${listName} is a singleton list with an existing record`], {});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
400
573
|
// 1. Check create access (skip if sudo mode)
|
|
401
574
|
if (!context._isSudo) {
|
|
402
575
|
const createAccess = listConfig.access?.operation?.create;
|
|
@@ -410,20 +583,26 @@ listConfig, prisma, context, config) {
|
|
|
410
583
|
}
|
|
411
584
|
// 2. Execute list-level resolveInput hook
|
|
412
585
|
let resolvedData = await executeResolveInput(listConfig.hooks, {
|
|
586
|
+
listKey: listName,
|
|
413
587
|
operation: 'create',
|
|
588
|
+
inputData: args.data,
|
|
414
589
|
resolvedData: args.data,
|
|
415
590
|
item: undefined,
|
|
416
591
|
context,
|
|
417
592
|
});
|
|
418
593
|
// 2.5. Execute field-level resolveInput hooks (e.g., hash passwords)
|
|
419
|
-
resolvedData = await executeFieldResolveInputHooks(resolvedData, listConfig.fields, 'create', context, listName);
|
|
420
|
-
// 3. Execute
|
|
421
|
-
await
|
|
594
|
+
resolvedData = await executeFieldResolveInputHooks(args.data, resolvedData, listConfig.fields, 'create', context, listName);
|
|
595
|
+
// 3. Execute list-level validate hook
|
|
596
|
+
await executeValidate(listConfig.hooks, {
|
|
597
|
+
listKey: listName,
|
|
422
598
|
operation: 'create',
|
|
599
|
+
inputData: args.data,
|
|
423
600
|
resolvedData,
|
|
424
601
|
item: undefined,
|
|
425
602
|
context,
|
|
426
603
|
});
|
|
604
|
+
// 3.5. Execute field-level validate hooks
|
|
605
|
+
await executeFieldValidateHooks(args.data, resolvedData, listConfig.fields, 'create', context, listName);
|
|
427
606
|
// 4. Field validation (isRequired, length, etc.)
|
|
428
607
|
const validation = validateFieldRules(resolvedData, listConfig.fields, 'create');
|
|
429
608
|
if (validation.errors.length > 0) {
|
|
@@ -433,14 +612,18 @@ listConfig, prisma, context, config) {
|
|
|
433
612
|
const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
|
|
434
613
|
session: context.session,
|
|
435
614
|
context: { ...context, _isSudo: context._isSudo },
|
|
615
|
+
inputData: args.data,
|
|
436
616
|
});
|
|
437
617
|
// 5.5. Process nested relationship operations
|
|
438
618
|
const data = await processNestedOperations(filteredData, listConfig.fields, config, { ...context, prisma }, 'create');
|
|
439
619
|
// 6. Execute field-level beforeOperation hooks (side effects only)
|
|
440
|
-
await executeFieldBeforeOperationHooks(data, listConfig.fields, 'create', context, listName);
|
|
620
|
+
await executeFieldBeforeOperationHooks(args.data, resolvedData, listConfig.fields, 'create', context, listName);
|
|
441
621
|
// 7. Execute list-level beforeOperation hook
|
|
442
622
|
await executeBeforeOperation(listConfig.hooks, {
|
|
623
|
+
listKey: listName,
|
|
443
624
|
operation: 'create',
|
|
625
|
+
inputData: args.data,
|
|
626
|
+
resolvedData,
|
|
444
627
|
context,
|
|
445
628
|
});
|
|
446
629
|
// 8. Execute database create
|
|
@@ -452,13 +635,15 @@ listConfig, prisma, context, config) {
|
|
|
452
635
|
});
|
|
453
636
|
// 9. Execute list-level afterOperation hook
|
|
454
637
|
await executeAfterOperation(listConfig.hooks, {
|
|
638
|
+
listKey: listName,
|
|
455
639
|
operation: 'create',
|
|
640
|
+
inputData: args.data,
|
|
456
641
|
item,
|
|
457
|
-
|
|
642
|
+
resolvedData,
|
|
458
643
|
context,
|
|
459
644
|
});
|
|
460
645
|
// 10. Execute field-level afterOperation hooks (side effects only)
|
|
461
|
-
await executeFieldAfterOperationHooks(item, data, listConfig.fields, 'create', context, listName, undefined);
|
|
646
|
+
await executeFieldAfterOperationHooks(item, args.data, resolvedData, listConfig.fields, 'create', context, listName, undefined);
|
|
462
647
|
// 11. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
463
648
|
// Pass sudo flag through context to skip field-level access checks
|
|
464
649
|
const filtered = await filterReadableFields(item, listConfig.fields, {
|
|
@@ -508,20 +693,26 @@ listConfig, prisma, context, config) {
|
|
|
508
693
|
}
|
|
509
694
|
// 3. Execute list-level resolveInput hook
|
|
510
695
|
let resolvedData = await executeResolveInput(listConfig.hooks, {
|
|
696
|
+
listKey: listName,
|
|
511
697
|
operation: 'update',
|
|
698
|
+
inputData: args.data,
|
|
512
699
|
resolvedData: args.data,
|
|
513
700
|
item,
|
|
514
701
|
context,
|
|
515
702
|
});
|
|
516
703
|
// 3.5. Execute field-level resolveInput hooks (e.g., hash passwords)
|
|
517
|
-
resolvedData = await executeFieldResolveInputHooks(resolvedData, listConfig.fields, 'update', context, listName, item);
|
|
518
|
-
// 4. Execute
|
|
519
|
-
await
|
|
704
|
+
resolvedData = await executeFieldResolveInputHooks(args.data, resolvedData, listConfig.fields, 'update', context, listName, item);
|
|
705
|
+
// 4. Execute list-level validate hook
|
|
706
|
+
await executeValidate(listConfig.hooks, {
|
|
707
|
+
listKey: listName,
|
|
520
708
|
operation: 'update',
|
|
709
|
+
inputData: args.data,
|
|
521
710
|
resolvedData,
|
|
522
711
|
item,
|
|
523
712
|
context,
|
|
524
713
|
});
|
|
714
|
+
// 4.5. Execute field-level validate hooks
|
|
715
|
+
await executeFieldValidateHooks(args.data, resolvedData, listConfig.fields, 'update', context, listName, item);
|
|
525
716
|
// 5. Field validation (isRequired, length, etc.)
|
|
526
717
|
const validation = validateFieldRules(resolvedData, listConfig.fields, 'update');
|
|
527
718
|
if (validation.errors.length > 0) {
|
|
@@ -532,15 +723,19 @@ listConfig, prisma, context, config) {
|
|
|
532
723
|
session: context.session,
|
|
533
724
|
item,
|
|
534
725
|
context: { ...context, _isSudo: context._isSudo },
|
|
726
|
+
inputData: args.data,
|
|
535
727
|
});
|
|
536
728
|
// 6.5. Process nested relationship operations
|
|
537
729
|
const data = await processNestedOperations(filteredData, listConfig.fields, config, { ...context, prisma }, 'update');
|
|
538
730
|
// 7. Execute field-level beforeOperation hooks (side effects only)
|
|
539
|
-
await executeFieldBeforeOperationHooks(data, listConfig.fields, 'update', context, listName, item);
|
|
731
|
+
await executeFieldBeforeOperationHooks(args.data, resolvedData, listConfig.fields, 'update', context, listName, item);
|
|
540
732
|
// 8. Execute list-level beforeOperation hook
|
|
541
733
|
await executeBeforeOperation(listConfig.hooks, {
|
|
734
|
+
listKey: listName,
|
|
542
735
|
operation: 'update',
|
|
736
|
+
inputData: args.data,
|
|
543
737
|
item,
|
|
738
|
+
resolvedData,
|
|
544
739
|
context,
|
|
545
740
|
});
|
|
546
741
|
// 9. Execute database update
|
|
@@ -550,13 +745,16 @@ listConfig, prisma, context, config) {
|
|
|
550
745
|
});
|
|
551
746
|
// 10. Execute list-level afterOperation hook
|
|
552
747
|
await executeAfterOperation(listConfig.hooks, {
|
|
748
|
+
listKey: listName,
|
|
553
749
|
operation: 'update',
|
|
554
|
-
|
|
750
|
+
inputData: args.data,
|
|
555
751
|
originalItem: item, // item is the original item before the update
|
|
752
|
+
item: updated,
|
|
753
|
+
resolvedData,
|
|
556
754
|
context,
|
|
557
755
|
});
|
|
558
756
|
// 11. Execute field-level afterOperation hooks (side effects only)
|
|
559
|
-
await executeFieldAfterOperationHooks(updated, data, listConfig.fields, 'update', context, listName, item);
|
|
757
|
+
await executeFieldAfterOperationHooks(updated, args.data, resolvedData, listConfig.fields, 'update', context, listName, item);
|
|
560
758
|
// 12. Filter readable fields and apply resolveOutput hooks (including nested relationships)
|
|
561
759
|
// Pass sudo flag through context to skip field-level access checks
|
|
562
760
|
const filtered = await filterReadableFields(updated, listConfig.fields, {
|
|
@@ -573,6 +771,10 @@ function createDelete(listName,
|
|
|
573
771
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
574
772
|
listConfig, prisma, context) {
|
|
575
773
|
return async (args) => {
|
|
774
|
+
// 0. Check singleton constraint (enforce even in sudo mode)
|
|
775
|
+
if (isSingletonList(listConfig)) {
|
|
776
|
+
throw new ValidationError([`Cannot delete: ${listName} is a singleton list`], {});
|
|
777
|
+
}
|
|
576
778
|
// 1. Fetch the item to pass to access control and hooks
|
|
577
779
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
578
780
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -604,27 +806,37 @@ listConfig, prisma, context) {
|
|
|
604
806
|
}
|
|
605
807
|
}
|
|
606
808
|
}
|
|
607
|
-
// 3. Execute
|
|
608
|
-
await
|
|
609
|
-
|
|
809
|
+
// 3. Execute list-level validate hook
|
|
810
|
+
await executeValidate(listConfig.hooks, {
|
|
811
|
+
listKey: listName,
|
|
812
|
+
operation: 'delete',
|
|
813
|
+
item,
|
|
814
|
+
context,
|
|
815
|
+
});
|
|
816
|
+
// 3.5. Execute field-level validate hooks
|
|
817
|
+
await executeFieldValidateHooks(undefined, undefined, listConfig.fields, 'delete', context, listName, item);
|
|
818
|
+
// 4. Execute field-level beforeOperation hooks (side effects only)
|
|
819
|
+
await executeFieldBeforeOperationHooks({}, {}, listConfig.fields, 'delete', context, listName, item);
|
|
820
|
+
// 5. Execute list-level beforeOperation hook
|
|
610
821
|
await executeBeforeOperation(listConfig.hooks, {
|
|
822
|
+
listKey: listName,
|
|
611
823
|
operation: 'delete',
|
|
612
824
|
item,
|
|
613
825
|
context,
|
|
614
826
|
});
|
|
615
|
-
//
|
|
827
|
+
// 6. Execute database delete
|
|
616
828
|
const deleted = await model.delete({
|
|
617
829
|
where: args.where,
|
|
618
830
|
});
|
|
619
|
-
//
|
|
831
|
+
// 7. Execute list-level afterOperation hook
|
|
620
832
|
await executeAfterOperation(listConfig.hooks, {
|
|
833
|
+
listKey: listName,
|
|
621
834
|
operation: 'delete',
|
|
622
|
-
item: deleted,
|
|
623
835
|
originalItem: item, // item is the original item before deletion
|
|
624
836
|
context,
|
|
625
837
|
});
|
|
626
|
-
//
|
|
627
|
-
await executeFieldAfterOperationHooks(deleted, undefined, listConfig.fields, 'delete', context, listName, item);
|
|
838
|
+
// 8. Execute field-level afterOperation hooks (side effects only)
|
|
839
|
+
await executeFieldAfterOperationHooks(deleted, undefined, undefined, listConfig.fields, 'delete', context, listName, item);
|
|
628
840
|
return deleted;
|
|
629
841
|
};
|
|
630
842
|
}
|
|
@@ -663,4 +875,62 @@ listConfig, prisma, context) {
|
|
|
663
875
|
return count;
|
|
664
876
|
};
|
|
665
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* Create get operation for singleton lists
|
|
880
|
+
* Returns the single record, or auto-creates it if enabled
|
|
881
|
+
*/
|
|
882
|
+
function createGet(listName,
|
|
883
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
884
|
+
listConfig, prisma, context, config,
|
|
885
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
886
|
+
createFn) {
|
|
887
|
+
return async () => {
|
|
888
|
+
// First try to find the existing record
|
|
889
|
+
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
890
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
891
|
+
const model = prisma[getDbKey(listName)];
|
|
892
|
+
// Check query access (skip if sudo mode)
|
|
893
|
+
let where = {};
|
|
894
|
+
if (!context._isSudo) {
|
|
895
|
+
const queryAccess = listConfig.access?.operation?.query;
|
|
896
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
897
|
+
session: context.session,
|
|
898
|
+
context,
|
|
899
|
+
});
|
|
900
|
+
if (accessResult === false) {
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
// Merge access filter (for singleton, we don't have a specific where clause)
|
|
904
|
+
if (accessResult && typeof accessResult === 'object') {
|
|
905
|
+
where = accessResult;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// Build include with access control filters
|
|
909
|
+
const accessControlledInclude = await buildIncludeWithAccessControl(listConfig.fields, {
|
|
910
|
+
session: context.session,
|
|
911
|
+
context,
|
|
912
|
+
}, config);
|
|
913
|
+
// Try to find the record
|
|
914
|
+
const item = await model.findFirst({
|
|
915
|
+
where,
|
|
916
|
+
include: accessControlledInclude,
|
|
917
|
+
});
|
|
918
|
+
// If record exists, return it
|
|
919
|
+
if (item) {
|
|
920
|
+
// Filter readable fields and apply resolveOutput hooks
|
|
921
|
+
const filtered = await filterReadableFields(item, listConfig.fields, {
|
|
922
|
+
session: context.session,
|
|
923
|
+
context: { ...context, _isSudo: context._isSudo },
|
|
924
|
+
}, config, 0, listName);
|
|
925
|
+
return filtered;
|
|
926
|
+
}
|
|
927
|
+
// If no record and auto-create is enabled, create it
|
|
928
|
+
if (shouldAutoCreate(listConfig)) {
|
|
929
|
+
const defaultData = getDefaultData(listConfig);
|
|
930
|
+
return await createFn({ data: defaultData });
|
|
931
|
+
}
|
|
932
|
+
// No record and auto-create is disabled
|
|
933
|
+
return null;
|
|
934
|
+
};
|
|
935
|
+
}
|
|
666
936
|
//# sourceMappingURL=index.js.map
|