diff --git a/server/typescript/packages/codegen-ts/src/column-mapper.ts b/server/typescript/packages/codegen-ts/src/column-mapper.ts index 59c5c424d..87f2854f4 100644 --- a/server/typescript/packages/codegen-ts/src/column-mapper.ts +++ b/server/typescript/packages/codegen-ts/src/column-mapper.ts @@ -396,6 +396,15 @@ export function mapColumnType( case FIELD_SUBTYPE_DATE: case FIELD_SUBTYPE_TIME: case FIELD_SUBTYPE_TIMESTAMP: + // FIELD_SUBTYPE_TIMESTAMP deliberately ignores `timestampMode` here — + // Drizzle's sqlite-core `text()` has no Date-typed column mode (only + // pg-core's `timestamp()` does), so a bare string column is the only + // correct output. Safe: `timestampMode` is normalized to "string" for + // dialect === "sqlite" upstream, at the config choke points + // (normalizeConfig / makeRenderContext) — this parameter is always + // "string" by the time it reaches here for this dialect. + fnName = "text"; + break; case FIELD_SUBTYPE_STRING: case FIELD_SUBTYPE_ENUM: case FIELD_SUBTYPE_UUID: diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 71c050af9..6d67e7365 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -113,6 +113,21 @@ export interface MetaobjectsGenConfig extends Omit][gte]=...`) is not yet supported — + * `runtime-ts`'s filter parser keeps the qs value a string, which throws at + * request time against a Date-mode Drizzle column. Known limitation; not + * threaded through at runtime, but `runGen` DOES warn (once per run, naming + * every offending entity+field) when this mode meets a `@filterable` + * `field.timestamp` — see `runner.ts`'s Important-4 check. Track before + * recommending date mode for a filterable timestamp field. */ timestampMode?: "date" | "string"; /** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */ @@ -275,15 +290,19 @@ export function resolveGenerators(specs: readonly GeneratorSpec[]): Generator[] /** Apply defaults to a MetaobjectsGenConfig, returning a NormalizedMetaobjectsGenConfig. */ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobjectsGenConfig { + const dialect = config.dialect ?? DEFAULT_DIALECT; return { ...config, dbImport: config.dbImport ?? DEFAULT_DB_IMPORT, - dialect: config.dialect ?? DEFAULT_DIALECT, + dialect, generators: resolveGenerators(config.generators), columnNamingStrategy: config.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY, pluralizeCollections: config.pluralizeCollections ?? true, collectionNameOverrides: config.collectionNameOverrides ?? {}, - timestampMode: config.timestampMode ?? "string", + // "date" mode is Postgres-only (see the doc comment on timestampMode above) — + // normalize to "string" on sqlite/D1 at this one choke point so the option + // can never silently emit a non-compiling column + a disagreeing Zod schema. + timestampMode: dialect === "sqlite" ? "string" : (config.timestampMode ?? "string"), apiPrefix: config.apiPrefix ?? "", emitAbstractShapes: config.emitAbstractShapes ?? true, outputLayout: config.outputLayout ?? "flat", diff --git a/server/typescript/packages/codegen-ts/src/render-context.ts b/server/typescript/packages/codegen-ts/src/render-context.ts index 2400f2753..8817ec4ec 100644 --- a/server/typescript/packages/codegen-ts/src/render-context.ts +++ b/server/typescript/packages/codegen-ts/src/render-context.ts @@ -46,6 +46,10 @@ export interface RenderContext { * ISO-8601 strings (matching the generated Zod + cross-port wire contract); * "date" uses drizzle's native JS-Date mode (for consumers whose hand-written * code works with `Date`). Opt in via `codegen.timestampMode`. + * + * Postgres-only — normalized to "string" for `dialect === "sqlite"` (covers D1) + * at the `makeRenderContext` / `normalizeConfig` choke points; see the fuller + * doc comment on `MetaobjectsGenConfig.timestampMode` in metaobjects-config.ts. */ timestampMode: "date" | "string"; /** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */ @@ -175,7 +179,11 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext { extStyle: opts.extStyle ?? "js", omImport: opts.omImport ?? "../index", columnNamingStrategy: opts.columnNamingStrategy ?? "snake_case", - timestampMode: opts.timestampMode ?? "string", + // "date" mode is Postgres-only — normalize to "string" on sqlite/D1 here too + // (the OTHER choke point besides normalizeConfig; a bare-context caller, e.g. + // a unit test or a generator invoked outside `runGen`, must get the same + // safe-no-op guarantee). See MetaobjectsGenConfig.timestampMode's doc comment. + timestampMode: opts.dialect === "sqlite" ? "string" : (opts.timestampMode ?? "string"), apiPrefix: opts.apiPrefix ?? "", emitAbstractShapes: opts.emitAbstractShapes ?? true, outputLayout, diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index 074a4a14d..9ff5c0b35 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; import type { MetaData, MetaObject } from "@metaobjectsdev/metadata"; -import { MetaRoot, OBJECT_SUBTYPE_VALUE } from "@metaobjectsdev/metadata"; +import { MetaRoot, OBJECT_SUBTYPE_VALUE, FIELD_SUBTYPE_TIMESTAMP, FIELD_ATTR_FILTERABLE } from "@metaobjectsdev/metadata"; import { assignEmittedNames } from "./naming/collision-names.js"; import { isAbstract } from "./instance-artifacts.js"; import { hasAnyRdbSource } from "./source-detect.js"; @@ -179,6 +179,38 @@ export async function runGen(opts: RunGenOpts): Promise { // 2. Resolve targets + entity-module target. const config = normalizeConfig(opts.config); + + // Important-4 (post-#281 pre-publish review) — a @filterable timestamp field + // under timestampMode:"date" throws at REQUEST time in runtime-ts's filter + // parser (documented limitation; see drizzle-fastify/filter-parser.ts's + // "datetime" coerce case). Detect-and-WARN at generation time instead of + // leaving it a silent build-time no-signal (repo precedent: #226/#258 + // detect-and-refuse an un-appliable migration at gen time rather than fail + // at apply). Reading config.timestampMode AFTER normalizeConfig means this is + // naturally silent on sqlite/D1 (normalized to "string" there) and in the + // default "string" mode — no dialect/mode branching needed here. One warning + // for the whole run, naming every offending entity+field, not one per field. + if (config.timestampMode === "date") { + const offenders = safeEntities + .filter((e) => !e.isAbstract) + .map((e) => ({ + entity: e.name, + fields: e.fields() + .filter((f) => f.subType === FIELD_SUBTYPE_TIMESTAMP && f.attr(FIELD_ATTR_FILTERABLE) === true) + .map((f) => f.name), + })) + .filter((o) => o.fields.length > 0); + if (offenders.length > 0) { + const named = offenders.map((o) => `${o.entity}.${o.fields.join(",")}`).join("; "); + warnings.push( + `timestampMode: "date" — @filterable timestamp field(s) [${named}] will throw at ` + + `request time when filtered (e.g. ?filter[field][gte]=...) — runtime-ts's filter ` + + `parser does not yet thread the Date-mode column type through. Not enforced; ` + + `remove @filterable from these fields or avoid filtering them until this is fixed.`, + ); + } + } + const targets = config.targets; const targetOf = (g: Generator): ResolvedTarget => { const name = g.target ?? DEFAULT_TARGET_NAME; diff --git a/server/typescript/packages/codegen-ts/src/templates/field-meta.ts b/server/typescript/packages/codegen-ts/src/templates/field-meta.ts index 076025bd6..56e3ed934 100644 --- a/server/typescript/packages/codegen-ts/src/templates/field-meta.ts +++ b/server/typescript/packages/codegen-ts/src/templates/field-meta.ts @@ -80,8 +80,17 @@ function defaultViewForSubType(subType: string): string { /** * Resolve the Zod validator expression for a field's storage type. + * + * `timestampMode` (default "string") mirrors zod-validators.ts's zodFieldExpr: + * a "date"-mode FIELD_SUBTYPE_TIMESTAMP column (view/projection read schemas — + * see view-decl.ts's renderViewReadZodObject, which sources its column TYPE from + * `mapColumnType(..., timestampMode)`) must agree with the Zod line built here or + * `z.infer<>` disagrees with the Drizzle column and callers fail to typecheck. + * FIELD_SUBTYPE_DATE / FIELD_SUBTYPE_TIME are NOT governed by timestampMode — + * calendar date / time-of-day stay ISO-string-shaped always (verified correct; + * see zodFieldExpr's identical DATE/TIME case). */ -export function zodTypeFor(field: MetaField): string { +export function zodTypeFor(field: MetaField, timestampMode: "date" | "string" = "string"): string { switch (field.subType) { case FIELD_SUBTYPE_STRING: { // ADR-0036/0037 Wave 3: @stringFormat narrows a plain string. Codegen owns @@ -108,9 +117,19 @@ export function zodTypeFor(field: MetaField): string { return "z.boolean()"; case FIELD_SUBTYPE_DATE: case FIELD_SUBTYPE_TIME: - case FIELD_SUBTYPE_TIMESTAMP: - // Returned as ISO strings from SQLite/Postgres drivers. + // Calendar date / time-of-day — always ISO-string-shaped, not governed by + // timestampMode (verified correct; mirrors zodFieldExpr). return "z.string()"; + case FIELD_SUBTYPE_TIMESTAMP: + // CRITICAL 3 (#281 sweep miss): must agree with the Drizzle view/projection + // column's mode (mapColumnType via ViewDeclOpts.timestampMode) — a + // hardcoded z.string() here disagreed with a "date"-mode column (Date-typed), + // producing the same TS2322 cascade Critical 1 fixed for insert/update. + // z.coerce.date() (not z.date()) for uniformity with zodFieldExpr: it + // passes a DB-driver Date through unchanged (the read case this function + // serves) and would equally accept an ISO string if ever reused for a + // wire-parsing schema. + return timestampMode === "date" ? "z.coerce.date()" : "z.string()"; case FIELD_SUBTYPE_INT: case FIELD_SUBTYPE_LONG: case FIELD_SUBTYPE_CURRENCY: diff --git a/server/typescript/packages/codegen-ts/src/templates/view-decl.ts b/server/typescript/packages/codegen-ts/src/templates/view-decl.ts index 254974cbb..ef9a7581b 100644 --- a/server/typescript/packages/codegen-ts/src/templates/view-decl.ts +++ b/server/typescript/packages/codegen-ts/src/templates/view-decl.ts @@ -130,7 +130,9 @@ export function renderViewReadZodObject(fields: readonly MetaField[], opts: View return code` ${f.name}: ${base}${nullable}`; } // #204 — an array passthrough reads as `T[]`; zodTypeFor returns the ELEMENT type. - const inner = code`${z}.${zodTypeFor(f).replace(/^z\./, "")}`; + // CRITICAL 3: thread timestampMode through so a FIELD_SUBTYPE_TIMESTAMP column + // agrees with the Drizzle view column's mode (both sourced from `opts` above). + const inner = code`${z}.${zodTypeFor(f, timestampMode).replace(/^z\./, "")}`; const zbase = f.resolvedIsArray() ? code`${z}.array(${inner})` : inner; return code` ${f.name}: ${zbase}${nullable}`; }); 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 b3c5cdfcc..76fbe8a6f 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -28,6 +28,7 @@ import { VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN, GENERATION_INCREMENT, GENERATION_UUID, OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE, + OBJECT_SUBTYPE_VALUE, } from "@metaobjectsdev/metadata"; import { enumValues, zodEnumExpr } from "../enum-meta.js"; import { ZOD_INET_EXPR } from "./net-regex.js"; @@ -194,7 +195,11 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) { insertFieldLines.push( ctx?.timestampMode === "date" - ? code` ${child.name}: z.date().optional().transform(() => new Date())` + // CRITICAL 1: these schemas validate raw JSON request bodies (wire + // timestamps are ISO strings), never a live `Date` — z.coerce.date() + // (not z.date()) so a string round-trips; z.date() rejects every JSON + // wire value outright (verified: z.date().safeParse(isoString) is false). + ? code` ${child.name}: z.coerce.date().optional().transform(() => new Date())` : code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`, ); } else { @@ -341,7 +346,11 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code if (autoSet === AUTO_SET_ON_CREATE || autoSet === AUTO_SET_ON_UPDATE) { insertFieldLines.push( ctx?.timestampMode === "date" - ? code` ${child.name}: z.date().optional().transform(() => new Date())` + // CRITICAL 1: these schemas validate raw JSON request bodies (wire + // timestamps are ISO strings), never a live `Date` — z.coerce.date() + // (not z.date()) so a string round-trips; z.date() rejects every JSON + // wire value outright (verified: z.date().safeParse(isoString) is false). + ? code` ${child.name}: z.coerce.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 @@ -359,7 +368,11 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code } else if (autoSet === AUTO_SET_ON_UPDATE) { updateFieldLines.push( ctx?.timestampMode === "date" - ? code` ${child.name}: z.date().optional().transform(() => new Date())` + // CRITICAL 1: these schemas validate raw JSON request bodies (wire + // timestamps are ISO strings), never a live `Date` — z.coerce.date() + // (not z.date()) so a string round-trips; z.date() rejects every JSON + // wire value outright (verified: z.date().safeParse(isoString) is false). + ? code` ${child.name}: z.coerce.date().optional().transform(() => new Date())` : code` ${child.name}: z.string().optional().transform(() => new Date().toISOString())`, ); } else { @@ -519,13 +532,27 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext) 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: + case FIELD_SUBTYPE_TIMESTAMP: { // 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()"; + // + // IMPORTANT 5: a VO-hosted (object.value) timestamp is jsonb storage — + // inherently ISO-string JSON, never a live `Date` — so it stays z.string() + // regardless of ctx.timestampMode; that matches the VO structural interface + // (inferred-types.ts's SCALAR_TS_BY_SUBTYPE, deliberately NOT mode-aware — + // the two are documented as lock-step). An entity/TPH-subtype field (a real + // DB column) honors the mode. + const voHosted = owner?.subType === OBJECT_SUBTYPE_VALUE; + // CRITICAL 1: z.coerce.date() (not z.date()) — the ONE expression correct + // for both readers of zodFieldExpr's output: the TPH read schema parses DB + // rows (already a `Date` under pg date mode — z.coerce.date() passes a + // Date through unchanged) while insert/update/preserving parse wire JSON + // (an ISO string — z.coerce.date() parses it; z.date() would reject it). + baseStr = ctx?.timestampMode === "date" && !voHosted ? "z.coerce.date()" : "z.string()"; break; + } case FIELD_SUBTYPE_ENUM: { const values = enumValues(field); if (values === undefined) { 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 adcd1d3e0..06e65b4e6 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 @@ -17,7 +17,12 @@ 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", () => { + // + // CRITICAL 1 (post-#281 pre-publish review): z.coerce.date(), not z.date() — + // these schemas parse raw JSON request bodies (ISO strings on the wire); + // z.date() rejects every JSON wire value outright. See the execution test + // below (safeParse against an ISO string) for the behavioral pin. + test("timestampMode: \"date\" — field.timestamp gets z.coerce.date(), @autoSet gets a Date-returning transform", () => { const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post"); const id = metaField(FIELD_SUBTYPE_LONG, "id"); post.addChild(id); @@ -44,8 +49,9 @@ describe("renderZodValidators", () => { 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).toContain("updatedAt: z.coerce.date()"); // plain field, general zodFieldExpr path + expect(out).toContain("z.coerce.date().optional().transform(() =>"); // @autoSet insert path + expect(out).not.toContain("z.date()"); // never the bare (non-coercing) form expect(out).not.toContain("z.string()"); // no stale string-typed timestamp anywhere expect(out).not.toContain(".toISOString()"); }); diff --git a/server/typescript/packages/codegen-ts/test/timestamp-mode-execution.test.ts b/server/typescript/packages/codegen-ts/test/timestamp-mode-execution.test.ts new file mode 100644 index 000000000..1818163a7 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/timestamp-mode-execution.test.ts @@ -0,0 +1,295 @@ +// `timestampMode: "date"` — EXECUTION pins (post-#281 pre-publish review). +// +// Every #281 test asserted on emitted SOURCE TEXT; none executed a generated +// schema. This repo has a named precedent for exactly that failure mode +// (0.20.6: `z.string().ip()` — a Zod-4 removal that hid until a test actually +// ran the schema against real Zod). These tests write the REAL rendered +// output to a temp `.ts` file INSIDE this package (so a bare `"zod"` / +// `"drizzle-orm/*"` import resolves through the workspace's node_modules) and +// dynamically `import()` it, then call the real `safeParse`/`parse` on the +// real Zod object it exports — not a string match. +// +// Covers the three Criticals + Important 5 found by that review, plus a +// codegen-time WARNING for Important 4 (added on top after the initial fix +// was reviewed clean — a cheap generation-time detect for the one runtime gap +// left documented-not-fixed: filtering a date-mode timestamp throws at +// request time; see runGen's warning + filter-parser.ts's limitation note): +// CRITICAL 1 — z.date() rejects every JSON wire value; fix is z.coerce.date(). +// CRITICAL 2 — sqlite/D1 + date mode used to emit non-compiling code (a +// regression #281 itself introduced); fix normalizes the mode +// to "string" for dialect:"sqlite" at the config choke points. +// CRITICAL 3 — a projection/write-through view's read schema (zodTypeFor, +// via view-decl.ts) was a fifth timestamp-Zod emitter #281's +// sweep missed and still hardcoded z.string(). +// IMPORTANT 5 — a VO-hosted (object.value) timestamp must stay z.string() +// even in date mode: VO storage is inherently ISO-string jsonb. +// IMPORTANT 4 — a @filterable timestamp field under date mode warns once at +// `runGen` time (detect, don't fix the runtime threading — +// repo precedent: #226/#258 detect-and-refuse at gen time). + +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + MetaDataLoader, InMemoryStringSource, + TypeId, TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, + OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE, + FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_TIMESTAMP, +} from "@metaobjectsdev/metadata"; +import { meta, metaObject, metaField, metaRoot } from "./_meta-build.js"; +import { renderZodValidators, renderInsertSchemaOnly } from "../src/templates/zod-validators.js"; +import { renderProjectionDecl } from "../src/templates/projection-decl.js"; +import { makeRenderContext } from "../src/render-context.js"; +import { normalizeConfig, defineConfig } from "../src/metaobjects-config.js"; +import { runGen } from "../src/runner.js"; +import { buildPkMap } from "../src/pk-resolver.js"; +import { buildRelationMap } from "../src/relation-resolver.js"; + +// biome-ignore lint/suspicious/noExplicitAny: dynamically imported generated module — no static shape +type GeneratedModule = Record; + +const tmpDirs: string[] = []; +afterEach(() => { + while (tmpDirs.length > 0) { + rmSync(tmpDirs.pop()!, { recursive: true, force: true }); + } +}); + +/** + * Write `source` (a full rendered file, imports included — ts-poet's + * `Code.toString()` already hoists them) to a temp `.ts` file INSIDE this + * package (`import.meta.dir`) so bare-specifier imports resolve through the + * workspace's node_modules, then dynamically import it. This is REAL + * execution of the generated code — not a text match against the source. + */ +async function executeGenerated(source: string): Promise { + const dir = mkdtempSync(join(import.meta.dir, "tmp-ts-mode-exec-")); + tmpDirs.push(dir); + const file = join(dir, "schema.ts"); + writeFileSync(file, source); + return import(pathToFileURL(file).href); +} + +function makePost(): ReturnType { + const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post"); + post.addChild(metaField(FIELD_SUBTYPE_LONG, "id")); + post.addChild(metaField(FIELD_SUBTYPE_TIMESTAMP, "updatedAt")); // plain, non-required, non-autoSet + const primary = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "primary"); + primary.setAttr("fields", ["id"]); + primary.setAttr("generation", "increment"); + post.addChild(primary); + return post; +} + +describe('timestampMode: "date" — execution pins', () => { + test("CRITICAL 1: InsertSchema.safeParse accepts a JSON wire ISO string (z.date() rejects it outright)", async () => { + const post = makePost(); + 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 mod = await executeGenerated(renderZodValidators(post, ctx).toString()); + + // The exact failure mode this fixes: z.date().safeParse(isoString) is false. + const ok = mod.PostInsertSchema.safeParse({ updatedAt: "2026-08-08T10:00:00.000Z" }); + expect(ok.success).toBe(true); + expect(ok.data.updatedAt instanceof Date).toBe(true); + expect(ok.data.updatedAt.toISOString()).toBe("2026-08-08T10:00:00.000Z"); + + // Not a rubber stamp — a genuinely invalid value still fails. + const bad = mod.PostInsertSchema.safeParse({ updatedAt: "not-a-date" }); + expect(bad.success).toBe(false); + + // FR-035 present-null clearing must survive the coerce switch: a present + // `null` still short-circuits through `.nullable()` on the UpdateSchema. + const cleared = mod.PostUpdateSchema.safeParse({ updatedAt: null }); + expect(cleared.success).toBe(true); + expect(cleared.data.updatedAt).toBeNull(); + }); + + test('CRITICAL 2: dialect:"sqlite" normalizes timestampMode to "string" at both config choke points, end to end', async () => { + // Function-level pin: normalizeConfig (the `meta gen` entry point). + const normalized = normalizeConfig(defineConfig({ + outDir: "out", extStyle: "none", dbImport: "../db", dialect: "sqlite", + timestampMode: "date", generators: [], + })); + expect(normalized.timestampMode).toBe("string"); + // Postgres is genuinely unaffected by the same normalization. + const pgNormalized = normalizeConfig(defineConfig({ + outDir: "out", extStyle: "none", dbImport: "../db", dialect: "postgres", + timestampMode: "date", generators: [], + })); + expect(pgNormalized.timestampMode).toBe("date"); + + // Function-level pin: makeRenderContext (the OTHER choke point — a bare + // template-unit-test / generator call outside `runGen`). + const post = makePost(); + const root = metaRoot(); + root.addChild(post); + const ctx = makeRenderContext({ + dialect: "sqlite", timestampMode: "date", loadedRoot: root, + outDir: "/x", dbImport: "~/db", pkMap: buildPkMap(root), relationMap: buildRelationMap(root), + }); + expect(ctx.timestampMode).toBe("string"); + + // End-to-end: the sqlite+date combination must not silently produce broken + // output (#281's regression) — execute the resulting schema and confirm + // the field stays a plain STRING (not coerced to Date), proving the mode + // really normalized rather than merely that z.coerce.date() also accepts + // a string. + const mod = await executeGenerated(renderZodValidators(post, ctx).toString()); + const ok = mod.PostInsertSchema.safeParse({ updatedAt: "2026-08-08T10:00:00.000Z" }); + expect(ok.success).toBe(true); + expect(typeof ok.data.updatedAt).toBe("string"); + }); + + test("CRITICAL 3: a projection's view read schema executes against a DB-row Date (the fifth timestamp-Zod emitter #281's sweep missed)", async () => { + const json = JSON.stringify({ + "metadata.root": { + package: "test", + children: [ + { "object.entity": { name: "Cfg", children: [ + { "source.rdb": { "@table": "cfgs" } }, + { "field.uuid": { name: "id" } }, + { "field.timestamp": { name: "createdAt" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + ] } }, + { "object.projection": { name: "CfgView", children: [ + { "source.rdb": { "@kind": "view", "@table": "v_cfg" } }, + { "field.uuid": { name: "id", extends: "Cfg.id" } }, + { "field.timestamp": { name: "created_at", + children: [{ "origin.passthrough": { "@from": "Cfg.createdAt" } }] } }, + { "identity.primary": { name: "id", extends: "Cfg.id" } }, + ] } }, + ], + }, + }); + const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]); + expect(result.errors).toEqual([]); + const projection = result.root.objects().find((o) => o.name === "CfgView")!; + + const code = renderProjectionDecl(projection, result.root, { + columnNamingStrategy: "snake_case", dialect: "postgres", timestampMode: "date", allowlists: false, + }); + const mod = await executeGenerated(code); + + // Before this fix: zodTypeFor hardcoded z.string() here regardless of mode — + // a "date"-mode view column (Date-typed, mapColumnType honors the mode) fed + // through a z.string() read schema would reject the real DB-driver-returned + // Date at runtime. + const row = mod.CfgViewSchema.safeParse({ + id: "3fa85f64-5717-4562-b3fc-2c963f66afa6", + created_at: new Date("2026-08-08T10:00:00.000Z"), + }); + expect(row.success).toBe(true); + expect(row.data.created_at instanceof Date).toBe(true); + }); + + test("IMPORTANT 5: a value-object-hosted timestamp stays z.string() even in date mode (VO storage is jsonb, always string)", async () => { + const note = metaObject(OBJECT_SUBTYPE_VALUE, "Note"); + note.addChild(metaField(FIELD_SUBTYPE_TIMESTAMP, "loggedAt")); + const root = metaRoot(); + root.addChild(note); + const ctx = makeRenderContext({ + dialect: "postgres", timestampMode: "date", loadedRoot: root, + outDir: "/x", dbImport: "~/db", pkMap: buildPkMap(root), relationMap: buildRelationMap(root), + }); + const mod = await executeGenerated(renderInsertSchemaOnly(note, ctx).toString()); + + const ok = mod.NoteInsertSchema.safeParse({ loggedAt: "2026-08-08T10:00:00.000Z" }); + expect(ok.success).toBe(true); + // NOT coerced to Date — the VO structural interface (inferred-types.ts's + // SCALAR_TS_BY_SUBTYPE, deliberately not mode-aware) and this Zod schema + // must agree (documented lock-step), and VO jsonb storage is always string. + expect(typeof ok.data.loggedAt).toBe("string"); + expect(ok.data.loggedAt).toBe("2026-08-08T10:00:00.000Z"); + }); +}); + +describe('IMPORTANT 4: runGen warns (once) when timestampMode: "date" meets a @filterable timestamp field', () => { + // Two entities, each with a @filterable timestamp field PLUS a non-filterable + // timestamp field — proves the warning is field-selective (only @filterable + // fields are named) and fires ONCE for the whole run (not once per field or + // per entity), naming every offender in that one line. + const TWO_FILTERABLE_TIMESTAMPS = JSON.stringify({ + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "Post", children: [ + { "source.rdb": { "@table": "posts" } }, + { "field.long": { name: "id" } }, + { "field.timestamp": { name: "updatedAt", "@filterable": true } }, + { "field.timestamp": { name: "archivedAt" } }, // NOT filterable — must not be named + { "identity.primary": { name: "primary", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Comment", children: [ + { "source.rdb": { "@table": "comments" } }, + { "field.long": { name: "id" } }, + { "field.timestamp": { name: "postedAt", "@filterable": true } }, + { "identity.primary": { name: "primary", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + ], + }, + }); + + const NO_FILTERABLE_TIMESTAMP = JSON.stringify({ + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "Post", children: [ + { "source.rdb": { "@table": "posts" } }, + { "field.long": { name: "id" } }, + { "field.timestamp": { name: "updatedAt" } }, // present, but NOT filterable + { "field.string": { name: "title", "@filterable": true } }, // filterable, but not a timestamp + { "identity.primary": { name: "primary", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + ], + }, + }); + + async function runWithMetadata(json: string, dialect: "postgres" | "sqlite", timestampMode?: "date" | "string") { + const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]); + expect(result.errors).toEqual([]); + const dir = mkdtempSync(join(tmpdir(), "codegen-runner-important4-")); + try { + return await runGen({ + config: defineConfig({ + outDir: dir, extStyle: "none", dbImport: "../db", dialect, + ...(timestampMode !== undefined && { timestampMode }), + generators: [], + }), + metadata: result.root, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + test('warns exactly once, naming every offending entity+field, when dialect:"postgres" + timestampMode:"date"', async () => { + const { warnings } = await runWithMetadata(TWO_FILTERABLE_TIMESTAMPS, "postgres", "date"); + const hits = warnings.filter((w) => w.includes('timestampMode: "date"')); + expect(hits.length).toBe(1); // once per run, not once per field/entity + expect(hits[0]).toContain("Post.updatedAt"); + expect(hits[0]).toContain("Comment.postedAt"); + expect(hits[0]).not.toContain("archivedAt"); // non-filterable — not named + }); + + test('silent in the default "string" mode (timestampMode omitted)', async () => { + const { warnings } = await runWithMetadata(TWO_FILTERABLE_TIMESTAMPS, "postgres"); + expect(warnings.filter((w) => w.includes('timestampMode: "date"'))).toEqual([]); + }); + + test('silent when no field is both @filterable and a timestamp, even in date mode', async () => { + const { warnings } = await runWithMetadata(NO_FILTERABLE_TIMESTAMP, "postgres", "date"); + expect(warnings.filter((w) => w.includes('timestampMode: "date"'))).toEqual([]); + }); + + test('silent under dialect:"sqlite" — timestampMode normalizes to "string" (Critical 2), so Important 4 never fires', async () => { + const { warnings } = await runWithMetadata(TWO_FILTERABLE_TIMESTAMPS, "sqlite", "date"); + expect(warnings.filter((w) => w.includes('timestampMode: "date"'))).toEqual([]); + }); +}); diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts index 24b61da07..c4a2f852e 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/filter-parser.ts @@ -197,6 +197,20 @@ function coerce(value: unknown, subType: string, field: string, op: string): unk } return n; } + // KNOWN LIMITATION (codegen-ts's `timestampMode: "date"` config option, + // Important-4 assessment): this keeps the qs value a STRING unconditionally. + // Under codegen's Postgres-only "date" mode the Drizzle column is Date-typed + // and calls `value.toISOString()` on any bound value — comparing it against a + // string here throws `TypeError: value.toISOString is not a function` for + // every op except isNull (eq/ne/gt/gte/lt/lte/in all bind through the same + // typed column). Not reachable in the default "string" mode (the only mode + // this parser was originally designed against). A correct fix needs the + // entity's `timestampMode` threaded from codegen into the generated + // allowlist or mount options so this function can `new Date(s)` — deferred + // as a separate, more invasive change; do not filter a "date"-mode timestamp + // field until that lands. `codegen-ts`'s `runGen` warns (once per run) at + // generation time when this combination is reachable — see the Important-4 + // check in runner.ts — but does not refuse the build. case "datetime": return s; default: return s; }