Skip to content

fix(core): apply field defaultValue before create validation (#615)#620

Merged
borisno2 merged 2 commits into
mainfrom
claude/issue-615-default-before-validate
Jun 28, 2026
Merged

fix(core): apply field defaultValue before create validation (#615)#620
borisno2 merged 2 commits into
mainfrom
claude/issue-615-default-before-validate

Conversation

@borisno2

Copy link
Copy Markdown
Member

Implements #615.

Problem

A field's defaultValue was only realised as a Prisma @default(...) applied by the DB at write time — after the write pipeline's validation phase. Because validation never consulted defaultValue, a create that omitted a required-with-default field (e.g. select({ validation: { isRequired: true }, defaultValue: 'STANDARD' })) failed isRequired validation instead of resolving to its default. This diverges from Keystone 6's resolve-then-validate ordering and affected every field type that supports defaultValue, not just select.

Fix (resolve-then-validate, Keystone parity)

A single shared helper applyCreateDefaults() (packages/core/src/context/apply-defaults.ts) fills omitted fields with their declared defaultValue during the resolve phase — after resolveInput hooks, before validation:

  • Top-level create: wired into the Hook Pipeline (hook-pipeline.ts) between field resolveInput and list validate.
  • Nested-relation create: wired into the nested create path (nested-operations.ts) at the same point.

Guard rails

  • Create only — update never injects defaults for omitted fields (helper invoked only when operation === 'create').
  • Explicit values preserved — only keys whose value is undefined (omitted) are filled; an explicitly-provided value, including explicit null, is left untouched.
  • Skips virtual, system (id/createdAt/updatedAt) and relationship fields.
  • Timestamp { kind: 'now' } sentinel is not injected (it is a DB-level @default(now()) request, not a literal); a concrete Date default is injected.
  • Prisma @default(...) generation is unchanged.

Tests

  • New tests/default-value-create.test.ts: Hook-Pipeline unit coverage (omitted select/text/integer/checkbox resolve to defaults; explicit value and explicit null preserved; update does not inject; required-without-default still throws) plus full context.db create through a tx-aware mock for top-level and nested-relation creates.
  • Updated two tests/sudo.test.ts assertions to reflect the now-correct injection of a field's defaultValue: 0 into the create payload.
  • Changeset added (patch, @opensaas/stack-core).

All 714 core tests pass; build + lint clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SigyUcdxbBsdeLVWiFbYtS


Generated by Claude Code

A field's defaultValue was only realised as a Prisma @default(...) at DB
write time — after the write pipeline's validation phase. That ordering
made a required-with-default field (e.g. select({ validation: { isRequired:
true }, defaultValue: 'STANDARD' })) fail isRequired validation when the
field was omitted on create, diverging from Keystone 6's resolve-then-
validate ordering.

Introduce a single shared applyCreateDefaults() helper that fills omitted
fields with their declared defaultValue during the resolve phase (after
resolveInput hooks, before validation), wired into both the top-level Hook
Pipeline and the nested-relation create path. Create-only; explicit values
(including explicit null) are preserved; the timestamp { kind: 'now' }
sentinel is left to the DB-level @default(now()). Prisma @default(...)
generation is unchanged.

Implements #615

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: 37bf2e5

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-core Patch
@opensaas/stack-auth Patch
@opensaas/stack-cli Patch
@opensaas/stack-rag Patch
@opensaas/stack-storage Patch
@opensaas/stack-tiptap Patch
@opensaas/stack-ui Patch
@opensaas/stack-storage-s3 Patch
@opensaas/stack-storage-vercel 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:42am

@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 — /review (verdict: APPROVE)

I reviewed this strictly from the PR diff and the code it touches (no prior context). The fix is well-scoped, correctly placed in the resolve-then-validate span, and the guard rails hold up under scrutiny. Verdict: APPROVE. (Posting as a Comment review rather than a formal Approve since the change originates from the integration identity; the verdict is an explicit approve.)

What it does

Adds a single shared helper applyCreateDefaults() that fills omitted (undefined) fields with their declared defaultValue during the resolve phase — after resolveInput, before validation — wired into both the top-level Hook Pipeline (Phase 1.75) and the nested-relation create path (processNestedCreate step 3.5). This fixes #615 (a required-with-default field failing isRequired on an omitted create input), matching Keystone's ordering.

Risk-area verification (all pass)

  • Placement / create-only. Injection runs after field resolveInput and before list/field validate and validateFieldRules, in both paths. Top-level is gated by if (operation === 'create'); the nested helper lives inside processNestedCreate, which only ever runs with 'create'. Update never injects. Confirmed validateFieldRules validates resolvedData, so injected defaults satisfy isRequired. ✓
  • Explicit-value preservation incl. null. if (resolvedData[fieldKey] !== undefined) continue skips any present key, so an explicit null is preserved and not overwritten. Falsy literals (false, 0, '') are correctly injected since the guard tests undefined, not falsiness. ✓
  • Nested create covered. Wired and tested (profile: { create: {} } resolves the nested kind default). ✓
  • Timestamp { kind: 'now' } sentinel. isNowSentinel matches { kind: 'now' } exactly the shape timestamp() emits and skips it (left to Prisma @default(now())); a concrete Date default is still injected, which is a valid Prisma create value. ✓
  • Field skips. Virtual fields are reliably skipped — virtual() sets virtual: true (required on VirtualField), so if (fieldConfig.virtual) matches. System (id/createdAt/updatedAt) and relationship fields are skipped. ✓
  • Prisma @default(...) generation unchanged. No generator files are touched by this PR. ✓
  • House rules. No any and no type casting introduced in the new helper; defaultValue is read via 'defaultValue' in fieldConfig against the typed config and the lone unknown narrowing (isNowSentinel) is internal. Changeset present (patch, @opensaas/stack-core). ✓
  • All field-type defaults safe. Audited every field type: text/select/calendarDay = string, decimal = string (Prisma coerces), integer = number, checkbox = boolean, json = literal, timestamp = the only object sentinel and it's guarded. No raw-object corruption path exists. ✓

Test coverage

Exercises both the Hook-Pipeline unit surface and full context.db creates: omitted select/text/integer/checkbox resolve to defaults; explicit value and explicit null are preserved; update does not inject; required-without-default still throws; and both top-level and nested-relation creates store the default through the full pipeline. The two sudo.test.ts assertion updates correctly reflect the now-injected views: 0. Good coverage of every flagged case.

Minor observation (non-blocking, no change required)

Injecting a default into resolvedData means a field that was omitted but has a default now appears in resolvedData, so its create-side field-level beforeOperation/afterOperation hooks (gated by fieldKey in resolvedData) will now fire where previously they would not. This is the correct Keystone-aligned behavior (the field genuinely has a resolved value now), but it is a subtle behavioral change for anyone relying on those hooks only firing for explicitly-provided fields. Worth a mention in the changeset/notes, not a code change.

No correctness, regression, or convention issues found.


Generated by Claude Code

…k-firing in changeset

Adds a dedicated unit test for the real `applyCreateDefaults` exercising the
previously-uncovered guard rails (virtual/system/relationship skips, no-default
skip, explicit-value and explicit-null preservation, the timestamp { kind: 'now' }
sentinel skip, and concrete Date injection). This lifts statement coverage for
src/context/apply-defaults.ts from 82.35% to 100%, clearing the 85% per-directory
threshold for src/context/** that failed CI. No runtime behavior changed.

Also appends a note to the changeset documenting that defaulted fields now have
their create-side field-level beforeOperation/afterOperation hooks fire (since the
default is filled into resolvedData before validation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SigyUcdxbBsdeLVWiFbYtS
@github-actions

Copy link
Copy Markdown
Contributor

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

Status Category Percentage Covered / Total
🔵 Lines 91.63% (🎯 65%) 855 / 933
🔵 Statements 90.91% (🎯 65%) 891 / 980
🔵 Functions 97.95% (🎯 62%) 144 / 147
🔵 Branches 79.72% (🎯 50%) 574 / 720
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/context/apply-defaults.ts 100% 100% 100% 100%
packages/core/src/context/hook-pipeline.ts 100% 100% 100% 100%
packages/core/src/context/nested-operations.ts 91.2% 76.47% 97.29% 91.9% 84-89, 120, 209, 468, 488, 578, 645, 761, 776, 789, 922, 933, 1043, 1301, 1319-1320
Generated in workflow #1217 for commit 37bf2e5 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 #1217 for commit 37bf2e5 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 #1217 for commit 37bf2e5 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 #1217 for commit 37bf2e5 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 CoverageNo changed files found.
Generated in workflow #1217 for commit 37bf2e5 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 #1217 for commit 37bf2e5 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 #1217 for commit 37bf2e5 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 #1217 for commit 37bf2e5 by the Vitest Coverage Report Action

@borisno2 borisno2 merged commit 0be254e into main Jun 28, 2026
6 checks passed
@borisno2 borisno2 deleted the claude/issue-615-default-before-validate branch June 28, 2026 04:47
@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