@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.
@@ -1,5 +1,38 @@
1
- import type { OpenSaasConfig, ListConfig, OperationAccess, Hooks } from './types.js'
1
+ import type {
2
+ OpenSaasConfig,
3
+ ListConfig,
4
+ ListConfigInput,
5
+ OperationAccess,
6
+ ListAccessControl,
7
+ } from './types.js'
2
8
  import { executePlugins } from './plugin-engine.js'
9
+ import type { AccessControl } from '../access/types.js'
10
+
11
+ /**
12
+ * Normalize access control shorthand to object form
13
+ * Converts function shorthand to { operation: { query, create, update, delete } } form
14
+ */
15
+ function normalizeListAccess<T>(
16
+ access: ListAccessControl<T> | undefined,
17
+ ): { operation?: OperationAccess<T> } | undefined {
18
+ if (!access) return undefined
19
+
20
+ // If it's a function, convert to object form applying to all operations
21
+ if (typeof access === 'function') {
22
+ const fn = access as AccessControl<T>
23
+ return {
24
+ operation: {
25
+ query: fn,
26
+ create: fn,
27
+ update: fn,
28
+ delete: fn,
29
+ },
30
+ }
31
+ }
32
+
33
+ // Already in object form
34
+ return access
35
+ }
3
36
 
4
37
  /**
5
38
  * Helper function to define configuration with type safety
@@ -61,25 +94,37 @@ export function config(userConfig: OpenSaasConfig): OpenSaasConfig | Promise<Ope
61
94
  * fields: { title: text() },
62
95
  * hooks: { ... }
63
96
  * })
97
+ *
98
+ * // Access control shorthand
99
+ * const isAdmin = ({ session }) => session?.role === 'admin'
100
+ *
101
+ * Settings: list({
102
+ * access: isAdmin, // Applies to all operations
103
+ * isSingleton: true,
104
+ * fields: { ... }
105
+ * })
64
106
  * ```
65
107
  */
66
- export function list<TTypeInfo extends import('./types.js').TypeInfo>(config: {
67
- fields: import('./types.js').FieldsWithTypeInfo<TTypeInfo>
68
- access?: {
69
- operation?: OperationAccess<TTypeInfo['item']>
108
+ export function list<TTypeInfo extends import('./types.js').TypeInfo>(
109
+ config: ListConfigInput<TTypeInfo>,
110
+ ): ListConfig<TTypeInfo> {
111
+ // Normalize access control shorthand to object form
112
+ const normalizedConfig = {
113
+ ...config,
114
+ access: normalizeListAccess(config.access),
70
115
  }
71
- hooks?: Hooks<TTypeInfo['item'], TTypeInfo['inputs']['create'], TTypeInfo['inputs']['update']>
72
- mcp?: import('./types.js').ListMcpConfig
73
- }): ListConfig<TTypeInfo> {
116
+
74
117
  // At runtime, field configs are unchanged
75
118
  // At type level, they're transformed to inject TypeInfo types
76
- return config as ListConfig<TTypeInfo>
119
+ return normalizedConfig as ListConfig<TTypeInfo>
77
120
  }
78
121
 
79
122
  // Re-export all types
80
123
  export type {
81
124
  OpenSaasConfig,
82
125
  ListConfig,
126
+ ListConfigInput,
127
+ ListAccessControl,
83
128
  FieldConfig,
84
129
  BaseFieldConfig,
85
130
  TextField,
@@ -115,4 +160,15 @@ export type {
115
160
  Plugin,
116
161
  PluginContext,
117
162
  GeneratedFiles,
163
+ // List-level hook argument types
164
+ ResolveInputHookArgs,
165
+ ValidateHookArgs,
166
+ BeforeOperationHookArgs,
167
+ AfterOperationHookArgs,
168
+ // Field-level hook argument types
169
+ FieldResolveInputHookArgs,
170
+ FieldValidateHookArgs,
171
+ FieldBeforeOperationHookArgs,
172
+ FieldAfterOperationHookArgs,
173
+ FieldResolveOutputHookArgs,
118
174
  } from './types.js'
@@ -14,6 +14,158 @@ export type FieldType =
14
14
  | 'relationship'
15
15
  | string // Allow custom field types from third-party packages
16
16
 
17
+ /**
18
+ * Field-level hook argument types (exported for user annotations)
19
+ */
20
+
21
+ /**
22
+ * Arguments for field-level resolveInput hook
23
+ * Used to transform field values before database write
24
+ */
25
+ export type FieldResolveInputHookArgs<
26
+ TTypeInfo extends TypeInfo,
27
+ TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
28
+ > =
29
+ | {
30
+ listKey: string
31
+ fieldKey: TFieldKey
32
+ operation: 'create'
33
+ inputData: TTypeInfo['inputs']['create']
34
+ item: undefined
35
+ resolvedData: TTypeInfo['inputs']['create']
36
+ context: import('../access/types.js').AccessContext
37
+ }
38
+ | {
39
+ listKey: string
40
+ fieldKey: TFieldKey
41
+ operation: 'update'
42
+ inputData: TTypeInfo['inputs']['update']
43
+ item: TTypeInfo['item']
44
+ resolvedData: TTypeInfo['inputs']['update']
45
+ context: import('../access/types.js').AccessContext
46
+ }
47
+
48
+ /**
49
+ * Arguments for field-level validate hook
50
+ * Used for custom validation logic
51
+ */
52
+ export type FieldValidateHookArgs<
53
+ TTypeInfo extends TypeInfo,
54
+ TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
55
+ > =
56
+ | {
57
+ listKey: string
58
+ fieldKey: TFieldKey
59
+ operation: 'create'
60
+ inputData: TTypeInfo['inputs']['create']
61
+ item: undefined
62
+ resolvedData: TTypeInfo['inputs']['create']
63
+ context: import('../access/types.js').AccessContext
64
+ addValidationError: (msg: string) => void
65
+ }
66
+ | {
67
+ listKey: string
68
+ fieldKey: TFieldKey
69
+ operation: 'update'
70
+ inputData: TTypeInfo['inputs']['update']
71
+ item: TTypeInfo['item']
72
+ resolvedData: TTypeInfo['inputs']['update']
73
+ context: import('../access/types.js').AccessContext
74
+ addValidationError: (msg: string) => void
75
+ }
76
+ | {
77
+ listKey: string
78
+ fieldKey: TFieldKey
79
+ operation: 'delete'
80
+ item: TTypeInfo['item']
81
+ context: import('../access/types.js').AccessContext
82
+ addValidationError: (msg: string) => void
83
+ }
84
+
85
+ /**
86
+ * Arguments for field-level beforeOperation hook
87
+ * Used for side effects before database write
88
+ */
89
+ export type FieldBeforeOperationHookArgs<
90
+ TTypeInfo extends TypeInfo,
91
+ TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
92
+ > =
93
+ | {
94
+ listKey: string
95
+ fieldKey: TFieldKey
96
+ operation: 'create'
97
+ inputData: TTypeInfo['inputs']['create']
98
+ resolvedData: TTypeInfo['inputs']['create']
99
+ context: import('../access/types.js').AccessContext
100
+ }
101
+ | {
102
+ listKey: string
103
+ fieldKey: TFieldKey
104
+ operation: 'update'
105
+ inputData: TTypeInfo['inputs']['update']
106
+ item: TTypeInfo['item']
107
+ resolvedData: TTypeInfo['inputs']['update']
108
+ context: import('../access/types.js').AccessContext
109
+ }
110
+ | {
111
+ listKey: string
112
+ fieldKey: TFieldKey
113
+ operation: 'delete'
114
+ item: TTypeInfo['item']
115
+ context: import('../access/types.js').AccessContext
116
+ }
117
+
118
+ /**
119
+ * Arguments for field-level afterOperation hook
120
+ * Used for side effects after database operation
121
+ */
122
+ export type FieldAfterOperationHookArgs<
123
+ TTypeInfo extends TypeInfo,
124
+ TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
125
+ > =
126
+ | {
127
+ listKey: string
128
+ fieldKey: TFieldKey
129
+ operation: 'create'
130
+ inputData: TTypeInfo['inputs']['create']
131
+ item: TTypeInfo['item']
132
+ resolvedData: TTypeInfo['inputs']['create']
133
+ context: import('../access/types.js').AccessContext
134
+ }
135
+ | {
136
+ listKey: string
137
+ fieldKey: TFieldKey
138
+ operation: 'update'
139
+ inputData: TTypeInfo['inputs']['update']
140
+ originalItem: TTypeInfo['item']
141
+ item: TTypeInfo['item']
142
+ resolvedData: TTypeInfo['inputs']['update']
143
+ context: import('../access/types.js').AccessContext
144
+ }
145
+ | {
146
+ listKey: string
147
+ fieldKey: TFieldKey
148
+ operation: 'delete'
149
+ originalItem: TTypeInfo['item']
150
+ context: import('../access/types.js').AccessContext
151
+ }
152
+
153
+ /**
154
+ * Arguments for field-level resolveOutput hook
155
+ * Used to transform field values after database read
156
+ */
157
+ export type FieldResolveOutputHookArgs<
158
+ TTypeInfo extends TypeInfo,
159
+ TFieldKey extends FieldKeys<TTypeInfo['fields']> = FieldKeys<TTypeInfo['fields']>,
160
+ > = {
161
+ operation: 'query'
162
+ value: GetFieldValueType<TTypeInfo['fields'], TFieldKey>
163
+ item: TTypeInfo['item']
164
+ listKey: string
165
+ fieldName: TFieldKey
166
+ context: import('../access/types.js').AccessContext
167
+ }
168
+
17
169
  /**
18
170
  * Field-level hooks for data transformation and side effects
19
171
  * Allows field types to define custom behavior during operations
@@ -55,25 +207,7 @@ export type FieldHooks<
55
207
  * ```
56
208
  */
57
209
  resolveInput?: (
58
- args:
59
- | {
60
- listKey: string
61
- fieldKey: TFieldKey
62
- operation: 'create'
63
- inputData: TTypeInfo['inputs']['create']
64
- item: undefined
65
- resolvedData: TTypeInfo['inputs']['create']
66
- context: import('../access/types.js').AccessContext
67
- }
68
- | {
69
- listKey: string
70
- fieldKey: TFieldKey
71
- operation: 'update'
72
- inputData: TTypeInfo['inputs']['update']
73
- item: TTypeInfo['item']
74
- resolvedData: TTypeInfo['inputs']['update']
75
- context: import('../access/types.js').AccessContext
76
- },
210
+ args: FieldResolveInputHookArgs<TTypeInfo, TFieldKey>,
77
211
  ) =>
78
212
  | Promise<GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined>
79
213
  | GetFieldValueType<TTypeInfo['fields'], TFieldKey>
@@ -95,37 +229,12 @@ export type FieldHooks<
95
229
  * }
96
230
  * ```
97
231
  */
98
- validate?: (
99
- args:
100
- | {
101
- listKey: string
102
- fieldKey: TFieldKey
103
- operation: 'create'
104
- inputData: TTypeInfo['inputs']['create']
105
- item: undefined
106
- resolvedData: TTypeInfo['inputs']['create']
107
- context: import('../access/types.js').AccessContext
108
- addValidationError: (msg: string) => void
109
- }
110
- | {
111
- listKey: string
112
- fieldKey: TFieldKey
113
- operation: 'update'
114
- inputData: TTypeInfo['inputs']['update']
115
- item: TTypeInfo['item']
116
- resolvedData: TTypeInfo['inputs']['update']
117
- context: import('../access/types.js').AccessContext
118
- addValidationError: (msg: string) => void
119
- }
120
- | {
121
- listKey: string
122
- fieldKey: TFieldKey
123
- operation: 'delete'
124
- item: TTypeInfo['item']
125
- context: import('../access/types.js').AccessContext
126
- addValidationError: (msg: string) => void
127
- },
128
- ) => Promise<void> | void
232
+ validate?: (args: FieldValidateHookArgs<TTypeInfo, TFieldKey>) => Promise<void> | void
233
+
234
+ /**
235
+ * @deprecated Use 'validate' instead. This alias is provided for backwards compatibility.
236
+ */
237
+ validateInput?: (args: FieldValidateHookArgs<TTypeInfo, TFieldKey>) => Promise<void> | void
129
238
 
130
239
  /**
131
240
  * Perform side effects before database write
@@ -146,31 +255,7 @@ export type FieldHooks<
146
255
  * ```
147
256
  */
148
257
  beforeOperation?: (
149
- args:
150
- | {
151
- listKey: string
152
- fieldKey: TFieldKey
153
- operation: 'create'
154
- inputData: TTypeInfo['inputs']['create']
155
- resolvedData: TTypeInfo['inputs']['create']
156
- context: import('../access/types.js').AccessContext
157
- }
158
- | {
159
- listKey: string
160
- fieldKey: TFieldKey
161
- operation: 'update'
162
- inputData: TTypeInfo['inputs']['update']
163
- item: TTypeInfo['item']
164
- resolvedData: TTypeInfo['inputs']['update']
165
- context: import('../access/types.js').AccessContext
166
- }
167
- | {
168
- listKey: string
169
- fieldKey: TFieldKey
170
- operation: 'delete'
171
- item: TTypeInfo['item']
172
- context: import('../access/types.js').AccessContext
173
- },
258
+ args: FieldBeforeOperationHookArgs<TTypeInfo, TFieldKey>,
174
259
  ) => Promise<void> | void
175
260
 
176
261
  /**
@@ -191,35 +276,7 @@ export type FieldHooks<
191
276
  * }
192
277
  * ```
193
278
  */
194
- afterOperation?: (
195
- args:
196
- | {
197
- listKey: string
198
- fieldKey: TFieldKey
199
- operation: 'create'
200
- inputData: TTypeInfo['inputs']['create']
201
- item: TTypeInfo['item']
202
- resolvedData: TTypeInfo['inputs']['create']
203
- context: import('../access/types.js').AccessContext
204
- }
205
- | {
206
- listKey: string
207
- fieldKey: TFieldKey
208
- operation: 'update'
209
- inputData: TTypeInfo['inputs']['update']
210
- originalItem: TTypeInfo['item']
211
- item: TTypeInfo['item']
212
- resolvedData: TTypeInfo['inputs']['update']
213
- context: import('../access/types.js').AccessContext
214
- }
215
- | {
216
- listKey: string
217
- fieldKey: TFieldKey
218
- operation: 'delete'
219
- originalItem: TTypeInfo['item']
220
- context: import('../access/types.js').AccessContext
221
- },
222
- ) => Promise<void> | void
279
+ afterOperation?: (args: FieldAfterOperationHookArgs<TTypeInfo, TFieldKey>) => Promise<void> | void
223
280
 
224
281
  /**
225
282
  * Transform field value after database read
@@ -236,14 +293,9 @@ export type FieldHooks<
236
293
  * }
237
294
  * ```
238
295
  */
239
- resolveOutput?: (args: {
240
- operation: 'query'
241
- value: GetFieldValueType<TTypeInfo['fields'], TFieldKey>
242
- item: TTypeInfo['item']
243
- listKey: string
244
- fieldName: TFieldKey
245
- context: import('../access/types.js').AccessContext
246
- }) => GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
296
+ resolveOutput?: (
297
+ args: FieldResolveOutputHookArgs<TTypeInfo, TFieldKey>,
298
+ ) => GetFieldValueType<TTypeInfo['fields'], TFieldKey> | undefined
247
299
  }
248
300
 
249
301
  /**
@@ -274,7 +326,11 @@ export type ResultExtensionConfig = {
274
326
 
275
327
  export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
276
328
  type: string
277
- access?: FieldAccess
329
+ access?: FieldAccess<
330
+ TTypeInfo['item'],
331
+ TTypeInfo['inputs']['create'],
332
+ TTypeInfo['inputs']['update']
333
+ >
278
334
  defaultValue?: unknown
279
335
  hooks?: FieldHooks<TTypeInfo>
280
336
  /**
@@ -539,6 +595,37 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
539
595
  * ```
540
596
  */
541
597
  foreignKey?: boolean | { map?: string }
598
+ /**
599
+ * Extend or modify the generated Prisma schema lines for this relationship field
600
+ * Receives the generated FK line (if applicable) and relation line
601
+ * Returns the modified lines
602
+ *
603
+ * @example Add onDelete cascade for self-referential relationship
604
+ * ```typescript
605
+ * parent: relationship({
606
+ * ref: 'Category.children',
607
+ * db: {
608
+ * foreignKey: true,
609
+ * extendPrismaSchema: ({ fkLine, relationLine }) => ({
610
+ * fkLine,
611
+ * relationLine: relationLine.replace(
612
+ * '@relation(',
613
+ * '@relation(onDelete: SetNull, '
614
+ * )
615
+ * })
616
+ * }
617
+ * })
618
+ * ```
619
+ */
620
+ extendPrismaSchema?: (lines: {
621
+ /** The foreign key field line (e.g., "parentId String?"), only present for single relationships that own the FK */
622
+ fkLine?: string
623
+ /** The relation field line (e.g., "parent Category? @relation(...)") */
624
+ relationLine: string
625
+ }) => {
626
+ fkLine?: string
627
+ relationLine: string
628
+ }
542
629
  }
543
630
  ui?: {
544
631
  displayMode?: 'select' | 'cards'
@@ -752,6 +839,48 @@ export type OperationAccess<T = any> = {
752
839
  delete?: AccessControl<T>
753
840
  }
754
841
 
842
+ /**
843
+ * List-level access control configuration
844
+ * Supports two patterns:
845
+ *
846
+ * 1. Function shorthand - applies to all CRUD operations:
847
+ * `access: isAdmin`
848
+ *
849
+ * 2. Object form - configure operations individually:
850
+ * `access: { operation: { query: () => true, create: isAdmin } }`
851
+ *
852
+ * @example Function shorthand
853
+ * ```typescript
854
+ * const isAdmin = ({ session }) => session?.role === 'admin'
855
+ *
856
+ * list({
857
+ * access: isAdmin, // Applies to query, create, update, delete
858
+ * fields: { ... }
859
+ * })
860
+ * ```
861
+ *
862
+ * @example Object form
863
+ * ```typescript
864
+ * list({
865
+ * access: {
866
+ * operation: {
867
+ * query: () => true,
868
+ * create: isAdmin,
869
+ * update: isOwner,
870
+ * delete: isAdmin,
871
+ * }
872
+ * },
873
+ * fields: { ... }
874
+ * })
875
+ * ```
876
+ */
877
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
878
+ export type ListAccessControl<T = any> =
879
+ | AccessControl<T>
880
+ | {
881
+ operation?: OperationAccess<T>
882
+ }
883
+
755
884
  /**
756
885
  * Hook arguments for resolveInput hook
757
886
  * Uses discriminated union to provide proper types based on operation
@@ -911,10 +1040,19 @@ export type Hooks<
911
1040
 
912
1041
  // Generic `any` default allows ListConfig to work with any list item type
913
1042
  // This is needed because the item type varies per list and is inferred from Prisma models
1043
+ /**
1044
+ * Internal list configuration type (after normalization by list() function)
1045
+ * Access control is always in object form internally.
1046
+ * Use list() function which accepts both function shorthand and object form.
1047
+ */
914
1048
  export type ListConfig<TTypeInfo extends TypeInfo> = {
915
1049
  // Field configs are automatically transformed to inject the full TypeInfo
916
1050
  // This enables proper typing in field hooks where item, create input, and update input are all typed
917
1051
  fields: FieldsWithTypeInfo<TTypeInfo>
1052
+ /**
1053
+ * Access control configuration for this list (normalized object form).
1054
+ * The list() function normalizes function shorthand to this object form.
1055
+ */
918
1056
  access?: {
919
1057
  operation?: OperationAccess<TTypeInfo['item']>
920
1058
  }
@@ -923,6 +1061,58 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
923
1061
  * MCP server configuration for this list
924
1062
  */
925
1063
  mcp?: ListMcpConfig
1064
+ /**
1065
+ * Restricts this list to a single record (singleton pattern)
1066
+ * When true:
1067
+ * - Prevents creating multiple records
1068
+ * - Auto-creates the single record on first access (if autoCreate: true, which is the default)
1069
+ * - Provides a get() method for easy access to the singleton
1070
+ * - Blocks delete and findMany operations
1071
+ * - Changes UI to show edit form instead of list view
1072
+ *
1073
+ * @example Simple boolean (auto-create enabled)
1074
+ * ```typescript
1075
+ * isSingleton: true
1076
+ * ```
1077
+ *
1078
+ * @example With options
1079
+ * ```typescript
1080
+ * isSingleton: {
1081
+ * autoCreate: false // Don't auto-create, must be created manually
1082
+ * }
1083
+ * ```
1084
+ */
1085
+ isSingleton?:
1086
+ | boolean
1087
+ | {
1088
+ /**
1089
+ * Auto-create the singleton record on first access using field defaults
1090
+ * @default true
1091
+ */
1092
+ autoCreate?: boolean
1093
+ }
1094
+ }
1095
+
1096
+ /**
1097
+ * Input type for the list() function
1098
+ * Accepts both function shorthand and object form for access control.
1099
+ */
1100
+ export type ListConfigInput<TTypeInfo extends TypeInfo> = Omit<ListConfig<TTypeInfo>, 'access'> & {
1101
+ /**
1102
+ * Access control configuration for this list.
1103
+ * Supports both function shorthand and object form.
1104
+ *
1105
+ * @example Function shorthand (applies to all operations)
1106
+ * ```typescript
1107
+ * access: isAdmin
1108
+ * ```
1109
+ *
1110
+ * @example Object form (per-operation)
1111
+ * ```typescript
1112
+ * access: { operation: { query: () => true, create: isAdmin } }
1113
+ * ```
1114
+ */
1115
+ access?: ListAccessControl<TTypeInfo['item']>
926
1116
  }
927
1117
 
928
1118
  /**