Skip to content

Execution-level test coverage: jsonb temporal wire form, date-mode filtering, pre-publish migrate gate - #284

Merged
dmealing merged 4 commits into
mainfrom
fix/temporal-coverage-and-date-mode-filtering
Aug 9, 2026
Merged

Execution-level test coverage: jsonb temporal wire form, date-mode filtering, pre-publish migrate gate#284
dmealing merged 4 commits into
mainfrom
fix/temporal-coverage-and-date-mode-filtering

Conversation

@dmealing

@dmealing dmealing commented Aug 9, 2026

Copy link
Copy Markdown
Member

Four independent units, all originating from the same lesson: a test that inspects the shape of generated code cannot tell you the code is wrong.

1. Execution pins for the Zod validator matrix (test, no product change)

zod-validators.test.ts is ~390 lines of toContain. Three defects shipped or nearly shipped through that gap — z.string().ip() (removed in Zod 4), timestampMode: "date" emitting Date-typed columns against string-producing validators, and the fix for that reintroducing the same class on SQLite. The last one stayed text-green precisely because the goldens were regenerated to match the new output.

17 new tests render the real output, write it to a temp module, dynamically import() it, and call the real safeParse — asserting ACCEPT/REJECT of concrete values. Same technique as timestamp-mode-execution.test.ts.

Verified non-vacuous by mutation: three seeded regressions are each caught. One of them — FR-036 A3 degrading from strictest-wins to last-writer-wins, so a @maxLength: 200 field with validator.length @max: 50 starts accepting 200-char values — leaves the existing text suite entirely green. That path had no behavioral coverage at all.

Every current emission was already correct; nothing needed fixing.

2. OMDB jsonb temporal — closes the last #275 carry-forward (fix, Java)

The OMDB jsonb temporal path was the motivating blast radius for the whole #275 fix and had no test at any level. The reason was concrete: the jsonbtest fixture's value objects carried only string and int, so no temporal value had ever crossed that codec.

Writing the test surfaced a live defect on the adjacent branch. MetaObjectGsonInitializer registers the metadata-driven serializers against each MetaObject's declared @object class, so a value object bound to a POJO via ObjectClassRegistry is serialized by Gson's default reflection and its Date properties never reach TemporalWireFormat. A @storage: jsonb column holding one stored:

"Jun 3, 2026, 10:30:00 AM"

— the JVM's local zone rather than UTC, varying with default locale, silently dropping milliseconds (so even a Java-only round-trip lost the instant), @localTime ignored, and unreadable by the other four ports, which expect the ISO form in normalization.md. Same defect class as #275, on the one path #275 did not reach.

TemporalGsonAdapter now handles java.util.Date on that builder: writes the canonical instant, reads TemporalWireFormat.parse first with a fallback to the former localized default so existing rows still load. The metadata-driven path is provably untouched — MetaObjectSerializer's DATE branch formats and addPropertys directly instead of delegating to context.serialize.

Bounded narrowing, deliberate: without an owning MetaField the field.date and @localTime shapes are unknowable, so a POJO-bound temporal writes as a full instant. Lossless and portable, where the previous behavior was neither.

Tests assert the stored column text, not just instant equality — Gson round-trips its own format inside Java, so an equality-only assertion would have passed against the bug. Mutation-verified (dropping the Z; dropping the millisecond fraction).

3. Date-mode timestamp filtering now works (feat, npm)

Filtering a @filterable field.timestamp under timestampMode: "date" threw at request time: the Drizzle pg column binds a JS Date and calls .toISOString() on whatever it gets, while the parser passed the raw query-string value through. Every op except isNull died with TypeError: value.toISOString is not a function.

The generated allowlist now carries dateValues: true for exactly those columns and the parser coerces with new Date(...); a malformed value is rejected as filter.invalid_value rather than bound as an Invalid Date. The flag is optional, so an allowlist generated before this existed keeps its exact previous behavior. Only field.timestamp is marked — Drizzle types field.date/field.time as strings under every dialect — and sqlite/D1 normalizes the mode away upstream, so the emitter needs no dialect branching.

The gen-time warning that announced this limitation is removed rather than left to cry wolf; its test now pins the warning's absence in all four mode/dialect combinations.

Tests drive PgDialect.sqlToQuery (which applies mapToDriverValue) rather than asserting on the expression tree — the failure lived inside that binding step. Includes a control that binds a raw string and asserts it still throws the original TypeError.

4. migrate-ts real-Postgres gated before publish, not after (ci)

The migrate-ts PG suites ran only on the v* tag push. Tags are pushed after bun publish, so red arrived strictly after four immutable registries were updated — which is why the lane sat red for eight consecutive releases (v0.20.11v0.21.1, repaired in #280) with nobody acting on it. Worse, the suites describe.skip silently without MIGRATE_TS_PG_URL, including inside conformance.yml's own migrate-ts step — so the repo's CI looked covered while the real-engine half executed nowhere except post-publish.

ADR-0015 makes migrate-ts the only migrate engine, so this is the only real-engine gate on migration correctness.

local-ci.yml's ts-slow lane now arms the suites from its existing Postgres sidecar on every push to main — no new container, no hosted minutes (the job is ~50s; the "EXPENSIVE" label belongs to the 5-port Testcontainers matrix, not this). Ordered before the docker integration step so a container-readiness flake can't mask the migrate verdict.

A sentinel test makes the silent skip loud where it matters: a lane that intends real PG sets MIGRATE_TS_PG_EXPECT=1 beside the URL, and the sentinel fails if the URL ever stops being set. A workflow-level test -n check cannot do this — it inspects the workflow's env, not what the test process reads, so a rename inside the tests is exactly the drift it would miss.

The tag job is kept as the cold-environment backstop, its comment rewritten so red there reads as a broken release already live. RELEASING.md gains a wait-for-local-ci-green step before publish, plus a step 0 (git branch -a --no-merged) — 0.21.1 shipped without a fix that already existed on an unmerged branch and needed 0.21.2 within the hour.

Residual risk, stated plainly: red is post-merge rather than pre-merge; a publish cut minutes after a merge can still beat the verdict; a cold-only failure still surfaces first on the tag; and if the self-hosted runner is down, nothing goes red at all.


Verification

  • metadata (1347) and omdb (51) Java suites green.
  • Full server/typescript suite: 4 failures, all pre-existing. Confirmed by running the same suite at pristine origin/main, which shows 7 — this branch's 4 are a strict subset (the extra 3 upstream are fresh-worktree artifacts: unbuilt dist/, un-run bundle script). All 4 pass in isolation on both sides; migrate-ts depends on neither package changed here.
  • Workspace build + typecheck clean (the pre-push gate caught two casts the earlier typecheck missed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS

dmealing and others added 4 commits August 9, 2026 08:29
…e matrix

The existing zod-validators tests are ~390 lines of `toContain`. Those prove
the generator emitted an expected string; they cannot prove the emitted schema
compiles or behaves. Three defects shipped or nearly shipped through that gap:
`z.string().ip()` (removed in Zod 4), `timestampMode: "date"` emitting
Date-typed columns against string-producing validators, and the fix for the
latter reintroducing the same class on SQLite — the last one text-green
precisely because the goldens were regenerated to match the new output.

These tests render the real output, write it to a temp module, dynamically
import it, and call the real `safeParse`, asserting ACCEPT/REJECT of concrete
values. Same technique as timestamp-mode-execution.test.ts.

Covers the semantics a string match structurally cannot check: FR-036 Pin 2
full-match regex (an alternation pattern proves the `(?:…)` group is
load-bearing — a naive `^cat|dog$` accepts "cathouse"), Pin 1 non-empty
`@required` with the #224 explicit-`@min` opt-out, A3 strictest-wins across
`@maxLength` × `validator.length @max`, the Zod-version-fragile format
validators (`field.inet` incl. the #234 H1/H2 cases, `field.uri`,
`@stringFormat`, and both `@lenient` opt-outs), enum membership, and the
numeric/array bound chains.

Verified non-vacuous by mutation: three seeded regressions are each caught.
One of them — A3 degrading to last-writer-wins, so a `@maxLength: 200` field
with `validator.length @max: 50` starts accepting 200-char values — leaves the
existing text suite entirely green. That path had no behavioral coverage.

No product code changed; every current emission was already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS
… carry-forward)

Closes the last carry-forward item from the #275 batch. The OMDB jsonb
temporal path was the motivating blast radius for that entire fix and had no
test at any level; writing one surfaced a live defect on the adjacent branch.

MetaObjectGsonInitializer registers the metadata-driven serializers against
each MetaObject's declared @object class, so a value object bound to a POJO
via ObjectClassRegistry is serialized by Gson's default reflection and its
Date properties never reached TemporalWireFormat. A @storage:jsonb column
holding one stored "Jun 3, 2026, 10:30:00 AM" — the JVM's local zone rather
than UTC, varying with default locale, dropping milliseconds so even a
Java-only round-trip lost the instant, and unreadable by the other four ports.

TemporalGsonAdapter now handles java.util.Date on that builder: writes the
canonical instant, reads TemporalWireFormat.parse first and falls back to the
former localized default so existing rows still load. The metadata-driven path
is untouched — MetaObjectSerializer's DATE branch formats and addProperty's
directly instead of delegating to context.serialize.

Without an owning MetaField the field.date / @localTime shapes are unknowable,
so a POJO-bound temporal writes as a full instant: a deliberate narrowing that
is lossless and portable where the previous behavior was neither.

Tests assert the stored COLUMN TEXT, not just instant equality — Gson's
default format round-trips inside Java while being unportable, so an
equality-only assertion would have passed against the bug. The jsonbtest
fixture gained a Moment value object covering all three temporal shapes; it
previously held only string and int, which is why no temporal value had ever
crossed this codec. Mutation-verified: dropping the Z, and dropping the
millisecond fraction, each fail the new tests.

metadata (1347) and omdb (51) suites green, no regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS
Filtering a @filterable field.timestamp under timestampMode: "date" threw at
request time. The Drizzle pg column binds a JS Date and calls .toISOString()
on whatever it is given, while the filter parser passed the raw query-string
value through — so eq/ne/gt/gte/lt/lte/in all died with
`TypeError: value.toISOString is not a function`. Only isNull survived, since
it coerces as a boolean regardless of subtype.

The generated FilterAllowlist rule now carries `dateValues: true` for exactly
those columns, and the parser coerces with `new Date(...)`. A malformed value
is rejected as filter.invalid_value rather than bound as an Invalid Date, which
would have emitted NaN-shaped SQL instead of a 400.

`dateValues` is optional, so an allowlist generated before this existed keeps
its exact previous behavior. Only field.timestamp is marked — Drizzle types
field.date and field.time as strings under every dialect, so neither is
governed by timestampMode — and ctx.timestampMode is already normalized to
"string" for sqlite/D1 at both config choke points, so no dialect branching is
needed in the emitter.

The gen-time warning that announced this limitation is removed rather than
left to cry wolf; its test now pins the warning's ABSENCE in all four
mode/dialect combinations, so reverting the fix cannot quietly re-land the
warning in place of the behavior.

Tests drive Drizzle's real parameter serialization (PgDialect.sqlToQuery,
which applies mapToDriverValue) rather than asserting on the expression tree —
the failure lived inside that binding step. Includes a control test that binds
a raw string to a date-mode column and asserts it still throws the original
TypeError, so the suite is provably non-vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS
The migrate-ts PG suites ran only on the v* tag push. Tags are pushed AFTER
`bun publish`, so red arrived strictly after four immutable registries were
already updated — which is why the lane sat red for eight consecutive releases
(v0.20.11 through v0.21.1, repaired in #280) with nobody acting on it. The
same suites `describe.skip` silently without MIGRATE_TS_PG_URL, so local runs,
PR runs, and even conformance.yml's own migrate-ts step all looked green while
the real-engine half executed nowhere except post-publish.

ADR-0015 makes migrate-ts the project's only migrate engine, so this is the
only real-engine gate on migration correctness.

local-ci.yml's ts-slow lane now arms the suite from its EXISTING Postgres
sidecar — no new container, no hosted minutes (the job takes ~50s; the
"EXPENSIVE" label on integration-tests.yml belongs to the 5-port Testcontainers
matrix, not this). It is ordered before the docker integration step so a
container-readiness flake there cannot mask the migrate verdict.

A sentinel test makes the silent skip loud where it matters: a lane that
intends real PG sets MIGRATE_TS_PG_EXPECT=1 beside the URL, and the sentinel
fails if the URL ever stops being set. A workflow-level `test -n` check could
not do this — it inspects the workflow's env, not what the test process reads,
so a rename inside the tests is precisely the drift it would miss. The skip
stays the default for contributors without Postgres and for the deliberately
DB-free lanes.

The tag job is kept as the cold-environment release backstop (the self-hosted
runner is warm), with its comment rewritten so red there reads as a broken
release already live, not as noise. RELEASING.md gains a wait-for-local-ci-green
step before publish — the last gate that can precede the irreversible one.

Residual, stated plainly: red is now post-merge rather than pre-merge; a
publish cut minutes after a merge can still beat the verdict; a cold-only
failure still surfaces first on the tag; and if the self-hosted runner is down
nothing goes red at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS
@dmealing
dmealing merged commit 65142f3 into main Aug 9, 2026
1 check passed
@dmealing
dmealing deleted the fix/temporal-coverage-and-date-mode-filtering branch August 9, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant