Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions server/typescript/packages/codegen-ts/src/column-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 21 additions & 2 deletions server/typescript/packages/codegen-ts/src/metaobjects-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,21 @@ export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport"
* ISO-8601 strings (matches the generated Zod + cross-port wire contract); "date"
* uses drizzle's native JS-Date mode (for consumers whose hand-written code works
* with `Date`).
*
* **Postgres-only.** Drizzle's sqlite-core `text()` timestamp column has no
* Date-typed mode (only `pg-core`'s `timestamp()` does), so `"date"` is
* normalized to `"string"` whenever `dialect: "sqlite"` (which also covers
* Cloudflare D1 — D1 is sqlite-at-the-SQL-level, see the D1 note in the repo's
* porting docs). This keeps the option a safe no-op on sqlite/D1 instead of
* emitting a non-compiling column + a Zod schema disagreeing with it.
*
* Date-mode filtering (`?filter[<timestamp>][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 "". */
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion server/typescript/packages/codegen-ts/src/render-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "". */
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 33 additions & 1 deletion server/typescript/packages/codegen-ts/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -179,6 +179,38 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {

// 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;
Expand Down
25 changes: 22 additions & 3 deletions server/typescript/packages/codegen-ts/src/templates/field-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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()");
});
Expand Down
Loading
Loading