@opensaas/stack-core 0.13.0 → 0.15.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
  /**
@@ -503,6 +559,29 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
503
559
  type: 'relationship'
504
560
  ref: string // Format: 'ListName.fieldName' or 'ListName'
505
561
  many?: boolean
562
+ /**
563
+ * Controls whether to create an index on the foreign key field
564
+ * Defaults to true for all foreign key fields (matching Keystone behavior)
565
+ * Can be set to 'unique' for unique constraints or false to disable indexing
566
+ *
567
+ * @default true (for foreign key fields)
568
+ *
569
+ * @example
570
+ * ```typescript
571
+ * // Standard indexed foreign key (default)
572
+ * author: relationship({ ref: 'User.posts' })
573
+ * // Generates: @@index([authorId])
574
+ *
575
+ * // Unique foreign key (one-to-one)
576
+ * author: relationship({ ref: 'User.posts', isIndexed: 'unique' })
577
+ * // Generates: @@unique([authorId])
578
+ *
579
+ * // Disable indexing (not recommended, may cause performance issues)
580
+ * author: relationship({ ref: 'User.posts', isIndexed: false })
581
+ * // No index generated
582
+ * ```
583
+ */
584
+ isIndexed?: boolean | 'unique'
506
585
  db?: {
507
586
  /**
508
587
  * Controls foreign key placement and column name for bidirectional relationships
@@ -539,6 +618,37 @@ export type RelationshipField<TTypeInfo extends TypeInfo = TypeInfo> =
539
618
  * ```
540
619
  */
541
620
  foreignKey?: boolean | { map?: string }
621
+ /**
622
+ * Extend or modify the generated Prisma schema lines for this relationship field
623
+ * Receives the generated FK line (if applicable) and relation line
624
+ * Returns the modified lines
625
+ *
626
+ * @example Add onDelete cascade for self-referential relationship
627
+ * ```typescript
628
+ * parent: relationship({
629
+ * ref: 'Category.children',
630
+ * db: {
631
+ * foreignKey: true,
632
+ * extendPrismaSchema: ({ fkLine, relationLine }) => ({
633
+ * fkLine,
634
+ * relationLine: relationLine.replace(
635
+ * '@relation(',
636
+ * '@relation(onDelete: SetNull, '
637
+ * )
638
+ * })
639
+ * }
640
+ * })
641
+ * ```
642
+ */
643
+ extendPrismaSchema?: (lines: {
644
+ /** The foreign key field line (e.g., "parentId String?"), only present for single relationships that own the FK */
645
+ fkLine?: string
646
+ /** The relation field line (e.g., "parent Category? @relation(...)") */
647
+ relationLine: string
648
+ }) => {
649
+ fkLine?: string
650
+ relationLine: string
651
+ }
542
652
  }
543
653
  ui?: {
544
654
  displayMode?: 'select' | 'cards'
@@ -752,6 +862,48 @@ export type OperationAccess<T = any> = {
752
862
  delete?: AccessControl<T>
753
863
  }
754
864
 
865
+ /**
866
+ * List-level access control configuration
867
+ * Supports two patterns:
868
+ *
869
+ * 1. Function shorthand - applies to all CRUD operations:
870
+ * `access: isAdmin`
871
+ *
872
+ * 2. Object form - configure operations individually:
873
+ * `access: { operation: { query: () => true, create: isAdmin } }`
874
+ *
875
+ * @example Function shorthand
876
+ * ```typescript
877
+ * const isAdmin = ({ session }) => session?.role === 'admin'
878
+ *
879
+ * list({
880
+ * access: isAdmin, // Applies to query, create, update, delete
881
+ * fields: { ... }
882
+ * })
883
+ * ```
884
+ *
885
+ * @example Object form
886
+ * ```typescript
887
+ * list({
888
+ * access: {
889
+ * operation: {
890
+ * query: () => true,
891
+ * create: isAdmin,
892
+ * update: isOwner,
893
+ * delete: isAdmin,
894
+ * }
895
+ * },
896
+ * fields: { ... }
897
+ * })
898
+ * ```
899
+ */
900
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
901
+ export type ListAccessControl<T = any> =
902
+ | AccessControl<T>
903
+ | {
904
+ operation?: OperationAccess<T>
905
+ }
906
+
755
907
  /**
756
908
  * Hook arguments for resolveInput hook
757
909
  * Uses discriminated union to provide proper types based on operation
@@ -911,10 +1063,19 @@ export type Hooks<
911
1063
 
912
1064
  // Generic `any` default allows ListConfig to work with any list item type
913
1065
  // This is needed because the item type varies per list and is inferred from Prisma models
1066
+ /**
1067
+ * Internal list configuration type (after normalization by list() function)
1068
+ * Access control is always in object form internally.
1069
+ * Use list() function which accepts both function shorthand and object form.
1070
+ */
914
1071
  export type ListConfig<TTypeInfo extends TypeInfo> = {
915
1072
  // Field configs are automatically transformed to inject the full TypeInfo
916
1073
  // This enables proper typing in field hooks where item, create input, and update input are all typed
917
1074
  fields: FieldsWithTypeInfo<TTypeInfo>
1075
+ /**
1076
+ * Access control configuration for this list (normalized object form).
1077
+ * The list() function normalizes function shorthand to this object form.
1078
+ */
918
1079
  access?: {
919
1080
  operation?: OperationAccess<TTypeInfo['item']>
920
1081
  }
@@ -923,6 +1084,58 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
923
1084
  * MCP server configuration for this list
924
1085
  */
925
1086
  mcp?: ListMcpConfig
1087
+ /**
1088
+ * Restricts this list to a single record (singleton pattern)
1089
+ * When true:
1090
+ * - Prevents creating multiple records
1091
+ * - Auto-creates the single record on first access (if autoCreate: true, which is the default)
1092
+ * - Provides a get() method for easy access to the singleton
1093
+ * - Blocks delete and findMany operations
1094
+ * - Changes UI to show edit form instead of list view
1095
+ *
1096
+ * @example Simple boolean (auto-create enabled)
1097
+ * ```typescript
1098
+ * isSingleton: true
1099
+ * ```
1100
+ *
1101
+ * @example With options
1102
+ * ```typescript
1103
+ * isSingleton: {
1104
+ * autoCreate: false // Don't auto-create, must be created manually
1105
+ * }
1106
+ * ```
1107
+ */
1108
+ isSingleton?:
1109
+ | boolean
1110
+ | {
1111
+ /**
1112
+ * Auto-create the singleton record on first access using field defaults
1113
+ * @default true
1114
+ */
1115
+ autoCreate?: boolean
1116
+ }
1117
+ }
1118
+
1119
+ /**
1120
+ * Input type for the list() function
1121
+ * Accepts both function shorthand and object form for access control.
1122
+ */
1123
+ export type ListConfigInput<TTypeInfo extends TypeInfo> = Omit<ListConfig<TTypeInfo>, 'access'> & {
1124
+ /**
1125
+ * Access control configuration for this list.
1126
+ * Supports both function shorthand and object form.
1127
+ *
1128
+ * @example Function shorthand (applies to all operations)
1129
+ * ```typescript
1130
+ * access: isAdmin
1131
+ * ```
1132
+ *
1133
+ * @example Object form (per-operation)
1134
+ * ```typescript
1135
+ * access: { operation: { query: () => true, create: isAdmin } }
1136
+ * ```
1137
+ */
1138
+ access?: ListAccessControl<TTypeInfo['item']>
926
1139
  }
927
1140
 
928
1141
  /**