@opensaas/stack-core 0.20.1 → 0.22.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 +334 -0
- package/CLAUDE.md +29 -11
- 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 +178 -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/access/multi-column-read-write.test.d.ts +2 -0
- package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
- package/dist/access/multi-column-read-write.test.js +149 -0
- package/dist/access/multi-column-read-write.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +334 -5
- 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/format-prisma-default.d.ts +35 -0
- package/dist/fields/format-prisma-default.d.ts.map +1 -0
- package/dist/fields/format-prisma-default.js +52 -0
- package/dist/fields/format-prisma-default.js.map +1 -0
- package/dist/fields/format-prisma-default.test.d.ts +2 -0
- package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
- package/dist/fields/format-prisma-default.test.js +54 -0
- package/dist/fields/format-prisma-default.test.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 +267 -18
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/select.test.js +85 -0
- package/dist/fields/select.test.js.map +1 -1
- package/dist/fields/text-keystone-compat.test.d.ts +2 -0
- package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
- package/dist/fields/text-keystone-compat.test.js +93 -0
- package/dist/fields/text-keystone-compat.test.js.map +1 -0
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +246 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +6 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -9
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +33 -0
- package/dist/index.test.js.map +1 -0
- 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/mcp/handler.js +0 -1
- package/dist/mcp/handler.js.map +1 -1
- 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 +269 -0
- package/src/access/index.ts +7 -4
- package/src/access/multi-column-read-write.test.ts +255 -0
- package/src/config/index.ts +3 -0
- package/src/config/types.ts +342 -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 +19 -0
- package/src/fields/format-prisma-default.test.ts +64 -0
- package/src/fields/format-prisma-default.ts +67 -0
- package/src/fields/index.ts +375 -20
- package/src/fields/select.test.ts +99 -0
- package/src/fields/text-keystone-compat.test.ts +126 -0
- package/src/hooks/index.ts +270 -0
- package/src/index.test.ts +50 -0
- package/src/index.ts +35 -82
- package/src/internal.ts +49 -0
- package/src/mcp/handler.ts +0 -2
- 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,8 +11,31 @@ 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'
|
|
19
|
+
import { formatPrismaDefault } from './format-prisma-default.js'
|
|
20
|
+
|
|
21
|
+
// Field-config types live here, alongside the builders that produce them.
|
|
22
|
+
// (The umbrella `FieldConfig` and authoring `BaseFieldConfig` stay on the root
|
|
23
|
+
// and `/extend` entry points respectively.)
|
|
24
|
+
export type {
|
|
25
|
+
TextField,
|
|
26
|
+
IntegerField,
|
|
27
|
+
DecimalField,
|
|
28
|
+
CheckboxField,
|
|
29
|
+
TimestampField,
|
|
30
|
+
CalendarDayField,
|
|
31
|
+
PasswordField,
|
|
32
|
+
SelectField,
|
|
33
|
+
RelationshipField,
|
|
34
|
+
JsonField,
|
|
35
|
+
VirtualField,
|
|
36
|
+
PrismaRelationResult,
|
|
37
|
+
MultiColumnPrismaResult,
|
|
38
|
+
} from '../config/types.js'
|
|
16
39
|
|
|
17
40
|
/**
|
|
18
41
|
* Format field name for display in error messages
|
|
@@ -66,7 +89,12 @@ export function text<
|
|
|
66
89
|
|
|
67
90
|
return !isRequired ? withMax.optional().nullable() : withMax
|
|
68
91
|
},
|
|
69
|
-
getPrismaType: (
|
|
92
|
+
getPrismaType: (
|
|
93
|
+
_fieldName: string,
|
|
94
|
+
_provider?: string,
|
|
95
|
+
_listName?: string,
|
|
96
|
+
keystoneCompat?: boolean,
|
|
97
|
+
) => {
|
|
70
98
|
const validation = options?.validation
|
|
71
99
|
const db = options?.db
|
|
72
100
|
const isRequired = validation?.isRequired
|
|
@@ -83,6 +111,23 @@ export function text<
|
|
|
83
111
|
modifiers += ` @db.${db.nativeType}`
|
|
84
112
|
}
|
|
85
113
|
|
|
114
|
+
// Default value. An explicit `defaultValue` always wins. When none is set
|
|
115
|
+
// and Keystone-compat mode is on, a non-null text column gets Keystone's
|
|
116
|
+
// implicit empty-string default. Both go through formatPrismaDefault, so
|
|
117
|
+
// the empty-string literal (`""`) is produced the same way as any other
|
|
118
|
+
// text default. Independent of the nullable `?` modifier above — the
|
|
119
|
+
// default never overwrites nullability.
|
|
120
|
+
const defaultSource =
|
|
121
|
+
options?.defaultValue !== undefined
|
|
122
|
+
? options.defaultValue
|
|
123
|
+
: keystoneCompat && !isNullable
|
|
124
|
+
? ''
|
|
125
|
+
: undefined
|
|
126
|
+
const defaultLiteral = formatPrismaDefault(defaultSource, 'text')
|
|
127
|
+
if (defaultLiteral !== undefined) {
|
|
128
|
+
modifiers += ` @default(${defaultLiteral})`
|
|
129
|
+
}
|
|
130
|
+
|
|
86
131
|
// Unique/index modifiers
|
|
87
132
|
if (options?.isIndexed === 'unique') {
|
|
88
133
|
modifiers += ' @unique'
|
|
@@ -161,6 +206,13 @@ export function integer<
|
|
|
161
206
|
modifiers += ` @db.${db.nativeType}`
|
|
162
207
|
}
|
|
163
208
|
|
|
209
|
+
// Default value if provided (bare numeric literal). Independent of the
|
|
210
|
+
// nullable `?` modifier above — the default never overwrites nullability.
|
|
211
|
+
const defaultLiteral = formatPrismaDefault(options?.defaultValue, 'integer')
|
|
212
|
+
if (defaultLiteral !== undefined) {
|
|
213
|
+
modifiers += ` @default(${defaultLiteral})`
|
|
214
|
+
}
|
|
215
|
+
|
|
164
216
|
// Map modifier
|
|
165
217
|
if (db?.map) {
|
|
166
218
|
modifiers += ` @map("${db.map}")`
|
|
@@ -642,7 +694,7 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
|
|
|
642
694
|
type: 'password',
|
|
643
695
|
...options,
|
|
644
696
|
resultExtension: {
|
|
645
|
-
outputType: "import('@opensaas/stack-core').HashedPassword",
|
|
697
|
+
outputType: "import('@opensaas/stack-core/internal').HashedPassword",
|
|
646
698
|
// No compute - delegates to resolveOutput hook
|
|
647
699
|
},
|
|
648
700
|
ui: {
|
|
@@ -656,7 +708,6 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
|
|
|
656
708
|
resolveInput: async ({ inputData, fieldKey }: { inputData: any; fieldKey: string }) => {
|
|
657
709
|
// Skip if undefined or null (allows partial updates)
|
|
658
710
|
const inputValue = inputData[fieldKey]
|
|
659
|
-
console.log('Password resolveInput called with value:', inputValue)
|
|
660
711
|
if (inputValue === undefined || inputValue === null) {
|
|
661
712
|
return inputValue
|
|
662
713
|
}
|
|
@@ -802,21 +853,35 @@ export function select<
|
|
|
802
853
|
},
|
|
803
854
|
getPrismaType: (fieldName: string, _provider?: string, listName?: string) => {
|
|
804
855
|
const isRequired = options.validation?.isRequired
|
|
856
|
+
const hasDefault = options.defaultValue !== undefined
|
|
857
|
+
// Nullability rules (Keystone parity):
|
|
858
|
+
// - `db.isNullable` is an explicit override and always wins. Setting it
|
|
859
|
+
// `true` forces the `?` even when a `defaultValue` is present.
|
|
860
|
+
// - Otherwise a select is nullable only when it is neither required nor
|
|
861
|
+
// carrying a default: a `defaultValue` makes the column NOT NULL (the
|
|
862
|
+
// long-standing default behaviour). This mirrors the previous logic
|
|
863
|
+
// where a present default overwrote the `?`.
|
|
864
|
+
// Nullability and the default are assembled independently with `+=`
|
|
865
|
+
// (mirroring text/integer) so the default never overwrites the `?`.
|
|
866
|
+
const isNullable = options.db?.isNullable ?? (!isRequired && !hasDefault)
|
|
805
867
|
let modifiers = ''
|
|
806
868
|
|
|
869
|
+
// Optional modifier
|
|
870
|
+
if (isNullable) {
|
|
871
|
+
modifiers += '?'
|
|
872
|
+
}
|
|
873
|
+
|
|
807
874
|
if (isNativeEnum) {
|
|
808
|
-
//
|
|
875
|
+
// Enum type name: explicit `db.enumName` wins, otherwise derive from
|
|
876
|
+
// list name + field name in PascalCase. The same name is used for the
|
|
877
|
+
// generated enum block (via `result.type`) and the column reference.
|
|
809
878
|
const capitalizedField = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
// Required fields don't get the ? modifier
|
|
813
|
-
if (!isRequired) {
|
|
814
|
-
modifiers = '?'
|
|
815
|
-
}
|
|
879
|
+
const derivedEnumName = listName ? `${listName}${capitalizedField}` : capitalizedField
|
|
880
|
+
const enumName = options.db?.enumName ?? derivedEnumName
|
|
816
881
|
|
|
817
882
|
// Add default value if provided (no quotes for enum values)
|
|
818
|
-
if (
|
|
819
|
-
modifiers
|
|
883
|
+
if (hasDefault) {
|
|
884
|
+
modifiers += ` @default(${options.defaultValue})`
|
|
820
885
|
}
|
|
821
886
|
|
|
822
887
|
// Map modifier
|
|
@@ -833,14 +898,9 @@ export function select<
|
|
|
833
898
|
|
|
834
899
|
// String type (default)
|
|
835
900
|
|
|
836
|
-
// Required fields don't get the ? modifier
|
|
837
|
-
if (!isRequired) {
|
|
838
|
-
modifiers = '?'
|
|
839
|
-
}
|
|
840
|
-
|
|
841
901
|
// Add default value if provided
|
|
842
|
-
if (
|
|
843
|
-
modifiers
|
|
902
|
+
if (hasDefault) {
|
|
903
|
+
modifiers += ` @default("${options.defaultValue}")`
|
|
844
904
|
}
|
|
845
905
|
|
|
846
906
|
// Map modifier
|
|
@@ -865,6 +925,284 @@ export function select<
|
|
|
865
925
|
}
|
|
866
926
|
}
|
|
867
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Parse a relationship ref into its target list and optional target field.
|
|
930
|
+
* Supports both 'ListName.fieldName' (bidirectional) and 'ListName' (list-only) formats.
|
|
931
|
+
*/
|
|
932
|
+
function parseRelationshipRef(ref: string): { list: string; field?: string } {
|
|
933
|
+
const parts = ref.split('.')
|
|
934
|
+
if (parts.length === 1) {
|
|
935
|
+
const list = parts[0]
|
|
936
|
+
if (!list) {
|
|
937
|
+
throw new Error(`Invalid relationship ref: ${ref}`)
|
|
938
|
+
}
|
|
939
|
+
return { list }
|
|
940
|
+
} else if (parts.length === 2) {
|
|
941
|
+
const [list, field] = parts
|
|
942
|
+
if (!list || !field) {
|
|
943
|
+
throw new Error(`Invalid relationship ref: ${ref}`)
|
|
944
|
+
}
|
|
945
|
+
return { list, field }
|
|
946
|
+
} else {
|
|
947
|
+
throw new Error(`Invalid relationship ref: ${ref}`)
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Check if a relationship is one-to-one (bidirectional with both sides having many: false).
|
|
953
|
+
*/
|
|
954
|
+
function isOneToOneRelationship(
|
|
955
|
+
fieldName: string,
|
|
956
|
+
field: RelationshipField,
|
|
957
|
+
config: OpenSaasConfig,
|
|
958
|
+
): boolean {
|
|
959
|
+
const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
|
|
960
|
+
if (!targetField) {
|
|
961
|
+
return false
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (field.many) {
|
|
965
|
+
return false
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const targetListConfig = config.lists[targetList]
|
|
969
|
+
if (!targetListConfig) {
|
|
970
|
+
throw new Error(`Referenced list "${targetList}" not found in config`)
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const targetFieldConfig = targetListConfig.fields[targetField]
|
|
974
|
+
if (!targetFieldConfig) {
|
|
975
|
+
throw new Error(
|
|
976
|
+
`Referenced field "${targetList}.${targetField}" not found. If you want a one-sided relationship, use ref: "${targetList}" instead of ref: "${targetList}.${targetField}"`,
|
|
977
|
+
)
|
|
978
|
+
}
|
|
979
|
+
if (targetFieldConfig.type !== 'relationship') {
|
|
980
|
+
throw new Error(`Referenced field "${targetList}.${targetField}" is not a relationship field`)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return !(targetFieldConfig as RelationshipField).many
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Determine if this side of a relationship should store the foreign key.
|
|
988
|
+
* For one-to-one relationships, only one side stores the foreign key.
|
|
989
|
+
*/
|
|
990
|
+
function shouldHaveForeignKey(
|
|
991
|
+
listKey: string,
|
|
992
|
+
fieldName: string,
|
|
993
|
+
field: RelationshipField,
|
|
994
|
+
config: OpenSaasConfig,
|
|
995
|
+
): boolean {
|
|
996
|
+
const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
|
|
997
|
+
if (!targetField) {
|
|
998
|
+
return true
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (field.many) {
|
|
1002
|
+
return false
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const isOneToOne = isOneToOneRelationship(fieldName, field, config)
|
|
1006
|
+
if (!isOneToOne) {
|
|
1007
|
+
return true
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const targetListConfig = config.lists[targetList]!
|
|
1011
|
+
const targetFieldConfig = targetListConfig.fields[targetField] as RelationshipField
|
|
1012
|
+
|
|
1013
|
+
const thisSideExplicit = field.db?.foreignKey
|
|
1014
|
+
const otherSideExplicit = targetFieldConfig.db?.foreignKey
|
|
1015
|
+
|
|
1016
|
+
if (thisSideExplicit === true && otherSideExplicit === true) {
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
`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.`,
|
|
1019
|
+
)
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (thisSideExplicit === true) {
|
|
1023
|
+
return true
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (otherSideExplicit === true) {
|
|
1027
|
+
return false
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Default: the alphabetically "smaller" list name gets the foreign key
|
|
1031
|
+
const comparison = listKey.localeCompare(targetList)
|
|
1032
|
+
if (comparison !== 0) {
|
|
1033
|
+
return comparison < 0
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Self-referential: use field name ordering
|
|
1037
|
+
return fieldName.localeCompare(targetField) < 0
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Check whether a many relationship is a true many-to-many (both sides many).
|
|
1042
|
+
*/
|
|
1043
|
+
function isManyToMany(
|
|
1044
|
+
fieldName: string,
|
|
1045
|
+
field: RelationshipField,
|
|
1046
|
+
config: OpenSaasConfig,
|
|
1047
|
+
): boolean {
|
|
1048
|
+
if (!field.many) {
|
|
1049
|
+
return false
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
|
|
1053
|
+
|
|
1054
|
+
// List-only ref with many: true is implicitly many-to-many
|
|
1055
|
+
if (!targetField) {
|
|
1056
|
+
return true
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const targetFieldConfig = config.lists[targetList]?.fields[targetField]
|
|
1060
|
+
if (!targetFieldConfig || targetFieldConfig.type !== 'relationship') {
|
|
1061
|
+
return false
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
return !!(targetFieldConfig as RelationshipField).many
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Compute the explicit relation name for a bidirectional many-to-many relationship,
|
|
1069
|
+
* or `undefined` when Prisma's default naming should be used.
|
|
1070
|
+
*
|
|
1071
|
+
* Honours per-field `db.relationName` (which must match on both sides) and the
|
|
1072
|
+
* global `db.joinTableNaming: 'keystone'` setting, picking a deterministic owner
|
|
1073
|
+
* for bidirectional relationships so both sides resolve to the same name.
|
|
1074
|
+
*/
|
|
1075
|
+
function computeManyToManyRelationName(
|
|
1076
|
+
listKey: string,
|
|
1077
|
+
fieldName: string,
|
|
1078
|
+
field: RelationshipField,
|
|
1079
|
+
config: OpenSaasConfig,
|
|
1080
|
+
): string | undefined {
|
|
1081
|
+
const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
|
|
1082
|
+
const joinTableNaming = config.db.joinTableNaming || 'prisma'
|
|
1083
|
+
|
|
1084
|
+
const sourceRelationName = field.db?.relationName
|
|
1085
|
+
let targetRelationName: string | undefined
|
|
1086
|
+
if (targetField) {
|
|
1087
|
+
const targetFieldConfig = config.lists[targetList]?.fields[targetField]
|
|
1088
|
+
if (targetFieldConfig?.type === 'relationship') {
|
|
1089
|
+
targetRelationName = (targetFieldConfig as RelationshipField).db?.relationName
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (sourceRelationName && targetRelationName && sourceRelationName !== targetRelationName) {
|
|
1094
|
+
throw new Error(
|
|
1095
|
+
`Relation name mismatch: ${listKey}.${fieldName} has relationName "${sourceRelationName}" but ${targetList}.${targetField} has "${targetRelationName}". Both sides must use the same relationName.`,
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const explicitRelationName = sourceRelationName || targetRelationName
|
|
1100
|
+
if (explicitRelationName) {
|
|
1101
|
+
return explicitRelationName
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (joinTableNaming === 'keystone') {
|
|
1105
|
+
if (targetField) {
|
|
1106
|
+
// Pick a deterministic owner so both sides agree on the relation name
|
|
1107
|
+
const sourceKey = `${listKey}.${fieldName}`
|
|
1108
|
+
const targetKey = `${targetList}.${targetField}`
|
|
1109
|
+
return sourceKey.localeCompare(targetKey) < 0
|
|
1110
|
+
? `${listKey}_${fieldName}`
|
|
1111
|
+
: `${targetList}_${targetField}`
|
|
1112
|
+
}
|
|
1113
|
+
return `${listKey}_${fieldName}`
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Default Prisma naming - no explicit relation name needed
|
|
1117
|
+
return undefined
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Build the Prisma schema contribution for a relationship field.
|
|
1122
|
+
*/
|
|
1123
|
+
function getPrismaRelation(
|
|
1124
|
+
field: RelationshipField,
|
|
1125
|
+
fieldName: string,
|
|
1126
|
+
listKey: string,
|
|
1127
|
+
config: OpenSaasConfig,
|
|
1128
|
+
): PrismaRelationResult {
|
|
1129
|
+
const { list: targetList, field: targetField } = parseRelationshipRef(field.ref)
|
|
1130
|
+
const paddedName = fieldName.padEnd(12)
|
|
1131
|
+
|
|
1132
|
+
// Synthetic back-relation for list-only refs (Prisma requires an opposite field)
|
|
1133
|
+
let backRelation: PrismaRelationResult['backRelation']
|
|
1134
|
+
if (!targetField) {
|
|
1135
|
+
const syntheticFieldName = `from_${listKey}_${fieldName}`
|
|
1136
|
+
const relationName = field.db?.relationName ?? `${listKey}_${fieldName}`
|
|
1137
|
+
backRelation = {
|
|
1138
|
+
targetList,
|
|
1139
|
+
line: ` ${syntheticFieldName.padEnd(12)} ${listKey}[] @relation("${relationName}")`,
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (field.many) {
|
|
1144
|
+
let relationLine: string
|
|
1145
|
+
|
|
1146
|
+
if (targetField) {
|
|
1147
|
+
// Bidirectional many side: use explicit relation name only for true many-to-many
|
|
1148
|
+
const m2mName = isManyToMany(fieldName, field, config)
|
|
1149
|
+
? computeManyToManyRelationName(listKey, fieldName, field, config)
|
|
1150
|
+
: undefined
|
|
1151
|
+
relationLine = m2mName
|
|
1152
|
+
? ` ${paddedName} ${targetList}[] @relation("${m2mName}")`
|
|
1153
|
+
: ` ${paddedName} ${targetList}[]`
|
|
1154
|
+
} else {
|
|
1155
|
+
// List-only ref many side: always a named relation paired with the synthetic field
|
|
1156
|
+
const relationName = field.db?.relationName ?? `${listKey}_${fieldName}`
|
|
1157
|
+
relationLine = ` ${paddedName} ${targetList}[] @relation("${relationName}")`
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (field.db?.extendPrismaSchema) {
|
|
1161
|
+
relationLine = field.db.extendPrismaSchema({ relationLine }).relationLine
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return { modelLines: [relationLine], backRelation }
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Single relationship
|
|
1168
|
+
if (shouldHaveForeignKey(listKey, fieldName, field, config)) {
|
|
1169
|
+
const foreignKeyField = `${fieldName}Id`
|
|
1170
|
+
const fkPaddedName = foreignKeyField.padEnd(12)
|
|
1171
|
+
|
|
1172
|
+
const uniqueModifier = isOneToOneRelationship(fieldName, field, config) ? ' @unique' : ''
|
|
1173
|
+
|
|
1174
|
+
const mapModifier =
|
|
1175
|
+
typeof field.db?.foreignKey === 'object' && field.db.foreignKey.map
|
|
1176
|
+
? ` @map("${field.db.foreignKey.map}")`
|
|
1177
|
+
: ` @map("${fieldName}")`
|
|
1178
|
+
|
|
1179
|
+
let fkLine = ` ${fkPaddedName} String?${uniqueModifier}${mapModifier}`
|
|
1180
|
+
let relationLine = targetField
|
|
1181
|
+
? ` ${paddedName} ${targetList}? @relation(fields: [${foreignKeyField}], references: [id])`
|
|
1182
|
+
: ` ${paddedName} ${targetList}? @relation("${listKey}_${fieldName}", fields: [${foreignKeyField}], references: [id])`
|
|
1183
|
+
|
|
1184
|
+
if (field.db?.extendPrismaSchema) {
|
|
1185
|
+
const extended = field.db.extendPrismaSchema({ fkLine, relationLine })
|
|
1186
|
+
fkLine = extended.fkLine ?? fkLine
|
|
1187
|
+
relationLine = extended.relationLine
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Default to indexing foreign keys (matching Keystone behaviour) unless disabled
|
|
1191
|
+
const indexType = field.isIndexed ?? true
|
|
1192
|
+
const foreignKeyIndex = indexType !== false ? { foreignKeyField, indexType } : undefined
|
|
1193
|
+
|
|
1194
|
+
return { modelLines: [fkLine, relationLine], foreignKeyIndex, backRelation }
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Non-FK side of a one-to-one relationship: just the relation field
|
|
1198
|
+
let relationLine = ` ${paddedName} ${targetList}?`
|
|
1199
|
+
if (field.db?.extendPrismaSchema) {
|
|
1200
|
+
relationLine = field.db.extendPrismaSchema({ relationLine }).relationLine
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
return { modelLines: [relationLine], backRelation }
|
|
1204
|
+
}
|
|
1205
|
+
|
|
868
1206
|
/**
|
|
869
1207
|
* Relationship field
|
|
870
1208
|
*/
|
|
@@ -902,10 +1240,19 @@ export function relationship<
|
|
|
902
1240
|
}
|
|
903
1241
|
}
|
|
904
1242
|
|
|
905
|
-
|
|
1243
|
+
const field: RelationshipField<TTypeInfo> = {
|
|
906
1244
|
type: 'relationship',
|
|
907
1245
|
...options,
|
|
908
1246
|
}
|
|
1247
|
+
|
|
1248
|
+
field.getPrismaRelation = (
|
|
1249
|
+
fieldName: string,
|
|
1250
|
+
_allFields: Record<string, FieldConfig>,
|
|
1251
|
+
listKey: string,
|
|
1252
|
+
config: OpenSaasConfig,
|
|
1253
|
+
) => getPrismaRelation(field as RelationshipField, fieldName, listKey, config)
|
|
1254
|
+
|
|
1255
|
+
return field
|
|
909
1256
|
}
|
|
910
1257
|
|
|
911
1258
|
/**
|
|
@@ -992,6 +1339,14 @@ export function json<
|
|
|
992
1339
|
modifiers += ` @db.${db.nativeType}`
|
|
993
1340
|
}
|
|
994
1341
|
|
|
1342
|
+
// Default value if provided. Uses Keystone's JSON-literal form: canonical
|
|
1343
|
+
// (space-free) JSON wrapped in escaped double quotes. Independent of the
|
|
1344
|
+
// nullable `?` modifier above — the default never overwrites nullability.
|
|
1345
|
+
const defaultLiteral = formatPrismaDefault(options?.defaultValue, 'json')
|
|
1346
|
+
if (defaultLiteral !== undefined) {
|
|
1347
|
+
modifiers += ` @default(${defaultLiteral})`
|
|
1348
|
+
}
|
|
1349
|
+
|
|
995
1350
|
// Map modifier
|
|
996
1351
|
if (db?.map) {
|
|
997
1352
|
modifiers += ` @map("${db.map}")`
|
|
@@ -52,6 +52,48 @@ describe('select field builder', () => {
|
|
|
52
52
|
expect(result.modifiers).toBe(' @default("draft")')
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
+
it('should emit NOT NULL (no ?) for optional string select with a default', () => {
|
|
56
|
+
const field = select({
|
|
57
|
+
options: [
|
|
58
|
+
{ label: 'Draft', value: 'draft' },
|
|
59
|
+
{ label: 'Published', value: 'published' },
|
|
60
|
+
],
|
|
61
|
+
defaultValue: 'draft',
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const result = field.getPrismaType!('status', 'sqlite', 'Post')
|
|
65
|
+
// Default behaviour: a present default makes the column NOT NULL
|
|
66
|
+
expect(result.modifiers).toBe(' @default("draft")')
|
|
67
|
+
expect(result.modifiers).not.toContain('?')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should force ? with db.isNullable even when a default is present (string)', () => {
|
|
71
|
+
const field = select({
|
|
72
|
+
options: [
|
|
73
|
+
{ label: 'Draft', value: 'draft' },
|
|
74
|
+
{ label: 'Published', value: 'published' },
|
|
75
|
+
],
|
|
76
|
+
defaultValue: 'draft',
|
|
77
|
+
db: { isNullable: true },
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const result = field.getPrismaType!('status', 'sqlite', 'Post')
|
|
81
|
+
expect(result.type).toBe('String')
|
|
82
|
+
expect(result.modifiers).toBe('? @default("draft")')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should keep ? from db.isNullable for a required string select with default', () => {
|
|
86
|
+
const field = select({
|
|
87
|
+
options: [{ label: 'Draft', value: 'draft' }],
|
|
88
|
+
defaultValue: 'draft',
|
|
89
|
+
validation: { isRequired: true },
|
|
90
|
+
db: { isNullable: true },
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const result = field.getPrismaType!('status', 'sqlite', 'Post')
|
|
94
|
+
expect(result.modifiers).toBe('? @default("draft")')
|
|
95
|
+
})
|
|
96
|
+
|
|
55
97
|
it('should generate union TypeScript type from options', () => {
|
|
56
98
|
const field = select({
|
|
57
99
|
options: [
|
|
@@ -205,6 +247,63 @@ describe('select field builder', () => {
|
|
|
205
247
|
expect(result.modifiers).not.toContain('"')
|
|
206
248
|
})
|
|
207
249
|
|
|
250
|
+
it('should emit NOT NULL (no ?) for optional enum select with a default', () => {
|
|
251
|
+
const field = select({
|
|
252
|
+
options: [
|
|
253
|
+
{ label: 'Draft', value: 'draft' },
|
|
254
|
+
{ label: 'Published', value: 'published' },
|
|
255
|
+
],
|
|
256
|
+
db: { type: 'enum' },
|
|
257
|
+
defaultValue: 'draft',
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const result = field.getPrismaType!('status', 'sqlite', 'Post')
|
|
261
|
+
expect(result.modifiers).toBe(' @default(draft)')
|
|
262
|
+
expect(result.modifiers).not.toContain('?')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('should force ? with db.isNullable even when a default is present (enum)', () => {
|
|
266
|
+
const field = select({
|
|
267
|
+
options: [
|
|
268
|
+
{ label: 'Draft', value: 'draft' },
|
|
269
|
+
{ label: 'Published', value: 'published' },
|
|
270
|
+
],
|
|
271
|
+
db: { type: 'enum', isNullable: true },
|
|
272
|
+
defaultValue: 'draft',
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const result = field.getPrismaType!('status', 'sqlite', 'Post')
|
|
276
|
+
expect(result.type).toBe('PostStatus')
|
|
277
|
+
expect(result.modifiers).toBe('? @default(draft)')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('should override the derived enum name with db.enumName', () => {
|
|
281
|
+
const field = select({
|
|
282
|
+
options: [
|
|
283
|
+
{ label: 'Open', value: 'open' },
|
|
284
|
+
{ label: 'Closed', value: 'closed' },
|
|
285
|
+
],
|
|
286
|
+
db: { type: 'enum', enumName: 'AccountNoteStatusType' },
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
const result = field.getPrismaType!('status', 'sqlite', 'AccountNote')
|
|
290
|
+
// result.type drives both the enum block name and the column reference
|
|
291
|
+
expect(result.type).toBe('AccountNoteStatusType')
|
|
292
|
+
expect(result.enumValues).toEqual(['open', 'closed'])
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('should ignore db.enumName for string (non-enum) selects', () => {
|
|
296
|
+
const field = select({
|
|
297
|
+
options: [{ label: 'Open', value: 'open' }],
|
|
298
|
+
// enumName only applies to native-enum selects; string selects stay String
|
|
299
|
+
db: { enumName: 'ShouldBeIgnored' },
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
const result = field.getPrismaType!('status', 'sqlite', 'AccountNote')
|
|
303
|
+
expect(result.type).toBe('String')
|
|
304
|
+
expect(result.enumValues).toBeUndefined()
|
|
305
|
+
})
|
|
306
|
+
|
|
208
307
|
it('should include @map modifier for enum field with map option', () => {
|
|
209
308
|
const field = select({
|
|
210
309
|
options: [{ label: 'Draft', value: 'draft' }],
|