@@ -28,6 +28,7 @@ import {
2828 VALIDATOR_ATTR_MAX , VALIDATOR_ATTR_MIN , VALIDATOR_ATTR_PATTERN ,
2929 GENERATION_INCREMENT , GENERATION_UUID ,
3030 OBJECT_ATTR_DISCRIMINATOR , OBJECT_ATTR_DISCRIMINATOR_VALUE ,
31+ OBJECT_SUBTYPE_VALUE ,
3132} from "@metaobjectsdev/metadata" ;
3233import { enumValues , zodEnumExpr } from "../enum-meta.js" ;
3334import { ZOD_INET_EXPR } from "./net-regex.js" ;
@@ -194,7 +195,11 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co
194195 if ( autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE ) {
195196 insertFieldLines . push (
196197 ctx ?. timestampMode === "date"
197- ? code ` ${ child . name } : z.date().optional().transform(() => new Date())`
198+ // CRITICAL 1: these schemas validate raw JSON request bodies (wire
199+ // timestamps are ISO strings), never a live `Date` — z.coerce.date()
200+ // (not z.date()) so a string round-trips; z.date() rejects every JSON
201+ // wire value outright (verified: z.date().safeParse(isoString) is false).
202+ ? code ` ${ child . name } : z.coerce.date().optional().transform(() => new Date())`
198203 : code ` ${ child . name } : z.string().optional().transform(() => new Date().toISOString())` ,
199204 ) ;
200205 } else {
@@ -341,7 +346,11 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
341346 if ( autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE ) {
342347 insertFieldLines . push (
343348 ctx ?. timestampMode === "date"
344- ? code ` ${ child . name } : z.date().optional().transform(() => new Date())`
349+ // CRITICAL 1: these schemas validate raw JSON request bodies (wire
350+ // timestamps are ISO strings), never a live `Date` — z.coerce.date()
351+ // (not z.date()) so a string round-trips; z.date() rejects every JSON
352+ // wire value outright (verified: z.date().safeParse(isoString) is false).
353+ ? code ` ${ child . name } : z.coerce.date().optional().transform(() => new Date())`
345354 : code ` ${ child . name } : z.string().optional().transform(() => new Date().toISOString())` ,
346355 ) ;
347356 // Preserving schema: the @autoSet column is validated verbatim (its natural
@@ -359,7 +368,11 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
359368 } else if ( autoSet === AUTO_SET_ON_UPDATE ) {
360369 updateFieldLines . push (
361370 ctx ?. timestampMode === "date"
362- ? code ` ${ child . name } : z.date().optional().transform(() => new Date())`
371+ // CRITICAL 1: these schemas validate raw JSON request bodies (wire
372+ // timestamps are ISO strings), never a live `Date` — z.coerce.date()
373+ // (not z.date()) so a string round-trips; z.date() rejects every JSON
374+ // wire value outright (verified: z.date().safeParse(isoString) is false).
375+ ? code ` ${ child . name } : z.coerce.date().optional().transform(() => new Date())`
363376 : code ` ${ child . name } : z.string().optional().transform(() => new Date().toISOString())` ,
364377 ) ;
365378 } else {
@@ -519,13 +532,27 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext)
519532 case FIELD_SUBTYPE_TIME :
520533 baseStr = "z.string()" ; // calendar date / time-of-day — always ISO-string-shaped, not governed by timestampMode
521534 break ;
522- case FIELD_SUBTYPE_TIMESTAMP :
535+ case FIELD_SUBTYPE_TIMESTAMP : {
523536 // Must agree with column-mapper.ts's mapColumnType, which already honors
524537 // ctx.timestampMode for the Drizzle column itself — z.string() here regardless would
525538 // disagree with a "date"-mode column (Date-typed) and fail to typecheck downstream
526539 // (reported against an adopting project).
527- baseStr = ctx ?. timestampMode === "date" ? "z.date()" : "z.string()" ;
540+ //
541+ // IMPORTANT 5: a VO-hosted (object.value) timestamp is jsonb storage —
542+ // inherently ISO-string JSON, never a live `Date` — so it stays z.string()
543+ // regardless of ctx.timestampMode; that matches the VO structural interface
544+ // (inferred-types.ts's SCALAR_TS_BY_SUBTYPE, deliberately NOT mode-aware —
545+ // the two are documented as lock-step). An entity/TPH-subtype field (a real
546+ // DB column) honors the mode.
547+ const voHosted = owner ?. subType === OBJECT_SUBTYPE_VALUE ;
548+ // CRITICAL 1: z.coerce.date() (not z.date()) — the ONE expression correct
549+ // for both readers of zodFieldExpr's output: the TPH read schema parses DB
550+ // rows (already a `Date` under pg date mode — z.coerce.date() passes a
551+ // Date through unchanged) while insert/update/preserving parse wire JSON
552+ // (an ISO string — z.coerce.date() parses it; z.date() would reject it).
553+ baseStr = ctx ?. timestampMode === "date" && ! voHosted ? "z.coerce.date()" : "z.string()" ;
528554 break ;
555+ }
529556 case FIELD_SUBTYPE_ENUM : {
530557 const values = enumValues ( field ) ;
531558 if ( values === undefined ) {
0 commit comments