fix(core): accept naive datetime-local values for datetime fields so they round-trip (#1368)#1561
Conversation
…they round-trip (emdash-cms#1368) A datetime field could not be saved through its own admin editor. The generated validator was z.string().datetime().or(z.string().date()), which only accepts ISO with a Z suffix or a bare YYYY-MM-DD date. But <input type="datetime-local"> (and many seeds) produce a naive datetime such as 2026-06-04T18:30:00 (no Z, no offset). Because the admin re-sends every loaded field on autosave, a stored naive datetime failed validation and the entry became unsavable -- the same class of round-trip bug as emdash-cms#867. The validator now uses z.iso.datetime({ offset: true, local: true }).or(z.iso.date()), which accepts ISO with Z, ISO with a timezone offset, naive datetimes (with or without seconds), and date-only values, while still rejecting non-dates and impossible dates. This also replaces the deprecated z.string().datetime() API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 8ed7b4f The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Approach
This is the right change for the right problem. Issue #1368 is a genuine round-trip bug: a datetime field value produced by <input type="datetime-local"> or a seed (a naive 2026-06-04T18:30:00, no Z/offset) was rejected by the generated validator z.string().datetime().or(z.string().date()), and because the admin re-sends every loaded field on autosave, the entry became unsavable through its own editor. The fix mirrors the established #867 pattern (broaden the round-trip validator while keeping semantic validation) and is strictly additive — the new accepted set is a superset of the old one, so there's no backwards-compat regression. Good taste, well scoped (no drive-by cleanup of the other deprecated z.string().datetime() call sites, which is correct per the scope-discipline rule).
What I checked
- Placement: The validator lives in
getBaseSchema(zod-generator.ts), which feedsgenerateZodSchema→validateContentData(api/handlers/validation.ts), the exact path content create/update (and thus admin autosave) validates through. The fix reaches the round-trip path. ✓ - Behavior:
z.iso.datetime({ offset: true, local: true }).or(z.iso.date())acceptsZ, offset, naive (with/without seconds), and date-only values, and rejects garbage / impossible dates.z.iso.*is the correct Zod 4 API (catalog pinszod@^4.4.1); the deprecatedz.string().datetime()is correctly replaced. ✓ - Serialization:
serializeValue/deserializeValuestore datetimes as TEXT and return them as strings (no JSON reparse, noDatecoercion), so a stored naive datetime round-trips as the same string the validator now accepts. ✓ - Validation interactions:
applyValidationonly applies string/number refinements whenschema instanceof z.ZodString/ZodNumber; the datetime schema is a union in both old and new code, sopattern/minLengthwere already no-ops — no behavior change. The required-empty-string check invalidateContentDatais independent and unaffected. ✓ - Test quality: The new test exercises the real
generateFieldSchema(no mocking), and its "accepts naive datetime" cases would fail against the old validator, so it's a true reproducer, not false confidence. TheFieldhelper omitssearchable/translatable, but tests are excluded frompackages/coretypecheck andgenerateFieldSchemanever reads them — consistent with the existingzod-generator.test.ts. ✓ - Sibling paths:
contentDateOverride/contentDateBound(forpublishedAt/createdAtoverrides and the list date-range filter) still require an offset and don't accept naive values. These are pre-existing, serve a different intent (precise timestamp overrides vs. round-trippable field values), and aren't touched by this PR — not a regression, and correctly left alone.
I could not execute the suite to confirm Zod 4.4.1's exact regex accepts the no-seconds form (2024-01-15T14:30), but the API and the test design are correct, and the no-seconds case is optional in Zod 4's ISO time portion.
Headline
The code and test are clean and correct. The only finding is the changeset prose, which reads like a commit message / PR description rather than a user-facing release note — a violation of the explicitly documented changeset convention in CONTRIBUTING.md.
| fix(core): accept naive datetime-local values for datetime fields so they round-trip (#1368) | ||
|
|
||
| A `datetime` field could not be saved through its own admin editor. The generated validator was `z.string().datetime().or(z.string().date())`, which only accepts ISO with a `Z` suffix or a bare `YYYY-MM-DD` date. But `<input type="datetime-local">` (and many seeds) produce a naive datetime such as `2026-06-04T18:30:00` (no `Z`, no offset). Because the admin re-sends every loaded field on autosave, a stored naive datetime failed validation and the entry became unsavable — the same class of round-trip bug as #867. | ||
|
|
||
| The validator now uses `z.iso.datetime({ offset: true, local: true }).or(z.iso.date())`, which accepts ISO with `Z`, ISO with a timezone offset, naive datetimes (with or without seconds), and date-only values, while still rejecting non-dates and impossible dates. This also replaces the deprecated `z.string().datetime()` API. |
There was a problem hiding this comment.
[needs fixing] This changeset reads like a commit message / PR description rather than a user-facing release note, which CONTRIBUTING.md §Changesets explicitly prohibits: "It is not a commit message, a PR description, or a summary of your diff. Don't paste your PR text into it … Start with a present-tense verb … Leave out internal mechanics — file names, function names … how you implemented it."
Two concrete deviations: (1) the heading uses conventional-commit format fix(core): …, and (2) the body is the PR description pasted verbatim, naming the old/new validator implementation (z.string().datetime().or(z.string().date()), z.iso.datetime({ offset: true, local: true })). None of the existing changesets in this repo use a type(scope): heading — they all lead with a bare present-tense verb ("Fixes …", "Speeds up …", "Declare …"). Rewrite it for the reader who will run the new version:
| fix(core): accept naive datetime-local values for datetime fields so they round-trip (#1368) | |
| A `datetime` field could not be saved through its own admin editor. The generated validator was `z.string().datetime().or(z.string().date())`, which only accepts ISO with a `Z` suffix or a bare `YYYY-MM-DD` date. But `<input type="datetime-local">` (and many seeds) produce a naive datetime such as `2026-06-04T18:30:00` (no `Z`, no offset). Because the admin re-sends every loaded field on autosave, a stored naive datetime failed validation and the entry became unsavable — the same class of round-trip bug as #867. | |
| The validator now uses `z.iso.datetime({ offset: true, local: true }).or(z.iso.date())`, which accepts ISO with `Z`, ISO with a timezone offset, naive datetimes (with or without seconds), and date-only values, while still rejecting non-dates and impossible dates. This also replaces the deprecated `z.string().datetime()` API. | |
| Fixes `datetime` fields rejecting values from the admin editor's date/time picker, so entries with a datetime field can be saved and autosaved again. Values without a timezone (e.g. `2026-06-04T18:30:00`, as produced by `<input type="datetime-local">` and many seeds) and values with a timezone offset now round-trip cleanly instead of failing validation on the next save. |
…elease note Per CONTRIBUTING.md, a changeset is the release note a user reads while upgrading, not a commit message or diff summary. Drop the commit-message title line and internal validator mechanics; lead with the observable effect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What does this PR do?
A
datetimefield could not be saved through its own admin editor. The generated validator wasz.string().datetime().or(z.string().date()), which only accepts ISO with aZsuffix or a bareYYYY-MM-DDdate. But<input type="datetime-local">(and many seeds) produce a naive datetime such as2026-06-04T18:30:00(noZ, no offset). Because the admin re-sends every loaded field on autosave, a stored naive datetime failed validation and the entry became unsavable — the same class of round-trip bug as #867.The validator now uses
z.iso.datetime({ offset: true, local: true }).or(z.iso.date()), which accepts ISO withZ, ISO with a timezone offset, naive datetimes (with or without seconds), and date-only values, while still rejecting non-dates and impossible dates. This also replaces the deprecatedz.string().datetime()API.Closes #1368
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (targeted: tests/unit/schema — 115 tests)pnpm formathas been runAI-generated code disclosure