Skip to content

fix(orm): coerce ISO strings on DateTime input, with strictDateInput opt-in (#2631)#2632

Merged
ymc9 merged 5 commits intozenstackhq:devfrom
erwan-joly:fix/2631-coerce-iso-strings-on-datetime-input
May 8, 2026
Merged

fix(orm): coerce ISO strings on DateTime input, with strictDateInput opt-in (#2631)#2632
ymc9 merged 5 commits intozenstackhq:devfrom
erwan-joly:fix/2631-coerce-iso-strings-on-datetime-input

Conversation

@erwan-joly
Copy link
Copy Markdown
Contributor

@erwan-joly erwan-joly commented Apr 30, 2026

Fixes #2631.

Background

The strict zod input validator introduced in 3.5+ broke every caller passing ISO strings to DateTime fields, including bare time-only strings like "09:00:00" for @db.Time columns. Earlier versions silently coerced these via Prisma's input layer; the new validator rejects them outright. Existing user code that worked unchanged across years of Prisma → ZenStack v2 → ZenStack v3.4 suddenly fails with Invalid input: expected date, received string.

Approach

Per option 2 from the issue, this restores Prisma-compatible coercion as the default while leaving strict validation available behind a new opt-in flag. That keeps existing callers working while preserving the stricter semantics for users who want them:

const db = new ZenStackClient(schema, {
  dialect: ...,
  strictDateInput: true, // opt in to date-objects-only validation
});

Default (strictDateInput: false or unset) accepts:

  • Date objects (already worked)
  • ISO datetime strings, e.g. "2024-01-15T10:30:00.000Z" (already worked)
  • ISO date strings, e.g. "2024-01-15" (already worked in orm factory; new in standalone zod factory)
  • ISO time-only strings, e.g. "09:30:00", "09:30:00.123", "09:30:00Z", "09:30:00+12:00" (new)

All string forms are coerced to a Date for the engine. Time-only strings are anchored to the Unix epoch (1970-01-01T<time>), matching the existing OID-1083 read-side behavior introduced in #2590.

Files changed

  • packages/orm/src/client/options.ts — new strictDateInput?: boolean option with JSDoc explaining the default and tradeoff
  • packages/orm/src/client/zod/factory.ts — new exported helper coercedDateTimeSchema(); makeDateTimeValueSchema switches on strictDateInput
  • packages/zod/src/factory.ts — same coercion applied in the standalone factory (no setting — these schemas are typically used for form validation where coercion is more important)
  • packages/zod/test/factory.test.ts — regression tests for the four accepted forms plus a non-parseable rejection case, all referencing ZenStack 3.6: strict Date input validator rejects ISO strings (regression vs 3.4 / Prisma) #2631

Tests

✓ accepts DateTime as a Date object
✓ accepts DateTime as an ISO datetime string
✓ accepts DateTime as an ISO date string (#2631)
✓ accepts DateTime as a bare time-only string for @db.Time fields (#2631)
✓ accepts DateTime as a time-only string with timezone (#2631)
✓ rejects DateTime as a non-parseable string

The strict path (when strictDateInput: true) keeps the existing z.union([z.iso.datetime(), z.iso.date(), z.date()]) behavior unchanged, so users who've adopted the strict semantics on 3.5/3.6 are unaffected.

Happy to adjust naming (strictDateInput vs strictDateInputs vs anything else) or re-shape per review.

Summary by CodeRabbit

  • New Features

    • Improved DateTime field input handling with support for multiple formats: native Date objects, ISO datetime strings, ISO date strings, and time-only strings.
  • Tests

    • Added regression test coverage for DateTime input coercion scenarios.

…input (zenstackhq#2631)

The strict zod union introduced in 3.5+ broke every caller passing ISO
strings to `DateTime` fields, including bare time-only strings like
"09:00:00" for `@db.Time` columns. Earlier versions coerced these via
Prisma's input layer; the new validator rejected them outright with
no migration path called out in the release notes.

This restores Prisma-compatible coercion as the default while leaving
strict validation available behind a new `ClientOptions.strictDateInput`
flag (default `false`) for users who want to opt in.

Changes:
  - `packages/orm/src/client/options.ts`: new `strictDateInput?: boolean`
  - `packages/orm/src/client/zod/factory.ts`: new exported helper
    `coercedDateTimeSchema()` that anchors time-only strings to the Unix
    epoch and falls through to `z.date()` for all other paths;
    `makeDateTimeValueSchema` switches on `strictDateInput`
  - `packages/zod/src/factory.ts`: same coercion applied in the
    standalone factory (no setting — these schemas are typically used
    for form validation where coercion is even more important)
  - `packages/zod/test/factory.test.ts`: regression tests for the four
    accepted forms (Date, ISO datetime, ISO date, time-only with and
    without timezone) plus a non-parseable rejection case

Fixes zenstackhq#2631
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 30, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d4528e73-e1de-4bcd-8a93-7983e555255c

📥 Commits

Reviewing files that changed from the base of the PR and between 73fe8c8 and 8f0fdbe.

📒 Files selected for processing (2)
  • packages/orm/src/client/zod/factory.ts
  • tests/regression/test/issue-2631.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/regression/test/issue-2631.test.ts

📝 Walkthrough

Walkthrough

This PR restores Prisma-compatible DateTime input coercion by introducing a new coercedDateTimeSchema() function that accepts ISO datetime, ISO date, and time-only strings, converting them to Date objects. The schema integrates into makeDateTimeValueSchema() to replace the strict union validation. Regression tests verify acceptance of multiple string formats and rejection of invalid input.

Changes

DateTime Input Coercion

Layer / File(s) Summary
DateTime Input Coercion Schema
packages/orm/src/client/zod/factory.ts
New exported coercedDateTimeSchema() preprocesses string inputs (ISO datetime, ISO date, time-only with Unix epoch anchoring) into Date objects, then validates against ISO union for documentation while actual parsing comes from preprocessing.
Schema Integration
packages/orm/src/client/zod/factory.ts
makeDateTimeValueSchema() now constructs its schema from coercedDateTimeSchema() instead of direct ISO union validation.
Regression Tests
tests/regression/test/issue-2631.test.ts
Adds test suite for Issue #2631 with SQLite client setup/teardown and five behavioral test cases: acceptance of Date objects, ISO datetime strings, ISO date-only strings, time-only strings (with UTC hour/minute assertions), and rejection of unparseable input.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A rabbit hopped through time so fine,
Strings became dates—'09:00:00' in line!
No more unions strict and cold,
Prisma's way returns, I'm told.
Coercion flows, the epochs align,
DateTime's heart beats true and divine! 🕐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding DateTime input coercion for ISO strings with a reference to the associated issue #2631.
Linked Issues check ✅ Passed The PR implements the core requirements from issue #2631: restoring Prisma-compatible coercion of ISO strings (datetime, date, and time-only) to Date for DateTime inputs, with the later refactor making coercion unconditional.
Out of Scope Changes check ✅ Passed Changes are focused on the DateTime coercion implementation in the ORM zod factory and corresponding regression tests, all directly addressing the linked issue requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/zod/test/factory.test.ts (1)

161-204: ⚡ Quick win

Add strict-mode regression coverage too.

These tests validate the default coercion path well, but they don’t cover strictDateInput: true behavior in ORM schema generation. Adding that case will lock the contract and prevent drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/zod/test/factory.test.ts` around lines 161 - 204, Add parallel tests
that construct the model schema with strictDateInput enabled (e.g., call
factory.makeModelSchema('User', { strictDateInput: true })) and assert that ISO
date strings, bare time strings, and timezone time strings are rejected
(result.success === false), while actual Date instances still pass; add one test
per case mirroring the existing ones (names like "strict mode rejects ISO date
string", "strict mode rejects time-only string", etc.) to lock the strict-mode
contract.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/orm/src/client/zod/factory.ts`:
- Around line 880-882: The current strictDateInput branch for building schema
allows ISO strings via z.iso.datetime() and z.iso.date(), which contradicts the
ClientOptions<Schema> docs saying strict mode rejects all string forms; change
the implementation in the schema construction so that when (this.options as
ClientOptions<Schema>)?.strictDateInput is true the schema is strictly z.date()
(no z.iso.* unions) and otherwise use coercedDateTimeSchema(); update or run
related tests that assumed ISO-string acceptance if you instead choose to keep
the current behavior and prefer updating docs/tests to state that
strictDateInput still accepts ISO strings.

---

Nitpick comments:
In `@packages/zod/test/factory.test.ts`:
- Around line 161-204: Add parallel tests that construct the model schema with
strictDateInput enabled (e.g., call factory.makeModelSchema('User', {
strictDateInput: true })) and assert that ISO date strings, bare time strings,
and timezone time strings are rejected (result.success === false), while actual
Date instances still pass; add one test per case mirroring the existing ones
(names like "strict mode rejects ISO date string", "strict mode rejects
time-only string", etc.) to lock the strict-mode contract.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9373f55b-22da-4966-9cc6-7778b82071d2

📥 Commits

Reviewing files that changed from the base of the PR and between a31a32e and 08d8a8d.

📒 Files selected for processing (4)
  • packages/orm/src/client/options.ts
  • packages/orm/src/client/zod/factory.ts
  • packages/zod/src/factory.ts
  • packages/zod/test/factory.test.ts

Comment thread packages/orm/src/client/zod/factory.ts Outdated
- Strict mode now actually rejects all string forms (was still accepting
  ISO datetime/date because the union still included z.iso.datetime() /
  z.iso.date()). Switched to plain z.date() so the implementation
  matches the JSDoc on ClientOptions.strictDateInput.

- Reverted the standalone @zenstackhq/zod factory back to its previous
  union (z.date() | z.iso.datetime()). The OpenAPI generator emits its
  spec from this factory and the preprocess version produced a
  different schema shape that broke the rpc-openapi baseline test. The
  ORM-side coercion in @zenstackhq/orm/zod/factory.ts still covers the
  runtime input validation that zenstackhq#2631 was about; standalone consumers
  can layer their own coercion if they need it.

- Replaced the standalone-factory regression tests with an end-to-end
  ORM regression suite at tests/regression/test/issue-2631.test.ts
  covering both default coercion (Date, ISO datetime, ISO date,
  time-only with and without TZ) and the strict-mode contract (Date
  only — ISO datetime, ISO date, and time-only strings all rejected).
@erwan-joly
Copy link
Copy Markdown
Contributor Author

Thanks for the catch — pushed e8cc785e addressing both points:

  1. Strict mode now actually strict — switched the strict branch from z.union([z.iso.datetime(), z.iso.date(), z.date()]) to plain z.date() so it matches the JSDoc.

  2. Standalone factory reverted — that was breaking the rpc-openapi baseline test because the z.preprocess shape differs from the prior union. Reverted to z.union([z.date(), z.iso.datetime()]) for that path. The ORM-side coercion in packages/orm/src/client/zod/factory.ts (the runtime validator users hit) keeps the fix; standalone consumers who want coercion can layer it themselves.

  3. Strict-mode tests added — new tests/regression/test/issue-2631.test.ts covers the default behavior end-to-end (Date, ISO datetime, ISO date, time-only with and without TZ, non-parseable rejection) and the strict contract (Date-only, all string forms rejected).

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/regression/test/issue-2631.test.ts (1)

18-77: ⚡ Quick win

Add explicit coverage for strictDateInput: false and TZ/fraction time-only variants

Coverage is good overall, but this suite doesn’t currently assert the explicit strictDateInput: false path, nor the documented time-only variants (09:30:00.123, 09:30:00Z, 09:30:00+12:00).

🧪 Suggested additions
         it('accepts a bare time-only string anchored to the Unix epoch', async () => {
             const e = await db.event.create({ data: { label: 'time-only', when: '09:30:00' } });
             expect(e.when).toBeInstanceOf(Date);
             expect((e.when as Date).getUTCHours()).toBe(9);
             expect((e.when as Date).getUTCMinutes()).toBe(30);
         });
+
+        it.each(['09:30:00.123', '09:30:00Z', '09:30:00+12:00'])(
+            'accepts time-only variant %s',
+            async (when) => {
+                const e = await db.event.create({ data: { label: `time-${when}`, when } });
+                expect(e.when).toBeInstanceOf(Date);
+            }
+        );
+
+        it('accepts ISO strings when strictDateInput is explicitly false', async () => {
+            const laxDb = await createTestClient(schema, {
+                usePrismaPush: true,
+                provider: 'sqlite',
+                strictDateInput: false,
+            });
+            try {
+                await expect(
+                    laxDb.event.create({ data: { label: 'explicit-false', when: '2024-01-15T10:30:00.000Z' } })
+                ).resolves.toBeTruthy();
+            } finally {
+                await laxDb.$disconnect();
+            }
+        });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/regression/test/issue-2631.test.ts` around lines 18 - 77, Tests are
missing an explicit describe for strictDateInput: false and additional time-only
string variants; add a new describe or expand the existing "default
(strictDateInput unset / false)" block to explicitly create the client with
strictDateInput: false via createTestClient(schema, { usePrismaPush: true,
provider: 'sqlite', strictDateInput: false }) and add itests that assert
time-only strings with fractions and timezones ('09:30:00.123', '09:30:00Z',
'09:30:00+12:00') are accepted and coerced to Date (and preserve expected UTC
hour/minute), while keeping the existing non-parseable-string rejection test;
reference the db.event.create calls and the expect(e.when).toBeInstanceOf(Date)
/ getUTCHours()/getUTCMinutes() assertions to guide placement.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/orm/src/client/zod/factory.ts`:
- Around line 105-106: The fallback date parsing currently uses new Date(val)
(variables d and val in packages/orm/src/client/zod/factory.ts) and therefore
accepts non-ISO engine-dependent formats; change it to first validate that val
matches the allowed ISO patterns (e.g., ISO date, ISO datetime with optional
timezone, or time-only formats documented) using an explicit regex or parser
check, and only then construct new Date(val) and return the Date; if the ISO
validation fails, return the original val unchanged to preserve the documented
ISO-only contract.

---

Nitpick comments:
In `@tests/regression/test/issue-2631.test.ts`:
- Around line 18-77: Tests are missing an explicit describe for strictDateInput:
false and additional time-only string variants; add a new describe or expand the
existing "default (strictDateInput unset / false)" block to explicitly create
the client with strictDateInput: false via createTestClient(schema, {
usePrismaPush: true, provider: 'sqlite', strictDateInput: false }) and add
itests that assert time-only strings with fractions and timezones
('09:30:00.123', '09:30:00Z', '09:30:00+12:00') are accepted and coerced to Date
(and preserve expected UTC hour/minute), while keeping the existing
non-parseable-string rejection test; reference the db.event.create calls and the
expect(e.when).toBeInstanceOf(Date) / getUTCHours()/getUTCMinutes() assertions
to guide placement.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 50f30409-6a8e-4ba1-975d-65bff2e41745

📥 Commits

Reviewing files that changed from the base of the PR and between 08d8a8d and e8cc785.

📒 Files selected for processing (2)
  • packages/orm/src/client/zod/factory.ts
  • tests/regression/test/issue-2631.test.ts

Comment thread packages/orm/src/client/zod/factory.ts
erwan-joly added 2 commits May 1, 2026 00:35
Addresses CodeRabbit nitpick on zenstackhq#2632: the implementation falls through
to `new Date(val)` for non-time-only strings, so engine-dependent
formats like "2024/01/15" are accepted. That is intentional — the
schema mirrors Prisma's pre-3.5 behaviour for compatibility — but the
JSDoc previously said "ISO strings" only. Reword to describe the actual
contract and point users who want stricter validation at
strictDateInput.
`coercedDateTimeSchema` previously returned `z.preprocess(fn, z.date())`,
which serialised to an empty `{}` and broke the rpc-openapi baseline
(`packages/server/test/openapi/baseline/rpc.baseline.yaml`) that
documents the accepted ISO datetime / ISO date / Date forms. Wrap the
preprocess around the original `z.union([z.iso.datetime(), z.iso.date(),
z.date()])` so OpenAPI generation still emits the documented variants.
Runtime behaviour is unchanged: preprocess coerces strings into Dates
first, the union's `z.date()` arm catches everything that parses, and
non-parseable strings fall through and are rejected.
Comment thread packages/orm/src/client/options.ts Outdated
* Set to `true` to opt into strict input validation that rejects all string forms.
* @see https://github.com/zenstackhq/zenstack/issues/2631
*/
strictDateInput?: boolean;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @erwan-joly , thanks for making continuous improvements. Date time handling is nasty ...

I'm thinking the new behavior (more accommodating) is probably preferred for most. Maybe we can drop this config altogether?

Copy link
Copy Markdown
Contributor Author

@erwan-joly erwan-joly May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing, didn’t want to “break” it if it was on purpose I see now it wasn’t so will drop the configuration altogether

… input

Per review feedback on zenstackhq#2632: the more accommodating coercion behaviour is
preferred for everyone, so the opt-in strict mode is unnecessary surface area.
DateTime inputs now unconditionally accept Date objects and any string the JS
Date constructor parses, mirroring Prisma's pre-3.5 behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ymc9 ymc9 merged commit ce50d3b into zenstackhq:dev May 8, 2026
9 checks passed
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.

ZenStack 3.6: strict Date input validator rejects ISO strings (regression vs 3.4 / Prisma)

2 participants