Skip to content

Commit 851b9c6

Browse files
dmealingclaude
andcommitted
fix(runtime-ts): a field.timestamp reached the wire as Postgres text, not ISO 8601
The TS generated routes answered `"createdAt":"2026-09-20 12:00:00+00"`. Codegen maps a Postgres field.timestamp to `timestamp(…, { mode: "string" })`, and in string mode Drizzle returns what node-postgres received: Postgres' output format, rendered in the SESSION time zone. The contract (api-contract.md, via normalization.md) is `YYYY-MM-DDTHH:MM:SS[.fff]Z`, always UTC, and the other four ports already answer that way. An adopter estate's strict ISO 8601 client found it; no in-repo gate could, because the api-contract runners normalize createdAt before comparing and every other adapter test ran on libsql. runtime-ts now canonicalizes timestamp columns where the Fastify and Hono mounts send rows (list, get, create, update, projections, M:N traversal, TPH). Columns are found from the Drizzle column objects (columnType + withTimezone), never from a value's shape. The offset is converted to UTC; the fraction is truncated to ms, trailing zeros stripped, omitted when zero; @localtime keeps its wall clock with no Z (and an @localtime value carrying an offset passes through untouched). Date-mode columns lose `.000`. No regen: the generated routes delegate to these mounts. `timestampWire` / `canonicalTimestamp` are exported for hand-written routes. The false "round-trips ISO-8601" comment in column-mapper.ts is corrected. Gated on a real Postgres whose session runs in America/New_York (so relabelling +00 as Z cannot pass), both adapters, src and dist entry points: runtime-ts/test/timestamp-wire-pg.test.ts. ci-local's ts-slow lane now runs it and read-only-raw-sql-pg.test.ts, which needed a database and skipped everywhere. Also fixed, found by the new test: the Hono update handler passed an empty PATCH body to `.set({})`, which throws `No values to set` and was redacted into a 500. It now answers as a read (200 with the row, or 404), as the Fastify mount does. Verified: runtime-ts 513 tests, 0 fail; real-PG files 71/71; TS api-contract lanes 82/82 (Testcontainers); estate TS port 43/43 on the local build (41/43 on rc.7); ci-local --quick green. Reviewed with /code-review high (one finding, fixed) and simplify-qwen (dedupe applied; a non-compiling edit reverted). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EaGv4zNk4oT7FooD4eK5MD
1 parent 01f7265 commit 851b9c6

14 files changed

Lines changed: 742 additions & 82 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,42 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
248248

249249
### Fixed
250250

251+
- **TypeScript: a `field.timestamp` reached the wire as Postgres' own text,
252+
`"2026-09-20 12:00:00+00"`, not ISO 8601.** `docs/features/api-contract.md` (and
253+
`normalization.md` behind it) promise `YYYY-MM-DDTHH:MM:SS[.fff]Z`, always UTC, and the
254+
other four ports answer the same row that way. Codegen maps a Postgres `field.timestamp` to
255+
`timestamp(…, { mode: "string" })`, and in string mode Drizzle returns exactly what
256+
node-postgres received: Postgres' OUTPUT format, with a space for the `T` and the offset of
257+
whatever time zone the session runs in. A strict ISO 8601 client rejects it, and under a
258+
non-UTC session it is not even a UTC value. The comment in `column-mapper.ts` claimed the
259+
column "round-trips ISO-8601 strings"; it never did. `@metaobjectsdev/runtime-ts`'s Fastify
260+
and Hono mounts — list, get, create, update, the TPH base and subtype routes, projections
261+
and M:N traversal — now put every timestamp column in the contract's spelling before they
262+
send a row. The offset is converted to UTC, the fraction is truncated to milliseconds with
263+
trailing zeros stripped, a zero fraction is omitted, and an `@localTime` column keeps its
264+
wall clock with no `Z`. Which columns count is read from the Drizzle column objects, never
265+
guessed from a value, so a text column holding timestamp-shaped text is left alone. Under
266+
`timestampMode: "date"` the same code drops the `.000` that `Date#toJSON` pads.
267+
**No regen needed**: the generated routes delegate to these mounts. A hand-written route
268+
that sends a row itself can map it through the newly exported `timestampWire(table)`
269+
(`@metaobjectsdev/runtime-ts/drizzle-fastify` or `/hono`). Two limits, stated: SQLite and
270+
D1 store a `field.timestamp` in a plain `text` column, so the value goes back as it was
271+
written; and a projection over an opaque `@sql` view has no declared columns to read.
272+
No api-contract scenario could see this: none asserts a timestamp literally, and the
273+
runners normalize `createdAt` before comparing. An adopter estate's strict client found
274+
it. It is now gated on a real Postgres whose session runs in `America/New_York`, so
275+
relabelling `+00` as `Z` would not pass. The test covers both adapters and both entry
276+
points (`runtime-ts/test/timestamp-wire-pg.test.ts`). `ci-local.sh`'s `ts-slow` lane now
277+
runs that file and `read-only-raw-sql-pg.test.ts`. The second file needed a database and
278+
had skipped everywhere.
279+
280+
- **TypeScript (Hono): an empty `PATCH` body was a 500.** `@metaobjectsdev/runtime-ts/hono`'s
281+
update handler passed `{}` straight to Drizzle's `.set()`, which throws `No values to set`.
282+
The redaction path turned that into `500 {"error":"database error"}`. The Fastify mount has
283+
answered an empty patch as a read (200 with the row, or 404) since the TPH discriminator
284+
work, and Hono now does the same. The generated api-contract lane runs on Fastify, so the
285+
corpus's `patch-empty-noop` scenario never reached Hono.
286+
251287
- **Web client: every filtered request 400'd against a Spring Boot backend.**
252288
`@metaobjectsdev/runtime-web`'s `buildFilterQs` — which every generated TanStack hook
253289
calls — sent the filter grammar with raw brackets (`filter[email][like]=…`). RFC 3986

‎fixtures/api-contract-conformance/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ Runners are responsible for normalizing `createdAt` (and any other
109109
non-deterministic fields) before comparison. The keys listed above are
110110
the only ones a runner must understand to be conformant.
111111

112+
A consequence worth stating: this corpus does **not** gate the SPELLING of a
113+
`field.timestamp` on the wire (`docs/features/api-contract.md`, "Type
114+
encodings"). No scenario asserts a timestamp literally, and the reference
115+
servers store `createdAt` in whatever column type each port chose. The TS
116+
generated routes shipped Postgres' output text (`2026-09-20 12:00:00+00`)
117+
through a fully green corpus for that reason. TS now gates the spelling in
118+
`server/typescript/packages/runtime-ts/test/timestamp-wire-pg.test.ts`.
119+
112120
## Filter operator coverage (FR-009)
113121

114122
The `filter-*` scenarios pin the 9 cross-port filter operators declared in

‎scripts/ci-local.sh‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,8 +469,14 @@ gate_migrate_ts_pg() { bun_install && ( cd server/typescript/packages/migrate-ts
469469
# while Fastify (fixed for the same thing) stayed green (#286). Runs here because ts-slow is
470470
# the lane with the sidecar; METAOBJECTS_TEST_PG_URL + RUNTIME_TS_PG_EXPECT=1 come from the
471471
# CI job, and without them the suite self-skips exactly as before.
472+
#
473+
# Every PG-gated runtime-ts file is listed here, because ts-unit has no database and a
474+
# file this lane does not name is a file that only ever skips: read-only-raw-sql-pg ran
475+
# nowhere with a database until it was added. timestamp-wire-pg is the only gate on the
476+
# field.timestamp wire spelling — the api-contract runners normalize `createdAt` first.
472477
gate_runtime_ts_pg() {
473-
bun_install && ( cd server/typescript/packages/runtime-ts && bun test test/dialect-matrix-pg.test.ts )
478+
bun_install && ( cd server/typescript/packages/runtime-ts && bun test \
479+
test/dialect-matrix-pg.test.ts test/read-only-raw-sql-pg.test.ts test/timestamp-wire-pg.test.ts )
474480
}
475481

476482
# ── conformance.yml — per-port conformance corpora (exact CI commands) ────────

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -589,13 +589,19 @@ export function mapColumnType(
589589
// value opts out with @localTime:true → timestamp({ withTimezone:
590590
// false }) = `timestamp without time zone`.
591591
//
592-
// mode:"string" so the column round-trips ISO-8601 strings — the
592+
// mode:"string" so the column binds and returns STRINGS — the
593593
// generated Zod schema validates timestamp fields as z.string() and
594594
// the cross-port wire format carries timestamps as JSON strings.
595595
// Drizzle's default timestamp mode is "date" (expects/returns a JS
596596
// Date and calls value.toISOString() on write), which is internally
597597
// inconsistent with the string-typed schema + wire contract and
598598
// throws on a string write. See SP-B api-contract-generated lane.
599+
//
600+
// A string read back is NOT ISO 8601: it is Postgres' output text,
601+
// `2026-09-20 12:00:00+00`, in the session's time zone. The runtime-ts
602+
// mounts the generated routes delegate to put it in the wire spelling;
603+
// a hand-written route that sends a row itself should map it through
604+
// `timestampWire(table)` from the same runtime-ts entry point.
599605
fnName = "timestamp";
600606
fnOptions = {
601607
mode: timestampMode,

‎server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts‎

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ import qs from "qs";
2222
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
2323
export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
2424
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
25-
import { isTruthyFlag, contractErrorCode, coerceIdForColumn } from "./util.js";
25+
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow } from "./util.js";
26+
import { timestampWire } from "../timestamp-wire.js";
2627
export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
28+
export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
2729

2830
// ---------------------------------------------------------------------------
2931
// Loose types — we don't bind to a specific Drizzle backend so the helper
@@ -106,6 +108,24 @@ function readSource(opts: VerbOptions): AnyTable {
106108
return opts.readView ?? opts.table;
107109
}
108110

111+
/**
112+
* Wire mapper for rows READ through `readSource` — canonicalized against
113+
* exactly that source, since a view may shadow a table column with a
114+
* non-timestamp of the same name.
115+
*/
116+
function readWire(opts: VerbOptions): (row: unknown) => unknown {
117+
return timestampWire(readSource(opts));
118+
}
119+
120+
/**
121+
* Wire mapper for a WRITE ECHO: `reReadThroughView` answers with the replica
122+
* view's row but falls back to the table row, so the echo is canonicalized
123+
* against the union of both sources.
124+
*/
125+
function echoWire(opts: VerbOptions): (row: unknown) => unknown {
126+
return timestampWire(opts.table, opts.readView);
127+
}
128+
109129
/** #214 — after a write to the base table, re-read the row THROUGH the replica
110130
* view by PK so the response carries derived (origin.passthrough) columns. When
111131
* there is no `readView`, return the write row unchanged (current behaviour). */
@@ -141,6 +161,7 @@ function routeOpts(opts: VerbOptions): RouteShorthandOptions {
141161
}
142162

143163
export function mountListRoute(opts: VerbOptions): void {
164+
const toWire = readWire(opts);
144165
opts.fastify.get(opts.path, routeOpts(opts), async (req, reply) => {
145166
try {
146167
// #214 — a write-through entity reads through its replica view.
@@ -196,7 +217,7 @@ export function mountListRoute(opts: VerbOptions): void {
196217
// drizzle-orm node-postgres query builder is thenable but has no `.all()`
197218
// method (that is a libsql/better-sqlite3-only API). Awaiting works on
198219
// both dialects and is what makes this helper genuinely Postgres-capable.
199-
const rows = await q;
220+
const rows = (await q as unknown[]).map(toWire);
200221

201222
if (!withCount) return rows;
202223

@@ -216,6 +237,7 @@ export function mountListRoute(opts: VerbOptions): void {
216237
}
217238

218239
export function mountGetRoute(opts: VerbOptions): void {
240+
const toWire = readWire(opts);
219241
opts.fastify.get(`${opts.path}/:id`, routeOpts(opts), async (req, reply) => {
220242
const { id } = req.params as { id: string };
221243
const discCond = discriminatorCond(opts);
@@ -228,20 +250,13 @@ export function mountGetRoute(opts: VerbOptions): void {
228250
return reply.code(400).send({ error: "invalid_id" });
229251
}
230252
const idCond = eq(src.id, idValue);
231-
// Await + take the first row rather than `.get()` — `.get()` is a
232-
// libsql/better-sqlite3-only method; the node-postgres builder is thenable
233-
// but has no `.get()`. Awaiting works on both dialects.
234-
const rows = await opts.db
235-
.select()
236-
.from(src)
237-
.where(discCond ? and(idCond, discCond) : idCond)
238-
.limit(1);
239-
const row = (rows as unknown[])[0];
240-
return row ?? reply.code(404).send({ error: "not_found" });
253+
const row = await firstRow(opts.db, src, discCond ? and(idCond, discCond) : idCond);
254+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
241255
});
242256
}
243257

244258
export function mountCreateRoute(opts: VerbOptions): void {
259+
const toWire = echoWire(opts);
245260
opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => {
246261
const parsed = opts.insertSchema.safeParse(req.body);
247262
if (!parsed.success) {
@@ -265,11 +280,12 @@ export function mountCreateRoute(opts: VerbOptions): void {
265280
const row = (result as unknown[])[0];
266281
// #214 — read-your-writes: re-read through the replica view so the response
267282
// carries derived (origin.passthrough) columns the base table excludes.
268-
return reply.code(201).send(await reReadThroughView(opts, row));
283+
return reply.code(201).send(toWire(await reReadThroughView(opts, row)));
269284
});
270285
}
271286

272287
export function mountUpdateRoute(opts: VerbOptions): void {
288+
const toWire = echoWire(opts);
273289
const handler = async (
274290
req: { params: unknown; body: unknown },
275291
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
@@ -304,13 +320,8 @@ export function mountUpdateRoute(opts: VerbOptions): void {
304320
return reply.code(400).send({ error: "invalid_id" });
305321
}
306322
const noopCond = eq(src.id, noopId);
307-
const rows = await opts.db
308-
.select()
309-
.from(src)
310-
.where(discCond ? and(noopCond, discCond) : noopCond)
311-
.limit(1);
312-
const row = (rows as unknown[])[0];
313-
return row ?? reply.code(404).send({ error: "not_found" });
323+
const row = await firstRow(opts.db, src, discCond ? and(noopCond, discCond) : noopCond);
324+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
314325
}
315326
// Compare against the PK's real type (see mountGetRoute) — a numeric-
316327
// LOOKING id on a TEXT pk would otherwise UPDATE the wrong row.
@@ -334,7 +345,7 @@ export function mountUpdateRoute(opts: VerbOptions): void {
334345
const row = (result as unknown[])[0];
335346
if (row == null) return reply.code(404).send({ error: "not_found" });
336347
// #214 — re-read through the replica view so the response carries derived columns.
337-
return await reReadThroughView(opts, row);
348+
return toWire(await reReadThroughView(opts, row));
338349
};
339350
const path = `${opts.path}/:id`;
340351
const ro = routeOpts(opts);

‎server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-m2m.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import type { FastifyInstance, RouteShorthandOptions } from "fastify";
3434
import { and, eq, or, inArray } from "drizzle-orm";
3535
import { coerceIdForColumn } from "./util.js";
36+
import { timestampWire } from "../timestamp-wire.js";
3637

3738
// Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
3839
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
@@ -84,6 +85,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
8485
const targetPk = opts.targetPkColumn ?? "id";
8586
const route = `${opts.path}/:id/${opts.relationName}`;
8687
const ro = opts.routeOptions ?? {};
88+
const toWire = timestampWire(opts.targetTable);
8789

8890
opts.fastify.get(route, ro, async (req, reply) => {
8991
const { id } = req.params as { id: string };
@@ -148,10 +150,11 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
148150
const pkCol = columnRef(opts.targetTable, targetPk);
149151
const byPk = inArray(pkCol, [...relatedIds]);
150152
const disc = opts.targetDiscriminator;
151-
return await opts.db
153+
const rows = await opts.db
152154
.select()
153155
.from(opts.targetTable)
154156
.where(disc ? and(byPk, eq(columnRef(opts.targetTable, disc.column), disc.value)) : byPk);
157+
return (rows as unknown[]).map(toWire);
155158
});
156159
}
157160

‎server/typescript/packages/runtime-ts/src/drizzle-fastify/mount-read-only.ts‎

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,12 @@ import { sql, eq, and, count } from "drizzle-orm";
33
import qs from "qs";
44
import { parseFilterParams, FilterParseError } from "./filter-parser.js";
55
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
6-
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral } from "./util.js";
6+
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js";
7+
import { timestampWire } from "../timestamp-wire.js";
78

89
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
910
type AnyView = any;
1011

11-
/**
12-
* Drizzle v0.45 stores view config under this well-known Symbol.
13-
* Accessing `view._` on a proxy-wrapped view (empty-column .existing()) throws
14-
* because the proxy tries to spread `subquery._.selectedFields` which is undefined.
15-
* Using the symbol bypasses the proxy entirely.
16-
*/
17-
const VIEW_BASE_CONFIG = Symbol.for("drizzle:ViewBaseConfig");
18-
1912
export interface MountReadOnlyOptions {
2013
readonly fastify: FastifyInstance;
2114
readonly path: string;
@@ -49,18 +42,8 @@ const REJECT_MUTATION = async (
4942
.send({ error: "method_not_allowed", message: `${request.method} is not supported on a projection (read-only).` });
5043
};
5144

52-
function getViewConfig(view: AnyView): Record<string, unknown> | undefined {
53-
try {
54-
const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
55-
if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
56-
} catch {
57-
// ignore — proxy handler may throw on unexpected shapes
58-
}
59-
return undefined;
60-
}
61-
6245
function resolveViewName(view: AnyView): string | undefined {
63-
const cfg = getViewConfig(view);
46+
const cfg = viewBaseConfig(view);
6447
if (cfg) {
6548
if (typeof cfg["name"] === "string") return cfg["name"] as string;
6649
}
@@ -78,7 +61,7 @@ function resolveViewName(view: AnyView): string | undefined {
7861
* `SELECT FROM ...` (invalid SQL), and we must fall back to raw SQL.
7962
*/
8063
function isEmptyColumnView(view: AnyView): boolean {
81-
const cfg = getViewConfig(view);
64+
const cfg = viewBaseConfig(view);
8265
if (cfg) {
8366
const fields = cfg["selectedFields"] as Record<string, unknown> | undefined;
8467
return fields !== undefined && Object.keys(fields).length === 0;
@@ -139,6 +122,8 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
139122

140123
const viewName = resolveViewName(view);
141124
const useRawSql = isEmptyColumnView(view) && !!viewName;
125+
// The raw-SQL branch has no declared columns, so nothing names a timestamp there.
126+
const toWire = timestampWire(view);
142127

143128
// ── List ──────────────────────────────────────────────────────────────────
144129
fastify.get(path, ro, async (req, reply) => {
@@ -189,7 +174,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
189174
// node-postgres builder is thenable. (The useRawSql branch above keeps
190175
// db.all(sql.raw(...)) because that is the libsql raw-exec API, only
191176
// reachable for `.existing()` empty-column views which are sqlite-only.)
192-
const rows = await q;
177+
const rows = (await q as unknown[]).map(toWire);
193178

194179
if (!withCount) return rows;
195180

@@ -231,7 +216,7 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
231216
colRef !== undefined ? eq(colRef, idValue) : undefined
232217
).limit(1);
233218
const row = (rows as unknown[])[0];
234-
return row ?? reply.code(404).send({ error: "not_found" });
219+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
235220
});
236221

237222
// ── Mutations explicitly rejected (405) ───────────────────────────────────

‎server/typescript/packages/runtime-ts/src/drizzle-fastify/util.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,36 @@ export function contractErrorCode(internal: string): string {
8181
return internal;
8282
}
8383
}
84+
85+
/**
86+
* Drizzle v0.45 stores view config under this well-known Symbol. Accessing
87+
* `view._` on a proxy-wrapped view (empty-column `.existing()`) throws because
88+
* the proxy tries to spread `subquery._.selectedFields` which is undefined; the
89+
* symbol read bypasses the proxy. The proxy handler can still throw on
90+
* unexpected shapes, so the read is guarded. One reader for the private shape —
91+
* the two read-only mounts and the timestamp-wire column lookup share it.
92+
*/
93+
const VIEW_BASE_CONFIG = Symbol.for("drizzle:ViewBaseConfig");
94+
95+
export function viewBaseConfig(view: unknown): Record<string, unknown> | undefined {
96+
try {
97+
const cfg = (view as Record<symbol, unknown>)[VIEW_BASE_CONFIG];
98+
if (cfg && typeof cfg === "object") return cfg as Record<string, unknown>;
99+
} catch {
100+
// ignore — proxy handler may throw on unexpected shapes
101+
}
102+
return undefined;
103+
}
104+
105+
/**
106+
* The portable one-row read: `.limit(1)` + await + first element. Awaiting is
107+
* what makes this work on both dialects — the node-postgres query builder is
108+
* thenable but has no `.get()` (that is a libsql/better-sqlite3-only API, #286).
109+
* The caller builds the WHERE (including any TPH discriminator), so the
110+
* condition stays with the mount that owns it.
111+
*/
112+
export async function firstRow(db: unknown, src: unknown, cond: unknown): Promise<unknown> {
113+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over the user's Drizzle client
114+
const rows: unknown = await (db as any).select().from(src).where(cond).limit(1);
115+
return (rows as unknown[])[0];
116+
}

0 commit comments

Comments
 (0)