From 0cd584b618f106e1765671905703083856b4a816 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 11:29:48 +0000 Subject: [PATCH] fix(core): validate multi-column fields before splitting into physical columns Move the splitColumns step (storage image()/file() in Keystone-parity mode) to run AFTER validateFieldRules instead of inline inside executeFieldResolveInputHooks. Previously an unrecognised value from a field's resolveInput fallback bypassed validation entirely, since it was split into null/undefined physical columns before the field's Zod schema ever saw it. Updated in lockstep across the top-level Hook Pipeline and both nested create/update write paths. Closes #789 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QPX69aoqSuBMAwD3bDCSWk --- .changeset/tidy-otters-validate.md | 5 + CONTEXT.md | 2 +- packages/core/src/access/field-access.ts | 12 +- .../access/multi-column-read-write.test.ts | 87 +++-- packages/core/src/context/hook-pipeline.ts | 19 ++ .../core/src/context/nested-operations.ts | 22 ++ packages/core/src/hooks/index.ts | 144 +++++---- .../multi-column-validation-ordering.test.ts | 305 ++++++++++++++++++ 8 files changed, 513 insertions(+), 83 deletions(-) create mode 100644 .changeset/tidy-otters-validate.md create mode 100644 packages/core/tests/multi-column-validation-ordering.test.ts diff --git a/.changeset/tidy-otters-validate.md b/.changeset/tidy-otters-validate.md new file mode 100644 index 00000000..f311ff66 --- /dev/null +++ b/.changeset/tidy-otters-validate.md @@ -0,0 +1,5 @@ +--- +'@opensaas/stack-core': patch +--- + +Fix multi-column fields (e.g. storage `image()`/`file()` in Keystone-parity mode) writing an unrecognised value silently instead of failing validation. The column split now runs after `validateFieldRules`, not before, at the top-level and nested write paths. diff --git a/CONTEXT.md b/CONTEXT.md index 6ebd2cc3..ea1f2ac8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -31,7 +31,7 @@ The single module that runs the canonical, secured write sequence (hooks → val _Avoid_: operation handler, mutation service **Hook Pipeline**: -The module that runs the transform+validate span of a write — list `resolveInput` → field `resolveInput` → list `validate` → field `validate` → built-in field rules — owning that order and the `resolvedData` threading through it. It throws a validation error (never silent) when a validate hook reports via `addValidationError` or a built-in field rule fails, and returns the transformed `resolvedData` on success. The Write Pipeline delegates this span to it; side-effect hooks (`beforeOperation`/`afterOperation`), access, writable-field filtering, persistence and Field Visibility stay in the Write Pipeline. +The module that runs the transform+validate span of a write — list `resolveInput` → field `resolveInput` → list `validate` → field `validate` → built-in field rules → split multi-column fields — owning that order and the `resolvedData` threading through it. It throws a validation error (never silent) when a validate hook reports via `addValidationError` or a built-in field rule fails, and returns the transformed `resolvedData` on success. A multi-column field (e.g. storage `image()`/`file()` in Keystone-parity mode) is validated under its logical field key BEFORE it is split into its per-part physical columns, so an unrecognised value throws instead of being silently split into null/undefined columns (#789). The Write Pipeline delegates this span to it; side-effect hooks (`beforeOperation`/`afterOperation`), access, writable-field filtering, persistence and Field Visibility stay in the Write Pipeline. _Avoid_: validation service, input resolver **In-transaction hooks** (`beforeOperation` / `afterOperation`): diff --git a/packages/core/src/access/field-access.ts b/packages/core/src/access/field-access.ts index feeb59a0..6fe6aaac 100644 --- a/packages/core/src/access/field-access.ts +++ b/packages/core/src/access/field-access.ts @@ -128,12 +128,12 @@ export async function filterWritableFields>( // Map each raw per-part column name contributed by a multi-column field // (e.g. storage image()/file() in Keystone-parity mode) back to its OWNING // declared field. These columns are injected into the write payload by the - // field's `splitColumns` AFTER resolveInput and are intentionally NOT declared - // as their own entries in `fieldConfigs`, so without this map they would trip - // the #564 undeclared-key reject below. + // field's `splitColumns`, AFTER validation (#789), and are intentionally NOT + // declared as their own entries in `fieldConfigs`, so without this map they + // would trip the #564 undeclared-key reject below. // // SECURITY (#568): a raw column must NOT be blanket-passed through. The hooks - // layer (`executeFieldResolveInputHooks`) only gates the owning field when the + // layer (`splitMultiColumnFields`) only gates the owning field when the // LOGICAL key (e.g. `media`) is present, because it iterates declared fields, // not data keys. A non-sudo caller who supplies the raw columns DIRECTLY // (`data: { media_url, media_size }`) never produces that logical key, so that @@ -184,8 +184,8 @@ export async function filterWritableFields>( // they must NOT be blanket-passed through either: gate each one by its // OWNING field's write access (see the SECURITY note where the map is built). // This is the real gate for callers who supply the raw columns directly, - // because the logical-key gate in `executeFieldResolveInputHooks` never fires - // for them. Denied (non-sudo) throws — same fail-loud behaviour as a denied + // because the logical-key gate in `splitMultiColumnFields` never fires for + // them. Denied (non-sudo) throws — same fail-loud behaviour as a denied // declared field (#568); allowed (or sudo, via `checkFieldAccess`) passes // through, preserving the legitimate multi-column write path. const splitColumnOwner = splitColumnOwners.get(fieldName) diff --git a/packages/core/src/access/multi-column-read-write.test.ts b/packages/core/src/access/multi-column-read-write.test.ts index e30ddf95..6f3f0d1f 100644 --- a/packages/core/src/access/multi-column-read-write.test.ts +++ b/packages/core/src/access/multi-column-read-write.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { filterReadableFields } from './field-visibility.js' -import { executeFieldResolveInputHooks } from '../hooks/index.js' +import { executeFieldResolveInputHooks, splitMultiColumnFields } from '../hooks/index.js' import type { FieldConfig } from '../config/types.js' import type { AccessContext, FieldAccess } from './types.js' @@ -10,6 +10,13 @@ import type { AccessContext, FieldAccess } from './types.js' * field-agnostic: they assert that ANY field implementing * getColumnNames/assembleColumns/splitColumns is assembled on read (raw columns * stripped) and split on write. + * + * The write side is split across two phases (#789): `executeFieldResolveInputHooks` + * (Phase 1.5) resolves the field's value under its LOGICAL key only — no split — + * so that validation (Phase 2-3, not exercised by these field-agnostic unit + * tests; see the Hook Pipeline / Write Pipeline test suites) runs against the + * logical value first. `splitMultiColumnFields` (Phase 4) then replaces the + * logical key with its physical per-part columns, AFTER validation has passed. */ // A minimal multi-column field: two physical columns `m_url` and `m_size` @@ -113,10 +120,10 @@ describe('multi-column read assembly (filterReadableFields)', () => { }) }) -describe('multi-column write split (executeFieldResolveInputHooks)', () => { +describe('multi-column resolveInput (executeFieldResolveInputHooks) does NOT split (#789)', () => { const fields = { media: multiColumnField() } - it('splits the logical value into per-part columns and removes the logical key', async () => { + it('resolves the field under its LOGICAL key and leaves it unsplit', async () => { const inputData = { media: { url: 'https://x/y.jpg', size: 99 } } const result = await executeFieldResolveInputHooks( inputData, @@ -126,33 +133,81 @@ describe('multi-column write split (executeFieldResolveInputHooks)', () => { makeContext(), 'Post', ) + // Still under the logical key — no split, no per-part columns yet. + expect(result).toEqual({ media: { url: 'https://x/y.jpg', size: 99 } }) + expect('m_url' in result).toBe(false) + expect('m_size' in result).toBe(false) + }) + + it('passes an unrecognised value through unsplit, under the logical key', async () => { + // The field's own resolveInput fallback (see fixture: "unknown → return as + // is and let validation catch it") must be able to hand this value to + // validation BEFORE any split happens. + const inputData = { media: 'not-a-valid-shape' } + const result = await executeFieldResolveInputHooks( + inputData, + { ...inputData }, + fields, + 'update', + makeContext(), + 'Post', + ) + expect(result).toEqual({ media: 'not-a-valid-shape' }) + }) + + it('does not touch the field when it is absent from the write', async () => { + const inputData = { title: 'no media in payload' } + const result = await executeFieldResolveInputHooks( + inputData, + { ...inputData }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs + { ...fields, title: { type: 'text' } as any }, + 'update', + makeContext(), + 'Post', + ) + expect(result).toEqual({ title: 'no media in payload' }) + expect('media' in result).toBe(false) + }) +}) + +describe('multi-column write split (splitMultiColumnFields, AFTER validation — #789)', () => { + const fields = { media: multiColumnField() } + + it('splits the logical value into per-part columns and removes the logical key', async () => { + const inputData = { media: { url: 'https://x/y.jpg', size: 99 } } + const result = await splitMultiColumnFields( + inputData, + { ...inputData }, + fields, + 'create', + makeContext(), + ) expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 }) expect('media' in result).toBe(false) }) it('splitting null clears all per-part columns', async () => { const inputData = { media: null } - const result = await executeFieldResolveInputHooks( + const result = await splitMultiColumnFields( inputData, { ...inputData }, fields, 'update', makeContext(), - 'Post', ) expect(result).toEqual({ m_url: null, m_size: null }) }) it('does not touch the columns when the logical field is absent from the write', async () => { const inputData = { title: 'no media in payload' } - const result = await executeFieldResolveInputHooks( + const result = await splitMultiColumnFields( inputData, { ...inputData }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs { ...fields, title: { type: 'text' } as any }, 'update', makeContext(), - 'Post', ) expect(result).toEqual({ title: 'no media in payload' }) expect('m_url' in result).toBe(false) @@ -163,13 +218,12 @@ describe('multi-column write split respects field-level write access', () => { it('does NOT write any per-part columns when update access is denied', async () => { const fields = { media: multiColumnField({ update: () => false }) } const inputData = { media: { url: 'https://x/y.jpg', size: 99 } } - const result = await executeFieldResolveInputHooks( + const result = await splitMultiColumnFields( inputData, { ...inputData }, fields, 'update', makeContext(), - 'Post', ) // The logical key is dropped (it is not a real column) AND none of its // per-part columns are written — identical to how filterWritableFields @@ -183,13 +237,12 @@ describe('multi-column write split respects field-level write access', () => { it('does NOT write any per-part columns when create access is denied', async () => { const fields = { media: multiColumnField({ create: () => false }) } const inputData = { media: { url: 'https://x/y.jpg', size: 99 } } - const result = await executeFieldResolveInputHooks( + const result = await splitMultiColumnFields( inputData, { ...inputData }, fields, 'create', makeContext(), - 'Post', ) expect(result).toEqual({}) expect('m_url' in result).toBe(false) @@ -198,13 +251,12 @@ describe('multi-column write split respects field-level write access', () => { it('still splits/writes the columns when write access is granted', async () => { const fields = { media: multiColumnField({ update: () => true, create: () => true }) } const inputData = { media: { url: 'https://x/y.jpg', size: 99 } } - const result = await executeFieldResolveInputHooks( + const result = await splitMultiColumnFields( inputData, { ...inputData }, fields, 'update', makeContext(), - 'Post', ) expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 }) expect('media' in result).toBe(false) @@ -214,13 +266,12 @@ describe('multi-column write split respects field-level write access', () => { // A field that denies `update` must still be writable on `create`. const fields = { media: multiColumnField({ update: () => false }) } const inputData = { media: { url: 'https://x/y.jpg', size: 99 } } - const result = await executeFieldResolveInputHooks( + const result = await splitMultiColumnFields( inputData, { ...inputData }, fields, 'create', makeContext(), - 'Post', ) expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 }) }) @@ -228,13 +279,12 @@ describe('multi-column write split respects field-level write access', () => { it('sudo bypasses the field-access gate and still splits', async () => { const fields = { media: multiColumnField({ update: () => false }) } const inputData = { media: { url: 'https://x/y.jpg', size: 99 } } - const result = await executeFieldResolveInputHooks( + const result = await splitMultiColumnFields( inputData, { ...inputData }, fields, 'update', makeContext({ isSudo: true }), - 'Post', ) expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 }) }) @@ -242,13 +292,12 @@ describe('multi-column write split respects field-level write access', () => { it('a multi-column field WITHOUT field-level access splits exactly as before', async () => { const fields = { media: multiColumnField() } const inputData = { media: { url: 'https://x/y.jpg', size: 99 } } - const result = await executeFieldResolveInputHooks( + const result = await splitMultiColumnFields( inputData, { ...inputData }, fields, 'update', makeContext(), - 'Post', ) expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 }) }) diff --git a/packages/core/src/context/hook-pipeline.ts b/packages/core/src/context/hook-pipeline.ts index 08a6a966..603c6a45 100644 --- a/packages/core/src/context/hook-pipeline.ts +++ b/packages/core/src/context/hook-pipeline.ts @@ -6,6 +6,7 @@ import { executeFieldResolveInputHooks, executeFieldValidateHooks, validateFieldRules, + splitMultiColumnFields, ValidationError, } from '../hooks/index.js' import { applyCreateDefaults } from './apply-defaults.js' @@ -66,12 +67,17 @@ export interface HookPipeline { * → list `validate` * → field `validate` * → built-in field rules (`validateFieldRules`) + * → split multi-column fields (`splitMultiColumnFields`) * * Contract preserved exactly: * - `resolvedData` starts as `inputData` and is threaded through each phase; * - validate hooks report failures via `addValidationError` → THROW * `ValidationError` (never silent); * - built-in field rule failures THROW `ValidationError`; + * - a multi-column field (e.g. storage image()/file() in Keystone-parity + * mode) is validated under its LOGICAL key BEFORE it is split into + * physical columns (#789) — an unrecognised value throws instead of being + * silently split into null/undefined columns; * - on success returns the transformed `resolvedData`. */ async function runHookPipeline(args: HookPipelineArgs): Promise { @@ -160,6 +166,19 @@ async function runHookPipeline(args: HookPipelineArgs): Promise[0]) + + // Create new object with updated field to avoid mutating the passed reference + result = { ...result, [fieldKey]: resolvedValue } + } - if (fieldConfig.hooks?.resolveInput) { - // Execute field hook - // Type assertion is safe here because hooks are typed correctly in field definitions - // and we're working with runtime values that match those types - resolvedValue = await fieldConfig.hooks.resolveInput({ - listKey, - fieldKey, - operation, - inputData, - item, - resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args - context, - } as Parameters[0]) - } else if (!fieldConfig.splitColumns) { - // No resolveInput and not a multi-column field — nothing to do. + return result +} + +/** + * Split multi-column fields' resolved LOGICAL values into their physical + * per-part columns (e.g. storage image()/file() in Keystone-parity mode — see + * ADR-0006). + * + * Runs AFTER `validateFieldRules` has passed (#789): a multi-column field's + * `getZodSchema` gets a genuine chance to reject an unrecognised/invalid + * logical value BEFORE it is split into `null`/`undefined` physical columns + * and silently written. Previously this split ran inline inside + * `executeFieldResolveInputHooks` (Phase 1.5, BEFORE validation), which let an + * unrecognised value bypass validation entirely. + * + * Preserves the field-level write-access gate exactly as before: the raw + * per-part column keys are not declared in `fieldConfigs`, so + * `filterWritableFields`'s undeclared-key reject cannot enforce this field's + * own write access — enforce it HERE, using the canonical field-access + * evaluator with the same arguments the write pipeline uses. A denied field + * drops its logical key and contributes NONE of its per-part columns (sudo + * bypasses via `checkFieldAccess`). + */ +export async function splitMultiColumnFields( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputData: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolvedData: Record, + fields: Record, + operation: 'create' | 'update', + context: AccessContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + item?: any, +): Promise> { + let result = { ...resolvedData } + + for (const [fieldKey, fieldConfig] of Object.entries(fields)) { + if (!fieldConfig.splitColumns) continue + if (!(fieldKey in result)) continue + + const resolvedValue = result[fieldKey] + + const canWrite = await checkFieldAccess(fieldConfig.access, operation, { + session: context.session, + item, + context, + inputData, + }) + + // Drop the logical key (it is not a real column) regardless of outcome — + // a denied field must not leave its logical key behind either. + const next = { ...result } + delete next[fieldKey] + result = next + + if (!canWrite) { + // Denied: write none of its per-part columns — exactly as + // filterWritableFields drops a denied single-column field. continue } - if (fieldConfig.splitColumns) { - // Multi-column field (e.g. storage image()/file() in Keystone-parity - // mode): replace the single logical key with its per-part columns so the - // write payload targets the live columns instead of a single one. - // - // The split removes the logical key from the payload BEFORE the - // canonical writable-field filter (`filterWritableFields`) runs, and the - // raw per-part column keys are not in `fieldConfigs` — so that later - // filter cannot enforce this field's own write access. Enforce it HERE, - // using the canonical field-access evaluator with the SAME arguments the - // write pipeline uses. A single-column field denied by `update`/`create` - // is simply omitted from the write; a denied multi-column field must - // likewise contribute NONE of its per-part columns. (sudo bypasses via - // `checkFieldAccess`.) - const canWrite = await checkFieldAccess(fieldConfig.access, operation, { - session: context.session, - item, - context, - inputData, - }) - if (!canWrite) { - // Denied: drop the logical key and write none of its columns — exactly - // as filterWritableFields drops a denied single-column field. - const next = { ...result } - delete next[fieldKey] - result = next - continue - } - const columns = fieldConfig.splitColumns(fieldKey, resolvedValue) - // Drop the logical key (it is not a real column) and merge the columns. - const next = { ...result, ...columns } - delete next[fieldKey] - result = next - } else { - // Create new object with updated field to avoid mutating the passed reference - result = { ...result, [fieldKey]: resolvedValue } - } + const columns = fieldConfig.splitColumns(fieldKey, resolvedValue) + result = { ...result, ...columns } } return result diff --git a/packages/core/tests/multi-column-validation-ordering.test.ts b/packages/core/tests/multi-column-validation-ordering.test.ts new file mode 100644 index 00000000..7809db4b --- /dev/null +++ b/packages/core/tests/multi-column-validation-ordering.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { z } from 'zod' +import { getContext } from '../src/context/index.js' +import { config, list } from '../src/config/index.js' +import { text, relationship } from '../src/fields/index.js' +import { ValidationError } from '../src/hooks/index.js' +import type { FieldConfig } from '../src/config/types.js' +import type { FieldAccess } from '../src/access/types.js' + +/** + * #789: a multi-column field's `resolveInput` can have a documented + * "unrecognised value — return as-is and let validation catch it" fallback + * (see storage image()/file() in Keystone-parity mode). Before this fix, the + * split into physical columns ran BEFORE validation (inline inside + * `executeFieldResolveInputHooks`), so an unrecognised value was silently split + * into null/undefined columns and the write succeeded with corrupted/absent + * data instead of throwing. + * + * These are full `context.db` integration tests (top-level AND nested writes) + * proving validation now runs against the LOGICAL value first — an + * unrecognised value throws `ValidationError` and the DB is never touched — + * while the legitimate shapes (null/undefined and a valid object) continue to + * split and persist exactly as before. + */ + +/** A minimal multi-column field: two physical columns split from/assembled into `{ url, size }`. */ +function mediaField(access?: FieldAccess): FieldConfig { + const COLUMNS = ['m_url', 'm_size'] + return { + type: 'multiColumn', + access, + getColumnNames: () => COLUMNS, + assembleColumns: (_fieldName: string, row: Record) => { + const url = row.m_url + if (url === null || url === undefined || url === '') return null + return { url, size: row.m_size ?? 0 } + }, + splitColumns: (_fieldName: string, value: unknown) => { + if (value === null || value === undefined) { + return { m_url: null, m_size: null } + } + const v = value as { url?: unknown; size?: unknown } + return { m_url: v.url ?? null, m_size: v.size ?? null } + }, + getZodSchema: () => z.object({ url: z.string(), size: z.number() }).nullable(), + hooks: { + // Mirrors storage image()/file()'s resolveInput contract: null/undefined + // and an already-shaped value pass through unchanged; anything else is + // the documented "unrecognised — return as-is and let validation catch + // it" fallback. This is the exact fallback #789 is about. + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic test hook + resolveInput: async ({ resolvedData, fieldKey }: any) => resolvedData?.[fieldKey], + }, + } as unknown as FieldConfig +} + +/** + * A tiny in-memory Prisma mock supporting a single nested to-one relation + * (`author` on `post`), for both nested create and nested update, mirroring + * the harness used by the other write-pipeline test suites. + */ +function createTxPrisma() { + const tables: Record>> = { + post: new Map(), + author: new Map(), + } + let idCounter = 0 + const nextId = () => `id-${++idCounter}` + + function applyNested( + table: string, + 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 || nested.update) { + if (nested.create) { + const created = doCreate('author', nested.create as Record) + result[`${key}Link`] = created.id + result[key] = created + } + if (nested.update) { + const upd = nested.update as { where: { id: string }; data: Record } + const updated = doUpdate('author', upd.where, upd.data) + result[key] = updated + } + continue + } + } + result[key] = value + } + return result + } + + function doCreate(table: string, data: Record): Record { + const id = (data.id as string) ?? nextId() + const record = applyNested(table, { id }, data) + tables[table].set(id, record) + return record + } + + function doUpdate( + table: string, + where: { id: string }, + data: Record, + ): Record { + const existing = tables[table].get(where.id) ?? { id: where.id } + const updated = applyNested(table, existing, data) + tables[table].set(where.id, updated) + return updated + } + + 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( + async ({ where, data }: { where: { id: string }; data: Record }) => + doUpdate(table, where, data), + ), + delete: vi.fn(), + } + } + + const client: Record = { + post: makeModel('post'), + author: makeModel('author'), + } + client.$transaction = async (fn: (tx: unknown) => Promise) => fn(client) + + return { client, tables } +} + +describe('#789 top-level write — multi-column validation runs BEFORE the split', () => { + let mock: ReturnType + + beforeEach(() => { + mock = createTxPrisma() + vi.clearAllMocks() + }) + + function makeTestConfig() { + return config({ + db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' }, + lists: { + Post: list({ + fields: { title: text(), media: mediaField() }, + access: { operation: { query: () => true, create: () => true, update: () => true } }, + }), + }, + }) + } + + it('create: an unrecognised media value throws ValidationError and never reaches the DB', async () => { + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + await expect( + context.db.post.create({ data: { title: 'hi', media: 'not-a-valid-shape' } }), + ).rejects.toBeInstanceOf(ValidationError) + + expect(mock.tables.post.size).toBe(0) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((mock.client.post as any).create).not.toHaveBeenCalled() + }) + + it('create: a valid media value passes validation and is split into physical columns', async () => { + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + const created = await context.db.post.create({ + data: { title: 'hi', media: { url: 'https://x/y.jpg', size: 99 } }, + }) + + expect(created).toBeTruthy() + const stored = mock.tables.post.values().next().value + expect(stored).toMatchObject({ m_url: 'https://x/y.jpg', m_size: 99 }) + expect('media' in (stored ?? {})).toBe(false) + }) + + it('create: a null media value passes validation and clears the columns', async () => { + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + await context.db.post.create({ data: { title: 'hi', media: null } }) + + const stored = mock.tables.post.values().next().value + expect(stored).toMatchObject({ m_url: null, m_size: null }) + }) + + it('update: an unrecognised media value throws ValidationError and never reaches the DB', async () => { + mock.tables.post.set('p1', { id: 'p1', title: 'old', m_url: null, m_size: null }) + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + await expect( + context.db.post.update({ where: { id: 'p1' }, data: { media: 42 } }), + ).rejects.toBeInstanceOf(ValidationError) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((mock.client.post as any).update).not.toHaveBeenCalled() + expect(mock.tables.post.get('p1')).toMatchObject({ title: 'old', m_url: null, m_size: null }) + }) + + it('update: a valid media value passes validation and is split into physical columns', async () => { + mock.tables.post.set('p1', { id: 'p1', title: 'old', m_url: null, m_size: null }) + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + await context.db.post.update({ + where: { id: 'p1' }, + data: { media: { url: 'https://x/new.jpg', size: 5 } }, + }) + + expect(mock.tables.post.get('p1')).toMatchObject({ m_url: 'https://x/new.jpg', m_size: 5 }) + }) +}) + +describe('#789 nested write — multi-column validation runs BEFORE the split', () => { + let mock: ReturnType + + beforeEach(() => { + mock = createTxPrisma() + vi.clearAllMocks() + }) + + function makeTestConfig() { + return config({ + db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' }, + lists: { + Author: list({ + fields: { name: text(), media: mediaField() }, + access: { operation: { query: () => true, create: () => true, update: () => true } }, + }), + Post: list({ + fields: { title: text(), author: relationship({ ref: 'Author' }) }, + access: { operation: { query: () => true, create: () => true, update: () => true } }, + }), + }, + }) + } + + it('nested create: an unrecognised media value throws ValidationError and nothing persists', async () => { + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + await expect( + context.db.post.create({ + data: { title: 'p', author: { create: { name: 'a', media: 'not-a-valid-shape' } } }, + }), + ).rejects.toBeInstanceOf(ValidationError) + + expect(mock.tables.post.size).toBe(0) + expect(mock.tables.author.size).toBe(0) + }) + + it('nested create: a valid media value is split into physical columns on the nested row', async () => { + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + await context.db.post.create({ + data: { + title: 'p', + author: { create: { name: 'a', media: { url: 'https://x/y.jpg', size: 3 } } }, + }, + }) + + const author = mock.tables.author.values().next().value + expect(author).toMatchObject({ m_url: 'https://x/y.jpg', m_size: 3 }) + expect('media' in (author ?? {})).toBe(false) + }) + + it('nested update: an unrecognised media value throws ValidationError and nothing persists', async () => { + mock.tables.post.set('p1', { id: 'p1', title: 'p', authorLink: 'a1' }) + mock.tables.author.set('a1', { id: 'a1', name: 'old', m_url: null, m_size: null }) + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + await expect( + context.db.post.update({ + where: { id: 'p1' }, + data: { author: { update: { where: { id: 'a1' }, data: { media: [] } } } }, + }), + ).rejects.toBeInstanceOf(ValidationError) + + expect(mock.tables.author.get('a1')).toMatchObject({ name: 'old', m_url: null, m_size: null }) + }) + + it('nested update: a valid media value is split into physical columns on the nested row', async () => { + mock.tables.post.set('p1', { id: 'p1', title: 'p', authorLink: 'a1' }) + mock.tables.author.set('a1', { id: 'a1', name: 'old', m_url: null, m_size: null }) + const context = getContext(await makeTestConfig(), mock.client, { userId: '1' }) + + await context.db.post.update({ + where: { id: 'p1' }, + data: { + author: { + update: { where: { id: 'a1' }, data: { media: { url: 'https://x/new.jpg', size: 8 } } }, + }, + }, + }) + + expect(mock.tables.author.get('a1')).toMatchObject({ m_url: 'https://x/new.jpg', m_size: 8 }) + }) +})