@opensaas/stack-core 0.1.7 → 0.3.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 (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +202 -0
  3. package/CLAUDE.md +46 -1
  4. package/dist/access/engine.d.ts +6 -5
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +17 -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 +38 -18
  15. package/dist/config/index.d.ts.map +1 -1
  16. package/dist/config/index.js +34 -14
  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 +6 -0
  20. package/dist/config/plugin-engine.js.map +1 -1
  21. package/dist/config/types.d.ts +128 -21
  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 +127 -14
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/fields/index.d.ts.map +1 -1
  28. package/dist/fields/index.js +9 -8
  29. package/dist/fields/index.js.map +1 -1
  30. package/dist/hooks/index.d.ts +28 -12
  31. package/dist/hooks/index.d.ts.map +1 -1
  32. package/dist/hooks/index.js +16 -0
  33. package/dist/hooks/index.js.map +1 -1
  34. package/package.json +3 -4
  35. package/src/access/engine.test.ts +145 -0
  36. package/src/access/engine.ts +25 -6
  37. package/src/access/types.ts +38 -8
  38. package/src/config/index.ts +46 -19
  39. package/src/config/plugin-engine.ts +7 -0
  40. package/src/config/types.ts +149 -18
  41. package/src/context/index.ts +163 -17
  42. package/src/fields/index.ts +8 -7
  43. package/src/hooks/index.ts +63 -20
  44. package/tests/context.test.ts +38 -6
  45. package/tests/field-types.test.ts +728 -0
  46. package/tests/password-type-distribution.test.ts +0 -1
  47. package/tests/password-types.test.ts +0 -1
  48. package/tests/plugin-engine.test.ts +1102 -0
  49. package/tests/sudo.test.ts +0 -1
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -14,6 +14,7 @@ import {
14
14
  executeAfterOperation,
15
15
  validateFieldRules,
16
16
  ValidationError,
17
+ DatabaseError,
17
18
  } from '../hooks/index.js'
18
19
  import { processNestedOperations } from './nested-operations.js'
19
20
  import { getDbKey } from '../lib/case-utils.js'
@@ -130,6 +131,69 @@ export type ServerActionProps =
130
131
  | { listKey: string; action: 'create'; data: Record<string, unknown> }
131
132
  | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
132
133
  | { listKey: string; action: 'delete'; id: string }
134
+
135
+ /**
136
+ * Parse Prisma error and convert to user-friendly DatabaseError
137
+ */
138
+ function parsePrismaError(error: unknown, listConfig: ListConfig): Error {
139
+ // Check if it's a Prisma error
140
+ if (
141
+ error &&
142
+ typeof error === 'object' &&
143
+ 'code' in error &&
144
+ 'meta' in error &&
145
+ typeof error.code === 'string'
146
+ ) {
147
+ const prismaError = error as { code: string; meta?: { target?: string[] }; message?: string }
148
+
149
+ // Handle unique constraint violation
150
+ if (prismaError.code === 'P2002') {
151
+ const target = prismaError.meta?.target
152
+ const fieldErrors: Record<string, string> = {}
153
+
154
+ if (target && Array.isArray(target)) {
155
+ // Get field names from the constraint target
156
+ for (const fieldName of target) {
157
+ // Get the field config to get a better label
158
+ const fieldConfig = listConfig.fields[fieldName]
159
+ const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
160
+
161
+ if (fieldConfig) {
162
+ fieldErrors[fieldName] = `This ${label.toLowerCase()} is already in use`
163
+ } else {
164
+ fieldErrors[fieldName] = `This value is already in use`
165
+ }
166
+ }
167
+
168
+ // Create a user-friendly general message
169
+ const fieldLabels = target.map((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
170
+ return new DatabaseError(
171
+ `${fieldLabels} must be unique. The value you entered is already in use.`,
172
+ fieldErrors,
173
+ prismaError.code,
174
+ )
175
+ }
176
+
177
+ return new DatabaseError('A record with this value already exists', {}, prismaError.code)
178
+ }
179
+
180
+ // Handle other Prisma errors - return generic message
181
+ return new DatabaseError(
182
+ prismaError.message || 'A database error occurred',
183
+ {},
184
+ prismaError.code,
185
+ )
186
+ }
187
+
188
+ // Not a Prisma error, return as-is if it's already an Error
189
+ if (error instanceof Error) {
190
+ return error
191
+ }
192
+
193
+ // Unknown error type
194
+ return new Error('An unknown error occurred')
195
+ }
196
+
133
197
  /**
134
198
  * Create an access-controlled context
135
199
  *
@@ -144,21 +208,23 @@ export function getContext<
144
208
  >(
145
209
  config: TConfig,
146
210
  prisma: TPrisma,
147
- session: Session,
211
+ session: Session | null,
148
212
  storage?: StorageUtils,
149
213
  _isSudo: boolean = false,
150
214
  ): {
151
215
  db: AccessControlledDB<TPrisma>
152
- session: Session
216
+ session: Session | null
153
217
  prisma: TPrisma
154
218
  storage: StorageUtils
219
+ plugins: Record<string, unknown>
155
220
  serverAction: (props: ServerActionProps) => Promise<unknown>
156
221
  _isSudo: boolean
157
222
  sudo: () => {
158
223
  db: AccessControlledDB<TPrisma>
159
- session: Session
224
+ session: Session | null
160
225
  prisma: TPrisma
161
226
  storage: StorageUtils
227
+ plugins: Record<string, unknown>
162
228
  serverAction: (props: ServerActionProps) => Promise<unknown>
163
229
  sudo: () => unknown
164
230
  _isSudo: boolean
@@ -196,6 +262,7 @@ export function getContext<
196
262
  )
197
263
  },
198
264
  },
265
+ plugins: {}, // Will be populated with plugin runtime services
199
266
  _isSudo,
200
267
  }
201
268
 
@@ -213,29 +280,107 @@ export function getContext<
213
280
  }
214
281
  }
215
282
 
283
+ // Execute plugin runtime functions and populate context.plugins
284
+ // Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
285
+ const pluginsToExecute = config._plugins || config.plugins || []
286
+ for (const plugin of pluginsToExecute) {
287
+ if (plugin.runtime) {
288
+ try {
289
+ context.plugins[plugin.name] = plugin.runtime(context)
290
+ } catch (error) {
291
+ console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
292
+ // Continue with other plugins even if one fails
293
+ }
294
+ }
295
+ }
296
+
216
297
  // Generic server action handler with discriminated union for type safety
217
- async function serverAction(props: ServerActionProps): Promise<unknown> {
298
+ // Returns a result object instead of throwing to work properly in Next.js production
299
+ async function serverAction(
300
+ props: ServerActionProps,
301
+ ): Promise<
302
+ | { success: true; data: unknown }
303
+ | { success: false; error: string; fieldErrors?: Record<string, string> }
304
+ > {
218
305
  const dbKey = getDbKey(props.listKey)
306
+ const listConfig = config.lists[props.listKey]
307
+
308
+ if (!listConfig) {
309
+ return {
310
+ success: false,
311
+ error: `List "${props.listKey}" not found in configuration`,
312
+ }
313
+ }
314
+
219
315
  const model = db[dbKey] as {
220
316
  create: (args: { data: Record<string, unknown> }) => Promise<unknown>
221
317
  update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown>
222
318
  delete: (args: { where: { id: string } }) => Promise<unknown>
223
319
  }
224
320
 
225
- if (props.action === 'create') {
226
- return await model.create({ data: props.data })
227
- } else if (props.action === 'update') {
228
- return await model.update({
229
- where: { id: props.id },
230
- data: props.data,
231
- })
232
- } else if (props.action === 'delete') {
233
- return await model.delete({
234
- where: { id: props.id },
235
- })
236
- }
321
+ try {
322
+ let result: unknown = null
323
+
324
+ if (props.action === 'create') {
325
+ result = await model.create({ data: props.data })
326
+ } else if (props.action === 'update') {
327
+ result = await model.update({
328
+ where: { id: props.id },
329
+ data: props.data,
330
+ })
331
+ } else if (props.action === 'delete') {
332
+ result = await model.delete({
333
+ where: { id: props.id },
334
+ })
335
+ }
336
+
337
+ // Check for access denial (null return from access-controlled operations)
338
+ if (result === null) {
339
+ return {
340
+ success: false,
341
+ error: 'Access denied or operation failed',
342
+ }
343
+ }
237
344
 
238
- return null
345
+ return {
346
+ success: true,
347
+ data: result,
348
+ }
349
+ } catch (error) {
350
+ // Handle ValidationError (has fieldErrors)
351
+ if (error instanceof ValidationError) {
352
+ return {
353
+ success: false,
354
+ error: error.message,
355
+ fieldErrors: error.fieldErrors,
356
+ }
357
+ }
358
+
359
+ // Handle DatabaseError (has fieldErrors)
360
+ if (error instanceof DatabaseError) {
361
+ return {
362
+ success: false,
363
+ error: error.message,
364
+ fieldErrors: error.fieldErrors,
365
+ }
366
+ }
367
+
368
+ // Parse and convert Prisma errors to user-friendly DatabaseError
369
+ const dbError = parsePrismaError(error, listConfig)
370
+ if (dbError instanceof DatabaseError) {
371
+ return {
372
+ success: false,
373
+ error: dbError.message,
374
+ fieldErrors: dbError.fieldErrors,
375
+ }
376
+ }
377
+
378
+ // Generic error fallback
379
+ return {
380
+ success: false,
381
+ error: dbError.message,
382
+ }
383
+ }
239
384
  }
240
385
 
241
386
  // Sudo function - creates a new context that bypasses access control
@@ -249,6 +394,7 @@ export function getContext<
249
394
  session,
250
395
  prisma,
251
396
  storage: context.storage,
397
+ plugins: context.plugins,
252
398
  serverAction,
253
399
  sudo,
254
400
  _isSudo,
@@ -59,7 +59,7 @@ export function text(options?: Omit<TextField, 'type'>): TextField {
59
59
  return z.union([withMax, z.undefined()])
60
60
  }
61
61
 
62
- return !isRequired ? withMax.optional() : withMax
62
+ return !isRequired ? withMax.optional().nullable() : withMax
63
63
  },
64
64
  getPrismaType: () => {
65
65
  const validation = options?.validation
@@ -122,7 +122,7 @@ export function integer(options?: Omit<IntegerField, 'type'>): IntegerField {
122
122
  : withMin
123
123
 
124
124
  return !options?.validation?.isRequired || operation === 'update'
125
- ? withMax.optional()
125
+ ? withMax.optional().nullable()
126
126
  : withMax
127
127
  },
128
128
  getPrismaType: () => {
@@ -152,7 +152,7 @@ export function checkbox(options?: Omit<CheckboxField, 'type'>): CheckboxField {
152
152
  type: 'checkbox',
153
153
  ...options,
154
154
  getZodSchema: () => {
155
- return z.boolean().optional()
155
+ return z.boolean().optional().nullable()
156
156
  },
157
157
  getPrismaType: () => {
158
158
  const hasDefault = options?.defaultValue !== undefined
@@ -184,7 +184,7 @@ export function timestamp(options?: Omit<TimestampField, 'type'>): TimestampFiel
184
184
  type: 'timestamp',
185
185
  ...options,
186
186
  getZodSchema: () => {
187
- return z.union([z.date(), z.iso.datetime()]).optional()
187
+ return z.union([z.date(), z.iso.datetime()]).optional().nullable()
188
188
  },
189
189
  getPrismaType: () => {
190
190
  let modifiers = '?'
@@ -347,6 +347,7 @@ export function password(options?: Omit<PasswordField, 'type'>): PasswordField {
347
347
  message: `${formatFieldName(fieldName)} must be text`,
348
348
  })
349
349
  .optional()
350
+ .nullable()
350
351
  }
351
352
  },
352
353
  getPrismaType: () => {
@@ -386,7 +387,7 @@ export function select(options: Omit<SelectField, 'type'>): SelectField {
386
387
  })
387
388
 
388
389
  if (!options.validation?.isRequired || operation === 'update') {
389
- schema = schema.optional()
390
+ schema = schema.optional().nullable()
390
391
  }
391
392
 
392
393
  return schema
@@ -499,8 +500,8 @@ export function json(options?: Omit<JsonField, 'type'>): JsonField {
499
500
  // Required in update mode: can be undefined for partial updates
500
501
  return z.union([baseSchema, z.undefined()])
501
502
  } else {
502
- // Not required: can be undefined
503
- return baseSchema.optional()
503
+ // Not required: can be undefined or null
504
+ return baseSchema.optional().nullable()
504
505
  }
505
506
  },
506
507
  getPrismaType: () => {
@@ -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> {
@@ -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