From 8653ae029ef526f35bc65ac51fd02a6daab4b851 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 09:49:31 +0000 Subject: [PATCH 1/3] feat(core): add db.isNullable and db.nativeType to all field types Restores Keystone 6 db field-level config options that were missing from OpenSaaS Stack, allowing independent control of DB-level nullability and native database type overrides across all field types. - Add isNullable and nativeType to BaseFieldConfig.db (inherited by all fields) - Update integer(), password(), json(), timestamp(), checkbox() to handle db.isNullable (overrides validation.isRequired for DB nullability) - Update integer(), password(), json(), timestamp() to handle db.nativeType (generates @db. attribute in Prisma schema) - text() already had support; simplify TextField.db (options now inherited) - Simplify DecimalField.db and CalendarDayField.db (isNullable now inherited) - Apply trimStart() consistently in getPrismaType returns for clean modifiers - Add comprehensive tests covering all new db config combinations Fixes #351 https://claude.ai/code/session_01LwV5V9az64n5xQr6gVKtuZ --- .changeset/restore-db-field-config.md | 63 ++++++++ packages/core/src/config/types.ts | 85 ++++++----- packages/core/src/fields/index.ts | 104 +++++++++---- packages/core/tests/field-types.test.ts | 190 +++++++++++++++++++++++- 4 files changed, 371 insertions(+), 71 deletions(-) create mode 100644 .changeset/restore-db-field-config.md diff --git a/.changeset/restore-db-field-config.md b/.changeset/restore-db-field-config.md new file mode 100644 index 00000000..f14ff825 --- /dev/null +++ b/.changeset/restore-db-field-config.md @@ -0,0 +1,63 @@ +--- +'@opensaas/stack-core': minor +--- + +Add `db.isNullable` and `db.nativeType` support to all field types + +All field types now support two new `db` configuration options that were previously only available in Keystone 6: + +### `db.isNullable` + +Controls DB-level nullability independently of `validation.isRequired`. This allows you to: +- Make a field non-nullable at the DB level without making it API-required +- Explicitly mark a field as nullable regardless of other settings + +```typescript +fields: { + // DB non-nullable, but API optional (relies on a default value or hook) + phoneNumber: text({ + db: { isNullable: false } + // Generates: phoneNumber String (non-nullable) + }), + + // DB nullable, explicitly set + lastMessagePreview: text({ + db: { isNullable: true } + // Generates: lastMessagePreview String? (nullable) + }), + + // DB non-nullable without API validation (field must always be set via hooks or defaults) + internalCode: integer({ + db: { isNullable: false } + // Generates: internalCode Int (non-nullable) + }) +} +``` + +### `db.nativeType` + +Overrides the native database column type. Generates a `@db.` attribute in the Prisma schema. Available types depend on your database provider. + +```typescript +fields: { + // PostgreSQL: use TEXT instead of VARCHAR for long content + medical: text({ + db: { isNullable: true, nativeType: 'Text' } + // Generates: medical String? @db.Text + }), + + // PostgreSQL: use SMALLINT for small numbers + score: integer({ + db: { nativeType: 'SmallInt' } + // Generates: score Int? @db.SmallInt + }), + + // PostgreSQL: use TIMESTAMPTZ for timezone-aware timestamps + scheduledAt: timestamp({ + db: { nativeType: 'Timestamptz' } + // Generates: scheduledAt DateTime? @db.Timestamptz + }) +} +``` + +Both options are supported on `text`, `integer`, `password`, `json`, `timestamp`, `checkbox` (isNullable only), `decimal`, and `calendarDay` fields. diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 489a8967..7b36bf35 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -374,6 +374,52 @@ export type BaseFieldConfig = { * ``` */ map?: string + /** + * Controls DB-level nullability independently of validation.isRequired. + * When specified, overrides the default behavior where nullability is inferred + * from validation.isRequired (required = non-nullable, optional = nullable). + * + * This allows you to: + * - Make a field non-nullable at the DB level without making it API-required + * - Explicitly mark a field as nullable even when it has isRequired validation + * + * @example + * ```typescript + * // DB non-nullable, but API optional (relies on a default value or hook) + * fields: { + * phoneNumber: text({ + * db: { isNullable: false } + * }) + * // Generates: phoneNumber String (non-nullable) + * + * // DB nullable (explicit), regardless of validation + * lastMessagePreview: text({ + * db: { isNullable: true } + * }) + * // Generates: lastMessagePreview String? (nullable) + * } + * ``` + */ + isNullable?: boolean + /** + * Override the native database type for the column. + * Generates a @db. attribute in the Prisma schema. + * The available types depend on your database provider. + * + * @example + * ```typescript + * // PostgreSQL: use TEXT instead of VARCHAR + * fields: { + * description: text({ db: { nativeType: 'Text' } }) + * // Generates: description String? @db.Text + * + * // PostgreSQL: use SMALLINT instead of INT + * count: integer({ db: { nativeType: 'SmallInt' } }) + * // Generates: count Int? @db.SmallInt + * } + * ``` + */ + nativeType?: string } ui?: { /** @@ -472,37 +518,6 @@ export type TextField = BaseFieldConfig = BaseFieldConfi defaultValue?: string precision?: number scale?: number - db?: { - map?: string - isNullable?: boolean - } validation?: { isRequired?: boolean min?: string @@ -546,10 +557,6 @@ export type TimestampField = BaseFieldCon export type CalendarDayField = BaseFieldConfig & { type: 'calendarDay' defaultValue?: string - db?: { - map?: string - isNullable?: boolean - } validation?: { isRequired?: boolean } diff --git a/packages/core/src/fields/index.ts b/packages/core/src/fields/index.ts index fae19c80..a0123037 100644 --- a/packages/core/src/fields/index.ts +++ b/packages/core/src/fields/index.ts @@ -97,7 +97,7 @@ export function text< return { type: 'String', - modifiers: modifiers || undefined, + modifiers: modifiers.trimStart() || undefined, } }, getTypeScriptType: () => { @@ -145,22 +145,30 @@ export function integer< : withMax }, getPrismaType: (_fieldName: string) => { - const isRequired = options?.validation?.isRequired + const validation = options?.validation + const db = options?.db + const isRequired = validation?.isRequired + const isNullable = db?.isNullable ?? !isRequired let modifiers = '' // Optional modifier - if (!isRequired) { + if (isNullable) { modifiers += '?' } + // Native type modifier (e.g., @db.SmallInt, @db.BigInt) + if (db?.nativeType) { + modifiers += ` @db.${db.nativeType}` + } + // Map modifier - if (options?.db?.map) { - modifiers += ` @map("${options.db.map}")` + if (db?.map) { + modifiers += ` @map("${db.map}")` } return { type: 'Int', - modifiers: modifiers || undefined, + modifiers: modifiers.trimStart() || undefined, } }, getTypeScriptType: () => { @@ -353,21 +361,28 @@ export function checkbox< return z.boolean().optional().nullable() }, getPrismaType: (_fieldName: string) => { + const db = options?.db const hasDefault = options?.defaultValue !== undefined let modifiers = '' + // Nullable modifier - checkbox fields are non-nullable by default (must be true or false) + // Use db.isNullable: true to allow NULL values in the database + if (db?.isNullable === true) { + modifiers += '?' + } + if (hasDefault) { - modifiers = ` @default(${options.defaultValue})` + modifiers += ` @default(${options.defaultValue})` } // Map modifier - if (options?.db?.map) { - modifiers += ` @map("${options.db.map}")` + if (db?.map) { + modifiers += ` @map("${db.map}")` } return { type: 'Boolean', - modifiers: modifiers || undefined, + modifiers: modifiers.trimStart() || undefined, } }, getTypeScriptType: () => { @@ -392,26 +407,41 @@ export function timestamp< return z.union([z.date(), z.iso.datetime()]).optional().nullable() }, getPrismaType: (_fieldName: string) => { - let modifiers = '?' - - // Check for default value - if ( + const db = options?.db + const hasDefaultNow = options?.defaultValue && typeof options.defaultValue === 'object' && 'kind' in options.defaultValue && options.defaultValue.kind === 'now' - ) { - modifiers = ' @default(now())' + + // Nullability: explicit db.isNullable overrides the default (nullable unless @default(now())) + const isNullable = db?.isNullable ?? !hasDefaultNow + + let modifiers = '' + + // Optional modifier + if (isNullable) { + modifiers += '?' + } + + // Default value + if (hasDefaultNow) { + modifiers += ' @default(now())' + } + + // Native type modifier (e.g., @db.Timestamptz for PostgreSQL) + if (db?.nativeType) { + modifiers += ` @db.${db.nativeType}` } // Map modifier - if (options?.db?.map) { - modifiers += ` @map("${options.db.map}")` + if (db?.map) { + modifiers += ` @map("${db.map}")` } return { type: 'DateTime', - modifiers, + modifiers: modifiers.trimStart() || undefined, } }, getTypeScriptType: () => { @@ -689,22 +719,30 @@ export function password { - const isRequired = options?.validation?.isRequired + const validation = options?.validation + const db = options?.db + const isRequired = validation?.isRequired + const isNullable = db?.isNullable ?? !isRequired let modifiers = '' // Optional modifier - if (!isRequired) { + if (isNullable) { modifiers += '?' } + // Native type modifier (e.g., @db.Text) + if (db?.nativeType) { + modifiers += ` @db.${db.nativeType}` + } + // Map modifier - if (options?.db?.map) { - modifiers += ` @map("${options.db.map}")` + if (db?.map) { + modifiers += ` @map("${db.map}")` } return { type: 'String', - modifiers: modifiers || undefined, + modifiers: modifiers.trimStart() || undefined, } }, getTypeScriptType: () => { @@ -938,22 +976,30 @@ export function json< } }, getPrismaType: (_fieldName: string) => { - const isRequired = options?.validation?.isRequired + const validation = options?.validation + const db = options?.db + const isRequired = validation?.isRequired + const isNullable = db?.isNullable ?? !isRequired let modifiers = '' // Optional modifier - if (!isRequired) { + if (isNullable) { modifiers += '?' } + // Native type modifier + if (db?.nativeType) { + modifiers += ` @db.${db.nativeType}` + } + // Map modifier - if (options?.db?.map) { - modifiers += ` @map("${options.db.map}")` + if (db?.map) { + modifiers += ` @map("${db.map}")` } return { type: 'Json', - modifiers: modifiers || undefined, + modifiers: modifiers.trimStart() || undefined, } }, getTypeScriptType: () => { diff --git a/packages/core/tests/field-types.test.ts b/packages/core/tests/field-types.test.ts index eef62a44..5ba7681c 100644 --- a/packages/core/tests/field-types.test.ts +++ b/packages/core/tests/field-types.test.ts @@ -110,6 +110,72 @@ describe('Field Types', () => { expect(prismaType.type).toBe('String') expect(prismaType.modifiers).toContain('@index') }) + + test('db.isNullable: true makes optional field explicitly nullable', () => { + const field = text({ db: { isNullable: true } }) + const prismaType = field.getPrismaType('description') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.isNullable: false makes field non-nullable regardless of validation', () => { + const field = text({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('phoneNumber') + + expect(prismaType.type).toBe('String') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: false on required field keeps it non-nullable', () => { + const field = text({ validation: { isRequired: true }, db: { isNullable: false } }) + const prismaType = field.getPrismaType('title') + + expect(prismaType.type).toBe('String') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on required field overrides to nullable', () => { + const field = text({ validation: { isRequired: true }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('title') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.nativeType generates @db. attribute', () => { + const field = text({ db: { nativeType: 'Text' } }) + const prismaType = field.getPrismaType('medical') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('@db.Text') + }) + + test('db.nativeType with nullable field includes both ? and @db. attribute', () => { + const field = text({ db: { isNullable: true, nativeType: 'Text' } }) + const prismaType = field.getPrismaType('bio') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toBe('? @db.Text') + }) + + test('db.nativeType with non-nullable field excludes ? but includes @db. attribute', () => { + const field = text({ db: { isNullable: false, nativeType: 'Text' } }) + const prismaType = field.getPrismaType('content') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toBe('@db.Text') + }) + + test('db.nativeType with required field generates non-nullable with @db. attribute', () => { + const field = text({ validation: { isRequired: true }, db: { nativeType: 'Text' } }) + const prismaType = field.getPrismaType('content') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toBe('@db.Text') + }) }) describe('getTypeScriptType', () => { @@ -203,6 +269,39 @@ describe('Field Types', () => { expect(prismaType.type).toBe('Int') expect(prismaType.modifiers).toBeUndefined() }) + + test('db.isNullable: false makes field non-nullable regardless of validation', () => { + const field = integer({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('count') + + expect(prismaType.type).toBe('Int') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on required field overrides to nullable', () => { + const field = integer({ validation: { isRequired: true }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('count') + + expect(prismaType.type).toBe('Int') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.nativeType generates @db. attribute', () => { + const field = integer({ db: { nativeType: 'SmallInt' } }) + const prismaType = field.getPrismaType('score') + + expect(prismaType.type).toBe('Int') + expect(prismaType.modifiers).toContain('@db.SmallInt') + }) + + test('db.nativeType with non-nullable field excludes ? but includes @db. attribute', () => { + const field = integer({ db: { isNullable: false, nativeType: 'BigInt' } }) + const prismaType = field.getPrismaType('largeId') + + expect(prismaType.type).toBe('Int') + expect(prismaType.modifiers).toBe('@db.BigInt') + }) }) describe('getTypeScriptType', () => { @@ -251,7 +350,7 @@ describe('Field Types', () => { const prismaType = field.getPrismaType('isActive') expect(prismaType.type).toBe('Boolean') - expect(prismaType.modifiers).toBe(' @default(true)') + expect(prismaType.modifiers).toBe('@default(true)') }) test('returns Boolean type with default false', () => { @@ -259,7 +358,24 @@ describe('Field Types', () => { const prismaType = field.getPrismaType('isActive') expect(prismaType.type).toBe('Boolean') - expect(prismaType.modifiers).toBe(' @default(false)') + expect(prismaType.modifiers).toBe('@default(false)') + }) + + test('db.isNullable: true makes Boolean field nullable', () => { + const field = checkbox({ db: { isNullable: true } }) + const prismaType = field.getPrismaType('agreed') + + expect(prismaType.type).toBe('Boolean') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.isNullable: true with default value makes nullable Boolean with default', () => { + const field = checkbox({ defaultValue: false, db: { isNullable: true } }) + const prismaType = field.getPrismaType('agreed') + + expect(prismaType.type).toBe('Boolean') + expect(prismaType.modifiers).toContain('?') + expect(prismaType.modifiers).toContain('@default(false)') }) }) @@ -309,7 +425,33 @@ describe('Field Types', () => { const prismaType = field.getPrismaType('createdAt') expect(prismaType.type).toBe('DateTime') - expect(prismaType.modifiers).toBe(' @default(now())') + expect(prismaType.modifiers).toBe('@default(now())') + }) + + test('db.isNullable: false makes timestamp non-nullable without default', () => { + const field = timestamp({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('publishedAt') + + expect(prismaType.type).toBe('DateTime') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on timestamp with @default(now()) overrides to nullable', () => { + const field = timestamp({ defaultValue: { kind: 'now' }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('createdAt') + + expect(prismaType.type).toBe('DateTime') + expect(prismaType.modifiers).toContain('?') + expect(prismaType.modifiers).toContain('@default(now())') + }) + + test('db.nativeType generates @db. attribute', () => { + const field = timestamp({ db: { nativeType: 'Timestamptz' } }) + const prismaType = field.getPrismaType('scheduledAt') + + expect(prismaType.type).toBe('DateTime') + expect(prismaType.modifiers).toContain('@db.Timestamptz') }) }) @@ -377,6 +519,31 @@ describe('Field Types', () => { expect(prismaType.type).toBe('String') expect(prismaType.modifiers).toBeUndefined() }) + + test('db.isNullable: false makes password non-nullable regardless of validation', () => { + const field = password({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('password') + + expect(prismaType.type).toBe('String') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on required password overrides to nullable', () => { + const field = password({ validation: { isRequired: true }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('password') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('?') + }) + + test('db.nativeType generates @db. attribute', () => { + const field = password({ db: { nativeType: 'Text' } }) + const prismaType = field.getPrismaType('password') + + expect(prismaType.type).toBe('String') + expect(prismaType.modifiers).toContain('@db.Text') + }) }) describe('getTypeScriptType', () => { @@ -641,6 +808,23 @@ describe('Field Types', () => { expect(prismaType.type).toBe('Json') expect(prismaType.modifiers).toBeUndefined() }) + + test('db.isNullable: false makes Json field non-nullable regardless of validation', () => { + const field = json({ db: { isNullable: false } }) + const prismaType = field.getPrismaType('settings') + + expect(prismaType.type).toBe('Json') + // Non-nullable with no other modifiers → modifiers is undefined + expect(prismaType.modifiers).toBeUndefined() + }) + + test('db.isNullable: true on required field overrides to nullable', () => { + const field = json({ validation: { isRequired: true }, db: { isNullable: true } }) + const prismaType = field.getPrismaType('settings') + + expect(prismaType.type).toBe('Json') + expect(prismaType.modifiers).toContain('?') + }) }) describe('getTypeScriptType', () => { From 8736ef5765122ca19c0b56beb018a1fcc728de56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 09:52:49 +0000 Subject: [PATCH 2/3] chore: format changeset file https://claude.ai/code/session_01LwV5V9az64n5xQr6gVKtuZ --- .changeset/restore-db-field-config.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/restore-db-field-config.md b/.changeset/restore-db-field-config.md index f14ff825..e1c741fd 100644 --- a/.changeset/restore-db-field-config.md +++ b/.changeset/restore-db-field-config.md @@ -9,6 +9,7 @@ All field types now support two new `db` configuration options that were previou ### `db.isNullable` Controls DB-level nullability independently of `validation.isRequired`. This allows you to: + - Make a field non-nullable at the DB level without making it API-required - Explicitly mark a field as nullable regardless of other settings From 87158dcbbe4892c76eb93c85d6abd0b8d0b0a450 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 09:59:42 +0000 Subject: [PATCH 3/3] fix(cli): handle modifier spacing for nullable and attribute modifiers After adding trimStart() to getPrismaType() in the core package, the CLI's Prisma schema generator was concatenating type and modifiers without proper spacing. The '?' nullability marker must attach directly to the type while other modifiers (@map, @default, @db.*) need a space separator. https://claude.ai/code/session_01LwV5V9az64n5xQr6gVKtuZ --- packages/cli/src/generator/prisma.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/generator/prisma.ts b/packages/cli/src/generator/prisma.ts index ff90db60..01e4842d 100644 --- a/packages/cli/src/generator/prisma.ts +++ b/packages/cli/src/generator/prisma.ts @@ -464,9 +464,12 @@ export function generatePrismaSchema(config: OpenSaasConfig): string { const modifiers = getFieldModifiers(fieldName, fieldConfig, config.db.provider, listName) - // Format with proper spacing + // Format with proper spacing: '?' attaches to type directly, other modifiers get a space const paddedName = fieldName.padEnd(12) - lines.push(` ${paddedName} ${prismaType}${modifiers}`) + const modStr = modifiers.trimStart() + const nullPart = modStr.startsWith('?') ? '?' : '' + const attrPart = modStr.startsWith('?') ? modStr.slice(1).trimStart() : modStr + lines.push(` ${paddedName} ${prismaType}${nullPart}${attrPart ? ' ' + attrPart : ''}`) } // Add relationship fields