fix(schema): coerce SQLite-shaped numeric booleans in zod field schema#853
Conversation
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
Fixes a validation mismatch where boolean fields can round-trip through the database as numeric 0/1, causing z.boolean()-based validation to reject unchanged boolean values during GET → edit → POST updates.
Changes:
- Wrap boolean field schemas with a
z.preprocessthat coerces numeric0/1intofalse/truewhile still rejecting other non-boolean inputs. - Add unit tests covering numeric boolean coercion,
.default(false)behavior with preprocess, and preservation of coercion under.partial()schemas. - Add a changeset documenting the bugfix.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
packages/core/src/schema/zod-generator.ts |
Updates boolean field schema generation to accept DB-shaped 0/1 via z.preprocess. |
packages/core/tests/unit/schema/zod-generator.test.ts |
Adds regression tests for 0/1 boolean coercion, defaults, and partial validation behavior. |
.changeset/zod-boolean-sqlite-coercion.md |
Adds release note for the boolean save/validation fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Saving any content entry on a collection with a boolean field failed with a validation error after a GET → edit → POST round-trip: - `serializeValue` in `database/repositories/content.ts` converts booleans to `0/1` on write for SQLite storage. - `deserializeValue` only parses JSON; it never converts `0/1` back to booleans. - `mapRow` runs every column through `deserializeValue`, so reads of boolean columns surface as numbers. - The per-field zod schema in `schema/zod-generator.ts` was `z.boolean()`, which rejects numbers. Admin POSTs that re-included untouched booleans in the form payload were therefore rejected — even when the user only toggled an unrelated field. Coerce `0/1` to `false/true` at the schema boundary using `z.preprocess`. This restores symmetry with the write path without touching the repository contract or the 30+ existing `ContentRepository(db)` call sites that read `data.*` directly. Other inputs (other numbers, strings, BigInt) still fall through to `z.boolean()`'s standard rejection so the validator stays strict. Boolean fields map to `INTEGER` columns on every dialect via `FIELD_TYPE_TO_COLUMN`, so this fix covers SQLite and Postgres.
903baab to
20c141d
Compare
|
Addressed all three Copilot notes — the bug is dialect-agnostic (any |
🦋 Changeset detectedLatest commit: 20c141d The changes in this PR will be included in the next version bump. This PR includes changesets to release 13 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 |
|
/review |
|
Good. Let me now think about whether there's anything worth flagging adversarially:
This is a tightly scoped, well-tested fix with clear documentation and the author has already responded to all Copilot review comments. I don't see any issues worth flagging. LGTM! |
What does this PR do?
Saving any content entry on a collection with a boolean field fails with a validation error after a GET → edit → POST round-trip. The repository converts booleans to
0/1on write but never converts them back on read, so reads surface boolean columns as numbers. The per-field zod schema isz.boolean(), which rejects numbers. The admin re-includes untouched booleans in its form payload, so a user toggling one switch sees every other boolean field's0/1value re-submitted and rejected.Trace:
packages/core/src/database/repositories/content.ts:54(serializeValue) — boolean →0/1.packages/core/src/database/repositories/content.ts:71(deserializeValue) — only parses JSON, never coerces numerics.packages/core/src/database/repositories/content.ts:1157(mapRow) — runs every column throughdeserializeValue.packages/core/src/schema/zod-generator.ts:70— emitsz.boolean()and rejects the read shape.packages/core/src/api/handlers/validation.ts:71— callsgenerateZodSchema(...).partial()for updates.Wrap the boolean field schema in a
z.preprocessthat coerces the SQLite shape (0/1) tofalse/true, leaving every other input (other numbers, strings,BigInt) to fall through toz.boolean()'s standard rejection. Surgical fix at the validation boundary that restores symmetry with the write path without touching the repository contract or the existingContentRepository(db)call sites that readdata.*directly. Boolean fields map toINTEGERcolumns on every dialect viaFIELD_TYPE_TO_COLUMN(schema/types.ts:64), so the fix covers SQLite and Postgres.Closes #
Type of change
Checklist
pnpm typecheckpassespnpm lintpasses (pnpm --silent lint:json | jq '.diagnostics | length'returns 0)pnpm testpasses (or targeted tests for my change) — full zod-generator suite (20/20 incl. 3 new cases) green; content-handlers suite (19/19) unaffectedpnpm formathas been runAI-generated code disclosure
Screenshots / test output
Adversarial review notes (out of scope, flagged for future work)
deserializeValuedoes not invertserializeValuefor booleans. A more comprehensive fix would track per-collection field types inmapRowand coerce there, returning real booleans ondata.*access. That changes the repository's read contract and would require an audit of everyContentRepository(db)consumer that currently readsdata.field as number(no enforcement today, but easy to slip in). This PR keeps that contract intact and patches only the validation boundary.generateTypeScriptemitsbooleanfor boolean fields, but consumers readingdata.*fromgetEmDashEntry/getEmDashCollectionstill see0/1until the repo-layer fix lands. This PR doesn't widen the emitted TS type, on the assumption that fixing the read path is preferable to documenting aboolean | numbercontract.INTEGERcolumns containing 0/1, and silently coercing would mask a potential driver bug.