fix(storage): make file()/image() fields optional on create/update#619
Conversation
file() and image() returned z.union([metadataSchema, z.null(),
z.undefined()]) from getZodSchema(). In Zod 4 a union that merely accepts
an undefined value does NOT make the object key optional, so an omitted
file/image field failed validation ("expected nonoptional, received
undefined"). Core composes input via z.object({ <field>: getZodSchema() })
with no extra wrapping, so key-optionality must come from the field schema.
Return metadataSchema.nullish() (= .nullable().optional()) instead, matching
the established idiom every optional core field uses. An omitted/null/
undefined file/image now validates and stores null, including for
multi-column (db: { columns: 'keystone' }) fields, while a correctly-shaped
metadata object still validates and a malformed one still fails.
Implements #618.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SigyUcdxbBsdeLVWiFbYtS
🦋 Changeset detectedLatest commit: 8afe166 The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
borisno2
left a comment
There was a problem hiding this comment.
Review — Verdict: APPROVE (posted as Comment; formal Approve blocked because the integration identity authored the PR)
Tightly-scoped fix: file()/image() getZodSchema() now returns metadataSchema.nullish() instead of z.union([metadataSchema, z.null(), z.undefined()]), making the object key genuinely optional so an omitted file/image field validates on create/update. Plus a new regression test that reproduces core's z.object({ <field>: getZodSchema() }) composition. The change matches the idiom every optional core field already uses and is strictly safer than the bare union.
Verification performed
I reproduced the exact composition core uses — validateFieldRules → validateWithZod → generateZodSchema builds z.object({ [fieldName]: getZodSchema(fieldName, op) }) (packages/core/src/validation/schema.ts, packages/core/src/hooks/index.ts:760) — and ran the real file()/image() builders against zod 4.3.6:
- Omitted key validates — single-column AND multi-column (
db: { columns: 'keystone' }), create AND update. ✅ - Explicit
null/undefinedvalidate. ✅ - A correctly-shaped metadata object validates. ✅
- A malformed metadata object still FAILS (e.g.
{ avatar: { filename: 'x.png' } }→success: false). The fix does NOT loosen validation. ✅ .nullish()makes the key optional (z.object({ avatar: meta.nullish() }).safeParse({})→ success), which a barez.union([…, z.undefined()])is the wrong tool for. ✅
Risk areas (all clear)
- Zod 4 key-optionality: Confirmed
.nullish()makes the composed object key optional — an omitted key passes, not merely anundefinedvalue. ✅ - Multi-column path: In the real write pipeline the logical key is split into per-part columns and deleted in field
resolveInput(hooks/index.ts~L555–558) beforevalidateFieldRulesruns (hook-pipeline.ts:158), so by validation time the key is omitted — exactly the case the fix now permits. The test asserts against the schema directly (logical key present) rather than the post-split payload; that's a touch artificial but not wrong, and the schema is correct either way. ✅ - No
any/ no type casting introduced: the source change is a single return-expression swap; the test types the field param structurally ({ getZodSchema?: (...) => z.ZodTypeAny }) with noanyand no casts. ✅ - Test fidelity: the new test reproduces
z.object({ <field>: getZodSchema() })(not a bare schema call) and covers bothfile()andimage()for the omitted-on-create case (and update, null, undefined, valid, malformed, multi-column). ✅ - Changeset present (
@opensaas/stack-storagepatch). ✅
Non-blocking notes
- Could not reproduce the original "expected nonoptional, received undefined" failure in isolation. With zod 4.3.6, even the old
z.union([meta, z.null(), z.undefined()])wrapped in a plainz.objectalready accepted an omitted key in my repro. The reported failure presumably depended on a slightly different zod patch or extra wrapping. This does not undermine the fix —.nullish()is the correct, conventional idiom and is provably safer — but the PR's "expected nonoptional" quote may not reproduce on the currently-pinned zod. Worth a one-line confirmation that the repro was seen on the pinned version. - Multi-column test is schema-level, not pipeline-level. Since the logical key is stripped before validation in keystone-columns mode, an end-to-end test (record create with
db: { columns: 'keystone' }omitting the field) would more faithfully guard the real path. Optional follow-up; the unit assertion is still valid.
No correctness, regression, convention, or type-safety issues found. Recommend merge.
Generated by Claude Code
Coverage Report for Core Package Coverage (./packages/core)
File CoverageNo changed files found. |
Coverage Report for UI Package Coverage (./packages/ui)
File CoverageNo changed files found. |
Coverage Report for CLI Package Coverage (./packages/cli)
File CoverageNo changed files found. |
Coverage Report for Auth Package Coverage (./packages/auth)
File CoverageNo changed files found. |
Coverage Report for Storage Package Coverage (./packages/storage)
File Coverage
|
||||||||||||||||||||||||||||||||||||||
Coverage Report for RAG Package Coverage (./packages/rag)
File CoverageNo changed files found. |
Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)
File CoverageNo changed files found. |
Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)
File CoverageNo changed files found. |
Implements #618.
Problem
file()andimage()(@opensaas/stack-storage) returned their create/update validation schema as:In Zod 4, a union that merely accepts an
undefinedvalue does not make the object key optional. Core composes create/update input viaz.object({ <field>: getZodSchema(...) })(generateZodSchemainpackages/core/src/validation/schema.ts) with no extra wrapping, so an omitted file/image field failed validation:This broke seeds and any create/update path that legitimately has no file/image. It's the same class of bug already fixed for core fields under #570.
Fix
Return
metadataSchema.nullish()(=.nullable().optional()) instead of the bare union, so the object key is genuinely optional — matching the established idiom every optional core field (text,integer,json,checkbox,timestamp,select) uses. Out of scope: no "required file/image" capability, no metadata-shape or multi-column-layout changes, no other field types touched.Acceptance criteria
file()field, omitting it, succeeds and stores null.image()field, omitting it, succeeds and stores null.null/undefinedstill validate on create and update, including multi-column (db: { columns: 'keystone' }) fields.file()andimage().Tests
New
packages/storage/tests/field-zod-schema.test.tsreproduces core'sz.object({ <field>: getZodSchema() })composition and covers omitted / null / undefined / valid / malformed across create and update, single- and multi-column modes.pnpm --filter @opensaas/stack-storage test: 147 passedpnpm --filter @opensaas/stack-core test: 707 passedpnpm lint: 0 errors (3 pre-existing unrelated warnings)Changeset added (
@opensaas/stack-storagepatch).🤖 Generated with Claude Code
https://claude.ai/code/session_01SigyUcdxbBsdeLVWiFbYtS
Generated by Claude Code