Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/spotty-llamas-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@opensaas/stack-core': patch
---

Apply a field's defaultValue to omitted inputs before create validation (resolve-then-validate, matching Keystone), so isRequired + defaultValue no longer fails on create.

Note: because an omitted-but-defaulted field is now filled into `resolvedData` before validation, that field's create-side field-level `beforeOperation`/`afterOperation` hooks (gated on the field key being present in `resolvedData`) now fire for defaulted fields where they previously would not.
79 changes: 79 additions & 0 deletions packages/core/src/context/apply-defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { FieldConfig } from '../config/types.js'

/**
* Apply field `defaultValue`s to omitted inputs on CREATE — the runtime half of
* the resolve-then-validate ordering (Keystone 6 parity, issue #615).
*
* A field's `defaultValue` is otherwise only realised as a Prisma `@default(...)`
* applied by the database at write time, which is AFTER the write pipeline's
* validation phase has already run. That ordering means a required-with-default
* field (e.g. `select({ validation: { isRequired: true }, defaultValue: 'X' })`)
* fails `isRequired` validation on an omitted input even though a default exists.
*
* This helper closes that gap: in the resolve phase (after `resolveInput` hooks,
* before validation) it fills `resolvedData[field]` with the field's
* `defaultValue` ONLY when the field was OMITTED (value is `undefined`). It is a
* SINGLE shared mechanism used by both the top-level create path (Hook Pipeline)
* and the nested-relation create path.
*
* Guard rails (each acceptance-criteria-driven):
* - CREATE only. Update never injects defaults for omitted fields (the caller
* only invokes this for `operation === 'create'`).
* - Explicitly-provided values are preserved. A key present in `resolvedData`
* — INCLUDING an explicit `null` — is left untouched; only `undefined`
* (omitted) keys are filled.
* - Virtual, system (`id`/`createdAt`/`updatedAt`) and relationship fields are
* skipped — they have no scalar `defaultValue` to inject and relationships
* carry connect/create payloads rather than literal defaults.
* - The timestamp `{ kind: 'now' }` sentinel is NOT injected: it is not a
* literal value but a request for the DB-level `@default(now())`, which still
* applies at write time. Injecting the sentinel object would corrupt the
* payload. (A concrete `Date` default is a real literal and IS injected.)
*
* The function mutates and returns `resolvedData` (consistent with the other
* resolve-phase helpers that thread `resolvedData` through the pipeline).
*/
export function applyCreateDefaults(
resolvedData: Record<string, unknown>,
fieldConfigs: Record<string, FieldConfig>,
): Record<string, unknown> {
for (const [fieldKey, fieldConfig] of Object.entries(fieldConfigs)) {
// Skip virtual fields — not stored in the database.
if (fieldConfig.virtual) continue

// Skip system fields — always managed by the framework/DB.
if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue

// Skip relationships — they carry connect/create payloads, not literal defaults.
if (fieldConfig.type === 'relationship') continue

// No declared default → nothing to inject.
if (!('defaultValue' in fieldConfig) || fieldConfig.defaultValue === undefined) continue

// Only fill OMITTED keys. An explicitly-provided value (including explicit
// `null`) is preserved and must not be overwritten by the default.
if (resolvedData[fieldKey] !== undefined) continue

const defaultValue = fieldConfig.defaultValue

// The timestamp `{ kind: 'now' }` sentinel is a DB-level `@default(now())`
// request, not a literal — leave it for Prisma to apply at write time.
if (isNowSentinel(defaultValue)) continue

resolvedData[fieldKey] = defaultValue
}

return resolvedData
}

/**
* Detect the timestamp `{ kind: 'now' }` default sentinel.
*/
function isNowSentinel(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
'kind' in value &&
(value as { kind: unknown }).kind === 'now'
)
}
11 changes: 11 additions & 0 deletions packages/core/src/context/hook-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
validateFieldRules,
ValidationError,
} from '../hooks/index.js'
import { applyCreateDefaults } from './apply-defaults.js'

/**
* Hook Pipeline — the single module that runs the transform+validate span of a
Expand Down Expand Up @@ -109,6 +110,16 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
item,
)

// ── Phase 1.75: apply field defaults to omitted inputs (CREATE only) ───────
// Resolve-then-validate (Keystone parity, #615): a field declaring a
// `defaultValue` is filled into `resolvedData` here — AFTER resolveInput hooks
// and BEFORE validation — but only when the field was OMITTED, so a
// required-with-default field passes `isRequired` instead of failing it.
// Update is untouched (no default injection on update).
if (operation === 'create') {
resolvedData = applyCreateDefaults(resolvedData, listConfig.fields)
}

// ── Phase 2: list-level validate ──────────────────────────────────────────
await executeValidate(
listConfig.hooks,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/context/nested-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ValidationError,
} from '../hooks/index.js'
import { getDbKey } from '../lib/case-utils.js'
import { applyCreateDefaults } from './apply-defaults.js'

/**
* Nested writes (#569 / ADR-0010).
Expand Down Expand Up @@ -273,6 +274,12 @@ async function processNestedCreate(
relatedListName,
)

// 3.5 Apply field defaults to omitted inputs (resolve-then-validate, #615).
// Mirrors the top-level Hook Pipeline so a nested required-with-default
// field resolves to its default before validation instead of failing
// `isRequired`. Create-only; explicit values (incl. null) are preserved.
resolvedData = applyCreateDefaults(resolvedData, relatedListConfig.fields)

// 4. Execute validate hook
await executeValidate(relatedListConfig.hooks, {
listKey: relatedListName,
Expand Down
119 changes: 119 additions & 0 deletions packages/core/tests/apply-defaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import { applyCreateDefaults } from '../src/context/apply-defaults.js'
import { text, integer, checkbox, timestamp, virtual, relationship } from '../src/fields/index.js'
import type { FieldConfig } from '../src/config/types.js'

/**
* Direct unit tests for `applyCreateDefaults` (#615). These exercise every guard
* rail of the real helper — virtual / system / relationship skips, the
* no-`defaultValue` skip, explicit-value and explicit-`null` preservation, the
* timestamp `{ kind: 'now' }` sentinel skip, and concrete `Date` injection — so
* the resolve-then-validate helper is fully covered without mocking its logic.
*/

function fields(config: Record<string, FieldConfig>): Record<string, FieldConfig> {
return config
}

describe('applyCreateDefaults', () => {
it('injects a declared defaultValue into an omitted field', () => {
const resolved = applyCreateDefaults(
{},
fields({
label: text({ defaultValue: 'PLACEHOLDER' }),
count: integer({ defaultValue: 7 }),
active: checkbox({ defaultValue: true }),
}),
)

expect(resolved).toEqual({ label: 'PLACEHOLDER', count: 7, active: true })
})

it('leaves a field with NO defaultValue untouched (key stays absent)', () => {
const resolved = applyCreateDefaults({}, fields({ label: text() }))

expect('label' in resolved).toBe(false)
})

it('preserves an explicitly-provided value over the default', () => {
const resolved = applyCreateDefaults(
{ count: 99 },
fields({ count: integer({ defaultValue: 7 }) }),
)

expect(resolved.count).toBe(99)
})

it('preserves an explicit null and does not overwrite it with the default', () => {
const resolved = applyCreateDefaults(
{ note: null },
fields({ note: text({ defaultValue: 'DEFAULT_NOTE' }) }),
)

expect(resolved.note).toBeNull()
})

it('skips virtual fields (no scalar default to inject)', () => {
const resolved = applyCreateDefaults(
{},
fields({
// A virtual field cannot carry a stored default; it must be skipped.
computed: virtual({
type: 'string',
hooks: { resolveOutput: () => 'x' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
}),
)

expect('computed' in resolved).toBe(false)
})

it('skips system fields (id/createdAt/updatedAt)', () => {
const resolved = applyCreateDefaults(
{},
fields({
id: text({ defaultValue: 'SHOULD_NOT_INJECT' }),
createdAt: timestamp({ defaultValue: new Date('2020-01-01T00:00:00Z') }),
updatedAt: timestamp({ defaultValue: new Date('2020-01-01T00:00:00Z') }),
}),
)

expect(resolved).toEqual({})
})

it('skips relationship fields (they carry connect/create payloads, not literals)', () => {
const resolved = applyCreateDefaults(
{},
fields({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
owner: relationship({ ref: 'User' }) as any,
}),
)

expect('owner' in resolved).toBe(false)
})

it("does NOT inject the timestamp { kind: 'now' } sentinel (left for the DB @default(now()))", () => {
const resolved = applyCreateDefaults(
{},
fields({ when: timestamp({ defaultValue: { kind: 'now' } }) }),
)

expect('when' in resolved).toBe(false)
})

it('DOES inject a concrete Date default (a real literal, unlike the now sentinel)', () => {
const date = new Date('2021-06-15T12:00:00Z')
const resolved = applyCreateDefaults({}, fields({ when: timestamp({ defaultValue: date }) }))

expect(resolved.when).toBe(date)
})

it('returns the same resolvedData object it was given (mutation contract)', () => {
const input: Record<string, unknown> = {}
const result = applyCreateDefaults(input, fields({ label: text({ defaultValue: 'x' }) }))

expect(result).toBe(input)
})
})
Loading
Loading