Skip to content

fix(schema): coerce SQLite-shaped numeric booleans in zod field schema#853

Merged
ascorbic merged 1 commit intoemdash-cms:mainfrom
drudge:drudge/fix-boolean-coercion
Apr 30, 2026
Merged

fix(schema): coerce SQLite-shaped numeric booleans in zod field schema#853
ascorbic merged 1 commit intoemdash-cms:mainfrom
drudge:drudge/fix-boolean-coercion

Conversation

@drudge
Copy link
Copy Markdown
Contributor

@drudge drudge commented Apr 30, 2026

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/1 on write but never converts them back on read, so reads surface boolean columns as numbers. The per-field zod schema is z.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's 0/1 value 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 through deserializeValue.
  • packages/core/src/schema/zod-generator.ts:70 — emits z.boolean() and rejects the read shape.
  • packages/core/src/api/handlers/validation.ts:71 — calls generateZodSchema(...).partial() for updates.

Wrap the boolean field schema in a z.preprocess that coerces the SQLite shape (0/1) to false/true, leaving every other input (other numbers, strings, BigInt) to fall through to z.boolean()'s standard rejection. Surgical fix at the validation boundary that restores symmetry with the write path without touching the repository contract or the existing ContentRepository(db) call sites that read data.* directly. Boolean fields map to INTEGER columns on every dialect via FIELD_TYPE_TO_COLUMN (schema/types.ts:64), so the fix covers SQLite and Postgres.

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (pnpm --silent lint:json | jq '.diagnostics | length' returns 0)
  • pnpm test passes (or targeted tests for my change) — full zod-generator suite (20/20 incl. 3 new cases) green; content-handlers suite (19/19) unaffected
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). N/A — no admin UI changes.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: N/A, bug fix

AI-generated code disclosure

  • This PR includes AI-generated code

Screenshots / test output

 ✓ tests/unit/schema/zod-generator.test.ts (20 tests)
   ✓ generateFieldSchema > should generate boolean schema
   ✓ generateFieldSchema > should coerce SQLite-shaped numeric booleans (0/1) to real booleans
   ✓ generateFieldSchema > should preserve `.default(false)` chaining through the boolean preprocess
   ✓ generateFieldSchema > should accept SQLite-shaped booleans in partial-mode validation
   ... (16 existing)

 Test Files  1 passed (1)
      Tests  20 passed (20)

Adversarial review notes (out of scope, flagged for future work)

  • Asymmetric serialization at the repo layer. The root cause is that deserializeValue does not invert serializeValue for booleans. A more comprehensive fix would track per-collection field types in mapRow and coerce there, returning real booleans on data.* access. That changes the repository's read contract and would require an audit of every ContentRepository(db) consumer that currently reads data.field as number (no enforcement today, but easy to slip in). This PR keeps that contract intact and patches only the validation boundary.
  • Type-generation drift. generateTypeScript emits boolean for boolean fields, but consumers reading data.* from getEmDashEntry / getEmDashCollection still see 0/1 until 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 a boolean | number contract.
  • BigInt rejection is intentional. No known SQLite driver returns BigInt for INTEGER columns containing 0/1, and silently coercing would mask a potential driver bug.

Copilot AI review requested due to automatic review settings April 30, 2026 13:35
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 30, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@853

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@853

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@853

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@853

emdash

npm i https://pkg.pr.new/emdash@853

create-emdash

npm i https://pkg.pr.new/create-emdash@853

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@853

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@853

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@853

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@853

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@853

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@853

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@853

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@853

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@853

commit: 20c141d

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.preprocess that coerces numeric 0/1 into false/true while 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.

Comment thread .changeset/zod-boolean-sqlite-coercion.md Outdated
Comment thread packages/core/src/schema/zod-generator.ts Outdated
Comment thread packages/core/tests/unit/schema/zod-generator.test.ts Outdated
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.
@drudge drudge force-pushed the drudge/fix-boolean-coercion branch from 903baab to 20c141d Compare April 30, 2026 13:44
@drudge
Copy link
Copy Markdown
Contributor Author

drudge commented Apr 30, 2026

Addressed all three Copilot notes — the bug is dialect-agnostic (any INTEGER-backed boolean), not SQLite-specific. Reworded the changeset, the inline code comment, and the test names + comments to say "INTEGER columns" / "stored 0/1 booleans" instead of "SQLite shape". Pushed as a force-update with no behavior change. pnpm format clean, lint:json 0, 20/20 zod-generator tests still passing.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 30, 2026

🦋 Changeset detected

Latest commit: 20c141d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@ascorbic
Copy link
Copy Markdown
Collaborator

/review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 30, 2026

Good. Let me now think about whether there's anything worth flagging adversarially:

  1. Coercion scope. The preprocess only coerces 0 and 1. true/false already passing — no change needed. Other values fall through to z.boolean() rejection. Strict identity check (v === 0 || v === 1) is the right call — it avoids coercing "0" strings or 2.

  2. Boolean(v) with v === 0 returns false; with v === 1 returns true. Correct.

  3. .optional() chain interaction. When field.required is false, the schema becomes z.preprocess(...).optional(). The preprocess fires before the optional check. undefined passes through the preprocess unchanged (not 0/1) → reaches z.boolean() which is wrapped in .optional(). Wait — actually the chain is z.preprocess(fn, z.boolean()).optional(). Let me think… z.preprocess produces a ZodEffects, calling .optional() on it wraps the whole thing in ZodOptional. So undefined skips the preprocess entirely (optional handling first). Test confirms parse(undefined) works with default false.

  4. .default() chain. z.preprocess(fn, z.boolean()).optional().default(false) — default applies before preprocess only when value is undefined. The test confirms parse(undefined) returns false.

  5. Other dialect concerns. PG INTEGER returned by pg driver as JS number. better-sqlite3 returns INTEGER as JS number (unless safe integers + bigint mode). LibSQL returns it as number. All covered.

  6. The Copilot review notes about wording were already addressed in the latest commit per the author's reply.

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!

github run

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

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

Thanks!

@ascorbic ascorbic merged commit eb6dbd0 into emdash-cms:main Apr 30, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants