@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.
Files changed (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +291 -0
  3. package/README.md +6 -3
  4. package/dist/access/engine.d.ts +2 -0
  5. package/dist/access/engine.d.ts.map +1 -1
  6. package/dist/access/engine.js +8 -6
  7. package/dist/access/engine.js.map +1 -1
  8. package/dist/access/engine.test.js +4 -0
  9. package/dist/access/engine.test.js.map +1 -1
  10. package/dist/access/types.d.ts +31 -4
  11. package/dist/access/types.d.ts.map +1 -1
  12. package/dist/config/index.d.ts +12 -10
  13. package/dist/config/index.d.ts.map +1 -1
  14. package/dist/config/index.js +37 -1
  15. package/dist/config/index.js.map +1 -1
  16. package/dist/config/types.d.ts +341 -82
  17. package/dist/config/types.d.ts.map +1 -1
  18. package/dist/context/index.d.ts.map +1 -1
  19. package/dist/context/index.js +330 -60
  20. package/dist/context/index.js.map +1 -1
  21. package/dist/context/nested-operations.d.ts.map +1 -1
  22. package/dist/context/nested-operations.js +38 -25
  23. package/dist/context/nested-operations.js.map +1 -1
  24. package/dist/hooks/index.d.ts +45 -7
  25. package/dist/hooks/index.d.ts.map +1 -1
  26. package/dist/hooks/index.js +10 -4
  27. package/dist/hooks/index.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/access/engine.test.ts +4 -0
  33. package/src/access/engine.ts +10 -7
  34. package/src/access/types.ts +45 -4
  35. package/src/config/index.ts +65 -9
  36. package/src/config/types.ts +402 -91
  37. package/src/context/index.ts +421 -82
  38. package/src/context/nested-operations.ts +40 -25
  39. package/src/hooks/index.ts +66 -14
  40. package/src/index.ts +11 -0
  41. package/tests/access.test.ts +28 -28
  42. package/tests/config.test.ts +20 -3
  43. package/tests/nested-access-and-hooks.test.ts +8 -3
  44. package/tests/singleton.test.ts +329 -0
  45. package/tests/sudo.test.ts +2 -13
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -9,7 +9,7 @@ import {
9
9
  } from '../access/index.js'
10
10
  import {
11
11
  executeResolveInput,
12
- executeValidateInput,
12
+ executeValidate,
13
13
  executeBeforeOperation,
14
14
  executeAfterOperation,
15
15
  validateFieldRules,
@@ -27,7 +27,9 @@ import type { FieldConfig } from '../config/types.js'
27
27
  */
28
28
  async function executeFieldResolveInputHooks(
29
29
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
- data: Record<string, any>,
30
+ inputData: Record<string, any>,
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ resolvedData: Record<string, any>,
31
33
  fields: Record<string, FieldConfig>,
32
34
  operation: 'create' | 'update',
33
35
  context: AccessContext,
@@ -35,11 +37,11 @@ async function executeFieldResolveInputHooks(
35
37
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
38
  item?: any,
37
39
  ): Promise<Record<string, unknown>> {
38
- const result = { ...data }
40
+ let result = { ...resolvedData }
39
41
 
40
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
42
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
41
43
  // Skip if field not in data
42
- if (!(fieldName in result)) continue
44
+ if (!(fieldKey in result)) continue
43
45
 
44
46
  // Skip if no hooks defined
45
47
  if (!fieldConfig.hooks?.resolveInput) continue
@@ -49,27 +51,102 @@ async function executeFieldResolveInputHooks(
49
51
  // and we're working with runtime values that match those types
50
52
 
51
53
  const transformedValue = await fieldConfig.hooks.resolveInput({
52
- inputValue: result[fieldName],
53
- operation,
54
- fieldName,
55
54
  listKey,
55
+ fieldKey,
56
+ operation,
57
+ inputData,
56
58
  item,
59
+ resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
57
60
  context,
58
- })
61
+ } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
59
62
 
60
- result[fieldName] = transformedValue
63
+ // Create new object with updated field to avoid mutating the passed reference
64
+ result = { ...result, [fieldKey]: transformedValue }
61
65
  }
62
66
 
63
67
  return result
64
68
  }
65
69
 
70
+ /**
71
+ * Execute field-level validate hooks
72
+ * Allows fields to perform custom validation after resolveInput but before database write
73
+ */
74
+ async function executeFieldValidateHooks(
75
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
+ inputData: Record<string, any> | undefined,
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ resolvedData: Record<string, any> | undefined,
79
+ fields: Record<string, FieldConfig>,
80
+ operation: 'create' | 'update' | 'delete',
81
+ context: AccessContext,
82
+ listKey: string,
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ item?: any,
85
+ ): Promise<void> {
86
+ const errors: string[] = []
87
+ const fieldErrors: Record<string, string> = {}
88
+
89
+ const addValidationError = (fieldKey: string) => (msg: string) => {
90
+ errors.push(msg)
91
+ fieldErrors[fieldKey] = msg
92
+ }
93
+
94
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
95
+ // Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
96
+ const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput
97
+ if (!validateHook) continue
98
+
99
+ // Execute field hook
100
+ // Type assertion is safe here because hooks are typed correctly in field definitions
101
+ if (operation === 'delete') {
102
+ await validateHook({
103
+ listKey,
104
+ fieldKey,
105
+ operation: 'delete',
106
+ item,
107
+ context,
108
+ addValidationError: addValidationError(fieldKey),
109
+ } as Parameters<typeof validateHook>[0])
110
+ } else if (operation === 'create') {
111
+ await validateHook({
112
+ listKey,
113
+ fieldKey,
114
+ operation: 'create',
115
+ inputData,
116
+ item: undefined,
117
+ resolvedData,
118
+ context,
119
+ addValidationError: addValidationError(fieldKey),
120
+ } as Parameters<typeof validateHook>[0])
121
+ } else {
122
+ // operation === 'update'
123
+ await validateHook({
124
+ listKey,
125
+ fieldKey,
126
+ operation: 'update',
127
+ inputData,
128
+ item,
129
+ resolvedData,
130
+ context,
131
+ addValidationError: addValidationError(fieldKey),
132
+ } as Parameters<typeof validateHook>[0])
133
+ }
134
+ }
135
+
136
+ if (errors.length > 0) {
137
+ throw new ValidationError(errors, fieldErrors)
138
+ }
139
+ }
140
+
66
141
  /**
67
142
  * Execute field-level beforeOperation hooks (side effects only)
68
143
  * Allows fields to perform side effects before database write
69
144
  */
70
145
  async function executeFieldBeforeOperationHooks(
71
146
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
- data: Record<string, any>,
147
+ inputData: Record<string, any>,
148
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
+ resolvedData: Record<string, any>,
73
150
  fields: Record<string, FieldConfig>,
74
151
  operation: 'create' | 'update' | 'delete',
75
152
  context: AccessContext,
@@ -77,21 +154,43 @@ async function executeFieldBeforeOperationHooks(
77
154
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
155
  item?: any,
79
156
  ): Promise<void> {
80
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
81
- // Skip if field not in data (for create/update) or if no hooks defined
157
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
158
+ // Skip if no hooks defined
82
159
  if (!fieldConfig.hooks?.beforeOperation) continue
83
- if (operation !== 'delete' && !(fieldName in data)) continue
160
+ // Skip if field not in data (for create/update)
161
+ if (operation !== 'delete' && !(fieldKey in resolvedData)) continue
84
162
 
85
163
  // Execute field hook (side effects only, no return value used)
86
164
  // Type assertion is safe here because hooks are typed correctly in field definitions
87
- await fieldConfig.hooks.beforeOperation({
88
- resolvedValue: data[fieldName],
89
- operation,
90
- fieldName,
91
- listKey,
92
- item,
93
- context,
94
- })
165
+ if (operation === 'delete') {
166
+ await fieldConfig.hooks.beforeOperation({
167
+ listKey,
168
+ fieldKey,
169
+ operation: 'delete',
170
+ item,
171
+ context,
172
+ } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
173
+ } else if (operation === 'create') {
174
+ await fieldConfig.hooks.beforeOperation({
175
+ listKey,
176
+ fieldKey,
177
+ operation: 'create',
178
+ inputData,
179
+ resolvedData,
180
+ context,
181
+ } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
182
+ } else {
183
+ // operation === 'update'
184
+ await fieldConfig.hooks.beforeOperation({
185
+ listKey,
186
+ fieldKey,
187
+ operation: 'update',
188
+ inputData,
189
+ item,
190
+ resolvedData,
191
+ context,
192
+ } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
193
+ }
95
194
  }
96
195
  }
97
196
 
@@ -102,31 +201,51 @@ async function executeFieldBeforeOperationHooks(
102
201
  async function executeFieldAfterOperationHooks(
103
202
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
203
  item: any,
105
- data: Record<string, unknown> | undefined,
204
+ inputData: Record<string, unknown> | undefined,
205
+ resolvedData: Record<string, unknown> | undefined,
106
206
  fields: Record<string, FieldConfig>,
107
- operation: 'create' | 'update' | 'delete' | 'query',
207
+ operation: 'create' | 'update' | 'delete',
108
208
  context: AccessContext,
109
209
  listKey: string,
110
210
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
211
  originalItem?: any,
112
212
  ): Promise<void> {
113
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
213
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
114
214
  // Skip if no hooks defined
115
215
  if (!fieldConfig.hooks?.afterOperation) continue
116
216
 
117
- // Get the value from item (for all operations)
118
- const value = item?.[fieldName]
119
-
120
217
  // Execute field hook (side effects only, no return value used)
121
- await fieldConfig.hooks.afterOperation({
122
- value,
123
- operation,
124
- fieldName,
125
- listKey,
126
- item,
127
- originalItem,
128
- context,
129
- })
218
+ if (operation === 'delete') {
219
+ await fieldConfig.hooks.afterOperation({
220
+ listKey,
221
+ fieldKey,
222
+ operation: 'delete',
223
+ originalItem,
224
+ context,
225
+ } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
226
+ } else if (operation === 'create') {
227
+ await fieldConfig.hooks.afterOperation({
228
+ listKey,
229
+ fieldKey,
230
+ operation: 'create',
231
+ inputData,
232
+ item,
233
+ resolvedData,
234
+ context,
235
+ } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
236
+ } else {
237
+ // operation === 'update'
238
+ await fieldConfig.hooks.afterOperation({
239
+ listKey,
240
+ fieldKey,
241
+ operation: 'update',
242
+ inputData,
243
+ originalItem,
244
+ item,
245
+ resolvedData,
246
+ context,
247
+ } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
248
+ }
130
249
  }
131
250
  }
132
251
 
@@ -135,6 +254,49 @@ export type ServerActionProps =
135
254
  | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
136
255
  | { listKey: string; action: 'delete'; id: string }
137
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
+
138
300
  /**
139
301
  * Parse Prisma error and convert to user-friendly DatabaseError
140
302
  */
@@ -274,14 +436,23 @@ export function getContext<
274
436
  for (const [listName, listConfig] of Object.entries(config.lists)) {
275
437
  const dbKey = getDbKey(listName)
276
438
 
277
- db[dbKey] = {
439
+ // Create base operations
440
+ const createOp = createCreate(listName, listConfig, prisma, context, config)
441
+ const operations: Record<string, unknown> = {
278
442
  findUnique: createFindUnique(listName, listConfig, prisma, context, config),
279
443
  findMany: createFindMany(listName, listConfig, prisma, context, config),
280
- create: createCreate(listName, listConfig, prisma, context, config),
444
+ create: createOp,
281
445
  update: createUpdate(listName, listConfig, prisma, context, config),
282
446
  delete: createDelete(listName, listConfig, prisma, context),
283
447
  count: createCount(listName, listConfig, prisma, context),
284
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
285
456
  }
286
457
 
287
458
  // Execute plugin runtime functions and populate context.plugins
@@ -478,17 +649,6 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
478
649
  listName,
479
650
  )
480
651
 
481
- // Execute field afterOperation hooks (side effects only)
482
- await executeFieldAfterOperationHooks(
483
- filtered,
484
- undefined,
485
- listConfig.fields,
486
- 'query',
487
- context,
488
- listName,
489
- undefined, // originalItem is undefined for query operations
490
- )
491
-
492
652
  return filtered
493
653
  }
494
654
  }
@@ -510,6 +670,14 @@ function createFindMany<TPrisma extends PrismaClientLike>(
510
670
  skip?: number
511
671
  include?: Record<string, unknown>
512
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
+
513
681
  // Check query access (skip if sudo mode)
514
682
  let where: Record<string, unknown> | undefined = args?.where
515
683
  if (!context._isSudo) {
@@ -573,21 +741,6 @@ function createFindMany<TPrisma extends PrismaClientLike>(
573
741
  ),
574
742
  )
575
743
 
576
- // Execute field afterOperation hooks for each item (side effects only)
577
- await Promise.all(
578
- filtered.map((item) =>
579
- executeFieldAfterOperationHooks(
580
- item,
581
- undefined,
582
- listConfig.fields,
583
- 'query',
584
- context,
585
- listName,
586
- undefined, // originalItem is undefined for query operations
587
- ),
588
- ),
589
- )
590
-
591
744
  return filtered
592
745
  }
593
746
  }
@@ -604,6 +757,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
604
757
  config: OpenSaasConfig,
605
758
  ) {
606
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
+
607
775
  // 1. Check create access (skip if sudo mode)
608
776
  if (!context._isSudo) {
609
777
  const createAccess = listConfig.access?.operation?.create
@@ -619,7 +787,9 @@ function createCreate<TPrisma extends PrismaClientLike>(
619
787
 
620
788
  // 2. Execute list-level resolveInput hook
621
789
  let resolvedData = await executeResolveInput(listConfig.hooks, {
790
+ listKey: listName,
622
791
  operation: 'create',
792
+ inputData: args.data,
623
793
  resolvedData: args.data,
624
794
  item: undefined,
625
795
  context,
@@ -627,6 +797,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
627
797
 
628
798
  // 2.5. Execute field-level resolveInput hooks (e.g., hash passwords)
629
799
  resolvedData = await executeFieldResolveInputHooks(
800
+ args.data,
630
801
  resolvedData,
631
802
  listConfig.fields,
632
803
  'create',
@@ -634,14 +805,26 @@ function createCreate<TPrisma extends PrismaClientLike>(
634
805
  listName,
635
806
  )
636
807
 
637
- // 3. Execute validateInput hook
638
- await executeValidateInput(listConfig.hooks, {
808
+ // 3. Execute list-level validate hook
809
+ await executeValidate(listConfig.hooks, {
810
+ listKey: listName,
639
811
  operation: 'create',
812
+ inputData: args.data,
640
813
  resolvedData,
641
814
  item: undefined,
642
815
  context,
643
816
  })
644
817
 
818
+ // 3.5. Execute field-level validate hooks
819
+ await executeFieldValidateHooks(
820
+ args.data,
821
+ resolvedData,
822
+ listConfig.fields,
823
+ 'create',
824
+ context,
825
+ listName,
826
+ )
827
+
645
828
  // 4. Field validation (isRequired, length, etc.)
646
829
  const validation = validateFieldRules(resolvedData, listConfig.fields, 'create')
647
830
  if (validation.errors.length > 0) {
@@ -652,6 +835,7 @@ function createCreate<TPrisma extends PrismaClientLike>(
652
835
  const filteredData = await filterWritableFields(resolvedData, listConfig.fields, 'create', {
653
836
  session: context.session,
654
837
  context: { ...context, _isSudo: context._isSudo },
838
+ inputData: args.data,
655
839
  })
656
840
 
657
841
  // 5.5. Process nested relationship operations
@@ -664,11 +848,21 @@ function createCreate<TPrisma extends PrismaClientLike>(
664
848
  )
665
849
 
666
850
  // 6. Execute field-level beforeOperation hooks (side effects only)
667
- await executeFieldBeforeOperationHooks(data, listConfig.fields, 'create', context, listName)
851
+ await executeFieldBeforeOperationHooks(
852
+ args.data,
853
+ resolvedData,
854
+ listConfig.fields,
855
+ 'create',
856
+ context,
857
+ listName,
858
+ )
668
859
 
669
860
  // 7. Execute list-level beforeOperation hook
670
861
  await executeBeforeOperation(listConfig.hooks, {
862
+ listKey: listName,
671
863
  operation: 'create',
864
+ inputData: args.data,
865
+ resolvedData,
672
866
  context,
673
867
  })
674
868
 
@@ -682,16 +876,19 @@ function createCreate<TPrisma extends PrismaClientLike>(
682
876
 
683
877
  // 9. Execute list-level afterOperation hook
684
878
  await executeAfterOperation(listConfig.hooks, {
879
+ listKey: listName,
685
880
  operation: 'create',
881
+ inputData: args.data,
686
882
  item,
687
- originalItem: undefined,
883
+ resolvedData,
688
884
  context,
689
885
  })
690
886
 
691
887
  // 10. Execute field-level afterOperation hooks (side effects only)
692
888
  await executeFieldAfterOperationHooks(
693
889
  item,
694
- data,
890
+ args.data,
891
+ resolvedData,
695
892
  listConfig.fields,
696
893
  'create',
697
894
  context,
@@ -768,7 +965,9 @@ function createUpdate<TPrisma extends PrismaClientLike>(
768
965
 
769
966
  // 3. Execute list-level resolveInput hook
770
967
  let resolvedData = await executeResolveInput(listConfig.hooks, {
968
+ listKey: listName,
771
969
  operation: 'update',
970
+ inputData: args.data,
772
971
  resolvedData: args.data,
773
972
  item,
774
973
  context,
@@ -776,6 +975,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
776
975
 
777
976
  // 3.5. Execute field-level resolveInput hooks (e.g., hash passwords)
778
977
  resolvedData = await executeFieldResolveInputHooks(
978
+ args.data,
779
979
  resolvedData,
780
980
  listConfig.fields,
781
981
  'update',
@@ -784,14 +984,27 @@ function createUpdate<TPrisma extends PrismaClientLike>(
784
984
  item,
785
985
  )
786
986
 
787
- // 4. Execute validateInput hook
788
- await executeValidateInput(listConfig.hooks, {
987
+ // 4. Execute list-level validate hook
988
+ await executeValidate(listConfig.hooks, {
989
+ listKey: listName,
789
990
  operation: 'update',
991
+ inputData: args.data,
790
992
  resolvedData,
791
993
  item,
792
994
  context,
793
995
  })
794
996
 
997
+ // 4.5. Execute field-level validate hooks
998
+ await executeFieldValidateHooks(
999
+ args.data,
1000
+ resolvedData,
1001
+ listConfig.fields,
1002
+ 'update',
1003
+ context,
1004
+ listName,
1005
+ item,
1006
+ )
1007
+
795
1008
  // 5. Field validation (isRequired, length, etc.)
796
1009
  const validation = validateFieldRules(resolvedData, listConfig.fields, 'update')
797
1010
  if (validation.errors.length > 0) {
@@ -803,6 +1016,7 @@ function createUpdate<TPrisma extends PrismaClientLike>(
803
1016
  session: context.session,
804
1017
  item,
805
1018
  context: { ...context, _isSudo: context._isSudo },
1019
+ inputData: args.data,
806
1020
  })
807
1021
 
808
1022
  // 6.5. Process nested relationship operations
@@ -816,7 +1030,8 @@ function createUpdate<TPrisma extends PrismaClientLike>(
816
1030
 
817
1031
  // 7. Execute field-level beforeOperation hooks (side effects only)
818
1032
  await executeFieldBeforeOperationHooks(
819
- data,
1033
+ args.data,
1034
+ resolvedData,
820
1035
  listConfig.fields,
821
1036
  'update',
822
1037
  context,
@@ -826,8 +1041,11 @@ function createUpdate<TPrisma extends PrismaClientLike>(
826
1041
 
827
1042
  // 8. Execute list-level beforeOperation hook
828
1043
  await executeBeforeOperation(listConfig.hooks, {
1044
+ listKey: listName,
829
1045
  operation: 'update',
1046
+ inputData: args.data,
830
1047
  item,
1048
+ resolvedData,
831
1049
  context,
832
1050
  })
833
1051
 
@@ -839,16 +1057,20 @@ function createUpdate<TPrisma extends PrismaClientLike>(
839
1057
 
840
1058
  // 10. Execute list-level afterOperation hook
841
1059
  await executeAfterOperation(listConfig.hooks, {
1060
+ listKey: listName,
842
1061
  operation: 'update',
843
- item: updated,
1062
+ inputData: args.data,
844
1063
  originalItem: item, // item is the original item before the update
1064
+ item: updated,
1065
+ resolvedData,
845
1066
  context,
846
1067
  })
847
1068
 
848
1069
  // 11. Execute field-level afterOperation hooks (side effects only)
849
1070
  await executeFieldAfterOperationHooks(
850
1071
  updated,
851
- data,
1072
+ args.data,
1073
+ resolvedData,
852
1074
  listConfig.fields,
853
1075
  'update',
854
1076
  context,
@@ -885,6 +1107,11 @@ function createDelete<TPrisma extends PrismaClientLike>(
885
1107
  context: AccessContext<TPrisma>,
886
1108
  ) {
887
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
+
888
1115
  // 1. Fetch the item to pass to access control and hooks
889
1116
  // Access Prisma model dynamically - required because model names are generated at runtime
890
1117
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -922,33 +1149,62 @@ function createDelete<TPrisma extends PrismaClientLike>(
922
1149
  }
923
1150
  }
924
1151
 
925
- // 3. Execute field-level beforeOperation hooks (side effects only)
926
- await executeFieldBeforeOperationHooks({}, listConfig.fields, 'delete', context, listName, item)
1152
+ // 3. Execute list-level validate hook
1153
+ await executeValidate(listConfig.hooks, {
1154
+ listKey: listName,
1155
+ operation: 'delete',
1156
+ item,
1157
+ context,
1158
+ })
1159
+
1160
+ // 3.5. Execute field-level validate hooks
1161
+ await executeFieldValidateHooks(
1162
+ undefined,
1163
+ undefined,
1164
+ listConfig.fields,
1165
+ 'delete',
1166
+ context,
1167
+ listName,
1168
+ item,
1169
+ )
1170
+
1171
+ // 4. Execute field-level beforeOperation hooks (side effects only)
1172
+ await executeFieldBeforeOperationHooks(
1173
+ {},
1174
+ {},
1175
+ listConfig.fields,
1176
+ 'delete',
1177
+ context,
1178
+ listName,
1179
+ item,
1180
+ )
927
1181
 
928
- // 4. Execute list-level beforeOperation hook
1182
+ // 5. Execute list-level beforeOperation hook
929
1183
  await executeBeforeOperation(listConfig.hooks, {
1184
+ listKey: listName,
930
1185
  operation: 'delete',
931
1186
  item,
932
1187
  context,
933
1188
  })
934
1189
 
935
- // 5. Execute database delete
1190
+ // 6. Execute database delete
936
1191
  const deleted = await model.delete({
937
1192
  where: args.where,
938
1193
  })
939
1194
 
940
- // 6. Execute list-level afterOperation hook
1195
+ // 7. Execute list-level afterOperation hook
941
1196
  await executeAfterOperation(listConfig.hooks, {
1197
+ listKey: listName,
942
1198
  operation: 'delete',
943
- item: deleted,
944
1199
  originalItem: item, // item is the original item before deletion
945
1200
  context,
946
1201
  })
947
1202
 
948
- // 7. Execute field-level afterOperation hooks (side effects only)
1203
+ // 8. Execute field-level afterOperation hooks (side effects only)
949
1204
  await executeFieldAfterOperationHooks(
950
1205
  deleted,
951
1206
  undefined,
1207
+ undefined,
952
1208
  listConfig.fields,
953
1209
  'delete',
954
1210
  context,
@@ -1003,3 +1259,86 @@ function createCount<TPrisma extends PrismaClientLike>(
1003
1259
  return count
1004
1260
  }
1005
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
+ }