From bd222438e6b05f75b96cb21706de46d815d9aaac Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Sat, 8 Aug 2026 23:19:43 -0400 Subject: [PATCH] fix(codegen-ts): @autoSet and plain field.timestamp now honor timestampMode timestampMode:"date" already worked for the base Drizzle column type (column-mapper.ts's mapColumnType) but two call sites still hardcoded string-shaped output regardless: 1. drizzle-schema.ts's @autoSet suffix always emitted .$defaultFn(() => new Date().toISOString()) -- a string -- into a column that's mode:"date" (expects a Date), a hard tsc failure ("Type 'string' is not assignable to type 'Date | SQL'"), cascading into every generated insert/update query touching the field. 2. zod-validators.ts's FIELD_SUBTYPE_TIMESTAMP case in zodFieldExpr, plus its own 3 separate @autoSet call sites, always emitted z.string() / a string-returning transform -- disagreeing with the Drizzle column even for a PLAIN (non-autoSet) timestamp field once timestampMode:"date" is set, not just @autoSet ones. Both now branch on ctx.timestampMode: "date" -> new Date() / z.date() (unchanged), "string" (default) -> the original .toISOString()/z.string() behavior, byte-identical to before for every existing caller that doesn't opt in. field.date/field.time stay z.string() unconditionally -- calendar date/time-of-day are inherently string-shaped on the wire, not governed by timestampMode (which is specifically about field.timestamp's instant representation). Not touched: zodScalarFor() (used for field.map's valueType) still returns z.string() for a timestamp-typed map value regardless of timestampMode -- narrower, unverified edge case, flagged rather than guessed at. Reported and reproduced against an adopting project: real entity, real `meta gen`, real `tsc --noEmit` failure before this fix, clean after. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01C7jsHCMNVmaXu883UFsh1e --- .../src/templates/drizzle-schema.ts | 14 ++++-- .../src/templates/zod-validators.ts | 20 ++++++-- .../test/templates/drizzle-schema.test.ts | 46 +++++++++++++++++++ .../test/templates/zod-validators.test.ts | 41 ++++++++++++++++- 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts index 52fced31c..0caeb048d 100644 --- a/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts +++ b/server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts @@ -372,12 +372,18 @@ function renderColumn( } } - // @autoSet fields: emit .$defaultFn(() => new Date().toISOString()) so Drizzle - // inserts stamp the server-side timestamp automatically. This means callers don't - // need to supply createdAt / updatedAt in INSERT calls — Drizzle fills them in. + // @autoSet fields: emit a $defaultFn so Drizzle inserts stamp the server-side timestamp + // automatically. This means callers don't need to supply createdAt / updatedAt in INSERT + // calls — Drizzle fills them in. The stamp's shape must match ctx.timestampMode — the base + // column type already does (mapColumnType, above) — or a "date" column ends up with a + // $defaultFn that hands Drizzle a string, which fails to typecheck (reported against an + // adopting project: TS2322 "Type 'string' is not assignable to type 'Date | SQL'", + // cascading into every generated insert/update query touching the field). const autoSet = field.attr(FIELD_ATTR_AUTO_SET); const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate") - ? `.$defaultFn(() => new Date().toISOString())` + ? ctx.timestampMode === "date" + ? `.$defaultFn(() => new Date())` + : `.$defaultFn(() => new Date().toISOString())` : ""; // $type() chain — emitted as Code (not a string modifier) so ts-poet can diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index 9b89a4795..b3c5cdfcc 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -193,7 +193,9 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) { insertFieldLines.push( - code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`, + ctx?.timestampMode === "date" + ? code` ${child.name}: z.date().optional().transform(() => new Date())` + : code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`, ); } else { insertFieldLines.push(code` ${child.name}: ${zodFieldExpr(child, obj, ctx)}`); @@ -338,7 +340,9 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code // Insert schema: @autoSet fields use transform (always override client input). if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) { insertFieldLines.push( - code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`, + ctx?.timestampMode === "date" + ? code` ${child.name}: z.date().optional().transform(() => new Date())` + : code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`, ); // Preserving schema: the @autoSet column is validated verbatim (its natural // field expr) so an import/restore keeps the caller's original timestamp. @@ -354,7 +358,9 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code // Omit: creation timestamps cannot be changed after creation } else if (autoSet === AUTO_SET_ON_UPDATE) { updateFieldLines.push( - code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`, + ctx?.timestampMode === "date" + ? code` ${child.name}: z.date().optional().transform(() => new Date())` + : code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`, ); } else { // All non-autoSet fields are optional in the update schema (PATCH semantics). @@ -511,8 +517,14 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext) break; case FIELD_SUBTYPE_DATE: case FIELD_SUBTYPE_TIME: + baseStr = "z.string()"; // calendar date / time-of-day — always ISO-string-shaped, not governed by timestampMode + break; case FIELD_SUBTYPE_TIMESTAMP: - baseStr = "z.string()"; + // Must agree with column-mapper.ts's mapColumnType, which already honors + // ctx.timestampMode for the Drizzle column itself — z.string() here regardless would + // disagree with a "date"-mode column (Date-typed) and fail to typecheck downstream + // (reported against an adopting project). + baseStr = ctx?.timestampMode === "date" ? "z.date()" : "z.string()"; break; case FIELD_SUBTYPE_ENUM: { const values = enumValues(field); diff --git a/server/typescript/packages/codegen-ts/test/templates/drizzle-schema.test.ts b/server/typescript/packages/codegen-ts/test/templates/drizzle-schema.test.ts index 89f7ddcab..0996f45cd 100644 --- a/server/typescript/packages/codegen-ts/test/templates/drizzle-schema.test.ts +++ b/server/typescript/packages/codegen-ts/test/templates/drizzle-schema.test.ts @@ -186,6 +186,52 @@ describe("renderDrizzleSchema — Postgres", () => { expect(out).toContain("varchar(\"title\", { length: 200 }).notNull()"); }); + // Reported against an adopting project: @autoSet's $defaultFn ignored timestampMode and always + // returned a string, failing to typecheck against a "date"-mode column (Date-typed). + test("@autoSet field respects timestampMode: \"date\" — $defaultFn returns Date, not a string", () => { + const post = makePost(); + const createdAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "createdAt"); + createdAt.setAttr("required", true); + createdAt.setAttr("autoSet", "onCreate"); + post.addChild(createdAt); + + const root = makeRoot([post]); + const ctx = makeRenderContext({ + dialect: "postgres", + timestampMode: "date", + loadedRoot: root, + outDir: "/x", + dbImport: "~/db", + pkMap: buildPkMap(root), + relationMap: buildRelationMap(root), + }); + const out = renderDrizzleSchema(root.findObject("Post")!, ctx).toString(); + expect(out).toContain(".$defaultFn(() => new Date())"); + expect(out).not.toContain(".toISOString()"); + expect(out).toMatch(/timestamp\("created_at",\s*\{\s*mode:\s*"date"/); + }); + + test("@autoSet field defaults to timestampMode: \"string\" unchanged (no config set)", () => { + const post = makePost(); + const createdAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "createdAt"); + createdAt.setAttr("required", true); + createdAt.setAttr("autoSet", "onCreate"); + post.addChild(createdAt); + + const root = makeRoot([post]); + const ctx = makeRenderContext({ + dialect: "postgres", + loadedRoot: root, + outDir: "/x", + dbImport: "~/db", + pkMap: buildPkMap(root), + relationMap: buildRelationMap(root), + }); + const out = renderDrizzleSchema(root.findObject("Post")!, ctx).toString(); + expect(out).toContain(".$defaultFn(() =>"); + expect(out).toContain("new Date().toISOString()"); + }); + test("Postgres long PK emits bigserial, not serial", () => { const root = makeRoot([makePost()]); const ctx = makeRenderContext({ diff --git a/server/typescript/packages/codegen-ts/test/templates/zod-validators.test.ts b/server/typescript/packages/codegen-ts/test/templates/zod-validators.test.ts index 06433eef2..adcd1d3e0 100644 --- a/server/typescript/packages/codegen-ts/test/templates/zod-validators.test.ts +++ b/server/typescript/packages/codegen-ts/test/templates/zod-validators.test.ts @@ -6,10 +6,49 @@ import { TypeId, TYPE_IDENTITY, TYPE_VALIDATOR, IDENTITY_SUBTYPE_PRIMARY, OBJECT_SUBTYPE_ENTITY, VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_LENGTH, MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; -import { meta, metaObject, metaField } from "../_meta-build.js"; +import { meta, metaObject, metaField, metaRoot } from "../_meta-build.js"; import { renderZodValidators } from "../../src/templates/zod-validators.js"; +import { makeRenderContext } from "../../src/render-context.js"; +import { buildPkMap } from "../../src/pk-resolver.js"; +import { buildRelationMap } from "../../src/relation-resolver.js"; +import { FIELD_SUBTYPE_TIMESTAMP } from "@metaobjectsdev/metadata"; describe("renderZodValidators", () => { + // Reported against an adopting project: a plain field.timestamp (autoSet or not) always got + // z.string() regardless of timestampMode, disagreeing with a "date"-mode Drizzle column + // (Date-typed) and failing to typecheck downstream. + test("timestampMode: \"date\" — field.timestamp gets z.date(), @autoSet gets a Date-returning transform", () => { + const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post"); + const id = metaField(FIELD_SUBTYPE_LONG, "id"); + post.addChild(id); + const updatedAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "updatedAt"); // plain, not autoSet + post.addChild(updatedAt); + const createdAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "createdAt"); + createdAt.setAttr("required", true); + createdAt.setAttr("autoSet", "onCreate"); + post.addChild(createdAt); + const primary = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "primary"); + primary.setAttr("fields", ["id"]); + primary.setAttr("generation", "increment"); + post.addChild(primary); + + const root = metaRoot(); + root.addChild(post); + const ctx = makeRenderContext({ + dialect: "postgres", + timestampMode: "date", + loadedRoot: root, + outDir: "/x", + dbImport: "~/db", + pkMap: buildPkMap(root), + relationMap: buildRelationMap(root), + }); + const out = renderZodValidators(post, ctx).toString(); + expect(out).toContain("updatedAt: z.date()"); // plain field, general zodFieldExpr path + expect(out).toContain("z.date().optional().transform(() =>"); // @autoSet insert path + expect(out).not.toContain("z.string()"); // no stale string-typed timestamp anywhere + expect(out).not.toContain(".toISOString()"); + }); test("emits InsertSchema with required fields and optional unset fields", () => { const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post"); const id = metaField(FIELD_SUBTYPE_LONG, "id");