Execution-level test coverage: jsonb temporal wire form, date-mode filtering, pre-publish migrate gate - #284
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsis ~390 lines oftoContain. Three defects shipped or nearly shipped through that gap —z.string().ip()(removed in Zod 4),timestampMode: "date"emittingDate-typed columns againststring-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 realsafeParse— asserting ACCEPT/REJECT of concrete values. Same technique astimestamp-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: 200field withvalidator.length @max: 50starts 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
jsonbtestfixture's value objects carried onlystringandint, so no temporal value had ever crossed that codec.Writing the test surfaced a live defect on the adjacent branch.
MetaObjectGsonInitializerregisters the metadata-driven serializers against eachMetaObject's declared@objectclass, so a value object bound to a POJO viaObjectClassRegistryis serialized by Gson's default reflection and itsDateproperties never reachTemporalWireFormat. A@storage: jsonbcolumn holding one stored:— 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),
@localTimeignored, and unreadable by the other four ports, which expect the ISO form innormalization.md. Same defect class as #275, on the one path #275 did not reach.TemporalGsonAdapternow handlesjava.util.Dateon that builder: writes the canonical instant, readsTemporalWireFormat.parsefirst with a fallback to the former localized default so existing rows still load. The metadata-driven path is provably untouched —MetaObjectSerializer'sDATEbranch formats andaddPropertys directly instead of delegating tocontext.serialize.Bounded narrowing, deliberate: without an owning
MetaFieldthefield.dateand@localTimeshapes 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
@filterablefield.timestampundertimestampMode: "date"threw at request time: the Drizzle pg column binds a JSDateand calls.toISOString()on whatever it gets, while the parser passed the raw query-string value through. Every op exceptisNulldied withTypeError: value.toISOString is not a function.The generated allowlist now carries
dateValues: truefor exactly those columns and the parser coerces withnew Date(...); a malformed value is rejected asfilter.invalid_valuerather than bound as an Invalid Date. The flag is optional, so an allowlist generated before this existed keeps its exact previous behavior. Onlyfield.timestampis marked — Drizzle typesfield.date/field.timeas 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 appliesmapToDriverValue) 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 originalTypeError.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 afterbun publish, so red arrived strictly after four immutable registries were updated — which is why the lane sat red for eight consecutive releases (v0.20.11→v0.21.1, repaired in #280) with nobody acting on it. Worse, the suitesdescribe.skipsilently withoutMIGRATE_TS_PG_URL, including insideconformance.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'sts-slowlane now arms the suites from its existing Postgres sidecar on every push tomain— 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=1beside the URL, and the sentinel fails if the URL ever stops being set. A workflow-leveltest -ncheck 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.mdgains a wait-for-local-ci-green step before publish, plus a step 0 (git branch -a --no-merged) —0.21.1shipped without a fix that already existed on an unmerged branch and needed0.21.2within 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) andomdb(51) Java suites green.server/typescriptsuite: 4 failures, all pre-existing. Confirmed by running the same suite at pristineorigin/main, which shows 7 — this branch's 4 are a strict subset (the extra 3 upstream are fresh-worktree artifacts: unbuiltdist/, un-run bundle script). All 4 pass in isolation on both sides;migrate-tsdepends on neither package changed here.🤖 Generated with Claude Code
https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS