@opensaas/stack-core 0.20.1 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +72 -0
- package/CLAUDE.md +18 -2
- package/dist/access/access-filter.d.ts +29 -0
- package/dist/access/access-filter.d.ts.map +1 -0
- package/dist/access/access-filter.js +68 -0
- package/dist/access/access-filter.js.map +1 -0
- package/dist/access/engine.d.ts +15 -48
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +14 -280
- package/dist/access/engine.js.map +1 -1
- package/dist/access/field-access.d.ts +44 -0
- package/dist/access/field-access.d.ts.map +1 -0
- package/dist/access/field-access.js +123 -0
- package/dist/access/field-access.js.map +1 -0
- package/dist/access/field-access.test.d.ts +2 -0
- package/dist/access/field-access.test.d.ts.map +1 -0
- package/dist/access/{engine.test.js → field-access.test.js} +2 -2
- package/dist/access/field-access.test.js.map +1 -0
- package/dist/access/field-visibility.d.ts +13 -0
- package/dist/access/field-visibility.d.ts.map +1 -0
- package/dist/access/field-visibility.js +155 -0
- package/dist/access/field-visibility.js.map +1 -0
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +8 -1
- package/dist/access/index.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +45 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +49 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -0
- package/dist/context/hook-pipeline.js +75 -0
- package/dist/context/hook-pipeline.js.map +1 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -462
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +72 -68
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +158 -0
- package/dist/context/write-pipeline.d.ts.map +1 -0
- package/dist/context/write-pipeline.js +306 -0
- package/dist/context/write-pipeline.js.map +1 -0
- package/dist/extend.d.ts +3 -0
- package/dist/extend.d.ts.map +1 -0
- package/dist/extend.js +10 -0
- package/dist/extend.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +213 -2
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +202 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +8 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/internal.js.map +1 -0
- package/dist/validation/field-config.d.ts +55 -0
- package/dist/validation/field-config.d.ts.map +1 -0
- package/dist/validation/field-config.js +100 -0
- package/dist/validation/field-config.js.map +1 -0
- package/dist/validation/field-config.test.d.ts +2 -0
- package/dist/validation/field-config.test.d.ts.map +1 -0
- package/dist/validation/field-config.test.js +159 -0
- package/dist/validation/field-config.test.js.map +1 -0
- package/package.json +11 -3
- package/src/access/access-filter.ts +97 -0
- package/src/access/engine.ts +13 -396
- package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
- package/src/access/field-access.ts +159 -0
- package/src/access/field-visibility.ts +247 -0
- package/src/access/index.ts +7 -4
- package/src/config/index.ts +1 -0
- package/src/config/types.ts +51 -4
- package/src/context/hook-pipeline.ts +160 -0
- package/src/context/index.ts +29 -667
- package/src/context/nested-operations.ts +142 -111
- package/src/context/write-pipeline.ts +543 -0
- package/src/extend.ts +14 -0
- package/src/fields/index.ts +310 -2
- package/src/hooks/index.ts +227 -0
- package/src/index.ts +27 -90
- package/src/internal.ts +49 -0
- package/src/validation/field-config.test.ts +199 -0
- package/src/validation/field-config.ts +145 -0
- package/tests/access-relationships.test.ts +4 -4
- package/tests/access.test.ts +1 -1
- package/tests/field-hooks.test.ts +410 -0
- package/tests/field-types.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +233 -0
- package/tests/nested-operation-registry.test.ts +206 -0
- package/tests/write-pipeline.test.ts +588 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +43 -1
- package/dist/access/engine.test.d.ts +0 -2
- package/dist/access/engine.test.d.ts.map +0 -1
- package/dist/access/engine.test.js.map +0 -1
package/src/fields/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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'
|