@opensaas/stack-core 0.1.7 → 0.4.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 +352 -0
- package/CLAUDE.md +46 -1
- package/dist/access/engine.d.ts +7 -6
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +55 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +125 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/types.d.ts +39 -9
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +40 -20
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +34 -15
- package/dist/config/index.js.map +1 -1
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +9 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +277 -84
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +5 -3
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +146 -20
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +88 -72
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/fields/index.d.ts +65 -9
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +98 -16
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +28 -12
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +16 -0
- 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/dist/mcp/handler.js +1 -0
- package/dist/mcp/handler.js.map +1 -1
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +4 -2
- package/dist/validation/schema.js.map +1 -1
- package/package.json +8 -9
- package/src/access/engine.test.ts +145 -0
- package/src/access/engine.ts +73 -9
- package/src/access/types.ts +38 -8
- package/src/config/index.ts +45 -23
- package/src/config/plugin-engine.ts +13 -3
- package/src/config/types.ts +347 -117
- package/src/context/index.ts +176 -23
- package/src/context/nested-operations.ts +83 -71
- package/src/fields/index.ts +132 -27
- package/src/hooks/index.ts +63 -20
- package/src/index.ts +9 -0
- package/src/mcp/handler.ts +2 -1
- package/src/validation/schema.ts +4 -2
- package/tests/context.test.ts +38 -6
- package/tests/field-types.test.ts +729 -0
- package/tests/password-type-distribution.test.ts +0 -1
- package/tests/password-types.test.ts +0 -1
- package/tests/plugin-engine.test.ts +1102 -0
- package/tests/sudo.test.ts +230 -2
- package/tsconfig.tsbuildinfo +1 -1
package/src/fields/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
SelectField,
|
|
9
9
|
RelationshipField,
|
|
10
10
|
JsonField,
|
|
11
|
+
VirtualField,
|
|
11
12
|
} from '../config/types.js'
|
|
12
13
|
import { hashPassword, isHashedPassword, HashedPassword } from '../utils/password.js'
|
|
13
14
|
|
|
@@ -24,7 +25,9 @@ function formatFieldName(fieldName: string): string {
|
|
|
24
25
|
/**
|
|
25
26
|
* Text field
|
|
26
27
|
*/
|
|
27
|
-
export function text
|
|
28
|
+
export function text<
|
|
29
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
30
|
+
>(options?: Omit<TextField<TTypeInfo>, 'type'>): TextField<TTypeInfo> {
|
|
28
31
|
return {
|
|
29
32
|
type: 'text',
|
|
30
33
|
...options,
|
|
@@ -59,7 +62,7 @@ export function text(options?: Omit<TextField, 'type'>): TextField {
|
|
|
59
62
|
return z.union([withMax, z.undefined()])
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
return !isRequired ? withMax.optional() : withMax
|
|
65
|
+
return !isRequired ? withMax.optional().nullable() : withMax
|
|
63
66
|
},
|
|
64
67
|
getPrismaType: () => {
|
|
65
68
|
const validation = options?.validation
|
|
@@ -98,7 +101,9 @@ export function text(options?: Omit<TextField, 'type'>): TextField {
|
|
|
98
101
|
/**
|
|
99
102
|
* Integer field
|
|
100
103
|
*/
|
|
101
|
-
export function integer
|
|
104
|
+
export function integer<
|
|
105
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
106
|
+
>(options?: Omit<IntegerField<TTypeInfo>, 'type'>): IntegerField<TTypeInfo> {
|
|
102
107
|
return {
|
|
103
108
|
type: 'integer',
|
|
104
109
|
...options,
|
|
@@ -122,7 +127,7 @@ export function integer(options?: Omit<IntegerField, 'type'>): IntegerField {
|
|
|
122
127
|
: withMin
|
|
123
128
|
|
|
124
129
|
return !options?.validation?.isRequired || operation === 'update'
|
|
125
|
-
? withMax.optional()
|
|
130
|
+
? withMax.optional().nullable()
|
|
126
131
|
: withMax
|
|
127
132
|
},
|
|
128
133
|
getPrismaType: () => {
|
|
@@ -147,12 +152,14 @@ export function integer(options?: Omit<IntegerField, 'type'>): IntegerField {
|
|
|
147
152
|
/**
|
|
148
153
|
* Checkbox (boolean) field
|
|
149
154
|
*/
|
|
150
|
-
export function checkbox
|
|
155
|
+
export function checkbox<
|
|
156
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
157
|
+
>(options?: Omit<CheckboxField<TTypeInfo>, 'type'>): CheckboxField<TTypeInfo> {
|
|
151
158
|
return {
|
|
152
159
|
type: 'checkbox',
|
|
153
160
|
...options,
|
|
154
161
|
getZodSchema: () => {
|
|
155
|
-
return z.boolean().optional()
|
|
162
|
+
return z.boolean().optional().nullable()
|
|
156
163
|
},
|
|
157
164
|
getPrismaType: () => {
|
|
158
165
|
const hasDefault = options?.defaultValue !== undefined
|
|
@@ -179,12 +186,14 @@ export function checkbox(options?: Omit<CheckboxField, 'type'>): CheckboxField {
|
|
|
179
186
|
/**
|
|
180
187
|
* Timestamp (DateTime) field
|
|
181
188
|
*/
|
|
182
|
-
export function timestamp
|
|
189
|
+
export function timestamp<
|
|
190
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
191
|
+
>(options?: Omit<TimestampField<TTypeInfo>, 'type'>): TimestampField<TTypeInfo> {
|
|
183
192
|
return {
|
|
184
193
|
type: 'timestamp',
|
|
185
194
|
...options,
|
|
186
195
|
getZodSchema: () => {
|
|
187
|
-
return z.union([z.date(), z.iso.datetime()]).optional()
|
|
196
|
+
return z.union([z.date(), z.iso.datetime()]).optional().nullable()
|
|
188
197
|
},
|
|
189
198
|
getPrismaType: () => {
|
|
190
199
|
let modifiers = '?'
|
|
@@ -270,33 +279,32 @@ export function timestamp(options?: Omit<TimestampField, 'type'>): TimestampFiel
|
|
|
270
279
|
* @param options - Field configuration options
|
|
271
280
|
* @returns Password field configuration
|
|
272
281
|
*/
|
|
273
|
-
export function password
|
|
282
|
+
export function password<TTypeInfo extends import('../config/types.js').TypeInfo>(
|
|
283
|
+
options?: Omit<PasswordField<TTypeInfo>, 'type'>,
|
|
284
|
+
): PasswordField<TTypeInfo> {
|
|
274
285
|
return {
|
|
275
286
|
type: 'password',
|
|
276
287
|
...options,
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
288
|
+
resultExtension: {
|
|
289
|
+
outputType: "import('@opensaas/stack-core').HashedPassword",
|
|
290
|
+
// No compute - delegates to resolveOutput hook
|
|
280
291
|
},
|
|
281
292
|
ui: {
|
|
282
293
|
...options?.ui,
|
|
283
294
|
valueForClientSerialization: ({ value }) => ({ isSet: !!value }),
|
|
284
295
|
},
|
|
296
|
+
// Cast hooks to any since field builders are generic and can't know the specific TFieldKey
|
|
285
297
|
hooks: {
|
|
286
298
|
// Hash password before writing to database
|
|
287
|
-
|
|
299
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
|
|
300
|
+
resolveInput: async ({ inputValue }: { inputValue: any }) => {
|
|
288
301
|
// Skip if undefined or null (allows partial updates)
|
|
289
302
|
if (inputValue === undefined || inputValue === null) {
|
|
290
303
|
return inputValue
|
|
291
304
|
}
|
|
292
305
|
|
|
293
306
|
// Skip if not a string
|
|
294
|
-
if (typeof inputValue !== 'string') {
|
|
295
|
-
return inputValue
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Skip empty strings (let validation handle this)
|
|
299
|
-
if (inputValue.length === 0) {
|
|
307
|
+
if (typeof inputValue !== 'string' || inputValue.length === 0) {
|
|
300
308
|
return inputValue
|
|
301
309
|
}
|
|
302
310
|
|
|
@@ -309,7 +317,8 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
309
317
|
return await hashPassword(inputValue)
|
|
310
318
|
},
|
|
311
319
|
// Wrap password with HashedPassword class after reading from database
|
|
312
|
-
|
|
320
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
|
|
321
|
+
resolveOutput: ({ value }: { value: any }) => {
|
|
313
322
|
// Only wrap string values (hashed passwords)
|
|
314
323
|
if (typeof value === 'string' && value.length > 0) {
|
|
315
324
|
return new HashedPassword(value)
|
|
@@ -318,7 +327,8 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
318
327
|
},
|
|
319
328
|
// Merge with user-provided hooks if any
|
|
320
329
|
...options?.hooks,
|
|
321
|
-
|
|
330
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hook object needs type assertion for field builder
|
|
331
|
+
} as any,
|
|
322
332
|
getZodSchema: (fieldName: string, operation: 'create' | 'update') => {
|
|
323
333
|
const validation = options?.validation
|
|
324
334
|
const isRequired = validation?.isRequired
|
|
@@ -347,6 +357,7 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
347
357
|
message: `${formatFieldName(fieldName)} must be text`,
|
|
348
358
|
})
|
|
349
359
|
.optional()
|
|
360
|
+
.nullable()
|
|
350
361
|
}
|
|
351
362
|
},
|
|
352
363
|
getPrismaType: () => {
|
|
@@ -371,7 +382,9 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
|
|
|
371
382
|
/**
|
|
372
383
|
* Select field (enum-like)
|
|
373
384
|
*/
|
|
374
|
-
export function select
|
|
385
|
+
export function select<
|
|
386
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
387
|
+
>(options: Omit<SelectField<TTypeInfo>, 'type'>): SelectField<TTypeInfo> {
|
|
375
388
|
if (!options.options || options.options.length === 0) {
|
|
376
389
|
throw new Error('Select field must have at least one option')
|
|
377
390
|
}
|
|
@@ -386,7 +399,7 @@ export function select(options: Omit<SelectField, 'type'>): SelectField {
|
|
|
386
399
|
})
|
|
387
400
|
|
|
388
401
|
if (!options.validation?.isRequired || operation === 'update') {
|
|
389
|
-
schema = schema.optional()
|
|
402
|
+
schema = schema.optional().nullable()
|
|
390
403
|
}
|
|
391
404
|
|
|
392
405
|
return schema
|
|
@@ -419,7 +432,9 @@ export function select(options: Omit<SelectField, 'type'>): SelectField {
|
|
|
419
432
|
/**
|
|
420
433
|
* Relationship field
|
|
421
434
|
*/
|
|
422
|
-
export function relationship
|
|
435
|
+
export function relationship<
|
|
436
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
437
|
+
>(options: Omit<RelationshipField<TTypeInfo>, 'type'>): RelationshipField<TTypeInfo> {
|
|
423
438
|
if (!options.ref) {
|
|
424
439
|
throw new Error('Relationship field must have a ref')
|
|
425
440
|
}
|
|
@@ -481,7 +496,9 @@ export function relationship(options: Omit<RelationshipField, 'type'>): Relation
|
|
|
481
496
|
* @param options - Field configuration options
|
|
482
497
|
* @returns JSON field configuration
|
|
483
498
|
*/
|
|
484
|
-
export function json
|
|
499
|
+
export function json<
|
|
500
|
+
TTypeInfo extends import('../config/types.js').TypeInfo = import('../config/types.js').TypeInfo,
|
|
501
|
+
>(options?: Omit<JsonField<TTypeInfo>, 'type'>): JsonField<TTypeInfo> {
|
|
485
502
|
return {
|
|
486
503
|
type: 'json',
|
|
487
504
|
...options,
|
|
@@ -499,8 +516,8 @@ export function json(options?: Omit<JsonField, 'type'>): JsonField {
|
|
|
499
516
|
// Required in update mode: can be undefined for partial updates
|
|
500
517
|
return z.union([baseSchema, z.undefined()])
|
|
501
518
|
} else {
|
|
502
|
-
// Not required: can be undefined
|
|
503
|
-
return baseSchema.optional()
|
|
519
|
+
// Not required: can be undefined or null
|
|
520
|
+
return baseSchema.optional().nullable()
|
|
504
521
|
}
|
|
505
522
|
},
|
|
506
523
|
getPrismaType: () => {
|
|
@@ -521,3 +538,91 @@ export function json(options?: Omit<JsonField, 'type'>): JsonField {
|
|
|
521
538
|
},
|
|
522
539
|
}
|
|
523
540
|
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Virtual field - not stored in database, computed via hooks
|
|
544
|
+
*
|
|
545
|
+
* **Features:**
|
|
546
|
+
* - Does not create a column in the database
|
|
547
|
+
* - Uses resolveOutput hook to compute value from other fields
|
|
548
|
+
* - Optionally uses resolveInput hook for write side effects (e.g., sync to external API)
|
|
549
|
+
* - Only computed when explicitly selected/included in queries
|
|
550
|
+
* - Supports both read and write operations via hooks
|
|
551
|
+
*
|
|
552
|
+
* **Usage Example:**
|
|
553
|
+
* ```typescript
|
|
554
|
+
* // Read-only computed field
|
|
555
|
+
* fields: {
|
|
556
|
+
* firstName: text(),
|
|
557
|
+
* lastName: text(),
|
|
558
|
+
* fullName: virtual({
|
|
559
|
+
* type: 'string',
|
|
560
|
+
* hooks: {
|
|
561
|
+
* resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}`
|
|
562
|
+
* }
|
|
563
|
+
* })
|
|
564
|
+
* }
|
|
565
|
+
*
|
|
566
|
+
* // Write side effects (e.g., sync to external API)
|
|
567
|
+
* fields: {
|
|
568
|
+
* externalSync: virtual({
|
|
569
|
+
* type: 'boolean',
|
|
570
|
+
* hooks: {
|
|
571
|
+
* resolveInput: async ({ item }) => {
|
|
572
|
+
* await syncToExternalAPI(item)
|
|
573
|
+
* return undefined // Don't store anything
|
|
574
|
+
* },
|
|
575
|
+
* resolveOutput: () => true
|
|
576
|
+
* }
|
|
577
|
+
* })
|
|
578
|
+
* }
|
|
579
|
+
*
|
|
580
|
+
* // Query with select
|
|
581
|
+
* const user = await context.db.user.findUnique({
|
|
582
|
+
* where: { id },
|
|
583
|
+
* select: { firstName: true, lastName: true, fullName: true } // fullName computed
|
|
584
|
+
* })
|
|
585
|
+
* ```
|
|
586
|
+
*
|
|
587
|
+
* **Requirements:**
|
|
588
|
+
* - Must provide `type` (TypeScript type string)
|
|
589
|
+
* - Must provide `resolveOutput` hook (for reads)
|
|
590
|
+
* - Optional `resolveInput` hook (for write side effects)
|
|
591
|
+
*
|
|
592
|
+
* @param options - Virtual field configuration
|
|
593
|
+
* @returns Virtual field configuration
|
|
594
|
+
*/
|
|
595
|
+
export function virtual<TTypeInfo extends import('../config/types.js').TypeInfo>(
|
|
596
|
+
options: Omit<VirtualField<TTypeInfo>, 'virtual' | 'outputType' | 'type'> & { type: string },
|
|
597
|
+
): VirtualField<TTypeInfo> {
|
|
598
|
+
// Validate that resolveOutput is provided
|
|
599
|
+
if (!options.hooks?.resolveOutput) {
|
|
600
|
+
throw new Error(
|
|
601
|
+
'Virtual fields must provide a resolveOutput hook to compute their value. ' +
|
|
602
|
+
'Example: hooks: { resolveOutput: ({ item }) => computeValue(item) }',
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const { type: outputType, ...rest } = options
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
type: 'virtual',
|
|
610
|
+
virtual: true,
|
|
611
|
+
outputType,
|
|
612
|
+
...rest,
|
|
613
|
+
// Virtual fields don't create database columns
|
|
614
|
+
// Return undefined to signal generator to skip this field
|
|
615
|
+
getPrismaType: undefined,
|
|
616
|
+
// Virtual fields appear in output types with their specified type
|
|
617
|
+
getTypeScriptType: () => {
|
|
618
|
+
return {
|
|
619
|
+
type: options.type,
|
|
620
|
+
optional: false, // Virtual fields always compute a value
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
// Virtual fields never validate input (they don't accept database input)
|
|
624
|
+
getZodSchema: () => {
|
|
625
|
+
return z.never()
|
|
626
|
+
},
|
|
627
|
+
}
|
|
628
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -18,24 +18,53 @@ export class ValidationError extends Error {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Database error with field-specific error information
|
|
23
|
+
* Used for Prisma errors like unique constraint violations
|
|
24
|
+
*/
|
|
25
|
+
export class DatabaseError extends Error {
|
|
26
|
+
public fieldErrors: Record<string, string>
|
|
27
|
+
public code?: string
|
|
28
|
+
|
|
29
|
+
constructor(message: string, fieldErrors: Record<string, string> = {}, code?: string) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = 'DatabaseError'
|
|
32
|
+
this.fieldErrors = fieldErrors
|
|
33
|
+
this.code = code
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
/**
|
|
22
38
|
* Execute resolveInput hook
|
|
23
39
|
* Allows modification of input data before validation
|
|
24
40
|
*/
|
|
25
|
-
export async function executeResolveInput<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
export async function executeResolveInput<
|
|
42
|
+
TOutput = Record<string, unknown>,
|
|
43
|
+
TCreateInput = Record<string, unknown>,
|
|
44
|
+
TUpdateInput = Record<string, unknown>,
|
|
45
|
+
>(
|
|
46
|
+
hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
|
|
47
|
+
args:
|
|
48
|
+
| {
|
|
49
|
+
operation: 'create'
|
|
50
|
+
resolvedData: TCreateInput
|
|
51
|
+
item?: undefined
|
|
52
|
+
context: AccessContext
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
operation: 'update'
|
|
56
|
+
resolvedData: TUpdateInput
|
|
57
|
+
item?: TOutput
|
|
58
|
+
context: AccessContext
|
|
59
|
+
},
|
|
60
|
+
): Promise<TCreateInput | TUpdateInput> {
|
|
34
61
|
if (!hooks?.resolveInput) {
|
|
35
62
|
return args.resolvedData
|
|
36
63
|
}
|
|
37
64
|
|
|
38
|
-
|
|
65
|
+
// Type assertion is safe because we've constrained the args type
|
|
66
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
67
|
+
const result = await hooks.resolveInput(args as any)
|
|
39
68
|
return result
|
|
40
69
|
}
|
|
41
70
|
|
|
@@ -43,12 +72,16 @@ export async function executeResolveInput<T = Record<string, unknown>>(
|
|
|
43
72
|
* Execute validateInput hook
|
|
44
73
|
* Allows custom validation logic
|
|
45
74
|
*/
|
|
46
|
-
export async function executeValidateInput<
|
|
47
|
-
|
|
75
|
+
export async function executeValidateInput<
|
|
76
|
+
TOutput = Record<string, unknown>,
|
|
77
|
+
TCreateInput = Record<string, unknown>,
|
|
78
|
+
TUpdateInput = Record<string, unknown>,
|
|
79
|
+
>(
|
|
80
|
+
hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
|
|
48
81
|
args: {
|
|
49
82
|
operation: 'create' | 'update'
|
|
50
|
-
resolvedData:
|
|
51
|
-
item?:
|
|
83
|
+
resolvedData: TCreateInput | TUpdateInput
|
|
84
|
+
item?: TOutput
|
|
52
85
|
context: AccessContext
|
|
53
86
|
},
|
|
54
87
|
): Promise<void> {
|
|
@@ -76,11 +109,16 @@ export async function executeValidateInput<T = Record<string, unknown>>(
|
|
|
76
109
|
* Execute beforeOperation hook
|
|
77
110
|
* Runs before database operation (cannot modify data)
|
|
78
111
|
*/
|
|
79
|
-
export async function executeBeforeOperation<
|
|
80
|
-
|
|
112
|
+
export async function executeBeforeOperation<
|
|
113
|
+
TOutput = Record<string, unknown>,
|
|
114
|
+
TCreateInput = Record<string, unknown>,
|
|
115
|
+
TUpdateInput = Record<string, unknown>,
|
|
116
|
+
>(
|
|
117
|
+
hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
|
|
81
118
|
args: {
|
|
82
119
|
operation: 'create' | 'update' | 'delete'
|
|
83
|
-
|
|
120
|
+
resolvedData?: TCreateInput | TUpdateInput
|
|
121
|
+
item?: TOutput
|
|
84
122
|
context: AccessContext
|
|
85
123
|
},
|
|
86
124
|
): Promise<void> {
|
|
@@ -95,11 +133,16 @@ export async function executeBeforeOperation<T = Record<string, unknown>>(
|
|
|
95
133
|
* Execute afterOperation hook
|
|
96
134
|
* Runs after database operation
|
|
97
135
|
*/
|
|
98
|
-
export async function executeAfterOperation<
|
|
99
|
-
|
|
136
|
+
export async function executeAfterOperation<
|
|
137
|
+
TOutput = Record<string, unknown>,
|
|
138
|
+
TCreateInput = Record<string, unknown>,
|
|
139
|
+
TUpdateInput = Record<string, unknown>,
|
|
140
|
+
>(
|
|
141
|
+
hooks: Hooks<TOutput, TCreateInput, TUpdateInput> | undefined,
|
|
100
142
|
args: {
|
|
101
143
|
operation: 'create' | 'update' | 'delete'
|
|
102
|
-
|
|
144
|
+
resolvedData?: TCreateInput | TUpdateInput
|
|
145
|
+
item: TOutput
|
|
103
146
|
context: AccessContext
|
|
104
147
|
},
|
|
105
148
|
): Promise<void> {
|
package/src/index.ts
CHANGED
|
@@ -12,15 +12,24 @@ export type {
|
|
|
12
12
|
PasswordField,
|
|
13
13
|
SelectField,
|
|
14
14
|
RelationshipField,
|
|
15
|
+
JsonField,
|
|
16
|
+
VirtualField,
|
|
17
|
+
TypeInfo,
|
|
15
18
|
OperationAccess,
|
|
16
19
|
Hooks,
|
|
17
20
|
FieldHooks,
|
|
21
|
+
FieldsWithTypeInfo,
|
|
18
22
|
DatabaseConfig,
|
|
19
23
|
SessionConfig,
|
|
20
24
|
UIConfig,
|
|
21
25
|
ThemeConfig,
|
|
22
26
|
ThemePreset,
|
|
23
27
|
ThemeColors,
|
|
28
|
+
McpConfig,
|
|
29
|
+
McpToolsConfig,
|
|
30
|
+
McpAuthConfig,
|
|
31
|
+
ListMcpConfig,
|
|
32
|
+
McpCustomTool,
|
|
24
33
|
FileMetadata,
|
|
25
34
|
ImageMetadata,
|
|
26
35
|
ImageTransformationResult,
|
package/src/mcp/handler.ts
CHANGED
|
@@ -248,7 +248,8 @@ function generateFieldSchemas(
|
|
|
248
248
|
if (
|
|
249
249
|
operation === 'create' &&
|
|
250
250
|
'validation' in fieldConfig &&
|
|
251
|
-
|
|
251
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Validation property varies by field type
|
|
252
|
+
(fieldConfig.validation as any)?.isRequired
|
|
252
253
|
) {
|
|
253
254
|
required.push(fieldName)
|
|
254
255
|
}
|
package/src/validation/schema.ts
CHANGED
|
@@ -11,10 +11,12 @@ export function generateZodSchema(
|
|
|
11
11
|
const shape: Record<string, z.ZodTypeAny> = {}
|
|
12
12
|
|
|
13
13
|
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
14
|
-
// Skip system fields and
|
|
14
|
+
// Skip system fields, relationships, and virtual fields
|
|
15
|
+
// Virtual fields don't accept input - they only compute output
|
|
15
16
|
if (
|
|
16
17
|
['id', 'createdAt', 'updatedAt'].includes(fieldName) ||
|
|
17
|
-
fieldConfig.type === 'relationship'
|
|
18
|
+
fieldConfig.type === 'relationship' ||
|
|
19
|
+
fieldConfig.virtual
|
|
18
20
|
) {
|
|
19
21
|
continue
|
|
20
22
|
}
|
package/tests/context.test.ts
CHANGED
|
@@ -101,7 +101,7 @@ describe('getContext', () => {
|
|
|
101
101
|
expect(mockPrisma.user.create).toHaveBeenCalledWith({
|
|
102
102
|
data: { name: 'John', email: 'john@example.com' },
|
|
103
103
|
})
|
|
104
|
-
expect(result).toEqual(mockCreatedUser)
|
|
104
|
+
expect(result).toEqual({ success: true, data: mockCreatedUser })
|
|
105
105
|
})
|
|
106
106
|
|
|
107
107
|
it('should update an item', async () => {
|
|
@@ -121,7 +121,7 @@ describe('getContext', () => {
|
|
|
121
121
|
|
|
122
122
|
expect(mockPrisma.user.findUnique).toHaveBeenCalled()
|
|
123
123
|
expect(mockPrisma.user.update).toHaveBeenCalled()
|
|
124
|
-
expect(result).toEqual(mockUpdatedUser)
|
|
124
|
+
expect(result).toEqual({ success: true, data: mockUpdatedUser })
|
|
125
125
|
})
|
|
126
126
|
|
|
127
127
|
it('should delete an item', async () => {
|
|
@@ -139,7 +139,7 @@ describe('getContext', () => {
|
|
|
139
139
|
|
|
140
140
|
expect(mockPrisma.user.findUnique).toHaveBeenCalled()
|
|
141
141
|
expect(mockPrisma.user.delete).toHaveBeenCalled()
|
|
142
|
-
expect(result).toEqual(mockDeletedUser)
|
|
142
|
+
expect(result).toEqual({ success: true, data: mockDeletedUser })
|
|
143
143
|
})
|
|
144
144
|
|
|
145
145
|
it('should convert listKey to lowercase for db operations', async () => {
|
|
@@ -147,16 +147,17 @@ describe('getContext', () => {
|
|
|
147
147
|
mockPrisma.post.create.mockResolvedValue(mockCreatedPost)
|
|
148
148
|
|
|
149
149
|
const context = await getContext(config, mockPrisma, null)
|
|
150
|
-
await context.serverAction({
|
|
150
|
+
const result = await context.serverAction({
|
|
151
151
|
listKey: 'Post',
|
|
152
152
|
action: 'create',
|
|
153
153
|
data: { title: 'Test Post' },
|
|
154
154
|
})
|
|
155
155
|
|
|
156
156
|
expect(mockPrisma.post.create).toHaveBeenCalled()
|
|
157
|
+
expect(result).toEqual({ success: true, data: mockCreatedPost })
|
|
157
158
|
})
|
|
158
159
|
|
|
159
|
-
it('should return
|
|
160
|
+
it('should return error for unknown action', async () => {
|
|
160
161
|
const context = await getContext(config, mockPrisma, null)
|
|
161
162
|
const result = await context.serverAction({
|
|
162
163
|
listKey: 'User',
|
|
@@ -164,7 +165,38 @@ describe('getContext', () => {
|
|
|
164
165
|
data: {},
|
|
165
166
|
})
|
|
166
167
|
|
|
167
|
-
expect(result).
|
|
168
|
+
expect(result).toEqual({ success: false, error: 'Access denied or operation failed' })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should return error for unknown list', async () => {
|
|
172
|
+
const context = await getContext(config, mockPrisma, null)
|
|
173
|
+
const result = await context.serverAction({
|
|
174
|
+
listKey: 'UnknownList',
|
|
175
|
+
action: 'create',
|
|
176
|
+
data: {},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
expect(result).toEqual({
|
|
180
|
+
success: false,
|
|
181
|
+
error: 'List "UnknownList" not found in configuration',
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('should handle database errors', async () => {
|
|
186
|
+
const dbError = new Error('Database connection failed')
|
|
187
|
+
mockPrisma.user.create.mockRejectedValue(dbError)
|
|
188
|
+
|
|
189
|
+
const context = await getContext(config, mockPrisma, null)
|
|
190
|
+
const result = await context.serverAction({
|
|
191
|
+
listKey: 'User',
|
|
192
|
+
action: 'create',
|
|
193
|
+
data: { name: 'John', email: 'john@example.com' },
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
expect(result).toMatchObject({
|
|
197
|
+
success: false,
|
|
198
|
+
error: 'Database connection failed',
|
|
199
|
+
})
|
|
168
200
|
})
|
|
169
201
|
})
|
|
170
202
|
|