@opensaas/stack-core 0.13.0 → 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.
@@ -92,22 +92,23 @@ async function executeFieldValidateHooks(
92
92
  }
93
93
 
94
94
  for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
95
- // Skip if no hooks defined
96
- if (!fieldConfig.hooks?.validate) continue
95
+ // Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
96
+ const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput
97
+ if (!validateHook) continue
97
98
 
98
99
  // Execute field hook
99
100
  // Type assertion is safe here because hooks are typed correctly in field definitions
100
101
  if (operation === 'delete') {
101
- await fieldConfig.hooks.validate({
102
+ await validateHook({
102
103
  listKey,
103
104
  fieldKey,
104
105
  operation: 'delete',
105
106
  item,
106
107
  context,
107
108
  addValidationError: addValidationError(fieldKey),
108
- } as Parameters<typeof fieldConfig.hooks.validate>[0])
109
+ } as Parameters<typeof validateHook>[0])
109
110
  } else if (operation === 'create') {
110
- await fieldConfig.hooks.validate({
111
+ await validateHook({
111
112
  listKey,
112
113
  fieldKey,
113
114
  operation: 'create',
@@ -116,10 +117,10 @@ async function executeFieldValidateHooks(
116
117
  resolvedData,
117
118
  context,
118
119
  addValidationError: addValidationError(fieldKey),
119
- } as Parameters<typeof fieldConfig.hooks.validate>[0])
120
+ } as Parameters<typeof validateHook>[0])
120
121
  } else {
121
122
  // operation === 'update'
122
- await fieldConfig.hooks.validate({
123
+ await validateHook({
123
124
  listKey,
124
125
  fieldKey,
125
126
  operation: 'update',
@@ -128,7 +129,7 @@ async function executeFieldValidateHooks(
128
129
  resolvedData,
129
130
  context,
130
131
  addValidationError: addValidationError(fieldKey),
131
- } as Parameters<typeof fieldConfig.hooks.validate>[0])
132
+ } as Parameters<typeof validateHook>[0])
132
133
  }
133
134
  }
134
135
 
@@ -253,6 +254,49 @@ export type ServerActionProps =
253
254
  | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
254
255
  | { listKey: string; action: 'delete'; id: string }
255
256
 
257
+ /**
258
+ * Check if a list is configured as a singleton
259
+ */
260
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
261
+ function isSingletonList(listConfig: ListConfig<any>): boolean {
262
+ return !!listConfig.isSingleton
263
+ }
264
+
265
+ /**
266
+ * Check if auto-create is enabled for a singleton list
267
+ * Defaults to true if not explicitly set to false
268
+ */
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
270
+ function shouldAutoCreate(listConfig: ListConfig<any>): boolean {
271
+ if (!listConfig.isSingleton) return false
272
+ if (typeof listConfig.isSingleton === 'boolean') return true
273
+ return listConfig.isSingleton.autoCreate !== false
274
+ }
275
+
276
+ /**
277
+ * Extract default values from field configs
278
+ * Used to auto-create singleton records with sensible defaults
279
+ */
280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
281
+ function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
282
+ const data: Record<string, unknown> = {}
283
+
284
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
285
+ // Skip virtual fields - they're not stored in database
286
+ if (fieldConfig.virtual) continue
287
+
288
+ // Skip system fields (id, createdAt, updatedAt)
289
+ if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
290
+
291
+ // Add default value if present
292
+ if ('defaultValue' in fieldConfig && fieldConfig.defaultValue !== undefined) {
293
+ data[fieldKey] = fieldConfig.defaultValue
294
+ }
295
+ }
296
+
297
+ return data
298
+ }
299
+
256
300
  /**
257
301
  * Parse Prisma error and convert to user-friendly DatabaseError
258
302
  */
@@ -392,14 +436,23 @@ export function getContext<
392
436
  for (const [listName, listConfig] of Object.entries(config.lists)) {
393
437
  const dbKey = getDbKey(listName)
394
438
 
395
- db[dbKey] = {
439
+ // Create base operations
440
+ const createOp = createCreate(listName, listConfig, prisma, context, config)
441
+ const operations: Record<string, unknown> = {
396
442
  findUnique: createFindUnique(listName, listConfig, prisma, context, config),
397
443
  findMany: createFindMany(listName, listConfig, prisma, context, config),
398
- create: createCreate(listName, listConfig, prisma, context, config),
444
+ create: createOp,
399
445
  update: createUpdate(listName, listConfig, prisma, context, config),
400
446
  delete: createDelete(listName, listConfig, prisma, context),
401
447
  count: createCount(listName, listConfig, prisma, context),
402
448
  }
449
+
450
+ // Add get() method for singleton lists
451
+ if (isSingletonList(listConfig)) {
452
+ operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
453
+ }
454
+
455
+ db[dbKey] = operations
403
456
  }
404
457
 
405
458
  // Execute plugin runtime functions and populate context.plugins
@@ -617,6 +670,14 @@ function createFindMany<TPrisma extends PrismaClientLike>(
617
670
  skip?: number
618
671
  include?: Record<string, unknown>
619
672
  }) => {
673
+ // Check singleton constraint (throw error instead of silently returning empty)
674
+ if (isSingletonList(listConfig)) {
675
+ throw new ValidationError(
676
+ [`Cannot use findMany: ${listName} is a singleton list. Use get() instead.`],
677
+ {},
678
+ )
679
+ }
680
+
620
681
  // Check query access (skip if sudo mode)
621
682
  let where: Record<string, unknown> | undefined = args?.where
622
683
  if (!context._isSudo) {
@@ -696,6 +757,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
696
757
  config: OpenSaasConfig,
697
758
  ) {
698
759
  return async (args: { data: Record<string, unknown> }) => {
760
+ // 0. Check singleton constraint (enforce even in sudo mode)
761
+ if (isSingletonList(listConfig)) {
762
+ // Access Prisma model dynamically - required because model names are generated at runtime
763
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
764
+ const model = (prisma as any)[getDbKey(listName)]
765
+ const existingCount = await model.count()
766
+
767
+ if (existingCount > 0) {
768
+ throw new ValidationError(
769
+ [`Cannot create: ${listName} is a singleton list with an existing record`],
770
+ {},
771
+ )
772
+ }
773
+ }
774
+
699
775
  // 1. Check create access (skip if sudo mode)
700
776
  if (!context._isSudo) {
701
777
  const createAccess = listConfig.access?.operation?.create
@@ -759,6 +835,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
759
835
  const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
760
836
  session: context.session,
761
837
  context: { ...context, _isSudo: context._isSudo },
838
+ inputData: args.data,
762
839
  })
763
840
 
764
841
  // 5.5. Process nested relationship operations
@@ -939,6 +1016,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
939
1016
  session: context.session,
940
1017
  item,
941
1018
  context: { ...context, _isSudo: context._isSudo },
1019
+ inputData: args.data,
942
1020
  })
943
1021
 
944
1022
  // 6.5. Process nested relationship operations
@@ -1029,6 +1107,11 @@ function createDelete<TPrisma extends PrismaClientLike>(
1029
1107
  context: AccessContext<TPrisma>,
1030
1108
  ) {
1031
1109
  return async (args: { where: { id: string } }) => {
1110
+ // 0. Check singleton constraint (enforce even in sudo mode)
1111
+ if (isSingletonList(listConfig)) {
1112
+ throw new ValidationError([`Cannot delete: ${listName} is a singleton list`], {})
1113
+ }
1114
+
1032
1115
  // 1. Fetch the item to pass to access control and hooks
1033
1116
  // Access Prisma model dynamically - required because model names are generated at runtime
1034
1117
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1176,3 +1259,86 @@ function createCount<TPrisma extends PrismaClientLike>(
1176
1259
  return count
1177
1260
  }
1178
1261
  }
1262
+
1263
+ /**
1264
+ * Create get operation for singleton lists
1265
+ * Returns the single record, or auto-creates it if enabled
1266
+ */
1267
+ function createGet<TPrisma extends PrismaClientLike>(
1268
+ listName: string,
1269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
1270
+ listConfig: ListConfig<any>,
1271
+ prisma: TPrisma,
1272
+ context: AccessContext<TPrisma>,
1273
+ config: OpenSaasConfig,
1274
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1275
+ createFn: any,
1276
+ ) {
1277
+ return async () => {
1278
+ // First try to find the existing record
1279
+ // Access Prisma model dynamically - required because model names are generated at runtime
1280
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1281
+ const model = (prisma as any)[getDbKey(listName)]
1282
+
1283
+ // Check query access (skip if sudo mode)
1284
+ let where: Record<string, unknown> = {}
1285
+ if (!context._isSudo) {
1286
+ const queryAccess = listConfig.access?.operation?.query
1287
+ const accessResult = await checkAccess(queryAccess, {
1288
+ session: context.session,
1289
+ context,
1290
+ })
1291
+
1292
+ if (accessResult === false) {
1293
+ return null
1294
+ }
1295
+
1296
+ // Merge access filter (for singleton, we don't have a specific where clause)
1297
+ if (accessResult && typeof accessResult === 'object') {
1298
+ where = accessResult
1299
+ }
1300
+ }
1301
+
1302
+ // Build include with access control filters
1303
+ const accessControlledInclude = await buildIncludeWithAccessControl(
1304
+ listConfig.fields,
1305
+ {
1306
+ session: context.session,
1307
+ context,
1308
+ },
1309
+ config,
1310
+ )
1311
+
1312
+ // Try to find the record
1313
+ const item = await model.findFirst({
1314
+ where,
1315
+ include: accessControlledInclude,
1316
+ })
1317
+
1318
+ // If record exists, return it
1319
+ if (item) {
1320
+ // Filter readable fields and apply resolveOutput hooks
1321
+ const filtered = await filterReadableFields(
1322
+ item,
1323
+ listConfig.fields,
1324
+ {
1325
+ session: context.session,
1326
+ context: { ...context, _isSudo: context._isSudo },
1327
+ },
1328
+ config,
1329
+ 0,
1330
+ listName,
1331
+ )
1332
+ return filtered
1333
+ }
1334
+
1335
+ // If no record and auto-create is enabled, create it
1336
+ if (shouldAutoCreate(listConfig)) {
1337
+ const defaultData = getDefaultData(listConfig)
1338
+ return await createFn({ data: defaultData })
1339
+ }
1340
+
1341
+ // No record and auto-create is disabled
1342
+ return null
1343
+ }
1344
+ }
@@ -140,6 +140,7 @@ async function processNestedCreate(
140
140
  {
141
141
  session: context.session,
142
142
  context,
143
+ inputData: item,
143
144
  },
144
145
  )
145
146
 
@@ -309,6 +310,7 @@ async function processNestedUpdate(
309
310
  session: context.session,
310
311
  item,
311
312
  context,
313
+ inputData: updateData,
312
314
  },
313
315
  )
314
316
 
package/src/index.ts CHANGED
@@ -38,6 +38,17 @@ export type {
38
38
  Plugin,
39
39
  PluginContext,
40
40
  GeneratedFiles,
41
+ // List-level hook argument types
42
+ ResolveInputHookArgs,
43
+ ValidateHookArgs,
44
+ BeforeOperationHookArgs,
45
+ AfterOperationHookArgs,
46
+ // Field-level hook argument types
47
+ FieldResolveInputHookArgs,
48
+ FieldValidateHookArgs,
49
+ FieldBeforeOperationHookArgs,
50
+ FieldAfterOperationHookArgs,
51
+ FieldResolveOutputHookArgs,
41
52
  } from './config/index.js'
42
53
 
43
54
  // Access control
@@ -192,60 +192,57 @@ describe('Access Control', () => {
192
192
  expect(result).toBe(true)
193
193
  })
194
194
 
195
- it('should check filter match when operation returns filter', async () => {
196
- const item = { userId: '123' }
195
+ it('should receive inputData for create operations', async () => {
196
+ const inputData = { title: 'Test', authorId: '123' }
197
197
  const fieldAccess: FieldAccess = {
198
- read: vi.fn(async () => ({ userId: '123' })),
198
+ create: vi.fn(async ({ inputData: data }) => {
199
+ // Field access can validate inputData
200
+ return data?.authorId === '123'
201
+ }),
199
202
  }
200
203
 
201
- const result = await checkFieldAccess(fieldAccess, 'read', {
204
+ const result = await checkFieldAccess(fieldAccess, 'create', {
202
205
  session: null,
203
- item,
204
206
  context: mockContext,
207
+ inputData,
205
208
  })
206
209
 
207
210
  expect(result).toBe(true)
211
+ expect(fieldAccess.create).toHaveBeenCalledWith(expect.objectContaining({ inputData }))
208
212
  })
209
213
 
210
- it('should deny access when filter does not match', async () => {
211
- const item = { userId: '456' }
212
- const fieldAccess: FieldAccess = {
213
- read: vi.fn(async () => ({ userId: '123' })),
214
- }
215
-
216
- const result = await checkFieldAccess(fieldAccess, 'read', {
217
- session: null,
218
- item,
219
- context: mockContext,
220
- })
221
-
222
- expect(result).toBe(false)
223
- })
224
-
225
- it('should work with equals condition', async () => {
226
- const item = { status: 'active' }
214
+ it('should receive inputData for update operations', async () => {
215
+ const inputData = { title: 'Updated', authorId: '123' }
216
+ const item = { id: '1', authorId: '123' }
227
217
  const fieldAccess: FieldAccess = {
228
- read: vi.fn(async () => ({ status: { equals: 'active' } })),
218
+ update: vi.fn(async ({ inputData: data, item: existingItem }) => {
219
+ // Field access can validate inputData and check existing item
220
+ return data?.authorId === existingItem?.authorId
221
+ }),
229
222
  }
230
223
 
231
- const result = await checkFieldAccess(fieldAccess, 'read', {
224
+ const result = await checkFieldAccess(fieldAccess, 'update', {
232
225
  session: null,
233
226
  item,
234
227
  context: mockContext,
228
+ inputData,
235
229
  })
236
230
 
237
231
  expect(result).toBe(true)
232
+ expect(fieldAccess.update).toHaveBeenCalledWith(expect.objectContaining({ inputData, item }))
238
233
  })
239
234
 
240
- it('should work with not condition', async () => {
241
- const item = { status: 'active' }
235
+ it('should not receive inputData for read operations', async () => {
242
236
  const fieldAccess: FieldAccess = {
243
- read: vi.fn(async () => ({ status: { not: 'deleted' } })),
237
+ read: vi.fn(async ({ inputData }) => {
238
+ // inputData should be undefined for read operations
239
+ expect(inputData).toBeUndefined()
240
+ return true
241
+ }),
244
242
  }
245
243
 
246
244
  const result = await checkFieldAccess(fieldAccess, 'read', {
247
245
  session: null,
248
- item,
249
246
  context: mockContext,
250
247
  })
251
248
 
@@ -487,12 +484,15 @@ describe('Access Control', () => {
487
484
  session: { userId: '123' },
488
485
  item,
489
486
  context: mockContext,
487
+ inputData: data,
490
488
  })
491
489
 
492
490
  expect(accessFn).toHaveBeenCalledWith({
493
491
  session: { userId: '123' },
494
492
  item,
495
493
  context: mockContext,
494
+ inputData: data,
495
+ operation: 'update',
496
496
  })
497
497
  })
498
498
  })
@@ -76,7 +76,7 @@ describe('config helpers', () => {
76
76
  })
77
77
 
78
78
  describe('list', () => {
79
- it('should return the same list config', () => {
79
+ it('should return normalized list config', () => {
80
80
  const testList: ListConfig = {
81
81
  fields: {
82
82
  name: { type: 'text' },
@@ -85,7 +85,9 @@ describe('config helpers', () => {
85
85
  }
86
86
 
87
87
  const result = list(testList)
88
- expect(result).toBe(testList)
88
+ // list() normalizes access control, so it creates a new object
89
+ expect(result.fields).toEqual(testList.fields)
90
+ expect(result.access).toBeUndefined()
89
91
  })
90
92
 
91
93
  it('should support text fields', () => {
@@ -161,7 +163,7 @@ describe('config helpers', () => {
161
163
  expect(testList.fields.author.type).toBe('relationship')
162
164
  })
163
165
 
164
- it('should support access control', () => {
166
+ it('should support access control object form', () => {
165
167
  const testList = list({
166
168
  fields: { name: { type: 'text' } },
167
169
  access: {
@@ -177,6 +179,21 @@ describe('config helpers', () => {
177
179
  expect(testList.access?.operation).toBeDefined()
178
180
  })
179
181
 
182
+ it('should support access control function shorthand', () => {
183
+ const isAdmin = () => true
184
+ const testList = list({
185
+ fields: { name: { type: 'text' } },
186
+ access: isAdmin,
187
+ })
188
+
189
+ // Function shorthand should be normalized to object form
190
+ expect(testList.access?.operation).toBeDefined()
191
+ expect(testList.access?.operation?.query).toBe(isAdmin)
192
+ expect(testList.access?.operation?.create).toBe(isAdmin)
193
+ expect(testList.access?.operation?.update).toBe(isAdmin)
194
+ expect(testList.access?.operation?.delete).toBe(isAdmin)
195
+ })
196
+
180
197
  it('should support hooks', () => {
181
198
  const testList = list({
182
199
  fields: { name: { type: 'text' } },