diff --git a/.changeset/tidy-plums-argue.md b/.changeset/tidy-plums-argue.md new file mode 100644 index 00000000..528b718e --- /dev/null +++ b/.changeset/tidy-plums-argue.md @@ -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. diff --git a/packages/core/src/fields/calendar-day.test.ts b/packages/core/src/fields/calendar-day.test.ts index 1d786ba8..3a04637b 100644 --- a/packages/core/src/fields/calendar-day.test.ts +++ b/packages/core/src/fields/calendar-day.test.ts @@ -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 @@ -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 = { startsOn: calendarDay({ validation: { isRequired: true } }), } @@ -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, + { startsOn: new Date('2025-01-15T00:00:00.000Z') } as unknown as Record, fields, 'create', ) - expect(result.success).toBe(false) + expect(result.success).toBe(true) }) it('zod schema for the field validates the YYYY-MM-DD shape', () => { @@ -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 + 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 + 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. @@ -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') + }) + }) }) diff --git a/packages/core/src/fields/index.ts b/packages/core/src/fields/index.ts index 734b4113..b76a4b51 100644 --- a/packages/core/src/fields/index.ts +++ b/packages/core/src/fields/index.ts @@ -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 @@ -573,6 +577,17 @@ 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 @@ -580,6 +595,15 @@ export function calendarDay< // 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 @@ -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