@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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +352 -0
  3. package/CLAUDE.md +46 -1
  4. package/dist/access/engine.d.ts +7 -6
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +55 -0
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.d.ts +2 -0
  9. package/dist/access/engine.test.d.ts.map +1 -0
  10. package/dist/access/engine.test.js +125 -0
  11. package/dist/access/engine.test.js.map +1 -0
  12. package/dist/access/types.d.ts +39 -9
  13. package/dist/access/types.d.ts.map +1 -1
  14. package/dist/config/index.d.ts +40 -20
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +34 -15
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/config/plugin-engine.d.ts.map +1 -1
  19. package/dist/config/plugin-engine.js +9 -0
  20. package/dist/config/plugin-engine.js.map +1 -1
  21. package/dist/config/types.d.ts +277 -84
  22. package/dist/config/types.d.ts.map +1 -1
  23. package/dist/context/index.d.ts +5 -3
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +146 -20
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts.map +1 -1
  28. package/dist/context/nested-operations.js +88 -72
  29. package/dist/context/nested-operations.js.map +1 -1
  30. package/dist/fields/index.d.ts +65 -9
  31. package/dist/fields/index.d.ts.map +1 -1
  32. package/dist/fields/index.js +98 -16
  33. package/dist/fields/index.js.map +1 -1
  34. package/dist/hooks/index.d.ts +28 -12
  35. package/dist/hooks/index.d.ts.map +1 -1
  36. package/dist/hooks/index.js +16 -0
  37. package/dist/hooks/index.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/mcp/handler.js +1 -0
  42. package/dist/mcp/handler.js.map +1 -1
  43. package/dist/validation/schema.d.ts.map +1 -1
  44. package/dist/validation/schema.js +4 -2
  45. package/dist/validation/schema.js.map +1 -1
  46. package/package.json +8 -9
  47. package/src/access/engine.test.ts +145 -0
  48. package/src/access/engine.ts +73 -9
  49. package/src/access/types.ts +38 -8
  50. package/src/config/index.ts +45 -23
  51. package/src/config/plugin-engine.ts +13 -3
  52. package/src/config/types.ts +347 -117
  53. package/src/context/index.ts +176 -23
  54. package/src/context/nested-operations.ts +83 -71
  55. package/src/fields/index.ts +132 -27
  56. package/src/hooks/index.ts +63 -20
  57. package/src/index.ts +9 -0
  58. package/src/mcp/handler.ts +2 -1
  59. package/src/validation/schema.ts +4 -2
  60. package/tests/context.test.ts +38 -6
  61. package/tests/field-types.test.ts +729 -0
  62. package/tests/password-type-distribution.test.ts +0 -1
  63. package/tests/password-types.test.ts +0 -1
  64. package/tests/plugin-engine.test.ts +1102 -0
  65. package/tests/sudo.test.ts +230 -2
  66. package/tsconfig.tsbuildinfo +1 -1
@@ -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(options?: Omit<TextField, 'type'>): TextField {
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(options?: Omit<IntegerField, 'type'>): IntegerField {
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(options?: Omit<CheckboxField, 'type'>): CheckboxField {
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(options?: Omit<TimestampField, 'type'>): TimestampField {
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(options?: Omit<PasswordField, 'type'>): PasswordField {
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
- typePatch: {
278
- resultType: "import('@opensaas/stack-core').HashedPassword",
279
- patchScope: 'scalars-only',
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
- resolveInput: async ({ inputValue }) => {
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
- resolveOutput: ({ value }) => {
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(options: Omit<SelectField, 'type'>): SelectField {
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(options: Omit<RelationshipField, 'type'>): RelationshipField {
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(options?: Omit<JsonField, 'type'>): JsonField {
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
+ }
@@ -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<T = Record<string, unknown>>(
26
- hooks: Hooks<T> | undefined,
27
- args: {
28
- operation: 'create' | 'update'
29
- resolvedData: Partial<T>
30
- item?: T
31
- context: AccessContext
32
- },
33
- ): Promise<Partial<T>> {
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
- const result = await hooks.resolveInput(args)
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<T = Record<string, unknown>>(
47
- hooks: Hooks<T> | undefined,
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: Partial<T>
51
- item?: T
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<T = Record<string, unknown>>(
80
- hooks: Hooks<T> | undefined,
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
- item?: T
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<T = Record<string, unknown>>(
99
- hooks: Hooks<T> | undefined,
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
- item: T
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,
@@ -248,7 +248,8 @@ function generateFieldSchemas(
248
248
  if (
249
249
  operation === 'create' &&
250
250
  'validation' in fieldConfig &&
251
- fieldConfig.validation?.isRequired
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
  }
@@ -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 relationships
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
  }
@@ -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 null for unknown action', async () => {
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).toBeNull()
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