Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-plums-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@opensaas/stack-core': patch
---

Fix `calendarDay` writes 500ing on Prisma 7 `@db.Date` columns — a `resolveInput` hook now coerces a `YYYY-MM-DD` string to a UTC-midnight `Date` before validation, and the field's zod schema accepts either shape.
165 changes: 158 additions & 7 deletions packages/core/src/fields/calendar-day.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, it, expect, expectTypeOf } from 'vitest'
import { describe, it, expect, expectTypeOf, vi } from 'vitest'
import { calendarDay } from './index.js'
import { generateZodSchema, validateWithZod } from '../validation/schema.js'
import { getContext } from '../context/index.js'
import type { FieldConfig } from '../config/types.js'
import type { OpenSaasConfig } from '../config/types.js'

/**
* calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
Expand Down Expand Up @@ -54,7 +56,7 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
})
})

describe('write validation (string-only)', () => {
describe('write validation (YYYY-MM-DD string, or a Date post-resolveInput)', () => {
const fields: Record<string, FieldConfig> = {
startsOn: calendarDay({ validation: { isRequired: true } }),
}
Expand All @@ -73,15 +75,18 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
}
})

it('rejects a Date instance at runtime (not a string)', () => {
// A typed caller cannot reach here (input type is `string`), but the
// validator is string-only as a runtime backstop.
it('accepts a Date instance (the shape resolveInput produces from a valid string)', () => {
// The write pipeline runs field `resolveInput` BEFORE this schema, and
// calendarDay's resolveInput turns a valid YYYY-MM-DD string into a UTC
// Date (see #621) so Prisma's `@db.Date` write validator accepts it. So
// by the time this schema runs, a successful write reaches it as a
// Date, not the original string — the schema must accept both shapes.
const result = validateWithZod(
{ startsOn: new Date('2025-01-15') } as unknown as Record<string, unknown>,
{ startsOn: new Date('2025-01-15T00:00:00.000Z') } as unknown as Record<string, unknown>,
fields,
'create',
)
expect(result.success).toBe(false)
expect(result.success).toBe(true)
})

it('zod schema for the field validates the YYYY-MM-DD shape', () => {
Expand All @@ -91,6 +96,62 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
})
})

describe('write transform (resolveInput coerces YYYY-MM-DD string to a UTC Date, #621)', () => {
// The write pipeline calls fieldConfig.hooks.resolveInput({ resolvedData,
// fieldKey, ... }) BEFORE zod validation runs. We exercise that hook
// directly with the value shapes a caller (or an upstream list-level
// resolveInput) can produce.
function writeValue(value: unknown): unknown {
const field = calendarDay()
const hook = field.hooks?.resolveInput
if (!hook) throw new Error('calendarDay must define a resolveInput hook')
return (
hook as unknown as (args: {
resolvedData: Record<string, unknown>
fieldKey: string
}) => unknown
)({ resolvedData: { startsOn: value }, fieldKey: 'startsOn' })
}

it('converts a YYYY-MM-DD string to a UTC-midnight Date', () => {
const result = writeValue('2025-01-15') as Date
expect(result).toBeInstanceOf(Date)
expect(result.toISOString()).toBe('2025-01-15T00:00:00.000Z')
})

it('passes an already-Date value through unchanged', () => {
const date = new Date('2025-06-01T00:00:00.000Z')
expect(writeValue(date)).toBe(date)
})

it('passes null/undefined through unchanged (so isRequired can still reject a missing value)', () => {
expect(writeValue(null)).toBeNull()
expect(writeValue(undefined)).toBeUndefined()
})

it('leaves a malformed string untouched so zod still rejects it with a clear message', () => {
expect(writeValue('15/01/2025')).toBe('15/01/2025')
})

it('reads resolvedData[fieldKey], not inputData — survives a list-level resolveInput default', () => {
// A list-level resolveInput can inject a default for an omitted key
// into resolvedData before field resolveInput runs. Reading
// resolvedData (not the original inputData) here means that injected
// default is what gets coerced, instead of being read as `undefined`
// and overwriting the injected default with null.
const field = calendarDay()
const hook = field.hooks?.resolveInput as unknown as (args: {
resolvedData: Record<string, unknown>
fieldKey: string
}) => unknown
const result = hook({
resolvedData: { startsOn: '2025-03-20' }, // injected by a list-level hook
fieldKey: 'startsOn',
}) as Date
expect(result.toISOString()).toBe('2025-03-20T00:00:00.000Z')
})
})

describe('read transform (resolveOutput returns a YYYY-MM-DD string)', () => {
// The read pipeline calls fieldConfig.hooks.resolveOutput({ value, ... }).
// We exercise that hook directly with the value shapes Prisma can return.
Expand Down Expand Up @@ -137,4 +198,94 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
expect(hook({ value: '2025-01-15' })).toBe('custom:2025-01-15')
})
})

describe('end-to-end via context.db.*.create/update (#621 repro)', () => {
// Prisma 7's client validator rejects a bare YYYY-MM-DD string for a
// `@db.Date` column. Assert the value actually forwarded to the Prisma
// client is a Date, not the string the caller passed in.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock Prisma client
let mockPrisma: any

function buildConfig(): OpenSaasConfig {
mockPrisma = {
event: {
findFirst: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
}
return {
db: {
provider: 'postgresql',
prismaClientConstructor: (PrismaClient) => new PrismaClient(),
},
lists: {
Event: {
fields: {
startsOn: calendarDay({ validation: { isRequired: true } }),
},
access: {
operation: {
query: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
},
},
},
}
}

it('create: a YYYY-MM-DD string reaches Prisma as a UTC-midnight Date', async () => {
const config = buildConfig()
mockPrisma.event.create.mockResolvedValue({
id: '1',
startsOn: new Date('2025-01-15T00:00:00.000Z'),
})
const context = await getContext(config, mockPrisma, null)

await context.db.event.create({ data: { startsOn: '2025-01-15' } })

expect(mockPrisma.event.create).toHaveBeenCalledTimes(1)
const callArgs = mockPrisma.event.create.mock.calls[0][0]
expect(callArgs.data.startsOn).toBeInstanceOf(Date)
expect((callArgs.data.startsOn as Date).toISOString()).toBe('2025-01-15T00:00:00.000Z')
})

it('update: a YYYY-MM-DD string reaches Prisma as a UTC-midnight Date', async () => {
const config = buildConfig()
const existing = { id: '1', startsOn: new Date('2025-01-15T00:00:00.000Z') }
mockPrisma.event.findUnique.mockResolvedValue(existing)
mockPrisma.event.update.mockResolvedValue({
...existing,
startsOn: new Date('2025-02-20T00:00:00.000Z'),
})
const context = await getContext(config, mockPrisma, null)

await context.db.event.update({ where: { id: '1' }, data: { startsOn: '2025-02-20' } })

expect(mockPrisma.event.update).toHaveBeenCalledTimes(1)
const callArgs = mockPrisma.event.update.mock.calls[0][0]
expect(callArgs.data.startsOn).toBeInstanceOf(Date)
expect((callArgs.data.startsOn as Date).toISOString()).toBe('2025-02-20T00:00:00.000Z')
})

it('the read result is still normalised back to a YYYY-MM-DD string', async () => {
const config = buildConfig()
mockPrisma.event.create.mockResolvedValue({
id: '1',
startsOn: new Date('2025-01-15T00:00:00.000Z'),
})
const context = await getContext(config, mockPrisma, null)

const result = await context.db.event.create({ data: { startsOn: '2025-01-15' } })

expect(result?.startsOn).toBe('2025-01-15')
})
})
})
50 changes: 39 additions & 11 deletions packages/core/src/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,9 +523,13 @@ export function timestamp<
* - Stores date values only (no time component)
* - PostgreSQL/MySQL: Uses native DATE type via @db.Date
* - SQLite: Uses String representation
* - **Writes:** accept only a `YYYY-MM-DD` string; a malformed string or a
* `Date` is rejected at runtime by validation (a `ValidationError`). Genuine
* compile-time rejection at the `context.db` call site is tracked in #599.
* - **Writes:** pass a `YYYY-MM-DD` string (the declared type). A
* `resolveInput` hook converts a valid string to a UTC-midnight `Date`
* before validation, since Prisma 7's client validator rejects a bare date
* string for a `@db.Date` column (#621); a `Date` is also accepted
* directly. A malformed string is rejected at runtime by validation (a
* `ValidationError`). Genuine compile-time rejection of a `Date` at the
* `context.db` call site is tracked in #599.
* - **Reads:** always return a `YYYY-MM-DD` string. Even though the underlying
* `@db.Date` column hands Prisma a `Date`, a `resolveOutput` transform
* normalises it back to a `YYYY-MM-DD` string so the runtime value matches
Expand Down Expand Up @@ -573,13 +577,33 @@ export function calendarDay<
return {
type: 'calendarDay',
...options,
// Writes: the write pipeline runs field resolveInput BEFORE zod
// validation (Hook Pipeline: field resolveInput → built-in field rules),
// so this is the only point a YYYY-MM-DD string can be turned into
// something Prisma's `@db.Date` write validator accepts — Prisma 7
// rejects a bare date string there (#621). Convert a valid string to a
// UTC-midnight Date; leave anything else (a Date already, null/undefined,
// or a malformed string) untouched so the zod schema below still rejects
// malformed input with a clear message. Reads resolvedData[fieldKey]
// (not raw inputData) so a list-level resolveInput that injects a default
// for an omitted key is still coerced instead of being overwritten.
//
// Reads: the underlying @db.Date column hands Prisma a Date (or a TEXT
// string under the SQLite fallback). Normalise to a YYYY-MM-DD string so the
// runtime value matches the declared `string` type. UTC components are used
// so the formatting never drifts a day in non-UTC timezones.
// Cast hooks to any since field builders are generic and can't know the
// specific TFieldKey (same pattern as password()).
hooks: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
resolveInput: ({ resolvedData, fieldKey }: { resolvedData: any; fieldKey: string }) => {
const value = resolvedData?.[fieldKey]
if (value == null || value instanceof Date) return value
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
return new Date(`${value}T00:00:00.000Z`)
}
return value
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
resolveOutput: ({ value }: { value: any }) => formatCalendarDay(value),
// Merge with user-provided hooks if any
Expand All @@ -590,15 +614,19 @@ export function calendarDay<
const validation = options?.validation
const isRequired = validation?.isRequired

// Accept ISO8601 date strings (YYYY-MM-DD)
const baseSchema = z.string({
message: `${formatFieldName(fieldName)} must be a valid date in ISO8601 format (YYYY-MM-DD)`,
})
// Accept ISO8601 date strings (YYYY-MM-DD) in the shape a caller passes,
// or a `Date` — the shape resolveInput above turns a valid string into
// before this schema runs. Malformed strings fall through resolveInput
// untouched and still fail the regex here with a clear message.
const stringSchema = z
.string({
message: `${formatFieldName(fieldName)} must be a valid date in ISO8601 format (YYYY-MM-DD)`,
})
.regex(/^\d{4}-\d{2}-\d{2}$/, {
message: `${formatFieldName(fieldName)} must be in YYYY-MM-DD format`,
})

// Validate ISO8601 date format (YYYY-MM-DD)
const dateSchema = baseSchema.regex(/^\d{4}-\d{2}-\d{2}$/, {
message: `${formatFieldName(fieldName)} must be in YYYY-MM-DD format`,
})
const dateSchema = z.union([stringSchema, z.date()])

if (isRequired && operation === 'create') {
return dateSchema
Expand Down
Loading