diff --git a/.changeset/spotty-llamas-cheer.md b/.changeset/spotty-llamas-cheer.md new file mode 100644 index 00000000..f6475b87 --- /dev/null +++ b/.changeset/spotty-llamas-cheer.md @@ -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. diff --git a/packages/core/src/context/apply-defaults.ts b/packages/core/src/context/apply-defaults.ts new file mode 100644 index 00000000..432a1a65 --- /dev/null +++ b/packages/core/src/context/apply-defaults.ts @@ -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, + fieldConfigs: Record, +): Record { + 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' + ) +} diff --git a/packages/core/src/context/hook-pipeline.ts b/packages/core/src/context/hook-pipeline.ts index 7f59a878..08a6a966 100644 --- a/packages/core/src/context/hook-pipeline.ts +++ b/packages/core/src/context/hook-pipeline.ts @@ -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 @@ -109,6 +110,16 @@ async function runHookPipeline(args: HookPipelineArgs): Promise): Record { + 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 = {} + const result = applyCreateDefaults(input, fields({ label: text({ defaultValue: 'x' }) })) + + expect(result).toBe(input) + }) +}) diff --git a/packages/core/tests/default-value-create.test.ts b/packages/core/tests/default-value-create.test.ts new file mode 100644 index 00000000..ae523abf --- /dev/null +++ b/packages/core/tests/default-value-create.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getContext } from '../src/context/index.js' +import { config, list } from '../src/config/index.js' +import { text, integer, checkbox, select, relationship } from '../src/fields/index.js' +import { hookPipeline } from '../src/context/hook-pipeline.js' +import { ValidationError } from '../src/hooks/index.js' +import type { ListConfig } from '../src/config/types.js' +import type { AccessContext } from '../src/access/types.js' + +/** + * Regression tests for #615: a field's `defaultValue` must be applied to omitted + * inputs BEFORE validation on create (resolve-then-validate, Keystone parity). + * + * Before the fix, a required-with-default field (`select`, `text`, `integer`, + * `checkbox`, …) failed `isRequired` validation on an omitted input because the + * default was only realised as a Prisma `@default(...)` at DB write time — after + * validation. These tests cover both the Hook-Pipeline unit surface and a full + * create through `context.db` (top-level + nested), plus the guard rails: + * explicit values (incl. explicit null) are preserved and update does not inject. + */ + +/** + * Minimal AccessContext for driving the Hook Pipeline directly. + */ +function makeContext(): AccessContext { + return { + session: { userId: 'u1' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storage: {} as any, + plugins: {}, + _isSudo: false, + _resolveOutputCounter: { depth: 0 }, + } +} + +/** + * A list whose every required field also declares a `defaultValue`, spanning the + * field types the issue calls out (`select` plus other defaultValue-supporting + * types). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeListConfig(): ListConfig { + return { + fields: { + kind: select({ + validation: { isRequired: true }, + options: [ + { label: 'Standard', value: 'STANDARD' }, + { label: 'Trial', value: 'TRIAL' }, + ], + defaultValue: 'STANDARD', + }), + label: text({ validation: { isRequired: true }, defaultValue: 'PLACEHOLDER' }), + count: integer({ validation: { isRequired: true }, defaultValue: 7 }), + active: checkbox({ defaultValue: true }), + // A required field WITHOUT a default — to prove validation still fails when + // there is genuinely nothing to resolve to. + name: text({ validation: { isRequired: true } }), + }, + access: { operation: { query: () => true, create: () => true, update: () => true } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as ListConfig +} + +describe('#615 Hook Pipeline — defaultValue applied before validation (create)', () => { + it('fills omitted required-with-default fields (select/text/integer/checkbox) and passes validation', async () => { + const { resolvedData } = await hookPipeline.run({ + operation: 'create', + listName: 'Thing', + listConfig: makeListConfig(), + // `name` is provided (required, no default); everything else omitted. + inputData: { name: 'given' }, + item: undefined, + context: makeContext(), + }) + + expect(resolvedData).toEqual({ + name: 'given', + kind: 'STANDARD', + label: 'PLACEHOLDER', + count: 7, + active: true, + }) + }) + + it('preserves an explicitly-provided value over the default', async () => { + const { resolvedData } = await hookPipeline.run({ + operation: 'create', + listName: 'Thing', + listConfig: makeListConfig(), + inputData: { name: 'given', kind: 'TRIAL', count: 99, active: false }, + item: undefined, + context: makeContext(), + }) + + expect(resolvedData.kind).toBe('TRIAL') + expect(resolvedData.count).toBe(99) + expect(resolvedData.active).toBe(false) + }) + + it('preserves an explicit null and does not overwrite it with the default', async () => { + // A nullable field with a default: explicit null must survive resolve. + const listConfig = { + fields: { + note: text({ defaultValue: 'DEFAULT_NOTE' }), + }, + access: { operation: { query: () => true, create: () => true } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as ListConfig + + const { resolvedData } = await hookPipeline.run({ + operation: 'create', + listName: 'Thing', + listConfig, + inputData: { note: null }, + item: undefined, + context: makeContext(), + }) + + expect(resolvedData.note).toBeNull() + }) + + it('does NOT inject defaults on update (omitted field stays omitted)', async () => { + const { resolvedData } = await hookPipeline.run({ + operation: 'update', + listName: 'Thing', + listConfig: makeListConfig(), + inputData: { name: 'changed' }, + item: { id: '1', name: 'old', kind: 'TRIAL', label: 'x', count: 1, active: false }, + context: makeContext(), + }) + + // Only the provided field is present; no default was injected on update. + expect(resolvedData).toEqual({ name: 'changed' }) + }) + + it('still throws when a required field WITHOUT a default is omitted', async () => { + await expect( + hookPipeline.run({ + operation: 'create', + listName: 'Thing', + listConfig: makeListConfig(), + inputData: {}, // `name` is required and has no default + item: undefined, + context: makeContext(), + }), + ).rejects.toBeInstanceOf(ValidationError) + }) +}) + +/** + * A tiny in-memory Prisma mock supporting interactive transactions and a single + * nested to-one `create`, mirroring the harness used by the nested-write tests. + */ +function createTxPrisma() { + const tables: Record>> = { + account: new Map(), + profile: new Map(), + } + let idCounter = 0 + const nextId = () => `id-${++idCounter}` + + function applyNested( + record: Record, + data: Record, + ): Record { + const result = { ...record } + for (const [key, value] of Object.entries(data)) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const nested = value as Record + if (nested.create) { + const created = doCreate('profile', nested.create as Record) + result[`${key}Link`] = created.id + result[key] = created + continue + } + } + result[key] = value + } + return result + } + + function doCreate(table: string, data: Record): Record { + const id = (data.id as string) ?? nextId() + const record = applyNested({ id }, data) + tables[table].set(id, record) + return record + } + + function makeModel(table: string) { + return { + findUnique: vi.fn( + async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null, + ), + findFirst: vi.fn(async () => tables[table].values().next().value ?? null), + findMany: vi.fn(async () => Array.from(tables[table].values())), + count: vi.fn(async () => tables[table].size), + create: vi.fn(async ({ data }: { data: Record }) => doCreate(table, data)), + update: vi.fn(), + delete: vi.fn(), + } + } + + const client: Record = { + account: makeModel('account'), + profile: makeModel('profile'), + } + client.$transaction = async (fn: (tx: unknown) => Promise) => fn(client) + + return { client, tables } +} + +describe('#615 context.db create — defaultValue resolves through the full pipeline', () => { + let mock: ReturnType + + beforeEach(() => { + mock = createTxPrisma() + vi.clearAllMocks() + }) + + it('top-level create omitting a required-with-default select succeeds and stores the default', async () => { + const testConfig = config({ + db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' }, + lists: { + Account: list({ + fields: { + name: text({ validation: { isRequired: true } }), + kind: select({ + validation: { isRequired: true }, + options: [ + { label: 'Standard', value: 'STANDARD' }, + { label: 'Trial', value: 'TRIAL' }, + ], + defaultValue: 'STANDARD', + }), + count: integer({ validation: { isRequired: true }, defaultValue: 7 }), + }, + access: { operation: { query: () => true, create: () => true } }, + }), + }, + }) + + const context = getContext(await testConfig, mock.client, { userId: '1' }) + + const created = await context.db.account.create({ data: { name: 'Acme' } }) + + expect(created).toBeTruthy() + expect(created?.kind).toBe('STANDARD') + expect(created?.count).toBe(7) + // The DB received the resolved default in its `data` payload. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createMock = (mock.client.account as any).create as ReturnType + expect(createMock.mock.calls[0][0].data).toMatchObject({ kind: 'STANDARD', count: 7 }) + }) + + it('nested-relation create omitting a required-with-default select stores the default', async () => { + const testConfig = config({ + db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' }, + lists: { + Account: list({ + fields: { + name: text({ validation: { isRequired: true } }), + profile: relationship({ ref: 'Profile.account' }), + }, + access: { operation: { query: () => true, create: () => true } }, + }), + Profile: list({ + fields: { + kind: select({ + validation: { isRequired: true }, + options: [ + { label: 'Standard', value: 'STANDARD' }, + { label: 'Trial', value: 'TRIAL' }, + ], + defaultValue: 'STANDARD', + }), + account: relationship({ ref: 'Account.profile' }), + }, + access: { operation: { query: () => true, create: () => true } }, + }), + }, + }) + + const context = getContext(await testConfig, mock.client, { userId: '1' }) + + const created = await context.db.account.create({ + data: { name: 'Acme', profile: { create: {} } }, + }) + + expect(created).toBeTruthy() + // The nested Profile was created with its default `kind` despite being omitted. + const profile = mock.tables.profile.values().next().value + expect(profile?.kind).toBe('STANDARD') + }) +}) diff --git a/packages/core/tests/sudo.test.ts b/packages/core/tests/sudo.test.ts index 2592f5c4..baf59a59 100644 --- a/packages/core/tests/sudo.test.ts +++ b/packages/core/tests/sudo.test.ts @@ -138,8 +138,10 @@ describe('Sudo Context', () => { data: { title: 'New Post' }, }) expect(sudoResult).toMatchObject({ title: 'New Post' }) + // `views` declares `defaultValue: 0`, so the omitted value is resolved to + // its default before persistence (#615 resolve-then-validate). expect(mockPrisma.post.create).toHaveBeenCalledWith({ - data: { title: 'New Post' }, + data: { title: 'New Post', views: 0 }, }) }) @@ -155,9 +157,11 @@ describe('Sudo Context', () => { data: { title: 'New Post', secretField: 'secret' }, }) - // Verify that secretField was passed to Prisma + // Verify that secretField was passed to Prisma. `views` declares + // `defaultValue: 0`, so the omitted value is resolved to its default + // before persistence (#615 resolve-then-validate). expect(mockPrisma.post.create).toHaveBeenCalledWith({ - data: { title: 'New Post', secretField: 'secret' }, + data: { title: 'New Post', secretField: 'secret', views: 0 }, }) })