Skip to content

fix(storage): make file()/image() fields optional on create/update#619

Merged
borisno2 merged 1 commit into
mainfrom
claude/issue-618-file-image-nullish
Jun 28, 2026
Merged

fix(storage): make file()/image() fields optional on create/update#619
borisno2 merged 1 commit into
mainfrom
claude/issue-618-file-image-nullish

Conversation

@borisno2

Copy link
Copy Markdown
Member

Implements #618.

Problem

file() and image() (@opensaas/stack-storage) returned their create/update validation schema as:

return z.union([fileMetadataSchema, z.null(), z.undefined()])   // file()
return z.union([imageMetadataSchema, z.null(), z.undefined()])  // image()

In Zod 4, a union that merely accepts an undefined value does not make the object key optional. Core composes create/update input via z.object({ <field>: getZodSchema(...) }) (generateZodSchema in packages/core/src/validation/schema.ts) with no extra wrapping, so an omitted file/image field failed validation:

ValidationError: Validation failed: image: Invalid input: expected nonoptional, received undefined

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

  • Creating a record with a file() field, omitting it, succeeds and stores null.
  • Creating a record with an image() field, omitting it, succeeds and stores null.
  • Same holds for UPDATE operations that omit the field.
  • Explicit null / undefined still validate on create and update, including multi-column (db: { columns: 'keystone' }) fields.
  • A correctly-shaped metadata object still validates.
  • A malformed metadata object still fails validation.
  • Regression test covers the omitted-field create case for both file() and image().

Tests

New packages/storage/tests/field-zod-schema.test.ts reproduces core's z.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 passed
  • pnpm --filter @opensaas/stack-core test: 707 passed
  • pnpm lint: 0 errors (3 pre-existing unrelated warnings)
  • Builds: storage + core green

Changeset added (@opensaas/stack-storage patch).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SigyUcdxbBsdeLVWiFbYtS


Generated by Claude Code

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-bot

changeset-bot Bot commented Jun 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8afe166

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

This PR includes changesets to release 9 packages
Name Type
@opensaas/stack-storage Patch
@opensaas/stack-storage-s3 Patch
@opensaas/stack-storage-vercel Patch
@opensaas/stack-auth Patch
@opensaas/stack-cli Patch
@opensaas/stack-core Patch
@opensaas/stack-rag Patch
@opensaas/stack-tiptap Patch
@opensaas/stack-ui 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

@vercel

vercel Bot commented Jun 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-docs Ready Ready Preview, Comment Jun 28, 2026 4:28am

@borisno2 borisno2 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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 — validateFieldRulesvalidateWithZodgenerateZodSchema 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 / undefined validate.
  • 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 bare z.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 an undefined value. ✅
  • 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) before validateFieldRules runs (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 no any and no casts. ✅
  • Test fidelity: the new test reproduces z.object({ <field>: getZodSchema() }) (not a bare schema call) and covers both file() and image() for the omitted-on-create case (and update, null, undefined, valid, malformed, multi-column). ✅
  • Changeset present (@opensaas/stack-storage patch). ✅

Non-blocking notes

  1. 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 plain z.object already 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.
  2. 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

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🔵 Lines 91.51% (🎯 65%) 841 / 919
🔵 Statements 90.72% (🎯 65%) 871 / 960
🔵 Functions 97.93% (🎯 62%) 142 / 145
🔵 Branches 79.05% (🎯 50%) 551 / 697
File CoverageNo changed files found.
Generated in workflow #1215 for commit 8afe166 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 76.03% 92 / 121
🔵 Statements 75.39% 95 / 126
🔵 Functions 75.6% 31 / 41
🔵 Branches 65.78% 75 / 114
File CoverageNo changed files found.
Generated in workflow #1215 for commit 8afe166 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 79.21% 1490 / 1881
🔵 Statements 78.92% 1550 / 1964
🔵 Functions 84.45% 201 / 238
🔵 Branches 67.25% 653 / 971
File CoverageNo changed files found.
Generated in workflow #1215 for commit 8afe166 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 74.64% 159 / 213
🔵 Statements 69.74% 166 / 238
🔵 Functions 83.11% 64 / 77
🔵 Branches 70.67% 94 / 133
File CoverageNo changed files found.
Generated in workflow #1215 for commit 8afe166 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 74.8% 190 / 254
🔵 Statements 76.44% 211 / 276
🔵 Functions 85.89% 67 / 78
🔵 Branches 70.73% 174 / 246
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/storage/src/fields/index.ts 68.75% 53.16% 80% 65.11% 230, 254-260, 269-283, 319-332, 412, 448-454, 463-477, 526-539
Generated in workflow #1215 for commit 8afe166 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 47.97% 355 / 740
🔵 Statements 48.14% 377 / 783
🔵 Functions 54.26% 70 / 129
🔵 Branches 42.55% 180 / 423
File CoverageNo changed files found.
Generated in workflow #1215 for commit 8afe166 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #1215 for commit 8afe166 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 38 / 38
🔵 Statements 100% 38 / 38
🔵 Functions 100% 8 / 8
🔵 Branches 100% 22 / 22
File CoverageNo changed files found.
Generated in workflow #1215 for commit 8afe166 by the Vitest Coverage Report Action

@borisno2 borisno2 merged commit 29ca3a9 into main Jun 28, 2026
6 checks passed
@borisno2 borisno2 deleted the claude/issue-618-file-image-nullish branch June 28, 2026 04:35
@github-actions github-actions Bot mentioned this pull request Jun 28, 2026
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.

2 participants