diff --git a/.changeset/neat-melons-invite.md b/.changeset/neat-melons-invite.md new file mode 100644 index 00000000000..27d3736345c --- /dev/null +++ b/.changeset/neat-melons-invite.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': patch +--- + +Field-level hooks and field-level create and update access control functions are now awaited in parallel. Note this means all field-level hooks and access control are now awaited in parallel because field-level read access control was already awaited in parallel. diff --git a/packages/keystone/src/lib/core/mutations/access-control.ts b/packages/keystone/src/lib/core/mutations/access-control.ts index 2023ccf83d0..20f1fbc0e79 100644 --- a/packages/keystone/src/lib/core/mutations/access-control.ts +++ b/packages/keystone/src/lib/core/mutations/access-control.ts @@ -167,29 +167,31 @@ export async function getAccessControlledItemForUpdate( } // Field level 'item' access control - const nonBooleans = []; - const fieldsDenied = []; - const accessErrors = []; - for (const fieldKey of Object.keys(inputData)) { - let result; - try { - result = - typeof list.fields[fieldKey].access[operation] === 'function' - ? await list.fields[fieldKey].access[operation]({ ...args, fieldKey }) - : access; - } catch (error: any) { - accessErrors.push({ error, tag: `${args.listKey}.${fieldKey}.access.${args.operation}` }); - continue; - } - if (typeof result !== 'boolean') { - nonBooleans.push({ - tag: `${args.listKey}.${fieldKey}.access.${args.operation}`, - returned: typeof result, - }); - } else if (!result) { - fieldsDenied.push(fieldKey); - } - } + const nonBooleans: { tag: string; returned: string }[] = []; + const fieldsDenied: string[] = []; + const accessErrors: { error: Error; tag: string }[] = []; + await Promise.all( + Object.keys(inputData).map(async fieldKey => { + let result; + try { + result = + typeof list.fields[fieldKey].access[operation] === 'function' + ? await list.fields[fieldKey].access[operation]({ ...args, fieldKey }) + : access; + } catch (error: any) { + accessErrors.push({ error, tag: `${args.listKey}.${fieldKey}.access.${args.operation}` }); + return; + } + if (typeof result !== 'boolean') { + nonBooleans.push({ + tag: `${args.listKey}.${fieldKey}.access.${args.operation}`, + returned: typeof result, + }); + } else if (!result) { + fieldsDenied.push(fieldKey); + } + }) + ); if (accessErrors.length) { throw extensionError('Access control', accessErrors); @@ -257,30 +259,31 @@ export async function applyAccessControlForCreate( } // Field level 'item' access control - // Field level 'item' access control - const nonBooleans = []; - const fieldsDenied = []; - const accessErrors = []; - for (const fieldKey of Object.keys(inputData)) { - let result; - try { - result = - typeof list.fields[fieldKey].access[operation] === 'function' - ? await list.fields[fieldKey].access[operation]({ ...args, fieldKey }) - : access; - } catch (error: any) { - accessErrors.push({ error, tag: `${args.listKey}.${fieldKey}.access.${args.operation}` }); - continue; - } - if (typeof result !== 'boolean') { - nonBooleans.push({ - tag: `${args.listKey}.${fieldKey}.access.${args.operation}`, - returned: typeof result, - }); - } else if (!result) { - fieldsDenied.push(fieldKey); - } - } + const nonBooleans: { tag: string; returned: string }[] = []; + const fieldsDenied: string[] = []; + const accessErrors: { error: Error; tag: string }[] = []; + await Promise.all( + Object.keys(inputData).map(async fieldKey => { + let result; + try { + result = + typeof list.fields[fieldKey].access[operation] === 'function' + ? await list.fields[fieldKey].access[operation]({ ...args, fieldKey }) + : access; + } catch (error: any) { + accessErrors.push({ error, tag: `${args.listKey}.${fieldKey}.access.${args.operation}` }); + return; + } + if (typeof result !== 'boolean') { + nonBooleans.push({ + tag: `${args.listKey}.${fieldKey}.access.${args.operation}`, + returned: typeof result, + }); + } else if (!result) { + fieldsDenied.push(fieldKey); + } + }) + ); if (accessErrors.length) { throw extensionError('Access control', accessErrors); diff --git a/packages/keystone/src/lib/core/mutations/create-update.ts b/packages/keystone/src/lib/core/mutations/create-update.ts index b62e7d291f6..662cdd992de 100644 --- a/packages/keystone/src/lib/core/mutations/create-update.ts +++ b/packages/keystone/src/lib/core/mutations/create-update.ts @@ -321,27 +321,33 @@ async function getResolvedData( // Resolve input hooks const hookName = 'resolveInput'; // Field hooks - let _resolvedData: Record = {}; const fieldsErrors: { error: Error; tag: string }[] = []; - for (const [fieldKey, field] of Object.entries(list.fields)) { - if (field.hooks.resolveInput === undefined) { - _resolvedData[fieldKey] = resolvedData[fieldKey]; - } else { - try { - _resolvedData[fieldKey] = await field.hooks.resolveInput({ - ...hookArgs, - resolvedData, - fieldKey, - }); - } catch (error: any) { - fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` }); - } - } - } + resolvedData = Object.fromEntries( + await Promise.all( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + if (field.hooks.resolveInput === undefined) { + return [fieldKey, resolvedData[fieldKey]]; + } else { + try { + return [ + fieldKey, + await field.hooks.resolveInput({ + ...hookArgs, + resolvedData, + fieldKey, + }), + ]; + } catch (error: any) { + fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` }); + return [fieldKey, undefined]; + } + } + }) + ) + ); if (fieldsErrors.length) { throw extensionError(hookName, fieldsErrors); } - resolvedData = _resolvedData; // List hooks if (list.hooks.resolveInput) { diff --git a/packages/keystone/src/lib/core/mutations/hooks.ts b/packages/keystone/src/lib/core/mutations/hooks.ts index 6c85b452905..b872a0f13c1 100644 --- a/packages/keystone/src/lib/core/mutations/hooks.ts +++ b/packages/keystone/src/lib/core/mutations/hooks.ts @@ -33,15 +33,18 @@ export async function runSideEffectOnlyHook< // Field hooks const fieldsErrors: { error: Error; tag: string }[] = []; - for (const [fieldKey, field] of Object.entries(list.fields)) { - if (shouldRunFieldLevelHook(fieldKey)) { - try { - await field.hooks[hookName]?.({ fieldKey, ...args }); - } catch (error: any) { - fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` }); + await Promise.all( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + if (shouldRunFieldLevelHook(fieldKey)) { + try { + await field.hooks[hookName]?.({ fieldKey, ...args }); + } catch (error: any) { + fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` }); + } } - } - } + }) + ); + if (fieldsErrors.length) { throw extensionError(hookName, fieldsErrors); } diff --git a/packages/keystone/src/lib/core/mutations/validation.ts b/packages/keystone/src/lib/core/mutations/validation.ts index d006723c61a..436f874e036 100644 --- a/packages/keystone/src/lib/core/mutations/validation.ts +++ b/packages/keystone/src/lib/core/mutations/validation.ts @@ -1,6 +1,8 @@ import { extensionError, validationFailureError } from '../graphql-errors'; import { InitialisedList } from '../types-for-lists'; +type DistributiveOmit = T extends any ? Omit : never; + type UpdateCreateHookArgs = Parameters< Exclude >[0]; @@ -9,22 +11,24 @@ export async function validateUpdateCreate({ hookArgs, }: { list: InitialisedList; - hookArgs: Omit; + hookArgs: DistributiveOmit; }) { const messages: string[] = []; const fieldsErrors: { error: Error; tag: string }[] = []; // Field validation hooks - for (const [fieldKey, field] of Object.entries(list.fields)) { - const addValidationError = (msg: string) => - messages.push(`${list.listKey}.${fieldKey}: ${msg}`); - try { - // @ts-ignore - await field.hooks.validateInput?.({ ...hookArgs, addValidationError, fieldKey }); - } catch (error: any) { - fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateInput` }); - } - } + await Promise.all( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + const addValidationError = (msg: string) => + messages.push(`${list.listKey}.${fieldKey}: ${msg}`); + try { + await field.hooks.validateInput?.({ ...hookArgs, addValidationError, fieldKey }); + } catch (error: any) { + fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateInput` }); + } + }) + ); + if (fieldsErrors.length) { throw extensionError('validateInput', fieldsErrors); } @@ -32,7 +36,6 @@ export async function validateUpdateCreate({ // List validation hooks const addValidationError = (msg: string) => messages.push(`${list.listKey}: ${msg}`); try { - // @ts-ignore await list.hooks.validateInput?.({ ...hookArgs, addValidationError }); } catch (error: any) { throw extensionError('validateInput', [{ error, tag: `${list.listKey}.hooks.validateInput` }]); @@ -54,15 +57,17 @@ export async function validateDelete({ const messages: string[] = []; const fieldsErrors: { error: Error; tag: string }[] = []; // Field validation - for (const [fieldKey, field] of Object.entries(list.fields)) { - const addValidationError = (msg: string) => - messages.push(`${list.listKey}.${fieldKey}: ${msg}`); - try { - await field.hooks.validateDelete?.({ ...hookArgs, addValidationError, fieldKey }); - } catch (error: any) { - fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateDelete` }); - } - } + await Promise.all( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + const addValidationError = (msg: string) => + messages.push(`${list.listKey}.${fieldKey}: ${msg}`); + try { + await field.hooks.validateDelete?.({ ...hookArgs, addValidationError, fieldKey }); + } catch (error: any) { + fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateDelete` }); + } + }) + ); if (fieldsErrors.length) { throw extensionError('validateDelete', fieldsErrors); }