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");