@opensaas/stack-core 0.20.0 → 0.21.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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +74 -0
  3. package/CLAUDE.md +18 -2
  4. package/dist/access/access-filter.d.ts +29 -0
  5. package/dist/access/access-filter.d.ts.map +1 -0
  6. package/dist/access/access-filter.js +68 -0
  7. package/dist/access/access-filter.js.map +1 -0
  8. package/dist/access/engine.d.ts +15 -48
  9. package/dist/access/engine.d.ts.map +1 -1
  10. package/dist/access/engine.js +14 -280
  11. package/dist/access/engine.js.map +1 -1
  12. package/dist/access/field-access.d.ts +44 -0
  13. package/dist/access/field-access.d.ts.map +1 -0
  14. package/dist/access/field-access.js +123 -0
  15. package/dist/access/field-access.js.map +1 -0
  16. package/dist/access/field-access.test.d.ts +2 -0
  17. package/dist/access/field-access.test.d.ts.map +1 -0
  18. package/dist/access/{engine.test.js → field-access.test.js} +2 -2
  19. package/dist/access/field-access.test.js.map +1 -0
  20. package/dist/access/field-visibility.d.ts +13 -0
  21. package/dist/access/field-visibility.d.ts.map +1 -0
  22. package/dist/access/field-visibility.js +155 -0
  23. package/dist/access/field-visibility.js.map +1 -0
  24. package/dist/access/index.d.ts +4 -1
  25. package/dist/access/index.d.ts.map +1 -1
  26. package/dist/access/index.js +8 -1
  27. package/dist/access/index.js.map +1 -1
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/types.d.ts +45 -4
  31. package/dist/config/types.d.ts.map +1 -1
  32. package/dist/context/hook-pipeline.d.ts +49 -0
  33. package/dist/context/hook-pipeline.d.ts.map +1 -0
  34. package/dist/context/hook-pipeline.js +75 -0
  35. package/dist/context/hook-pipeline.js.map +1 -0
  36. package/dist/context/index.d.ts.map +1 -1
  37. package/dist/context/index.js +30 -462
  38. package/dist/context/index.js.map +1 -1
  39. package/dist/context/nested-operations.d.ts.map +1 -1
  40. package/dist/context/nested-operations.js +72 -68
  41. package/dist/context/nested-operations.js.map +1 -1
  42. package/dist/context/write-pipeline.d.ts +158 -0
  43. package/dist/context/write-pipeline.d.ts.map +1 -0
  44. package/dist/context/write-pipeline.js +306 -0
  45. package/dist/context/write-pipeline.js.map +1 -0
  46. package/dist/extend.d.ts +3 -0
  47. package/dist/extend.d.ts.map +1 -0
  48. package/dist/extend.js +10 -0
  49. package/dist/extend.js.map +1 -0
  50. package/dist/fields/index.d.ts +1 -0
  51. package/dist/fields/index.d.ts.map +1 -1
  52. package/dist/fields/index.js +213 -2
  53. package/dist/fields/index.js.map +1 -1
  54. package/dist/hooks/index.d.ts +20 -0
  55. package/dist/hooks/index.d.ts.map +1 -1
  56. package/dist/hooks/index.js +202 -0
  57. package/dist/hooks/index.js.map +1 -1
  58. package/dist/index.d.ts +5 -9
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +19 -10
  61. package/dist/index.js.map +1 -1
  62. package/dist/internal.d.ts +8 -0
  63. package/dist/internal.d.ts.map +1 -0
  64. package/dist/internal.js +16 -0
  65. package/dist/internal.js.map +1 -0
  66. package/dist/validation/field-config.d.ts +55 -0
  67. package/dist/validation/field-config.d.ts.map +1 -0
  68. package/dist/validation/field-config.js +100 -0
  69. package/dist/validation/field-config.js.map +1 -0
  70. package/dist/validation/field-config.test.d.ts +2 -0
  71. package/dist/validation/field-config.test.d.ts.map +1 -0
  72. package/dist/validation/field-config.test.js +159 -0
  73. package/dist/validation/field-config.test.js.map +1 -0
  74. package/package.json +12 -4
  75. package/src/access/access-filter.ts +97 -0
  76. package/src/access/engine.ts +13 -396
  77. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  78. package/src/access/field-access.ts +159 -0
  79. package/src/access/field-visibility.ts +247 -0
  80. package/src/access/index.ts +7 -4
  81. package/src/config/index.ts +1 -0
  82. package/src/config/types.ts +51 -4
  83. package/src/context/hook-pipeline.ts +160 -0
  84. package/src/context/index.ts +29 -667
  85. package/src/context/nested-operations.ts +142 -111
  86. package/src/context/write-pipeline.ts +543 -0
  87. package/src/extend.ts +14 -0
  88. package/src/fields/index.ts +310 -2
  89. package/src/hooks/index.ts +227 -0
  90. package/src/index.ts +27 -90
  91. package/src/internal.ts +49 -0
  92. package/src/validation/field-config.test.ts +199 -0
  93. package/src/validation/field-config.ts +145 -0
  94. package/tests/access-relationships.test.ts +4 -4
  95. package/tests/access.test.ts +1 -1
  96. package/tests/field-hooks.test.ts +410 -0
  97. package/tests/field-types.test.ts +1 -1
  98. package/tests/hook-pipeline.test.ts +233 -0
  99. package/tests/nested-operation-registry.test.ts +206 -0
  100. package/tests/write-pipeline.test.ts +588 -0
  101. package/tsconfig.tsbuildinfo +1 -1
  102. package/vitest.config.ts +43 -1
  103. package/dist/access/engine.test.d.ts +0 -2
  104. package/dist/access/engine.test.d.ts.map +0 -1
  105. package/dist/access/engine.test.js.map +0 -1
@@ -11,9 +11,30 @@ import type {
11
11
  RelationshipField,
12
12
  JsonField,
13
13
  VirtualField,
14
+ OpenSaasConfig,
15
+ FieldConfig,
16
+ PrismaRelationResult,
14
17
  } from '../config/types.js'
15
18
  import { hashPassword, isHashedPassword, HashedPassword } from '../utils/password.js'
16
19
 
20
+ // Field-config types live here, alongside the builders that produce them.
21
+ // (The umbrella `FieldConfig` and authoring `BaseFieldConfig` stay on the root
22
+ // and `/extend` entry points respectively.)
23
+ export type {
24
+ TextField,
25
+ IntegerField,
26
+ DecimalField,
27
+ CheckboxField,
28
+ TimestampField,
29
+ CalendarDayField,
30
+ PasswordField,
31
+ SelectField,
32
+ RelationshipField,
33
+ JsonField,
34
+ VirtualField,
35
+ PrismaRelationResult,
36
+ } from '../config/types.js'
37
+
17
38
  /**
18
39
  * Format field name for display in error messages
19
40
  */
@@ -642,7 +663,7 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
642
663
  type: 'password',
643
664
  ...options,
644
665
  resultExtension: {
645
- outputType: "import('@opensaas/stack-core').HashedPassword",
666
+ outputType: "import('@opensaas/stack-core/internal').HashedPassword",
646
667
  // No compute - delegates to resolveOutput hook
647
668
  },
648
669
  ui: {
@@ -865,6 +886,284 @@ export function select<
865
886
  }
866
887
  }
867
888
 
889
+ /**
890
+ * Parse a relationship ref into its target list and optional target field.
891
+ * Supports both 'ListName.fieldName' (bidirectional) and 'ListName' (list-only) formats.
892
+ */
893
+ function parseRelationshipRef(ref: string): { list: string; field?: string } {
894
+ const parts = ref.split('.')
895
+ if (parts.length === 1) {
896
+ const list = parts[0]
897
+ if (!list) {
898
+ throw new Error(`Invalid relationship ref: ${ref}`)
899
+ }
900
+ return { list }
901
+ } else if (parts.length === 2) {
902
+ const [list, field] = parts
903
+ if (!list || !field) {
904
+ throw new Error(`Invalid relationship ref: ${ref}`)
905
+ }
906
+ return { list, field }
907
+ } else {
908
+ throw new Error(`Invalid relationship ref: ${ref}`)
909
+ }
910
+ }
911
+
912
+ /**
913
+ * Check if a relationship is one-to-one (bidirectional with both sides having many: false).
914
+ */
915
+ function isOneToOneRelationship(
916
+ fieldName: string,
917
+ field: RelationshipField,
918
+ config: OpenSaasConfig,
919
+ ): boolean {
920
+ const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
921
+ if (!targetField) {
922
+ return false
923
+ }
924
+
925
+ if (field.many) {
926
+ return false
927
+ }
928
+
929
+ const targetListConfig = config.lists[targetList]
930
+ if (!targetListConfig) {
931
+ throw new Error(`Referenced list "${targetList}" not found in config`)
932
+ }
933
+
934
+ const targetFieldConfig = targetListConfig.fields[targetField]
935
+ if (!targetFieldConfig) {
936
+ throw new Error(
937
+ `Referenced field "${targetList}.${targetField}" not found. If you want a one-sided relationship, use ref: "${targetList}" instead of ref: "${targetList}.${targetField}"`,
938
+ )
939
+ }
940
+ if (targetFieldConfig.type !== 'relationship') {
941
+ throw new Error(`Referenced field "${targetList}.${targetField}" is not a relationship field`)
942
+ }
943
+
944
+ return !(targetFieldConfig as RelationshipField).many
945
+ }
946
+
947
+ /**
948
+ * Determine if this side of a relationship should store the foreign key.
949
+ * For one-to-one relationships, only one side stores the foreign key.
950
+ */
951
+ function shouldHaveForeignKey(
952
+ listKey: string,
953
+ fieldName: string,
954
+ field: RelationshipField,
955
+ config: OpenSaasConfig,
956
+ ): boolean {
957
+ const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
958
+ if (!targetField) {
959
+ return true
960
+ }
961
+
962
+ if (field.many) {
963
+ return false
964
+ }
965
+
966
+ const isOneToOne = isOneToOneRelationship(fieldName, field, config)
967
+ if (!isOneToOne) {
968
+ return true
969
+ }
970
+
971
+ const targetListConfig = config.lists[targetList]!
972
+ const targetFieldConfig = targetListConfig.fields[targetField] as RelationshipField
973
+
974
+ const thisSideExplicit = field.db?.foreignKey
975
+ const otherSideExplicit = targetFieldConfig.db?.foreignKey
976
+
977
+ if (thisSideExplicit === true && otherSideExplicit === true) {
978
+ throw new Error(
979
+ `Invalid one-to-one relationship: both "${listKey}.${fieldName}" and "${targetList}.${targetField}" have db.foreignKey set to true. Only one side can store the foreign key.`,
980
+ )
981
+ }
982
+
983
+ if (thisSideExplicit === true) {
984
+ return true
985
+ }
986
+
987
+ if (otherSideExplicit === true) {
988
+ return false
989
+ }
990
+
991
+ // Default: the alphabetically "smaller" list name gets the foreign key
992
+ const comparison = listKey.localeCompare(targetList)
993
+ if (comparison !== 0) {
994
+ return comparison < 0
995
+ }
996
+
997
+ // Self-referential: use field name ordering
998
+ return fieldName.localeCompare(targetField) < 0
999
+ }
1000
+
1001
+ /**
1002
+ * Check whether a many relationship is a true many-to-many (both sides many).
1003
+ */
1004
+ function isManyToMany(
1005
+ fieldName: string,
1006
+ field: RelationshipField,
1007
+ config: OpenSaasConfig,
1008
+ ): boolean {
1009
+ if (!field.many) {
1010
+ return false
1011
+ }
1012
+
1013
+ const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
1014
+
1015
+ // List-only ref with many: true is implicitly many-to-many
1016
+ if (!targetField) {
1017
+ return true
1018
+ }
1019
+
1020
+ const targetFieldConfig = config.lists[targetList]?.fields[targetField]
1021
+ if (!targetFieldConfig || targetFieldConfig.type !== 'relationship') {
1022
+ return false
1023
+ }
1024
+
1025
+ return !!(targetFieldConfig as RelationshipField).many
1026
+ }
1027
+
1028
+ /**
1029
+ * Compute the explicit relation name for a bidirectional many-to-many relationship,
1030
+ * or `undefined` when Prisma's default naming should be used.
1031
+ *
1032
+ * Honours per-field `db.relationName` (which must match on both sides) and the
1033
+ * global `db.joinTableNaming: 'keystone'` setting, picking a deterministic owner
1034
+ * for bidirectional relationships so both sides resolve to the same name.
1035
+ */
1036
+ function computeManyToManyRelationName(
1037
+ listKey: string,
1038
+ fieldName: string,
1039
+ field: RelationshipField,
1040
+ config: OpenSaasConfig,
1041
+ ): string | undefined {
1042
+ const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
1043
+ const joinTableNaming = config.db.joinTableNaming || 'prisma'
1044
+
1045
+ const sourceRelationName = field.db?.relationName
1046
+ let targetRelationName: string | undefined
1047
+ if (targetField) {
1048
+ const targetFieldConfig = config.lists[targetList]?.fields[targetField]
1049
+ if (targetFieldConfig?.type === 'relationship') {
1050
+ targetRelationName = (targetFieldConfig as RelationshipField).db?.relationName
1051
+ }
1052
+ }
1053
+
1054
+ if (sourceRelationName && targetRelationName && sourceRelationName !== targetRelationName) {
1055
+ throw new Error(
1056
+ `Relation name mismatch: ${listKey}.${fieldName} has relationName "${sourceRelationName}" but ${targetList}.${targetField} has "${targetRelationName}". Both sides must use the same relationName.`,
1057
+ )
1058
+ }
1059
+
1060
+ const explicitRelationName = sourceRelationName || targetRelationName
1061
+ if (explicitRelationName) {
1062
+ return explicitRelationName
1063
+ }
1064
+
1065
+ if (joinTableNaming === 'keystone') {
1066
+ if (targetField) {
1067
+ // Pick a deterministic owner so both sides agree on the relation name
1068
+ const sourceKey = `${listKey}.${fieldName}`
1069
+ const targetKey = `${targetList}.${targetField}`
1070
+ return sourceKey.localeCompare(targetKey) < 0
1071
+ ? `${listKey}_${fieldName}`
1072
+ : `${targetList}_${targetField}`
1073
+ }
1074
+ return `${listKey}_${fieldName}`
1075
+ }
1076
+
1077
+ // Default Prisma naming - no explicit relation name needed
1078
+ return undefined
1079
+ }
1080
+
1081
+ /**
1082
+ * Build the Prisma schema contribution for a relationship field.
1083
+ */
1084
+ function getPrismaRelation(
1085
+ field: RelationshipField,
1086
+ fieldName: string,
1087
+ listKey: string,
1088
+ config: OpenSaasConfig,
1089
+ ): PrismaRelationResult {
1090
+ const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
1091
+ const paddedName = fieldName.padEnd(12)
1092
+
1093
+ // Synthetic back-relation for list-only refs (Prisma requires an opposite field)
1094
+ let backRelation: PrismaRelationResult['backRelation']
1095
+ if (!targetField) {
1096
+ const syntheticFieldName = `from_${listKey}_${fieldName}`
1097
+ const relationName = field.db?.relationName ?? `${listKey}_${fieldName}`
1098
+ backRelation = {
1099
+ targetList,
1100
+ line: ` ${syntheticFieldName.padEnd(12)} ${listKey}[] @relation("${relationName}")`,
1101
+ }
1102
+ }
1103
+
1104
+ if (field.many) {
1105
+ let relationLine: string
1106
+
1107
+ if (targetField) {
1108
+ // Bidirectional many side: use explicit relation name only for true many-to-many
1109
+ const m2mName = isManyToMany(fieldName, field, config)
1110
+ ? computeManyToManyRelationName(listKey, fieldName, field, config)
1111
+ : undefined
1112
+ relationLine = m2mName
1113
+ ? ` ${paddedName} ${targetList}[] @relation("${m2mName}")`
1114
+ : ` ${paddedName} ${targetList}[]`
1115
+ } else {
1116
+ // List-only ref many side: always a named relation paired with the synthetic field
1117
+ const relationName = field.db?.relationName ?? `${listKey}_${fieldName}`
1118
+ relationLine = ` ${paddedName} ${targetList}[] @relation("${relationName}")`
1119
+ }
1120
+
1121
+ if (field.db?.extendPrismaSchema) {
1122
+ relationLine = field.db.extendPrismaSchema({ relationLine }).relationLine
1123
+ }
1124
+
1125
+ return { modelLines: [relationLine], backRelation }
1126
+ }
1127
+
1128
+ // Single relationship
1129
+ if (shouldHaveForeignKey(listKey, fieldName, field, config)) {
1130
+ const foreignKeyField = `${fieldName}Id`
1131
+ const fkPaddedName = foreignKeyField.padEnd(12)
1132
+
1133
+ const uniqueModifier = isOneToOneRelationship(fieldName, field, config) ? ' @unique' : ''
1134
+
1135
+ const mapModifier =
1136
+ typeof field.db?.foreignKey === 'object' && field.db.foreignKey.map
1137
+ ? ` @map("${field.db.foreignKey.map}")`
1138
+ : ` @map("${fieldName}")`
1139
+
1140
+ let fkLine = ` ${fkPaddedName} String?${uniqueModifier}${mapModifier}`
1141
+ let relationLine = targetField
1142
+ ? ` ${paddedName} ${targetList}? @relation(fields: [${foreignKeyField}], references: [id])`
1143
+ : ` ${paddedName} ${targetList}? @relation("${listKey}_${fieldName}", fields: [${foreignKeyField}], references: [id])`
1144
+
1145
+ if (field.db?.extendPrismaSchema) {
1146
+ const extended = field.db.extendPrismaSchema({ fkLine, relationLine })
1147
+ fkLine = extended.fkLine ?? fkLine
1148
+ relationLine = extended.relationLine
1149
+ }
1150
+
1151
+ // Default to indexing foreign keys (matching Keystone behaviour) unless disabled
1152
+ const indexType = field.isIndexed ?? true
1153
+ const foreignKeyIndex = indexType !== false ? { foreignKeyField, indexType } : undefined
1154
+
1155
+ return { modelLines: [fkLine, relationLine], foreignKeyIndex, backRelation }
1156
+ }
1157
+
1158
+ // Non-FK side of a one-to-one relationship: just the relation field
1159
+ let relationLine = ` ${paddedName} ${targetList}?`
1160
+ if (field.db?.extendPrismaSchema) {
1161
+ relationLine = field.db.extendPrismaSchema({ relationLine }).relationLine
1162
+ }
1163
+
1164
+ return { modelLines: [relationLine], backRelation }
1165
+ }
1166
+
868
1167
  /**
869
1168
  * Relationship field
870
1169
  */
@@ -902,10 +1201,19 @@ export function relationship<
902
1201
  }
903
1202
  }
904
1203
 
905
- return {
1204
+ const field: RelationshipField<TTypeInfo> = {
906
1205
  type: 'relationship',
907
1206
  ...options,
908
1207
  }
1208
+
1209
+ field.getPrismaRelation = (
1210
+ fieldName: string,
1211
+ _allFields: Record<string, FieldConfig>,
1212
+ listKey: string,
1213
+ config: OpenSaasConfig,
1214
+ ) => getPrismaRelation(field as RelationshipField, fieldName, listKey, config)
1215
+
1216
+ return field
909
1217
  }
910
1218
 
911
1219
  /**
@@ -213,6 +213,233 @@ export async function executeAfterOperation<
213
213
  await hooks.afterOperation(args as Parameters<typeof hooks.afterOperation>[0])
214
214
  }
215
215
 
216
+ /**
217
+ * Execute field-level resolveInput hooks
218
+ * Allows fields to transform their input values before database write
219
+ */
220
+ export async function executeFieldResolveInputHooks(
221
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
222
+ inputData: Record<string, any>,
223
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
+ resolvedData: Record<string, any>,
225
+ fields: Record<string, FieldConfig>,
226
+ operation: 'create' | 'update',
227
+ context: AccessContext,
228
+ listKey: string,
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ item?: any,
231
+ ): Promise<Record<string, unknown>> {
232
+ let result = { ...resolvedData }
233
+
234
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
235
+ // Skip if field not in data
236
+ if (!(fieldKey in result)) continue
237
+
238
+ // Skip if no hooks defined
239
+ if (!fieldConfig.hooks?.resolveInput) continue
240
+
241
+ // Execute field hook
242
+ // Type assertion is safe here because hooks are typed correctly in field definitions
243
+ // and we're working with runtime values that match those types
244
+ const transformedValue = await fieldConfig.hooks.resolveInput({
245
+ listKey,
246
+ fieldKey,
247
+ operation,
248
+ inputData,
249
+ item,
250
+ resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
251
+ context,
252
+ } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
253
+
254
+ // Create new object with updated field to avoid mutating the passed reference
255
+ result = { ...result, [fieldKey]: transformedValue }
256
+ }
257
+
258
+ return result
259
+ }
260
+
261
+ /**
262
+ * Execute field-level validate hooks
263
+ * Allows fields to perform custom validation after resolveInput but before database write
264
+ */
265
+ export async function executeFieldValidateHooks(
266
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
267
+ inputData: Record<string, any> | undefined,
268
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
269
+ resolvedData: Record<string, any> | undefined,
270
+ fields: Record<string, FieldConfig>,
271
+ operation: 'create' | 'update' | 'delete',
272
+ context: AccessContext,
273
+ listKey: string,
274
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
275
+ item?: any,
276
+ ): Promise<void> {
277
+ const errors: string[] = []
278
+ const fieldErrors: Record<string, string> = {}
279
+
280
+ const addValidationError = (fieldKey: string) => (msg: string) => {
281
+ errors.push(msg)
282
+ fieldErrors[fieldKey] = msg
283
+ }
284
+
285
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
286
+ // Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
287
+ const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput
288
+ if (!validateHook) continue
289
+
290
+ // Execute field hook
291
+ // Type assertion is safe here because hooks are typed correctly in field definitions
292
+ if (operation === 'delete') {
293
+ await validateHook({
294
+ listKey,
295
+ fieldKey,
296
+ operation: 'delete',
297
+ item,
298
+ context,
299
+ addValidationError: addValidationError(fieldKey),
300
+ } as Parameters<typeof validateHook>[0])
301
+ } else if (operation === 'create') {
302
+ await validateHook({
303
+ listKey,
304
+ fieldKey,
305
+ operation: 'create',
306
+ inputData,
307
+ item: undefined,
308
+ resolvedData,
309
+ context,
310
+ addValidationError: addValidationError(fieldKey),
311
+ } as Parameters<typeof validateHook>[0])
312
+ } else {
313
+ // operation === 'update'
314
+ await validateHook({
315
+ listKey,
316
+ fieldKey,
317
+ operation: 'update',
318
+ inputData,
319
+ item,
320
+ resolvedData,
321
+ context,
322
+ addValidationError: addValidationError(fieldKey),
323
+ } as Parameters<typeof validateHook>[0])
324
+ }
325
+ }
326
+
327
+ if (errors.length > 0) {
328
+ throw new ValidationError(errors, fieldErrors)
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Execute field-level beforeOperation hooks (side effects only)
334
+ * Allows fields to perform side effects before database write
335
+ */
336
+ export async function executeFieldBeforeOperationHooks(
337
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
338
+ inputData: Record<string, any>,
339
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
340
+ resolvedData: Record<string, any>,
341
+ fields: Record<string, FieldConfig>,
342
+ operation: 'create' | 'update' | 'delete',
343
+ context: AccessContext,
344
+ listKey: string,
345
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
346
+ item?: any,
347
+ ): Promise<void> {
348
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
349
+ // Skip if no hooks defined
350
+ if (!fieldConfig.hooks?.beforeOperation) continue
351
+ // Skip if field not in data (for create/update)
352
+ if (operation !== 'delete' && !(fieldKey in resolvedData)) continue
353
+
354
+ // Execute field hook (side effects only, no return value used)
355
+ // Type assertion is safe here because hooks are typed correctly in field definitions
356
+ if (operation === 'delete') {
357
+ await fieldConfig.hooks.beforeOperation({
358
+ listKey,
359
+ fieldKey,
360
+ operation: 'delete',
361
+ item,
362
+ context,
363
+ } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
364
+ } else if (operation === 'create') {
365
+ await fieldConfig.hooks.beforeOperation({
366
+ listKey,
367
+ fieldKey,
368
+ operation: 'create',
369
+ inputData,
370
+ resolvedData,
371
+ context,
372
+ } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
373
+ } else {
374
+ // operation === 'update'
375
+ await fieldConfig.hooks.beforeOperation({
376
+ listKey,
377
+ fieldKey,
378
+ operation: 'update',
379
+ inputData,
380
+ item,
381
+ resolvedData,
382
+ context,
383
+ } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Execute field-level afterOperation hooks (side effects only)
390
+ * Allows fields to perform side effects after database operations
391
+ */
392
+ export async function executeFieldAfterOperationHooks(
393
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
394
+ item: any,
395
+ inputData: Record<string, unknown> | undefined,
396
+ resolvedData: Record<string, unknown> | undefined,
397
+ fields: Record<string, FieldConfig>,
398
+ operation: 'create' | 'update' | 'delete',
399
+ context: AccessContext,
400
+ listKey: string,
401
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
402
+ originalItem?: any,
403
+ ): Promise<void> {
404
+ for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
405
+ // Skip if no hooks defined
406
+ if (!fieldConfig.hooks?.afterOperation) continue
407
+
408
+ // Execute field hook (side effects only, no return value used)
409
+ if (operation === 'delete') {
410
+ await fieldConfig.hooks.afterOperation({
411
+ listKey,
412
+ fieldKey,
413
+ operation: 'delete',
414
+ originalItem,
415
+ context,
416
+ } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
417
+ } else if (operation === 'create') {
418
+ await fieldConfig.hooks.afterOperation({
419
+ listKey,
420
+ fieldKey,
421
+ operation: 'create',
422
+ inputData,
423
+ item,
424
+ resolvedData,
425
+ context,
426
+ } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
427
+ } else {
428
+ // operation === 'update'
429
+ await fieldConfig.hooks.afterOperation({
430
+ listKey,
431
+ fieldKey,
432
+ operation: 'update',
433
+ inputData,
434
+ originalItem,
435
+ item,
436
+ resolvedData,
437
+ context,
438
+ } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
439
+ }
440
+ }
441
+ }
442
+
216
443
  /**
217
444
  * Validate field-level validation rules using Zod
218
445
  * Checks isRequired, length constraints, etc.
package/src/index.ts CHANGED
@@ -1,105 +1,42 @@
1
- // Config system
1
+ // ───────────────────────────────────────────────────────────────
2
+ // @opensaas/stack-core — consumer entry point
3
+ //
4
+ // The everyday surface for defining a config and using a context.
5
+ // • Field builders → '@opensaas/stack-core/fields'
6
+ // • Plugin / field authoring → '@opensaas/stack-core/extend'
7
+ // • MCP runtime → '@opensaas/stack-core/mcp'
8
+ // Internal plumbing lives on '@opensaas/stack-core/internal' (unstable).
9
+ // ───────────────────────────────────────────────────────────────
10
+
11
+ // Config builders
2
12
  export { config, list } from './config/index.js'
3
- export type {
4
- OpenSaasConfig,
5
- ListConfig,
6
- FieldConfig,
7
- BaseFieldConfig,
8
- TextField,
9
- IntegerField,
10
- CheckboxField,
11
- TimestampField,
12
- PasswordField,
13
- SelectField,
14
- RelationshipField,
15
- JsonField,
16
- VirtualField,
17
- TypeDescriptor,
18
- TypeInfo,
19
- OperationAccess,
20
- Hooks,
21
- FieldHooks,
22
- FieldsWithTypeInfo,
23
- DatabaseConfig,
24
- SessionConfig,
25
- UIConfig,
26
- ThemeConfig,
27
- ThemePreset,
28
- ThemeColors,
29
- McpConfig,
30
- McpToolsConfig,
31
- McpAuthConfig,
32
- ListMcpConfig,
33
- McpCustomTool,
34
- FileMetadata,
35
- ImageMetadata,
36
- ImageTransformationResult,
37
- // Plugin system types
38
- Plugin,
39
- PluginContext,
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,
52
- } from './config/index.js'
53
13
 
54
- // Access control
14
+ // Config types a consumer annotates with.
15
+ // Concrete field-config types (TextField, …) live on '@opensaas/stack-core/fields'
16
+ // alongside their builders.
17
+ export type { OpenSaasConfig, ListConfig, FieldConfig, OperationAccess } from './config/index.js'
18
+
19
+ // Access control — the types a consumer writes against
55
20
  export type {
56
21
  AccessControl,
57
22
  FieldAccess,
58
23
  Session,
59
24
  AccessContext,
60
25
  PrismaFilter,
61
- AccessControlledDB,
62
- StorageUtils,
63
- AugmentedFindMany,
64
- AugmentedFindUnique,
65
- FindManyQueryArgs,
66
26
  } from './access/index.js'
67
27
 
68
- // Context
28
+ // Context factory
69
29
  export { getContext } from './context/index.js'
70
- export type { PrismaClientLike } from './access/types.js'
71
- export type { ServerActionProps } from './context/index.js'
72
30
 
73
- // Utilities
74
- export {
75
- getDbKey,
76
- getUrlKey,
77
- getListKeyFromUrl,
78
- pascalToCamel,
79
- pascalToKebab,
80
- kebabToPascal,
81
- kebabToCamel,
82
- } from './lib/case-utils.js'
31
+ // Naming utilities (documented public helpers; used for URLs and db keys)
32
+ export { getDbKey, getUrlKey, getListKeyFromUrl } from './lib/case-utils.js'
83
33
 
84
- // Hooks and validation
34
+ // Validation error surfaced by write operations
85
35
  export { ValidationError } from './hooks/index.js'
86
- export { validateWithZod, generateZodSchema } from './validation/schema.js'
87
36
 
88
- // Password utilities
89
- export {
90
- hashPassword,
91
- comparePassword,
92
- isHashedPassword,
93
- HashedPassword,
94
- } from './utils/password.js'
95
-
96
- // Query utilities — fragment-based, type-safe query helpers
97
- export { defineFragment, runQuery, runQueryOne } from './query/index.js'
98
- export type {
99
- Fragment,
100
- FieldSelection,
101
- ResultOf,
102
- RelationSelector,
103
- QueryArgs,
104
- QueryRunnerContext,
105
- } from './query/index.js'
37
+ // Field self-containment validation — checks each field implements the
38
+ // generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
39
+ // getPrismaRelation for relationships) so a misimplemented field fails early
40
+ // with a clear per-field message instead of deep inside generation.
41
+ export { validateFieldConfig, validateConfigFields } from './validation/field-config.js'
42
+ export type { FieldConfigValidationError } from './validation/field-config.js'