/* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { isUndefinedOrVoid } from '@speckle/shared' import type { Optional } from '@speckle/shared' import { ApolloError, defaultDataIdFromObject } from '@apollo/client/core' import type { FetchResult, DataProxy, TypedDocumentNode, ServerError, ServerParseError, ApolloCache } from '@apollo/client/core' import { GraphQLError } from 'graphql' import type { DocumentNode } from 'graphql' import { flatten, isUndefined, has, isFunction, isString, isArray, intersection } from 'lodash-es' import type { Modifier, Reference } from '@apollo/client/cache' import type { PartialDeep } from 'type-fest' import type { GraphQLErrors, NetworkError } from '@apollo/client/errors' import { nanoid } from 'nanoid' import { StackTrace } from '~~/lib/common/helpers/debugging' import dayjs from 'dayjs' import { base64Encode } from '~/lib/common/helpers/encodeDecode' import type { ErrorResponse } from '@apollo/client/link/error' export const isServerError = (err: Error): err is ServerError => has(err, 'response') && has(err, 'result') && has(err, 'statusCode') export const isServerParseError = (err: Error): err is ServerParseError => has(err, 'response') && has(err, 'bodyText') && has(err, 'statusCode') export const ROOT_QUERY = 'ROOT_QUERY' export const ROOT_MUTATION = 'ROOT_MUTATION' export const ROOT_SUBSCRIPTION = 'ROOT_SUBSCRIPTION' /** * Utility type for typing cached data in Apollo modify functions. * Essentially inside a modify function all references to cached objects that can be uniquely identified (have an ID field) are converted * to CacheObjectReference objects and additionally all properties are optional as you can never know what exactly has been requested & cached * and what hasn't */ export type ModifyFnCacheData = Data extends | Record | Record[] ? Data extends { id: string } ? CacheObjectReference : Data extends { id: string }[] ? CacheObjectReference[] : PartialDeep<{ [key in keyof Data]: Data[key] extends { id: string } ? CacheObjectReference : Data[key] extends { id: string }[] ? CacheObjectReference[] : ModifyFnCacheData }> : Data /** * Get a cached object's identifier */ export function getCacheId(typeName: string, id: string) { const cachedId = defaultDataIdFromObject({ __typename: typeName, id }) if (!cachedId) throw new Error('Unable to build Apollo cache ID') return cachedId } export function isInvalidAuth(error: ApolloError | NetworkError) { const networkError = error instanceof ApolloError ? error.networkError : error if ( !networkError || (!isServerError(networkError) && !isServerParseError(networkError)) ) return false const statusCode = networkError.statusCode const hasCorrectCode = [403].includes(statusCode) if (!hasCorrectCode) return false const message: string | undefined = isServerError(networkError) ? isString(networkError.result) ? networkError.result : networkError.result?.error : networkError.bodyText return (message || '').toLowerCase().includes('token') } /** * Convert an error thrown during $apollo.mutate() into a fetch result */ export function convertThrowIntoFetchResult( err: unknown ): FetchResult & { apolloError?: ApolloError; isInvalidAuth: boolean } { let gqlErrors: readonly GraphQLError[] let apolloError: Optional = undefined if (err instanceof ApolloError) { gqlErrors = err.graphQLErrors apolloError = err if (!gqlErrors.length) { if ( err.networkError && 'result' in err.networkError && !isString(err.networkError.result) && isArray(err.networkError.result.errors) ) { const errors = err.networkError.result.errors as Array<{ message: string }> gqlErrors = errors.map((e) => new GraphQLError(e.message)) } } } else if (err instanceof Error) { gqlErrors = [new GraphQLError(err.message)] } else { gqlErrors = [new GraphQLError(`${err}`)] } const hasAuthIssue = apolloError && isInvalidAuth(apolloError) return { data: undefined, errors: gqlErrors, apolloError, isInvalidAuth: !!hasAuthIssue } } /** * Get first error message from a GQL errors array */ export function getFirstErrorMessage( errs: readonly GraphQLError[] | undefined | null, fallbackMessage = 'An unexpected issue occurred' ): string { return errs?.[0]?.message || fallbackMessage } /** * Find some cached Apollo data through a fragment/query and use the updater function * to return the replacement for the data that the fragment initially found * @returns Whether an update was made */ export function updateCacheByFilter( cache: ApolloCache, filter: { fragment?: DataProxy.Fragment query?: DataProxy.Query }, /** * If returns undefined/void, then updating is essentially canceled. Be careful not to * mutate anything being passed into this function! E.g. if you want to mutate arrays, * create new arrays through slice()/filter() instead */ updater: (data: TData) => TData | undefined, options: Partial<{ /** * Whether to suppress errors that occur when the fragment being queried * doesn't find anything * Default: true */ ignoreCacheErrors: boolean /** * Whether to overwrite the old cache results, instead of triggering a merge function * to merge the old and new results. * Default: true */ overwrite: boolean }> = {} ): boolean { const { fragment, query } = filter const { ignoreCacheErrors = true, overwrite = true } = options const logger = useLogger() if (!fragment && !query) { throw new Error( 'Either fragment or query must be specified to be able to find cached data to update' ) } const readData = (): TData | null => { if (fragment) { return cache.readFragment(fragment) } else if (query) { return cache.readQuery(query) } else { return null } } const writeData = (data: TData): boolean => { if (fragment) { cache.writeFragment({ ...fragment, data, overwrite }) return true } else if (query) { cache.writeQuery({ ...query, data, overwrite }) return true } else { return false } } try { const currentData = readData() if (!currentData) return false const newData = updater(currentData) if (isUndefinedOrVoid(newData)) return false return writeData(newData) } catch (e: unknown) { if (e instanceof Error) { // Invalid fragment - throw always if (e.message.toLowerCase().includes('no fragment named')) { throw e } } if (ignoreCacheErrors) { logger.warn('Failed Apollo cache update:', e) return false } throw e } } /** * A graphql-codegen generated fragment doesn't include the definition of other fragments in it, * so if you ever need a document that contains a fragment as well as other fragments that it uses * you can use this helper * * TODO: Figure out if we can turn off dedupeFragments to get fragments to contain all dependency * fragments as well. Previously this caused an error when duplicate fragments were sent to the API * through a query/mutation. */ export function addFragmentDependencies( fragment: TypedDocumentNode, ...fragmentDependencies: DocumentNode[] ) { return { kind: 'Document', definitions: [ ...fragment.definitions.filter((d) => d.kind === 'FragmentDefinition'), ...flatten( fragmentDependencies.map((f) => f.definitions.filter((d) => d.kind === 'FragmentDefinition') ) ) ] } as TypedDocumentNode } /** * Resolve the string key of a field in the apollo cache. Is useful in cache.modify() calls. */ export function getStoreFieldName( fieldName: string, variables?: Record ) { return ( fieldName + (Object.values(variables || {}).length ? `:${JSON.stringify(variables)}` : '') ) } /** * Inside cache.modify calls you'll get these instead of full objects when reading fields that hold * identifiable objects or object arrays */ export type CacheObjectReference = Reference /** * Objects & object arrays in `cache.modify` calls are represented through reference objects, so * if you want to add new ones you shouldn't add the entire object, but only its reference */ export function getObjectReference(typeName: string, id: string): CacheObjectReference { return { __ref: getCacheId(typeName, id) } } export function isReference(obj: unknown): obj is CacheObjectReference { return has(obj, '__ref') } /** * Resolve the field name and variables from an Apollo store field name which * is usually a string like "fieldName:{"var1":"val1","var2":"val2"}" * @param storeFieldName * @param fieldName */ export const revolveFieldNameAndVariables = < V extends Optional> = undefined >( storeFieldName: string, fieldName?: string ) => { let variables: Optional = undefined if (!fieldName) { fieldName = /^[a-zA-Z0-9_-]+(?=[:(])/.exec(storeFieldName)?.[0] } if (!fieldName?.length) return { fieldName: storeFieldName, variables } const variablesStringbase = storeFieldName.substring(fieldName.length) if (variablesStringbase.startsWith(':')) { variables = JSON.parse(variablesStringbase.substring(1)) as V } else if (variablesStringbase.startsWith('(')) { variables = JSON.parse( variablesStringbase.substring(1, variablesStringbase.length - 1) ) as V } return { fieldName, variables } } /** * Iterate over a cached object's fields and optionally update them. Similar to cache.modify, except allows * better filtering capabilities to filter filters to update (e.g. you can actually get each field's variables) * Note: This uses cache.modify underneath which means that `data` will only hold object references (CacheObjectReference) not * full objects. Read more: https://www.apollographql.com/docs/react/caching/cache-interaction/#values-vs-references */ export function modifyObjectFields< Variables extends Optional> = undefined, FieldData = unknown >( cache: ApolloCache, id: string, updater: ( fieldName: string, variables: Variables, value: ModifyFnCacheData, details: Parameters>>[1] & { ref: typeof getObjectReference revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables } ) => | Optional> | Parameters>>[1]['DELETE'] | Parameters>>[1]['INVALIDATE'], options?: Partial<{ fieldNameWhitelist: string[] debug: boolean }> ) { const { fieldNameWhitelist, debug = !!(import.meta.dev && import.meta.client) } = options || {} const logger = useLogger() const invocationId = nanoid() const log = (...args: Parameters) => { if (!debug) return const [message, ...rest] = args logger.debug(`[${invocationId}] ${message}`, ...rest) } log( 'modifyObjectFields invoked', { id, fieldNameWhitelist }, new StackTrace() ) cache.modify({ id, fields(fieldValue, details) { const { storeFieldName, fieldName } = details if (fieldNameWhitelist?.length && !fieldNameWhitelist.includes(fieldName)) { return fieldValue as unknown } const { variables } = revolveFieldNameAndVariables( storeFieldName, fieldName ) log('invoking updater', { fieldName, variables, fieldValue }) const res = updater( fieldName, (variables || {}) as Variables, fieldValue as ModifyFnCacheData, { ...details, ref: getObjectReference, revolveFieldNameAndVariables } ) if (isUndefined(res)) { return fieldValue as unknown } else { log('updater returned', { res }) return res } } }) } /** * Iterate over a cached object's fields and evict/delete the ones that the predicate returns true for * Note: This uses cache.modify underneath which means that `data` will only hold object references (CacheObjectReference) not * full objects. Read more: https://www.apollographql.com/docs/react/caching/cache-interaction/#values-vs-references */ export function evictObjectFields< V extends Optional> = undefined, D = unknown >( cache: ApolloCache, id: string, predicate: | (( fieldName: string, variables: V, value: ModifyFnCacheData, details: Parameters>>[1] & { revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables } ) => boolean) | string[] ) { modifyObjectFields( cache, id, (fieldName, variables, value, details) => { if (isFunction(predicate)) { if ( !predicate(fieldName, variables, value, { ...details, revolveFieldNameAndVariables }) ) return undefined } else { const predicateFields = predicate if (!predicateFields.includes(fieldName)) return undefined } return details.DELETE as ModifyFnCacheData }, { debug: false } ) } export const resolveGenericStatusCode = (errors: GraphQLErrors) => { if (errors.some((e) => e.extensions?.code === 'FORBIDDEN')) return 403 if ( errors.some((e) => ['UNAUTHENTICATED', 'UNAUTHORIZED_ACCESS_ERROR'].includes( e.extensions?.code || '' ) ) ) return 401 if ( errors.some((e) => ['NOT_FOUND_ERROR', 'STREAM_NOT_FOUND', 'AUTOMATION_NOT_FOUND'].includes( e.extensions?.code || '' ) ) ) return 404 return 500 } export const errorFailedAtPathSegment = (error: GraphQLError, segment: string) => { const path = error.path || [] return path[path.length - 1] === segment } export const getDateCursorFromReference = (params: { ref: Reference dateProp: string readField: (fieldName: string, ref: Reference) => unknown }): string | null => { const dateStr = params.readField(params.dateProp, params.ref) as string if (!dateStr || !isString(dateStr)) return null const date = dayjs(dateStr) if (!date.isValid()) return null const iso = date.toISOString() return base64Encode(iso) } /** * Simplified version of modifyObjectFields, just targetting a single field * @see modifyObjectFields */ export const modifyObjectField = < FieldData = unknown, Variables extends Optional> = undefined >( cache: ApolloCache, id: string, fieldName: string, updater: (params: { fieldName: string variables: Variables value: ModifyFnCacheData details: Parameters>>[1] & { ref: typeof getObjectReference revolveFieldNameAndVariables: typeof revolveFieldNameAndVariables } }) => | Optional> | Parameters>>[1]['DELETE'] | Parameters>>[1]['INVALIDATE'], options?: Partial<{ debug: boolean }> ) => { modifyObjectFields( cache, id, (field, variables, value, details) => { if (field !== fieldName) return return updater({ fieldName: field, variables, value, details }) }, options ) } /** * Build skipLoggingErrors function that skips logging errors if there's only one error and it's related to a specific field */ export const skipLoggingErrorsIfOneFieldError = (fieldName: string | string[]) => (err: ErrorResponse): boolean => { const fieldNames = isArray(fieldName) ? fieldName : [fieldName] return ( err.graphQLErrors?.length === 1 && err.graphQLErrors.some((e) => intersection(e.path || [], fieldNames).length > 0) ) }