@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.
Files changed (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +334 -0
  3. package/CLAUDE.md +29 -11
  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 +178 -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/access/multi-column-read-write.test.d.ts +2 -0
  29. package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
  30. package/dist/access/multi-column-read-write.test.js +149 -0
  31. package/dist/access/multi-column-read-write.test.js.map +1 -0
  32. package/dist/config/index.d.ts +1 -1
  33. package/dist/config/index.d.ts.map +1 -1
  34. package/dist/config/types.d.ts +334 -5
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/context/hook-pipeline.d.ts +49 -0
  37. package/dist/context/hook-pipeline.d.ts.map +1 -0
  38. package/dist/context/hook-pipeline.js +75 -0
  39. package/dist/context/hook-pipeline.js.map +1 -0
  40. package/dist/context/index.d.ts.map +1 -1
  41. package/dist/context/index.js +30 -462
  42. package/dist/context/index.js.map +1 -1
  43. package/dist/context/nested-operations.d.ts.map +1 -1
  44. package/dist/context/nested-operations.js +72 -68
  45. package/dist/context/nested-operations.js.map +1 -1
  46. package/dist/context/write-pipeline.d.ts +158 -0
  47. package/dist/context/write-pipeline.d.ts.map +1 -0
  48. package/dist/context/write-pipeline.js +306 -0
  49. package/dist/context/write-pipeline.js.map +1 -0
  50. package/dist/extend.d.ts +3 -0
  51. package/dist/extend.d.ts.map +1 -0
  52. package/dist/extend.js +10 -0
  53. package/dist/extend.js.map +1 -0
  54. package/dist/fields/format-prisma-default.d.ts +35 -0
  55. package/dist/fields/format-prisma-default.d.ts.map +1 -0
  56. package/dist/fields/format-prisma-default.js +52 -0
  57. package/dist/fields/format-prisma-default.js.map +1 -0
  58. package/dist/fields/format-prisma-default.test.d.ts +2 -0
  59. package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
  60. package/dist/fields/format-prisma-default.test.js +54 -0
  61. package/dist/fields/format-prisma-default.test.js.map +1 -0
  62. package/dist/fields/index.d.ts +1 -0
  63. package/dist/fields/index.d.ts.map +1 -1
  64. package/dist/fields/index.js +267 -18
  65. package/dist/fields/index.js.map +1 -1
  66. package/dist/fields/select.test.js +85 -0
  67. package/dist/fields/select.test.js.map +1 -1
  68. package/dist/fields/text-keystone-compat.test.d.ts +2 -0
  69. package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
  70. package/dist/fields/text-keystone-compat.test.js +93 -0
  71. package/dist/fields/text-keystone-compat.test.js.map +1 -0
  72. package/dist/hooks/index.d.ts +20 -0
  73. package/dist/hooks/index.d.ts.map +1 -1
  74. package/dist/hooks/index.js +246 -0
  75. package/dist/hooks/index.js.map +1 -1
  76. package/dist/index.d.ts +6 -8
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +25 -9
  79. package/dist/index.js.map +1 -1
  80. package/dist/index.test.d.ts +2 -0
  81. package/dist/index.test.d.ts.map +1 -0
  82. package/dist/index.test.js +33 -0
  83. package/dist/index.test.js.map +1 -0
  84. package/dist/internal.d.ts +8 -0
  85. package/dist/internal.d.ts.map +1 -0
  86. package/dist/internal.js +16 -0
  87. package/dist/internal.js.map +1 -0
  88. package/dist/mcp/handler.js +0 -1
  89. package/dist/mcp/handler.js.map +1 -1
  90. package/dist/validation/field-config.d.ts +55 -0
  91. package/dist/validation/field-config.d.ts.map +1 -0
  92. package/dist/validation/field-config.js +100 -0
  93. package/dist/validation/field-config.js.map +1 -0
  94. package/dist/validation/field-config.test.d.ts +2 -0
  95. package/dist/validation/field-config.test.d.ts.map +1 -0
  96. package/dist/validation/field-config.test.js +159 -0
  97. package/dist/validation/field-config.test.js.map +1 -0
  98. package/package.json +11 -3
  99. package/src/access/access-filter.ts +97 -0
  100. package/src/access/engine.ts +13 -396
  101. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  102. package/src/access/field-access.ts +159 -0
  103. package/src/access/field-visibility.ts +269 -0
  104. package/src/access/index.ts +7 -4
  105. package/src/access/multi-column-read-write.test.ts +255 -0
  106. package/src/config/index.ts +3 -0
  107. package/src/config/types.ts +342 -4
  108. package/src/context/hook-pipeline.ts +160 -0
  109. package/src/context/index.ts +29 -667
  110. package/src/context/nested-operations.ts +142 -111
  111. package/src/context/write-pipeline.ts +543 -0
  112. package/src/extend.ts +19 -0
  113. package/src/fields/format-prisma-default.test.ts +64 -0
  114. package/src/fields/format-prisma-default.ts +67 -0
  115. package/src/fields/index.ts +375 -20
  116. package/src/fields/select.test.ts +99 -0
  117. package/src/fields/text-keystone-compat.test.ts +126 -0
  118. package/src/hooks/index.ts +270 -0
  119. package/src/index.test.ts +50 -0
  120. package/src/index.ts +35 -82
  121. package/src/internal.ts +49 -0
  122. package/src/mcp/handler.ts +0 -2
  123. package/src/validation/field-config.test.ts +199 -0
  124. package/src/validation/field-config.ts +145 -0
  125. package/tests/access-relationships.test.ts +4 -4
  126. package/tests/access.test.ts +1 -1
  127. package/tests/field-hooks.test.ts +410 -0
  128. package/tests/field-types.test.ts +1 -1
  129. package/tests/hook-pipeline.test.ts +233 -0
  130. package/tests/nested-operation-registry.test.ts +206 -0
  131. package/tests/write-pipeline.test.ts +588 -0
  132. package/tsconfig.tsbuildinfo +1 -1
  133. package/vitest.config.ts +43 -1
  134. package/dist/access/engine.test.d.ts +0 -2
  135. package/dist/access/engine.test.d.ts.map +0 -1
  136. package/dist/access/engine.test.js.map +0 -1
@@ -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: (_fieldName: string) => {
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
- // Derive enum name from list name + field name in PascalCase
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 enumName = listName ? `${listName}${capitalizedField}` : capitalizedField
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 (options.defaultValue !== undefined) {
819
- modifiers = ` @default(${options.defaultValue})`
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 (options.defaultValue !== undefined) {
843
- modifiers = ` @default("${options.defaultValue}")`
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
- return {
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' }],