@opensaas/stack-core 0.18.1 → 0.19.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.
@@ -97,7 +97,7 @@ export function text<
97
97
 
98
98
  return {
99
99
  type: 'String',
100
- modifiers: modifiers || undefined,
100
+ modifiers: modifiers.trimStart() || undefined,
101
101
  }
102
102
  },
103
103
  getTypeScriptType: () => {
@@ -145,22 +145,30 @@ export function integer<
145
145
  : withMax
146
146
  },
147
147
  getPrismaType: (_fieldName: string) => {
148
- const isRequired = options?.validation?.isRequired
148
+ const validation = options?.validation
149
+ const db = options?.db
150
+ const isRequired = validation?.isRequired
151
+ const isNullable = db?.isNullable ?? !isRequired
149
152
  let modifiers = ''
150
153
 
151
154
  // Optional modifier
152
- if (!isRequired) {
155
+ if (isNullable) {
153
156
  modifiers += '?'
154
157
  }
155
158
 
159
+ // Native type modifier (e.g., @db.SmallInt, @db.BigInt)
160
+ if (db?.nativeType) {
161
+ modifiers += ` @db.${db.nativeType}`
162
+ }
163
+
156
164
  // Map modifier
157
- if (options?.db?.map) {
158
- modifiers += ` @map("${options.db.map}")`
165
+ if (db?.map) {
166
+ modifiers += ` @map("${db.map}")`
159
167
  }
160
168
 
161
169
  return {
162
170
  type: 'Int',
163
- modifiers: modifiers || undefined,
171
+ modifiers: modifiers.trimStart() || undefined,
164
172
  }
165
173
  },
166
174
  getTypeScriptType: () => {
@@ -353,21 +361,28 @@ export function checkbox<
353
361
  return z.boolean().optional().nullable()
354
362
  },
355
363
  getPrismaType: (_fieldName: string) => {
364
+ const db = options?.db
356
365
  const hasDefault = options?.defaultValue !== undefined
357
366
  let modifiers = ''
358
367
 
368
+ // Nullable modifier - checkbox fields are non-nullable by default (must be true or false)
369
+ // Use db.isNullable: true to allow NULL values in the database
370
+ if (db?.isNullable === true) {
371
+ modifiers += '?'
372
+ }
373
+
359
374
  if (hasDefault) {
360
- modifiers = ` @default(${options.defaultValue})`
375
+ modifiers += ` @default(${options.defaultValue})`
361
376
  }
362
377
 
363
378
  // Map modifier
364
- if (options?.db?.map) {
365
- modifiers += ` @map("${options.db.map}")`
379
+ if (db?.map) {
380
+ modifiers += ` @map("${db.map}")`
366
381
  }
367
382
 
368
383
  return {
369
384
  type: 'Boolean',
370
- modifiers: modifiers || undefined,
385
+ modifiers: modifiers.trimStart() || undefined,
371
386
  }
372
387
  },
373
388
  getTypeScriptType: () => {
@@ -392,26 +407,41 @@ export function timestamp<
392
407
  return z.union([z.date(), z.iso.datetime()]).optional().nullable()
393
408
  },
394
409
  getPrismaType: (_fieldName: string) => {
395
- let modifiers = '?'
396
-
397
- // Check for default value
398
- if (
410
+ const db = options?.db
411
+ const hasDefaultNow =
399
412
  options?.defaultValue &&
400
413
  typeof options.defaultValue === 'object' &&
401
414
  'kind' in options.defaultValue &&
402
415
  options.defaultValue.kind === 'now'
403
- ) {
404
- modifiers = ' @default(now())'
416
+
417
+ // Nullability: explicit db.isNullable overrides the default (nullable unless @default(now()))
418
+ const isNullable = db?.isNullable ?? !hasDefaultNow
419
+
420
+ let modifiers = ''
421
+
422
+ // Optional modifier
423
+ if (isNullable) {
424
+ modifiers += '?'
425
+ }
426
+
427
+ // Default value
428
+ if (hasDefaultNow) {
429
+ modifiers += ' @default(now())'
430
+ }
431
+
432
+ // Native type modifier (e.g., @db.Timestamptz for PostgreSQL)
433
+ if (db?.nativeType) {
434
+ modifiers += ` @db.${db.nativeType}`
405
435
  }
406
436
 
407
437
  // Map modifier
408
- if (options?.db?.map) {
409
- modifiers += ` @map("${options.db.map}")`
438
+ if (db?.map) {
439
+ modifiers += ` @map("${db.map}")`
410
440
  }
411
441
 
412
442
  return {
413
443
  type: 'DateTime',
414
- modifiers,
444
+ modifiers: modifiers.trimStart() || undefined,
415
445
  }
416
446
  },
417
447
  getTypeScriptType: () => {
@@ -623,8 +653,10 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
623
653
  hooks: {
624
654
  // Hash password before writing to database
625
655
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
626
- resolveInput: async ({ inputValue }: { inputValue: any }) => {
656
+ resolveInput: async ({ inputData, fieldKey }: { inputData: any; fieldKey: string }) => {
627
657
  // Skip if undefined or null (allows partial updates)
658
+ const inputValue = inputData[fieldKey]
659
+ console.log('Password resolveInput called with value:', inputValue)
628
660
  if (inputValue === undefined || inputValue === null) {
629
661
  return inputValue
630
662
  }
@@ -640,7 +672,7 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
640
672
  }
641
673
 
642
674
  // Hash the password
643
- return await hashPassword(inputValue)
675
+ return (await hashPassword(inputValue)).toString()
644
676
  },
645
677
  // Wrap password with HashedPassword class after reading from database
646
678
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
@@ -687,22 +719,30 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
687
719
  }
688
720
  },
689
721
  getPrismaType: (_fieldName: string) => {
690
- const isRequired = options?.validation?.isRequired
722
+ const validation = options?.validation
723
+ const db = options?.db
724
+ const isRequired = validation?.isRequired
725
+ const isNullable = db?.isNullable ?? !isRequired
691
726
  let modifiers = ''
692
727
 
693
728
  // Optional modifier
694
- if (!isRequired) {
729
+ if (isNullable) {
695
730
  modifiers += '?'
696
731
  }
697
732
 
733
+ // Native type modifier (e.g., @db.Text)
734
+ if (db?.nativeType) {
735
+ modifiers += ` @db.${db.nativeType}`
736
+ }
737
+
698
738
  // Map modifier
699
- if (options?.db?.map) {
700
- modifiers += ` @map("${options.db.map}")`
739
+ if (db?.map) {
740
+ modifiers += ` @map("${db.map}")`
701
741
  }
702
742
 
703
743
  return {
704
744
  type: 'String',
705
- modifiers: modifiers || undefined,
745
+ modifiers: modifiers.trimStart() || undefined,
706
746
  }
707
747
  },
708
748
  getTypeScriptType: () => {
@@ -716,6 +756,11 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
716
756
  }
717
757
  }
718
758
 
759
+ /**
760
+ * Valid Prisma enum value pattern: starts with a letter, followed by letters, digits, or underscores
761
+ */
762
+ const PRISMA_ENUM_VALUE_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
763
+
719
764
  /**
720
765
  * Select field (enum-like)
721
766
  */
@@ -726,6 +771,20 @@ export function select<
726
771
  throw new Error('Select field must have at least one option')
727
772
  }
728
773
 
774
+ const isNativeEnum = options.db?.type === 'enum'
775
+
776
+ if (isNativeEnum) {
777
+ const invalidValues = options.options
778
+ .map((opt) => opt.value)
779
+ .filter((value) => !PRISMA_ENUM_VALUE_PATTERN.test(value))
780
+
781
+ if (invalidValues.length > 0) {
782
+ throw new Error(
783
+ `Enum select field values must be valid Prisma identifiers (letters, numbers, and underscores, starting with a letter). Invalid values: ${invalidValues.join(', ')}`,
784
+ )
785
+ }
786
+ }
787
+
729
788
  return {
730
789
  type: 'select',
731
790
  ...options,
@@ -741,10 +800,39 @@ export function select<
741
800
 
742
801
  return schema
743
802
  },
744
- getPrismaType: (_fieldName: string) => {
803
+ getPrismaType: (fieldName: string, _provider?: string, listName?: string) => {
745
804
  const isRequired = options.validation?.isRequired
746
805
  let modifiers = ''
747
806
 
807
+ if (isNativeEnum) {
808
+ // Derive enum name from list name + field name in PascalCase
809
+ 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
+ }
816
+
817
+ // Add default value if provided (no quotes for enum values)
818
+ if (options.defaultValue !== undefined) {
819
+ modifiers = ` @default(${options.defaultValue})`
820
+ }
821
+
822
+ // Map modifier
823
+ if (options.db?.map) {
824
+ modifiers += ` @map("${options.db.map}")`
825
+ }
826
+
827
+ return {
828
+ type: enumName,
829
+ modifiers: modifiers || undefined,
830
+ enumValues: options.options.map((opt) => opt.value),
831
+ }
832
+ }
833
+
834
+ // String type (default)
835
+
748
836
  // Required fields don't get the ? modifier
749
837
  if (!isRequired) {
750
838
  modifiers = '?'
@@ -766,7 +854,7 @@ export function select<
766
854
  }
767
855
  },
768
856
  getTypeScriptType: () => {
769
- // Generate union type from options
857
+ // Generate union type from options (same for both string and enum db types)
770
858
  const unionType = options.options.map((opt) => `'${opt.value}'`).join(' | ')
771
859
 
772
860
  return {
@@ -888,22 +976,30 @@ export function json<
888
976
  }
889
977
  },
890
978
  getPrismaType: (_fieldName: string) => {
891
- const isRequired = options?.validation?.isRequired
979
+ const validation = options?.validation
980
+ const db = options?.db
981
+ const isRequired = validation?.isRequired
982
+ const isNullable = db?.isNullable ?? !isRequired
892
983
  let modifiers = ''
893
984
 
894
985
  // Optional modifier
895
- if (!isRequired) {
986
+ if (isNullable) {
896
987
  modifiers += '?'
897
988
  }
898
989
 
990
+ // Native type modifier
991
+ if (db?.nativeType) {
992
+ modifiers += ` @db.${db.nativeType}`
993
+ }
994
+
899
995
  // Map modifier
900
- if (options?.db?.map) {
901
- modifiers += ` @map("${options.db.map}")`
996
+ if (db?.map) {
997
+ modifiers += ` @map("${db.map}")`
902
998
  }
903
999
 
904
1000
  return {
905
1001
  type: 'Json',
906
- modifiers: modifiers || undefined,
1002
+ modifiers: modifiers.trimStart() || undefined,
907
1003
  }
908
1004
  },
909
1005
  getTypeScriptType: () => {
@@ -0,0 +1,237 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { select } from './index.js'
3
+
4
+ describe('select field builder', () => {
5
+ describe('string type (default)', () => {
6
+ it('should throw when no options are provided', () => {
7
+ expect(() => select({ options: [] })).toThrow('Select field must have at least one option')
8
+ })
9
+
10
+ it('should return String prisma type for default select field', () => {
11
+ const field = select({
12
+ options: [
13
+ { label: 'Draft', value: 'draft' },
14
+ { label: 'Published', value: 'published' },
15
+ ],
16
+ })
17
+
18
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
19
+ expect(result.type).toBe('String')
20
+ expect(result.enumValues).toBeUndefined()
21
+ })
22
+
23
+ it('should add ? modifier for optional string select', () => {
24
+ const field = select({
25
+ options: [{ label: 'Draft', value: 'draft' }],
26
+ })
27
+
28
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
29
+ expect(result.modifiers).toBe('?')
30
+ })
31
+
32
+ it('should not add ? modifier for required string select', () => {
33
+ const field = select({
34
+ options: [{ label: 'Draft', value: 'draft' }],
35
+ validation: { isRequired: true },
36
+ })
37
+
38
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
39
+ expect(result.modifiers).toBeUndefined()
40
+ })
41
+
42
+ it('should generate quoted default value for string select', () => {
43
+ const field = select({
44
+ options: [
45
+ { label: 'Draft', value: 'draft' },
46
+ { label: 'Published', value: 'published' },
47
+ ],
48
+ defaultValue: 'draft',
49
+ })
50
+
51
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
52
+ expect(result.modifiers).toBe(' @default("draft")')
53
+ })
54
+
55
+ it('should generate union TypeScript type from options', () => {
56
+ const field = select({
57
+ options: [
58
+ { label: 'Draft', value: 'draft' },
59
+ { label: 'Published', value: 'published' },
60
+ ],
61
+ })
62
+
63
+ const result = field.getTypeScriptType!()
64
+ expect(result.type).toBe("'draft' | 'published'")
65
+ expect(result.optional).toBe(true)
66
+ })
67
+
68
+ it('should mark TypeScript type as non-optional when required', () => {
69
+ const field = select({
70
+ options: [
71
+ { label: 'Draft', value: 'draft' },
72
+ { label: 'Published', value: 'published' },
73
+ ],
74
+ validation: { isRequired: true },
75
+ })
76
+
77
+ const result = field.getTypeScriptType!()
78
+ expect(result.optional).toBe(false)
79
+ })
80
+ })
81
+
82
+ describe('enum type (db.type: enum)', () => {
83
+ it('should throw for values that are not valid Prisma identifiers (hyphens)', () => {
84
+ expect(() =>
85
+ select({
86
+ options: [{ label: 'In Progress', value: 'in-progress' }],
87
+ db: { type: 'enum' },
88
+ }),
89
+ ).toThrow(/valid Prisma identifiers/)
90
+ })
91
+
92
+ it('should throw for values starting with a digit', () => {
93
+ expect(() =>
94
+ select({
95
+ options: [{ label: 'First', value: '1st' }],
96
+ db: { type: 'enum' },
97
+ }),
98
+ ).toThrow(/valid Prisma identifiers/)
99
+ })
100
+
101
+ it('should throw for values with spaces', () => {
102
+ expect(() =>
103
+ select({
104
+ options: [{ label: 'In Progress', value: 'in progress' }],
105
+ db: { type: 'enum' },
106
+ }),
107
+ ).toThrow(/valid Prisma identifiers/)
108
+ })
109
+
110
+ it('should accept values with underscores', () => {
111
+ expect(() =>
112
+ select({
113
+ options: [
114
+ { label: 'In Progress', value: 'in_progress' },
115
+ { label: 'Done', value: 'done' },
116
+ ],
117
+ db: { type: 'enum' },
118
+ }),
119
+ ).not.toThrow()
120
+ })
121
+
122
+ it('should return derived enum name from listName + fieldName', () => {
123
+ const field = select({
124
+ options: [
125
+ { label: 'Draft', value: 'draft' },
126
+ { label: 'Published', value: 'published' },
127
+ ],
128
+ db: { type: 'enum' },
129
+ })
130
+
131
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
132
+ expect(result.type).toBe('PostStatus')
133
+ })
134
+
135
+ it('should capitalize fieldName when deriving enum name', () => {
136
+ const field = select({
137
+ options: [
138
+ { label: 'Article', value: 'article' },
139
+ { label: 'Video', value: 'video' },
140
+ ],
141
+ db: { type: 'enum' },
142
+ })
143
+
144
+ const result = field.getPrismaType!('contentType', 'sqlite', 'Post')
145
+ expect(result.type).toBe('PostContentType')
146
+ })
147
+
148
+ it('should fall back to capitalized fieldName when listName is not provided', () => {
149
+ const field = select({
150
+ options: [{ label: 'Draft', value: 'draft' }],
151
+ db: { type: 'enum' },
152
+ })
153
+
154
+ const result = field.getPrismaType!('status')
155
+ expect(result.type).toBe('Status')
156
+ })
157
+
158
+ it('should return enumValues in getPrismaType result', () => {
159
+ const field = select({
160
+ options: [
161
+ { label: 'Draft', value: 'draft' },
162
+ { label: 'Published', value: 'published' },
163
+ ],
164
+ db: { type: 'enum' },
165
+ })
166
+
167
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
168
+ expect(result.enumValues).toEqual(['draft', 'published'])
169
+ })
170
+
171
+ it('should add ? modifier for optional enum field', () => {
172
+ const field = select({
173
+ options: [{ label: 'Draft', value: 'draft' }],
174
+ db: { type: 'enum' },
175
+ })
176
+
177
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
178
+ expect(result.modifiers).toBe('?')
179
+ })
180
+
181
+ it('should not add ? modifier for required enum field', () => {
182
+ const field = select({
183
+ options: [{ label: 'Draft', value: 'draft' }],
184
+ db: { type: 'enum' },
185
+ validation: { isRequired: true },
186
+ })
187
+
188
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
189
+ expect(result.modifiers).toBeUndefined()
190
+ })
191
+
192
+ it('should generate unquoted default value for enum field', () => {
193
+ const field = select({
194
+ options: [
195
+ { label: 'Draft', value: 'draft' },
196
+ { label: 'Published', value: 'published' },
197
+ ],
198
+ db: { type: 'enum' },
199
+ defaultValue: 'draft',
200
+ })
201
+
202
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
203
+ expect(result.modifiers).toBe(' @default(draft)')
204
+ // Explicitly check there are no quotes
205
+ expect(result.modifiers).not.toContain('"')
206
+ })
207
+
208
+ it('should include @map modifier for enum field with map option', () => {
209
+ const field = select({
210
+ options: [{ label: 'Draft', value: 'draft' }],
211
+ db: { type: 'enum', map: 'post_status' },
212
+ })
213
+
214
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
215
+ expect(result.modifiers).toContain('@map("post_status")')
216
+ })
217
+
218
+ it('should generate same union TypeScript type as string select', () => {
219
+ const enumField = select({
220
+ options: [
221
+ { label: 'Draft', value: 'draft' },
222
+ { label: 'Published', value: 'published' },
223
+ ],
224
+ db: { type: 'enum' },
225
+ })
226
+
227
+ const stringField = select({
228
+ options: [
229
+ { label: 'Draft', value: 'draft' },
230
+ { label: 'Published', value: 'published' },
231
+ ],
232
+ })
233
+
234
+ expect(enumField.getTypeScriptType!()).toEqual(stringField.getTypeScriptType!())
235
+ })
236
+ })
237
+ })
@@ -49,6 +49,22 @@ describe('Zod Schema Generation', () => {
49
49
  expect(schema).toBeDefined()
50
50
  })
51
51
 
52
+ it('should generate schema for enum select field', () => {
53
+ const fields: Record<string, FieldConfig> = {
54
+ status: select({
55
+ options: [
56
+ { label: 'Draft', value: 'draft' },
57
+ { label: 'Published', value: 'published' },
58
+ ],
59
+ db: { type: 'enum' },
60
+ validation: { isRequired: true },
61
+ }),
62
+ }
63
+
64
+ const schema = generateZodSchema(fields, 'create')
65
+ expect(schema).toBeDefined()
66
+ })
67
+
52
68
  it('should make fields optional in update mode', () => {
53
69
  const fields: Record<string, FieldConfig> = {
54
70
  name: text({ validation: { isRequired: true } }),
@@ -149,6 +165,41 @@ describe('Zod Schema Generation', () => {
149
165
  }
150
166
  })
151
167
 
168
+ it('should pass validation for valid enum select value', () => {
169
+ const fields: Record<string, FieldConfig> = {
170
+ status: select({
171
+ options: [
172
+ { label: 'Draft', value: 'draft' },
173
+ { label: 'Published', value: 'published' },
174
+ ],
175
+ db: { type: 'enum' },
176
+ validation: { isRequired: true },
177
+ }),
178
+ }
179
+
180
+ const result = validateWithZod({ status: 'draft' }, fields, 'create')
181
+ expect(result.success).toBe(true)
182
+ })
183
+
184
+ it('should fail validation for invalid enum select value', () => {
185
+ const fields: Record<string, FieldConfig> = {
186
+ status: select({
187
+ options: [
188
+ { label: 'Draft', value: 'draft' },
189
+ { label: 'Published', value: 'published' },
190
+ ],
191
+ db: { type: 'enum' },
192
+ validation: { isRequired: true },
193
+ }),
194
+ }
195
+
196
+ const result = validateWithZod({ status: 'archived' }, fields, 'create')
197
+ expect(result.success).toBe(false)
198
+ if (!result.success) {
199
+ expect(result.errors.status).toBeDefined()
200
+ }
201
+ })
202
+
152
203
  it('should skip system fields in validation', () => {
153
204
  const fields: Record<string, FieldConfig> = {
154
205
  id: text(),
@@ -7,6 +7,23 @@ describe('Relationship Access Control', () => {
7
7
  session: null,
8
8
  prisma: {},
9
9
  db: {},
10
+ storage: {
11
+ uploadFile: async () => {
12
+ throw new Error('Not implemented')
13
+ },
14
+ uploadImage: async () => {
15
+ throw new Error('Not implemented')
16
+ },
17
+ deleteFile: async () => {
18
+ throw new Error('Not implemented')
19
+ },
20
+ deleteImage: async () => {
21
+ throw new Error('Not implemented')
22
+ },
23
+ },
24
+ plugins: {},
25
+ _isSudo: false,
26
+ _resolveOutputCounter: { depth: 0 },
10
27
  }
11
28
 
12
29
  describe('getRelatedListConfig', () => {