Skip to content

Commit e7c4828

Browse files
authored
Merge pull request #281 from metaobjectsdev/fix/autoset-timestamp-mode
fix(codegen-ts): @autoset and plain field.timestamp now honor timestampMode
2 parents 0c8e11b + bd22243 commit e7c4828

4 files changed

Lines changed: 112 additions & 9 deletions

File tree

server/typescript/packages/codegen-ts/src/templates/drizzle-schema.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -372,12 +372,18 @@ function renderColumn(
372372
}
373373
}
374374

375-
// @autoSet fields: emit .$defaultFn(() => new Date().toISOString()) so Drizzle
376-
// inserts stamp the server-side timestamp automatically. This means callers don't
377-
// need to supply createdAt / updatedAt in INSERT calls — Drizzle fills them in.
375+
// @autoSet fields: emit a $defaultFn so Drizzle inserts stamp the server-side timestamp
376+
// automatically. This means callers don't need to supply createdAt / updatedAt in INSERT
377+
// calls — Drizzle fills them in. The stamp's shape must match ctx.timestampMode — the base
378+
// column type already does (mapColumnType, above) — or a "date" column ends up with a
379+
// $defaultFn that hands Drizzle a string, which fails to typecheck (reported against an
380+
// adopting project: TS2322 "Type 'string' is not assignable to type 'Date | SQL<unknown>'",
381+
// cascading into every generated insert/update query touching the field).
378382
const autoSet = field.attr(FIELD_ATTR_AUTO_SET);
379383
const autoSetSuffix = (autoSet === "onCreate" || autoSet === "onUpdate")
380-
? `.$defaultFn(() => new Date().toISOString())`
384+
? ctx.timestampMode === "date"
385+
? `.$defaultFn(() => new Date())`
386+
: `.$defaultFn(() => new Date().toISOString())`
381387
: "";
382388

383389
// $type<E[]>() chain — emitted as Code (not a string modifier) so ts-poet can

server/typescript/packages/codegen-ts/src/templates/zod-validators.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,9 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co
193193

194194
if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) {
195195
insertFieldLines.push(
196-
code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
196+
ctx?.timestampMode === "date"
197+
? code` ${child.name}: z.date().optional().transform(() => new Date())`
198+
: code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
197199
);
198200
} else {
199201
insertFieldLines.push(code` ${child.name}: ${zodFieldExpr(child, obj, ctx)}`);
@@ -338,7 +340,9 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
338340
// Insert schema: @autoSet fields use transform (always override client input).
339341
if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) {
340342
insertFieldLines.push(
341-
code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
343+
ctx?.timestampMode === "date"
344+
? code` ${child.name}: z.date().optional().transform(() => new Date())`
345+
: code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
342346
);
343347
// Preserving schema: the @autoSet column is validated verbatim (its natural
344348
// field expr) so an import/restore keeps the caller's original timestamp.
@@ -354,7 +358,9 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code
354358
// Omit: creation timestamps cannot be changed after creation
355359
} else if (autoSet === AUTO_SET_ON_UPDATE) {
356360
updateFieldLines.push(
357-
code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
361+
ctx?.timestampMode === "date"
362+
? code` ${child.name}: z.date().optional().transform(() => new Date())`
363+
: code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`,
358364
);
359365
} else {
360366
// All non-autoSet fields are optional in the update schema (PATCH semantics).
@@ -511,8 +517,14 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext)
511517
break;
512518
case FIELD_SUBTYPE_DATE:
513519
case FIELD_SUBTYPE_TIME:
520+
baseStr = "z.string()"; // calendar date / time-of-day — always ISO-string-shaped, not governed by timestampMode
521+
break;
514522
case FIELD_SUBTYPE_TIMESTAMP:
515-
baseStr = "z.string()";
523+
// Must agree with column-mapper.ts's mapColumnType, which already honors
524+
// ctx.timestampMode for the Drizzle column itself — z.string() here regardless would
525+
// disagree with a "date"-mode column (Date-typed) and fail to typecheck downstream
526+
// (reported against an adopting project).
527+
baseStr = ctx?.timestampMode === "date" ? "z.date()" : "z.string()";
516528
break;
517529
case FIELD_SUBTYPE_ENUM: {
518530
const values = enumValues(field);

server/typescript/packages/codegen-ts/test/templates/drizzle-schema.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,52 @@ describe("renderDrizzleSchema — Postgres", () => {
186186
expect(out).toContain("varchar(\"title\", { length: 200 }).notNull()");
187187
});
188188

189+
// Reported against an adopting project: @autoSet's $defaultFn ignored timestampMode and always
190+
// returned a string, failing to typecheck against a "date"-mode column (Date-typed).
191+
test("@autoSet field respects timestampMode: \"date\" — $defaultFn returns Date, not a string", () => {
192+
const post = makePost();
193+
const createdAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "createdAt");
194+
createdAt.setAttr("required", true);
195+
createdAt.setAttr("autoSet", "onCreate");
196+
post.addChild(createdAt);
197+
198+
const root = makeRoot([post]);
199+
const ctx = makeRenderContext({
200+
dialect: "postgres",
201+
timestampMode: "date",
202+
loadedRoot: root,
203+
outDir: "/x",
204+
dbImport: "~/db",
205+
pkMap: buildPkMap(root),
206+
relationMap: buildRelationMap(root),
207+
});
208+
const out = renderDrizzleSchema(root.findObject("Post")!, ctx).toString();
209+
expect(out).toContain(".$defaultFn(() => new Date())");
210+
expect(out).not.toContain(".toISOString()");
211+
expect(out).toMatch(/timestamp\("created_at",\s*\{\s*mode:\s*"date"/);
212+
});
213+
214+
test("@autoSet field defaults to timestampMode: \"string\" unchanged (no config set)", () => {
215+
const post = makePost();
216+
const createdAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "createdAt");
217+
createdAt.setAttr("required", true);
218+
createdAt.setAttr("autoSet", "onCreate");
219+
post.addChild(createdAt);
220+
221+
const root = makeRoot([post]);
222+
const ctx = makeRenderContext({
223+
dialect: "postgres",
224+
loadedRoot: root,
225+
outDir: "/x",
226+
dbImport: "~/db",
227+
pkMap: buildPkMap(root),
228+
relationMap: buildRelationMap(root),
229+
});
230+
const out = renderDrizzleSchema(root.findObject("Post")!, ctx).toString();
231+
expect(out).toContain(".$defaultFn(() =>");
232+
expect(out).toContain("new Date().toISOString()");
233+
});
234+
189235
test("Postgres long PK emits bigserial, not serial", () => {
190236
const root = makeRoot([makePost()]);
191237
const ctx = makeRenderContext({

server/typescript/packages/codegen-ts/test/templates/zod-validators.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,49 @@ import { TypeId, TYPE_IDENTITY, TYPE_VALIDATOR,
66
IDENTITY_SUBTYPE_PRIMARY, OBJECT_SUBTYPE_ENTITY,
77
VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_LENGTH,
88
MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
9-
import { meta, metaObject, metaField } from "../_meta-build.js";
9+
import { meta, metaObject, metaField, metaRoot } from "../_meta-build.js";
1010
import { renderZodValidators } from "../../src/templates/zod-validators.js";
11+
import { makeRenderContext } from "../../src/render-context.js";
12+
import { buildPkMap } from "../../src/pk-resolver.js";
13+
import { buildRelationMap } from "../../src/relation-resolver.js";
14+
import { FIELD_SUBTYPE_TIMESTAMP } from "@metaobjectsdev/metadata";
1115

1216
describe("renderZodValidators", () => {
17+
// Reported against an adopting project: a plain field.timestamp (autoSet or not) always got
18+
// z.string() regardless of timestampMode, disagreeing with a "date"-mode Drizzle column
19+
// (Date-typed) and failing to typecheck downstream.
20+
test("timestampMode: \"date\" — field.timestamp gets z.date(), @autoSet gets a Date-returning transform", () => {
21+
const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post");
22+
const id = metaField(FIELD_SUBTYPE_LONG, "id");
23+
post.addChild(id);
24+
const updatedAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "updatedAt"); // plain, not autoSet
25+
post.addChild(updatedAt);
26+
const createdAt = metaField(FIELD_SUBTYPE_TIMESTAMP, "createdAt");
27+
createdAt.setAttr("required", true);
28+
createdAt.setAttr("autoSet", "onCreate");
29+
post.addChild(createdAt);
30+
const primary = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "primary");
31+
primary.setAttr("fields", ["id"]);
32+
primary.setAttr("generation", "increment");
33+
post.addChild(primary);
34+
35+
const root = metaRoot();
36+
root.addChild(post);
37+
const ctx = makeRenderContext({
38+
dialect: "postgres",
39+
timestampMode: "date",
40+
loadedRoot: root,
41+
outDir: "/x",
42+
dbImport: "~/db",
43+
pkMap: buildPkMap(root),
44+
relationMap: buildRelationMap(root),
45+
});
46+
const out = renderZodValidators(post, ctx).toString();
47+
expect(out).toContain("updatedAt: z.date()"); // plain field, general zodFieldExpr path
48+
expect(out).toContain("z.date().optional().transform(() =>"); // @autoSet insert path
49+
expect(out).not.toContain("z.string()"); // no stale string-typed timestamp anywhere
50+
expect(out).not.toContain(".toISOString()");
51+
});
1352
test("emits InsertSchema with required fields and optional unset fields", () => {
1453
const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post");
1554
const id = metaField(FIELD_SUBTYPE_LONG, "id");

0 commit comments

Comments
 (0)