Skip to content

Commit 9cd4483

Browse files
authored
Merge pull request #283 from metaobjectsdev/fix/timestamp-mode-finish
fix(codegen-ts): finish timestampMode:"date" — wire coercion, sqlite guard, view schemas
2 parents 5bf97e0 + 6f16d13 commit 9cd4483

10 files changed

Lines changed: 447 additions & 16 deletions

File tree

server/typescript/packages/codegen-ts/src/column-mapper.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,15 @@ export function mapColumnType(
396396
case FIELD_SUBTYPE_DATE:
397397
case FIELD_SUBTYPE_TIME:
398398
case FIELD_SUBTYPE_TIMESTAMP:
399+
// FIELD_SUBTYPE_TIMESTAMP deliberately ignores `timestampMode` here —
400+
// Drizzle's sqlite-core `text()` has no Date-typed column mode (only
401+
// pg-core's `timestamp()` does), so a bare string column is the only
402+
// correct output. Safe: `timestampMode` is normalized to "string" for
403+
// dialect === "sqlite" upstream, at the config choke points
404+
// (normalizeConfig / makeRenderContext) — this parameter is always
405+
// "string" by the time it reaches here for this dialect.
406+
fnName = "text";
407+
break;
399408
case FIELD_SUBTYPE_STRING:
400409
case FIELD_SUBTYPE_ENUM:
401410
case FIELD_SUBTYPE_UUID:

server/typescript/packages/codegen-ts/src/metaobjects-config.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport"
113113
* ISO-8601 strings (matches the generated Zod + cross-port wire contract); "date"
114114
* uses drizzle's native JS-Date mode (for consumers whose hand-written code works
115115
* with `Date`).
116+
*
117+
* **Postgres-only.** Drizzle's sqlite-core `text()` timestamp column has no
118+
* Date-typed mode (only `pg-core`'s `timestamp()` does), so `"date"` is
119+
* normalized to `"string"` whenever `dialect: "sqlite"` (which also covers
120+
* Cloudflare D1 — D1 is sqlite-at-the-SQL-level, see the D1 note in the repo's
121+
* porting docs). This keeps the option a safe no-op on sqlite/D1 instead of
122+
* emitting a non-compiling column + a Zod schema disagreeing with it.
123+
*
124+
* Date-mode filtering (`?filter[<timestamp>][gte]=...`) is not yet supported —
125+
* `runtime-ts`'s filter parser keeps the qs value a string, which throws at
126+
* request time against a Date-mode Drizzle column. Known limitation; not
127+
* threaded through at runtime, but `runGen` DOES warn (once per run, naming
128+
* every offending entity+field) when this mode meets a `@filterable`
129+
* `field.timestamp` — see `runner.ts`'s Important-4 check. Track before
130+
* recommending date mode for a filterable timestamp field.
116131
*/
117132
timestampMode?: "date" | "string";
118133
/** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
@@ -275,15 +290,19 @@ export function resolveGenerators(specs: readonly GeneratorSpec[]): Generator[]
275290

276291
/** Apply defaults to a MetaobjectsGenConfig, returning a NormalizedMetaobjectsGenConfig. */
277292
export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobjectsGenConfig {
293+
const dialect = config.dialect ?? DEFAULT_DIALECT;
278294
return {
279295
...config,
280296
dbImport: config.dbImport ?? DEFAULT_DB_IMPORT,
281-
dialect: config.dialect ?? DEFAULT_DIALECT,
297+
dialect,
282298
generators: resolveGenerators(config.generators),
283299
columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY,
284300
pluralizeCollections: config.pluralizeCollections ?? true,
285301
collectionNameOverrides: config.collectionNameOverrides ?? {},
286-
timestampMode: config.timestampMode ?? "string",
302+
// "date" mode is Postgres-only (see the doc comment on timestampMode above) —
303+
// normalize to "string" on sqlite/D1 at this one choke point so the option
304+
// can never silently emit a non-compiling column + a disagreeing Zod schema.
305+
timestampMode: dialect === "sqlite" ? "string" : (config.timestampMode ?? "string"),
287306
apiPrefix: config.apiPrefix ?? "",
288307
emitAbstractShapes: config.emitAbstractShapes ?? true,
289308
outputLayout: config.outputLayout ?? "flat",

server/typescript/packages/codegen-ts/src/render-context.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export interface RenderContext {
4646
* ISO-8601 strings (matching the generated Zod + cross-port wire contract);
4747
* "date" uses drizzle's native JS-Date mode (for consumers whose hand-written
4848
* code works with `Date`). Opt in via `codegen.timestampMode`.
49+
*
50+
* Postgres-only — normalized to "string" for `dialect === "sqlite"` (covers D1)
51+
* at the `makeRenderContext` / `normalizeConfig` choke points; see the fuller
52+
* doc comment on `MetaobjectsGenConfig.timestampMode` in metaobjects-config.ts.
4953
*/
5054
timestampMode: "date" | "string";
5155
/** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
@@ -175,7 +179,11 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
175179
extStyle: opts.extStyle ?? "js",
176180
omImport: opts.omImport ?? "../index",
177181
columnNamingStrategy: opts.columnNamingStrategy ?? "snake_case",
178-
timestampMode: opts.timestampMode ?? "string",
182+
// "date" mode is Postgres-only — normalize to "string" on sqlite/D1 here too
183+
// (the OTHER choke point besides normalizeConfig; a bare-context caller, e.g.
184+
// a unit test or a generator invoked outside `runGen`, must get the same
185+
// safe-no-op guarantee). See MetaobjectsGenConfig.timestampMode's doc comment.
186+
timestampMode: opts.dialect === "sqlite" ? "string" : (opts.timestampMode ?? "string"),
179187
apiPrefix: opts.apiPrefix ?? "",
180188
emitAbstractShapes: opts.emitAbstractShapes ?? true,
181189
outputLayout,

server/typescript/packages/codegen-ts/src/runner.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
33
import { fileURLToPath } from "node:url";
44
import { readFileSync } from "node:fs";
55
import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
6-
import { MetaRoot, OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata";
6+
import { MetaRoot, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_FILTERABLE } from "@metaobjectsdev/metadata";
77
import { assignEmittedNames } from "./naming/collision-names.js";
88
import { isAbstract } from "./instance-artifacts.js";
99
import { hasAnyRdbSource } from "./source-detect.js";
@@ -179,6 +179,38 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
179179

180180
// 2. Resolve targets + entity-module target.
181181
const config = normalizeConfig(opts.config);
182+
183+
// Important-4 (post-#281 pre-publish review) — a @filterable timestamp field
184+
// under timestampMode:"date" throws at REQUEST time in runtime-ts's filter
185+
// parser (documented limitation; see drizzle-fastify/filter-parser.ts's
186+
// "datetime" coerce case). Detect-and-WARN at generation time instead of
187+
// leaving it a silent build-time no-signal (repo precedent: #226/#258
188+
// detect-and-refuse an un-appliable migration at gen time rather than fail
189+
// at apply). Reading config.timestampMode AFTER normalizeConfig means this is
190+
// naturally silent on sqlite/D1 (normalized to "string" there) and in the
191+
// default "string" mode — no dialect/mode branching needed here. One warning
192+
// for the whole run, naming every offending entity+field, not one per field.
193+
if (config.timestampMode === "date") {
194+
const offenders = safeEntities
195+
.filter((e) => !e.isAbstract)
196+
.map((e) => ({
197+
entity: e.name,
198+
fields: e.fields()
199+
.filter((f) => f.subType === FIELD_SUBTYPE_TIMESTAMP && f.attr(FIELD_ATTR_FILTERABLE) === true)
200+
.map((f) => f.name),
201+
}))
202+
.filter((o) => o.fields.length > 0);
203+
if (offenders.length > 0) {
204+
const named = offenders.map((o) => `${o.entity}.${o.fields.join(",")}`).join("; ");
205+
warnings.push(
206+
`timestampMode: "date" — @filterable timestamp field(s) [${named}] will throw at ` +
207+
`request time when filtered (e.g. ?filter[field][gte]=...) — runtime-ts's filter ` +
208+
`parser does not yet thread the Date-mode column type through. Not enforced; ` +
209+
`remove @filterable from these fields or avoid filtering them until this is fixed.`,
210+
);
211+
}
212+
}
213+
182214
const targets = config.targets;
183215
const targetOf = (g: Generator): ResolvedTarget => {
184216
const name = g.target ?? DEFAULT_TARGET_NAME;

server/typescript/packages/codegen-ts/src/templates/field-meta.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,17 @@ function defaultViewForSubType(subType: string): string {
8080

8181
/**
8282
* Resolve the Zod validator expression for a field's storage type.
83+
*
84+
* `timestampMode` (default "string") mirrors zod-validators.ts's zodFieldExpr:
85+
* a "date"-mode FIELD_SUBTYPE_TIMESTAMP column (view/projection read schemas —
86+
* see view-decl.ts's renderViewReadZodObject, which sources its column TYPE from
87+
* `mapColumnType(..., timestampMode)`) must agree with the Zod line built here or
88+
* `z.infer<>` disagrees with the Drizzle column and callers fail to typecheck.
89+
* FIELD_SUBTYPE_DATE / FIELD_SUBTYPE_TIME are NOT governed by timestampMode —
90+
* calendar date / time-of-day stay ISO-string-shaped always (verified correct;
91+
* see zodFieldExpr's identical DATE/TIME case).
8392
*/
84-
export function zodTypeFor(field: MetaField): string {
93+
export function zodTypeFor(field: MetaField, timestampMode: "date" | "string" = "string"): string {
8594
switch (field.subType) {
8695
case FIELD_SUBTYPE_STRING: {
8796
// ADR-0036/0037 Wave 3: @stringFormat narrows a plain string. Codegen owns
@@ -108,9 +117,19 @@ export function zodTypeFor(field: MetaField): string {
108117
return "z.boolean()";
109118
case FIELD_SUBTYPE_DATE:
110119
case FIELD_SUBTYPE_TIME:
111-
case FIELD_SUBTYPE_TIMESTAMP:
112-
// Returned as ISO strings from SQLite/Postgres drivers.
120+
// Calendar date / time-of-day — always ISO-string-shaped, not governed by
121+
// timestampMode (verified correct; mirrors zodFieldExpr).
113122
return "z.string()";
123+
case FIELD_SUBTYPE_TIMESTAMP:
124+
// CRITICAL 3 (#281 sweep miss): must agree with the Drizzle view/projection
125+
// column's mode (mapColumnType via ViewDeclOpts.timestampMode) — a
126+
// hardcoded z.string() here disagreed with a "date"-mode column (Date-typed),
127+
// producing the same TS2322 cascade Critical 1 fixed for insert/update.
128+
// z.coerce.date() (not z.date()) for uniformity with zodFieldExpr: it
129+
// passes a DB-driver Date through unchanged (the read case this function
130+
// serves) and would equally accept an ISO string if ever reused for a
131+
// wire-parsing schema.
132+
return timestampMode === "date" ? "z.coerce.date()" : "z.string()";
114133
case FIELD_SUBTYPE_INT:
115134
case FIELD_SUBTYPE_LONG:
116135
case FIELD_SUBTYPE_CURRENCY:

server/typescript/packages/codegen-ts/src/templates/view-decl.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,9 @@ export function renderViewReadZodObject(fields: readonly MetaField[], opts: View
130130
return code` ${f.name}: ${base}${nullable}`;
131131
}
132132
// #204 — an array passthrough reads as `T[]`; zodTypeFor returns the ELEMENT type.
133-
const inner = code`${z}.${zodTypeFor(f).replace(/^z\./, "")}`;
133+
// CRITICAL 3: thread timestampMode through so a FIELD_SUBTYPE_TIMESTAMP column
134+
// agrees with the Drizzle view column's mode (both sourced from `opts` above).
135+
const inner = code`${z}.${zodTypeFor(f, timestampMode).replace(/^z\./, "")}`;
134136
const zbase = f.resolvedIsArray() ? code`${z}.array(${inner})` : inner;
135137
return code` ${f.name}: ${zbase}${nullable}`;
136138
});

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
3233
import { enumValues, zodEnumExpr } from "../enum-meta.js";
3334
import { 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) {

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ describe("renderZodValidators", () => {
1717
// Reported against an adopting project: a plain field.timestamp (autoSet or not) always got
1818
// z.string() regardless of timestampMode, disagreeing with a "date"-mode Drizzle column
1919
// (Date-typed) and failing to typecheck downstream.
20-
test("timestampMode: \"date\" — field.timestamp gets z.date(), @autoSet gets a Date-returning transform", () => {
20+
//
21+
// CRITICAL 1 (post-#281 pre-publish review): z.coerce.date(), not z.date() —
22+
// these schemas parse raw JSON request bodies (ISO strings on the wire);
23+
// z.date() rejects every JSON wire value outright. See the execution test
24+
// below (safeParse against an ISO string) for the behavioral pin.
25+
test("timestampMode: \"date\" — field.timestamp gets z.coerce.date(), @autoSet gets a Date-returning transform", () => {
2126
const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post");
2227
const id = metaField(FIELD_SUBTYPE_LONG, "id");
2328
post.addChild(id);
@@ -44,8 +49,9 @@ describe("renderZodValidators", () => {
4449
relationMap: buildRelationMap(root),
4550
});
4651
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
52+
expect(out).toContain("updatedAt: z.coerce.date()"); // plain field, general zodFieldExpr path
53+
expect(out).toContain("z.coerce.date().optional().transform(() =>"); // @autoSet insert path
54+
expect(out).not.toContain("z.date()"); // never the bare (non-coercing) form
4955
expect(out).not.toContain("z.string()"); // no stale string-typed timestamp anywhere
5056
expect(out).not.toContain(".toISOString()");
5157
});

0 commit comments

Comments
 (0)