diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 225bf6e34..066541f67 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -84,6 +84,37 @@ export function createQuerySchemaFactory(clientOrSchema: any, options?: any) { return new ZodSchemaFactory(clientOrSchema, options); } +/** + * Builds a `DateTime` value schema that accepts a `Date` object or any string + * the JS `Date` constructor parses, and coerces it to a `Date`. ISO datetime, + * ISO date, and time-only strings (e.g. `"09:00:00"` for `@db.Time` fields, + * anchored to the Unix epoch) are the documented happy paths; other formats + * accepted by `new Date(...)` also pass through, mirroring Prisma's pre-3.5 + * behaviour. Strings the engine can't parse fall through and are rejected by + * `z.date()` with the standard error. + * + * @see https://github.com/zenstackhq/zenstack/issues/2631 + */ +export function coercedDateTimeSchema(): ZodType { + // The schema keeps the original `z.iso.datetime() | z.iso.date() | z.date()` + // union so the generated OpenAPI spec still documents the accepted ISO + // forms. Preprocess runs first and coerces strings into `Date` objects, + // so the union's `z.date()` arm catches everything that successfully + // parses — including non-ISO formats like `"2024/01/15"` for Prisma + // compatibility (rejected with the standard error if `new Date(...)` + // returns Invalid Date). + return z.preprocess((val) => { + if (typeof val !== 'string') return val; + if (/^\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d\d(?::\d\d)?)?$/.test(val)) { + const hasTz = val.endsWith('Z') || /[+-]\d\d(?::\d\d)?$/.test(val); + const d = new Date(`1970-01-01T${val}${hasTz ? '' : 'Z'}`); + return isNaN(d.getTime()) ? val : d; + } + const d = new Date(val); + return isNaN(d.getTime()) ? val : d; + }, z.union([z.iso.datetime(), z.iso.date(), z.date()])); +} + /** * Options for creating Zod schemas. */ @@ -854,7 +885,7 @@ export class ZodSchemaFactory< @cache() private makeDateTimeValueSchema(): ZodType { - const schema = z.union([z.iso.datetime(), z.iso.date(), z.date()]); + const schema = coercedDateTimeSchema(); this.registerSchema('DateTime', schema); return schema; } diff --git a/tests/regression/test/issue-2631.test.ts b/tests/regression/test/issue-2631.test.ts new file mode 100644 index 000000000..84d270be6 --- /dev/null +++ b/tests/regression/test/issue-2631.test.ts @@ -0,0 +1,50 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +// Regression for #2631: ZenStack 3.5+ replaced Prisma's permissive +// datetime input coercion with a strict zod union, breaking every caller +// that passed ISO strings to `DateTime` fields. `DateTime` inputs now +// coerce strings the JS `Date` constructor parses back to `Date`, +// mirroring Prisma's pre-3.5 behaviour. +describe('Issue 2631 — DateTime input coercion', () => { + const schema = ` +model Event { + id Int @id @default(autoincrement()) + label String + when DateTime +} + `; + + let db: any; + + beforeEach(async () => { + db = await createTestClient(schema, { usePrismaPush: true, provider: 'sqlite' }); + }); + afterEach(async () => db?.$disconnect()); + + it('accepts a Date object', async () => { + const e = await db.event.create({ data: { label: 'date', when: new Date('2024-01-15T10:30:00Z') } }); + expect(e.when).toBeInstanceOf(Date); + }); + + it('accepts an ISO datetime string and coerces to Date', async () => { + const e = await db.event.create({ data: { label: 'iso', when: '2024-01-15T10:30:00.000Z' } }); + expect(e.when).toBeInstanceOf(Date); + }); + + it('accepts an ISO date string and coerces to Date', async () => { + const e = await db.event.create({ data: { label: 'date-only', when: '2024-01-15' } }); + expect(e.when).toBeInstanceOf(Date); + }); + + it('accepts a bare time-only string anchored to the Unix epoch', async () => { + const e = await db.event.create({ data: { label: 'time-only', when: '09:30:00' } }); + expect(e.when).toBeInstanceOf(Date); + expect((e.when as Date).getUTCHours()).toBe(9); + expect((e.when as Date).getUTCMinutes()).toBe(30); + }); + + it('rejects a non-parseable string', async () => { + await expect(db.event.create({ data: { label: 'junk', when: 'not-a-date' as any } })).rejects.toThrow(); + }); +});