@opensaas/stack-core 0.18.2 → 0.19.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-core",
3
- "version": "0.18.2",
3
+ "version": "0.19.1",
4
4
  "description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "devDependencies": {
51
51
  "@prisma/client": "^7.1.0",
52
- "@types/node": "^24.10.1",
52
+ "@types/node": "^24.11.0",
53
53
  "@vitest/coverage-v8": "^4.0.15",
54
54
  "typescript": "^5.9.3",
55
55
  "vitest": "^4.0.15"
@@ -374,6 +374,52 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
374
374
  * ```
375
375
  */
376
376
  map?: string
377
+ /**
378
+ * Controls DB-level nullability independently of validation.isRequired.
379
+ * When specified, overrides the default behavior where nullability is inferred
380
+ * from validation.isRequired (required = non-nullable, optional = nullable).
381
+ *
382
+ * This allows you to:
383
+ * - Make a field non-nullable at the DB level without making it API-required
384
+ * - Explicitly mark a field as nullable even when it has isRequired validation
385
+ *
386
+ * @example
387
+ * ```typescript
388
+ * // DB non-nullable, but API optional (relies on a default value or hook)
389
+ * fields: {
390
+ * phoneNumber: text({
391
+ * db: { isNullable: false }
392
+ * })
393
+ * // Generates: phoneNumber String (non-nullable)
394
+ *
395
+ * // DB nullable (explicit), regardless of validation
396
+ * lastMessagePreview: text({
397
+ * db: { isNullable: true }
398
+ * })
399
+ * // Generates: lastMessagePreview String? (nullable)
400
+ * }
401
+ * ```
402
+ */
403
+ isNullable?: boolean
404
+ /**
405
+ * Override the native database type for the column.
406
+ * Generates a @db.<nativeType> attribute in the Prisma schema.
407
+ * The available types depend on your database provider.
408
+ *
409
+ * @example
410
+ * ```typescript
411
+ * // PostgreSQL: use TEXT instead of VARCHAR
412
+ * fields: {
413
+ * description: text({ db: { nativeType: 'Text' } })
414
+ * // Generates: description String? @db.Text
415
+ *
416
+ * // PostgreSQL: use SMALLINT instead of INT
417
+ * count: integer({ db: { nativeType: 'SmallInt' } })
418
+ * // Generates: count Int? @db.SmallInt
419
+ * }
420
+ * ```
421
+ */
422
+ nativeType?: string
377
423
  }
378
424
  ui?: {
379
425
  /**
@@ -415,14 +461,21 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
415
461
  * Get Prisma type and modifiers for schema generation
416
462
  * @param fieldName - The name of the field (for generating modifiers)
417
463
  * @param provider - Optional database provider ('sqlite', 'postgresql', 'mysql', etc.)
418
- * @returns Prisma type string and optional modifiers
464
+ * @param listName - Optional list name (used for generating enum type names)
465
+ * @returns Prisma type string, optional modifiers, and optional enum values
419
466
  */
420
467
  getPrismaType?: (
421
468
  fieldName: string,
422
469
  provider?: string,
470
+ listName?: string,
423
471
  ) => {
424
472
  type: string
425
473
  modifiers?: string
474
+ /**
475
+ * If set, this field requires a Prisma enum definition with these values.
476
+ * The enum name is the value of `type`.
477
+ */
478
+ enumValues?: string[]
426
479
  }
427
480
  /**
428
481
  * Get TypeScript type information for type generation
@@ -465,37 +518,6 @@ export type TextField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<T
465
518
  }
466
519
  }
467
520
  isIndexed?: boolean | 'unique'
468
- db?: {
469
- map?: string
470
- /**
471
- * Prisma native database type attribute
472
- * Allows overriding the default String type for the database provider
473
- * @example
474
- * ```typescript
475
- * // PostgreSQL/MySQL
476
- * fields: {
477
- * description: text({ db: { nativeType: 'Text' } })
478
- * // Generates: description String @db.Text
479
- * }
480
- * ```
481
- */
482
- nativeType?: string
483
- /**
484
- * Controls nullability in the database schema
485
- * When specified, overrides the default behavior (isRequired determines nullability)
486
- * @example
487
- * ```typescript
488
- * fields: {
489
- * description: text({
490
- * validation: { isRequired: true },
491
- * db: { isNullable: false }
492
- * })
493
- * // Generates: description String (non-nullable)
494
- * }
495
- * ```
496
- */
497
- isNullable?: boolean
498
- }
499
521
  ui?: {
500
522
  displayMode?: 'input' | 'textarea'
501
523
  }
@@ -515,10 +537,6 @@ export type DecimalField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfi
515
537
  defaultValue?: string
516
538
  precision?: number
517
539
  scale?: number
518
- db?: {
519
- map?: string
520
- isNullable?: boolean
521
- }
522
540
  validation?: {
523
541
  isRequired?: boolean
524
542
  min?: string
@@ -539,10 +557,6 @@ export type TimestampField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldCon
539
557
  export type CalendarDayField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
540
558
  type: 'calendarDay'
541
559
  defaultValue?: string
542
- db?: {
543
- map?: string
544
- isNullable?: boolean
545
- }
546
560
  validation?: {
547
561
  isRequired?: boolean
548
562
  }
@@ -559,6 +573,21 @@ export type PasswordField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConf
559
573
  export type SelectField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
560
574
  type: 'select'
561
575
  options: Array<{ label: string; value: string }>
576
+ defaultValue?: string
577
+ db?: {
578
+ /**
579
+ * Whether to store as a native database enum type.
580
+ * - 'string' (default): stores as a plain string/varchar column
581
+ * - 'enum': stores as a Prisma enum, generating a native enum type in the schema
582
+ *
583
+ * Note: enum values must be valid Prisma identifiers (letters, numbers, underscores,
584
+ * starting with a letter) when using 'enum' type.
585
+ *
586
+ * @default 'string'
587
+ */
588
+ type?: 'string' | 'enum'
589
+ map?: string
590
+ }
562
591
  validation?: {
563
592
  isRequired?: boolean
564
593
  }
@@ -893,8 +893,10 @@ function createCreate<TPrisma extends PrismaClientLike>(
893
893
  // Access Prisma model dynamically - required because model names are generated at runtime
894
894
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
895
895
  const model = (prisma as any)[getDbKey(listName)]
896
+ // Singleton lists use Int @id with value always 1 (matching Keystone 6 behaviour)
897
+ const createData = isSingletonList(listConfig) ? { id: 1, ...data } : data
896
898
  const item = await model.create({
897
- data,
899
+ data: createData,
898
900
  })
899
901
 
900
902
  // 9. Execute list-level afterOperation hook
@@ -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: () => {
@@ -689,22 +719,30 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
689
719
  }
690
720
  },
691
721
  getPrismaType: (_fieldName: string) => {
692
- 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
693
726
  let modifiers = ''
694
727
 
695
728
  // Optional modifier
696
- if (!isRequired) {
729
+ if (isNullable) {
697
730
  modifiers += '?'
698
731
  }
699
732
 
733
+ // Native type modifier (e.g., @db.Text)
734
+ if (db?.nativeType) {
735
+ modifiers += ` @db.${db.nativeType}`
736
+ }
737
+
700
738
  // Map modifier
701
- if (options?.db?.map) {
702
- modifiers += ` @map("${options.db.map}")`
739
+ if (db?.map) {
740
+ modifiers += ` @map("${db.map}")`
703
741
  }
704
742
 
705
743
  return {
706
744
  type: 'String',
707
- modifiers: modifiers || undefined,
745
+ modifiers: modifiers.trimStart() || undefined,
708
746
  }
709
747
  },
710
748
  getTypeScriptType: () => {
@@ -718,6 +756,11 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
718
756
  }
719
757
  }
720
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
+
721
764
  /**
722
765
  * Select field (enum-like)
723
766
  */
@@ -728,6 +771,20 @@ export function select<
728
771
  throw new Error('Select field must have at least one option')
729
772
  }
730
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
+
731
788
  return {
732
789
  type: 'select',
733
790
  ...options,
@@ -743,10 +800,39 @@ export function select<
743
800
 
744
801
  return schema
745
802
  },
746
- getPrismaType: (_fieldName: string) => {
803
+ getPrismaType: (fieldName: string, _provider?: string, listName?: string) => {
747
804
  const isRequired = options.validation?.isRequired
748
805
  let modifiers = ''
749
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
+
750
836
  // Required fields don't get the ? modifier
751
837
  if (!isRequired) {
752
838
  modifiers = '?'
@@ -768,7 +854,7 @@ export function select<
768
854
  }
769
855
  },
770
856
  getTypeScriptType: () => {
771
- // Generate union type from options
857
+ // Generate union type from options (same for both string and enum db types)
772
858
  const unionType = options.options.map((opt) => `'${opt.value}'`).join(' | ')
773
859
 
774
860
  return {
@@ -890,22 +976,30 @@ export function json<
890
976
  }
891
977
  },
892
978
  getPrismaType: (_fieldName: string) => {
893
- 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
894
983
  let modifiers = ''
895
984
 
896
985
  // Optional modifier
897
- if (!isRequired) {
986
+ if (isNullable) {
898
987
  modifiers += '?'
899
988
  }
900
989
 
990
+ // Native type modifier
991
+ if (db?.nativeType) {
992
+ modifiers += ` @db.${db.nativeType}`
993
+ }
994
+
901
995
  // Map modifier
902
- if (options?.db?.map) {
903
- modifiers += ` @map("${options.db.map}")`
996
+ if (db?.map) {
997
+ modifiers += ` @map("${db.map}")`
904
998
  }
905
999
 
906
1000
  return {
907
1001
  type: 'Json',
908
- modifiers: modifiers || undefined,
1002
+ modifiers: modifiers.trimStart() || undefined,
909
1003
  }
910
1004
  },
911
1005
  getTypeScriptType: () => {