diff --git a/.changeset/tender-files-tie.md b/.changeset/tender-files-tie.md new file mode 100644 index 0000000000..f2a47eba95 --- /dev/null +++ b/.changeset/tender-files-tie.md @@ -0,0 +1,5 @@ +--- +'@urql/exchange-graphcache': minor +--- + +Add optional `logger` to the options, this allows you to filter out warnings or disable them all together diff --git a/docs/api/graphcache.md b/docs/api/graphcache.md index 0e77add7b7..2ee8bde119 100644 --- a/docs/api/graphcache.md +++ b/docs/api/graphcache.md @@ -32,6 +32,7 @@ options and returns an [`Exchange`](./core.md#exchange). | `optimistic` | A mapping of mutation fields to resolvers that may be used to provide _Graphcache_ with an optimistic result for a given mutation field that should be applied to the cached data temporarily. | | `schema` | A serialized GraphQL schema that is used by _Graphcache_ to resolve partial data, interfaces, and enums. The schema also used to provide helpful warnings for [schema awareness](../graphcache/schema-awareness.md). | | `storage` | A persisted storage interface that may be provided to preserve cache data for [offline support](../graphcache/offline.md). | +| `logger` | A function that will be invoked for warning/debug/... logs | The `@urql/exchange-graphcache` package also exports the `offlineExchange`; which is identical to the `cacheExchange` but activates [offline support](../graphcache/offline.md) when the `storage` option is passed. diff --git a/exchanges/graphcache/src/ast/schemaPredicates.test.ts b/exchanges/graphcache/src/ast/schemaPredicates.test.ts index 931e13de0d..4672a4bb23 100644 --- a/exchanges/graphcache/src/ast/schemaPredicates.test.ts +++ b/exchanges/graphcache/src/ast/schemaPredicates.test.ts @@ -57,13 +57,13 @@ describe('SchemaPredicates', () => { it('should indicate nullability', () => { expect( - SchemaPredicates.isFieldNullable(schema, 'Todo', 'text') + SchemaPredicates.isFieldNullable(schema, 'Todo', 'text', undefined) ).toBeFalsy(); expect( - SchemaPredicates.isFieldNullable(schema, 'Todo', 'complete') + SchemaPredicates.isFieldNullable(schema, 'Todo', 'complete', undefined) ).toBeTruthy(); expect( - SchemaPredicates.isFieldNullable(schema, 'Todo', 'author') + SchemaPredicates.isFieldNullable(schema, 'Todo', 'author', undefined) ).toBeTruthy(); }); @@ -89,7 +89,12 @@ describe('SchemaPredicates', () => { it('should throw if a requested type does not exist', () => { expect(() => - SchemaPredicates.isFieldNullable(schema, 'SomeInvalidType', 'complete') + SchemaPredicates.isFieldNullable( + schema, + 'SomeInvalidType', + 'complete', + undefined + ) ).toThrow( 'The type `SomeInvalidType` is not an object in the defined schema, but the GraphQL document is traversing it.\nhttps://bit.ly/2XbVrpR#3' ); @@ -97,7 +102,7 @@ describe('SchemaPredicates', () => { it('should warn in console if a requested field does not exist', () => { expect( - SchemaPredicates.isFieldNullable(schema, 'Todo', 'goof') + SchemaPredicates.isFieldNullable(schema, 'Todo', 'goof', undefined) ).toBeFalsy(); expect(console.warn).toBeCalledTimes(1); diff --git a/exchanges/graphcache/src/ast/schemaPredicates.ts b/exchanges/graphcache/src/ast/schemaPredicates.ts index 59abc37d09..b138bf959d 100644 --- a/exchanges/graphcache/src/ast/schemaPredicates.ts +++ b/exchanges/graphcache/src/ast/schemaPredicates.ts @@ -12,6 +12,7 @@ import type { UpdatesConfig, ResolverConfig, OptimisticMutationConfig, + Logger, } from '../types'; const BUILTIN_NAME = '__'; @@ -19,18 +20,20 @@ const BUILTIN_NAME = '__'; export const isFieldNullable = ( schema: SchemaIntrospector, typename: string, - fieldName: string + fieldName: string, + logger: Logger | undefined ): boolean => { - const field = getField(schema, typename, fieldName); + const field = getField(schema, typename, fieldName, logger); return !!field && field.type.kind !== 'NON_NULL'; }; export const isListNullable = ( schema: SchemaIntrospector, typename: string, - fieldName: string + fieldName: string, + logger: Logger | undefined ): boolean => { - const field = getField(schema, typename, fieldName); + const field = getField(schema, typename, fieldName, logger); if (!field) return false; const ofType = field.type.kind === 'NON_NULL' ? field.type.ofType : field.type; @@ -40,11 +43,12 @@ export const isListNullable = ( export const isFieldAvailableOnType = ( schema: SchemaIntrospector, typename: string, - fieldName: string + fieldName: string, + logger: Logger | undefined ): boolean => fieldName.indexOf(BUILTIN_NAME) === 0 || typename.indexOf(BUILTIN_NAME) === 0 || - !!getField(schema, typename, fieldName); + !!getField(schema, typename, fieldName, logger); export const isInterfaceOfType = ( schema: SchemaIntrospector, @@ -70,7 +74,8 @@ export const isInterfaceOfType = ( const getField = ( schema: SchemaIntrospector, typename: string, - fieldName: string + fieldName: string, + logger: Logger | undefined ) => { if ( fieldName.indexOf(BUILTIN_NAME) === 0 || @@ -90,7 +95,8 @@ const getField = ( '`, ' + 'but the GraphQL document expects it to exist.\n' + 'Traversal will continue, however this may lead to undefined behavior!', - 4 + 4, + logger ); } @@ -124,7 +130,8 @@ function expectAbstractType(schema: SchemaIntrospector, typename: string) { export function expectValidKeyingConfig( schema: SchemaIntrospector, - keys: KeyingConfig + keys: KeyingConfig, + logger: Logger | undefined ): void { if (process.env.NODE_ENV !== 'production') { for (const key in keys) { @@ -133,7 +140,8 @@ export function expectValidKeyingConfig( 'Invalid Object type: The type `' + key + '` is not an object in the defined schema, but the `keys` option is referencing it.', - 20 + 20, + logger ); } } @@ -142,7 +150,8 @@ export function expectValidKeyingConfig( export function expectValidUpdatesConfig( schema: SchemaIntrospector, - updates: UpdatesConfig + updates: UpdatesConfig, + logger: Logger | undefined ): void { if (process.env.NODE_ENV === 'production') { return; @@ -175,7 +184,8 @@ export function expectValidUpdatesConfig( typename + '` is not an object in the defined schema, but the `updates` config is referencing it.' + addition, - 21 + 21, + logger ); } @@ -188,35 +198,40 @@ export function expectValidUpdatesConfig( '` on `' + typename + '` is not in the defined schema, but the `updates` config is referencing it.', - 22 + 22, + logger ); } } } } -function warnAboutResolver(name: string): void { +function warnAboutResolver(name: string, logger: Logger | undefined): void { warn( `Invalid resolver: \`${name}\` is not in the defined schema, but the \`resolvers\` option is referencing it.`, - 23 + 23, + logger ); } function warnAboutAbstractResolver( name: string, - kind: 'UNION' | 'INTERFACE' + kind: 'UNION' | 'INTERFACE', + logger: Logger | undefined ): void { warn( `Invalid resolver: \`${name}\` does not match to a concrete type in the schema, but the \`resolvers\` option is referencing it. Implement the resolver for the types that ${ kind === 'UNION' ? 'make up the union' : 'implement the interface' } instead.`, - 26 + 26, + logger ); } export function expectValidResolversConfig( schema: SchemaIntrospector, - resolvers: ResolverConfig + resolvers: ResolverConfig, + logger: Logger | undefined ): void { if (process.env.NODE_ENV === 'production') { return; @@ -230,22 +245,23 @@ export function expectValidResolversConfig( ).fields(); for (const resolverQuery in resolvers.Query || {}) { if (!validQueries[resolverQuery]) { - warnAboutResolver('Query.' + resolverQuery); + warnAboutResolver('Query.' + resolverQuery, logger); } } } else { - warnAboutResolver('Query'); + warnAboutResolver('Query', logger); } } else { if (!schema.types!.has(key)) { - warnAboutResolver(key); + warnAboutResolver(key, logger); } else if ( schema.types!.get(key)!.kind === 'INTERFACE' || schema.types!.get(key)!.kind === 'UNION' ) { warnAboutAbstractResolver( key, - schema.types!.get(key)!.kind as 'INTERFACE' | 'UNION' + schema.types!.get(key)!.kind as 'INTERFACE' | 'UNION', + logger ); } else { const validTypeProperties = ( @@ -253,7 +269,7 @@ export function expectValidResolversConfig( ).fields(); for (const resolverProperty in resolvers[key] || {}) { if (!validTypeProperties[resolverProperty]) { - warnAboutResolver(key + '.' + resolverProperty); + warnAboutResolver(key + '.' + resolverProperty, logger); } } } @@ -263,7 +279,8 @@ export function expectValidResolversConfig( export function expectValidOptimisticMutationsConfig( schema: SchemaIntrospector, - optimisticMutations: OptimisticMutationConfig + optimisticMutations: OptimisticMutationConfig, + logger: Logger | undefined ): void { if (process.env.NODE_ENV === 'production') { return; @@ -277,7 +294,8 @@ export function expectValidOptimisticMutationsConfig( if (!validMutations[mutation]) { warn( `Invalid optimistic mutation field: \`${mutation}\` is not a mutation field in the defined schema, but the \`optimistic\` option is referencing it.`, - 24 + 24, + logger ); } } diff --git a/exchanges/graphcache/src/helpers/help.ts b/exchanges/graphcache/src/helpers/help.ts index 8668cdce5e..a38a79d379 100644 --- a/exchanges/graphcache/src/helpers/help.ts +++ b/exchanges/graphcache/src/helpers/help.ts @@ -7,6 +7,7 @@ import type { ExecutableDefinitionNode, InlineFragmentNode, } from '@0no-co/graphql.web'; +import type { Logger } from '../types'; import { Kind } from '@0no-co/graphql.web'; export type ErrorCode = @@ -89,9 +90,17 @@ export function invariant( } } -export function warn(message: string, code: ErrorCode) { +export function warn( + message: string, + code: ErrorCode, + logger: Logger | undefined +) { if (!cache.has(message)) { - console.warn(message + getDebugOutput() + helpUrl + code); + if (logger) { + logger('warn', message + getDebugOutput() + helpUrl + code); + } else { + console.warn(message + getDebugOutput() + helpUrl + code); + } cache.add(message); } } diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts index c0312c30d5..3c40e5a145 100644 --- a/exchanges/graphcache/src/operations/query.ts +++ b/exchanges/graphcache/src/operations/query.ts @@ -235,7 +235,8 @@ export const _queryFragment = ( ' but could only find ' + Object.keys(fragments).join(', ') + '.', - 6 + 6, + store.logger ); return null; @@ -247,7 +248,8 @@ export const _queryFragment = ( warn( 'readFragment(...) was called with an empty fragment.\n' + 'You have to call it with at least one fragment in your GraphQL document.', - 6 + 6, + store.logger ); return null; @@ -264,7 +266,8 @@ export const _queryFragment = ( 'You have to pass an `id` or `_id` field or create a custom `keys` config for `' + typename + '`.', - 7 + 7, + store.logger ); return null; @@ -327,7 +330,8 @@ function getFieldResolver( if (fieldResolver && directiveResolver) { warn( `A resolver and directive is being used at "${typename}.${fieldName}" simultaneously. Only the directive will apply.`, - 28 + 28, + ctx.store.logger ); } @@ -356,7 +360,8 @@ const readSelection = ( ctx.store.rootFields.subscription + '` types are special ' + 'Operation Root Types and cannot be read back from the cache.', - 25 + 25, + store.logger ); } @@ -373,7 +378,8 @@ const readSelection = ( entityKey + '` returned an ' + 'invalid typename that could not be reconciled with the cache.', - 8 + 8, + store.logger ); return; @@ -406,7 +412,12 @@ const readSelection = ( const resultValue = result ? result[fieldName] : undefined; if (process.env.NODE_ENV !== 'production' && store.schema && typename) { - isFieldAvailableOnType(store.schema, typename, fieldName); + isFieldAvailableOnType( + store.schema, + typename, + fieldName, + ctx.store.logger + ); } // Add the current alias to the walked path before processing the field's value @@ -466,7 +477,7 @@ const readSelection = ( if ( store.schema && dataFieldValue === null && - !isFieldNullable(store.schema, typename, fieldName) + !isFieldNullable(store.schema, typename, fieldName, ctx.store.logger) ) { // Special case for when null is not a valid value for the // current field @@ -519,7 +530,8 @@ const readSelection = ( dataFieldValue === undefined && (directives.optional || !!getFieldError(ctx) || - (store.schema && isFieldNullable(store.schema, typename, fieldName))) + (store.schema && + isFieldNullable(store.schema, typename, fieldName, ctx.store.logger))) ) { // The field is uncached or has errored, so it'll be set to null and skipped ctx.partial = true; @@ -570,7 +582,7 @@ const resolveResolverResult = ( // Check whether values of the list may be null; for resolvers we assume // that they can be, since it's user-provided data const _isListNullable = store.schema - ? isListNullable(store.schema, typename, fieldName) + ? isListNullable(store.schema, typename, fieldName, ctx.store.logger) : false; const hasPartials = ctx.partial; const data = InMemoryData.makeData(prevData, true); @@ -622,7 +634,8 @@ const resolveResolverResult = ( key + '` is a scalar (number, boolean, etc)' + ', but the GraphQL query expects a selection set for this field.', - 9 + 9, + ctx.store.logger ); return undefined; @@ -641,7 +654,7 @@ const resolveLink = ( if (Array.isArray(link)) { const { store } = ctx; const _isListNullable = store.schema - ? isListNullable(store.schema, typename, fieldName) + ? isListNullable(store.schema, typename, fieldName, ctx.store.logger) : false; const newLink = InMemoryData.makeData(prevData, true); const hasPartials = ctx.partial; diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts index 28e5aa1bb6..3c3d1b6a6b 100644 --- a/exchanges/graphcache/src/operations/shared.ts +++ b/exchanges/graphcache/src/operations/shared.ts @@ -25,6 +25,7 @@ import type { Link, Entity, Data, + Logger, } from '../types'; export interface Context { @@ -117,7 +118,8 @@ const isFragmentHeuristicallyMatching = ( node: FormattedNode, typename: void | string, entityKey: string, - vars: Variables + vars: Variables, + logger?: Logger ) => { if (!typename) return false; const typeCondition = getTypeCondition(node); @@ -134,7 +136,8 @@ const isFragmentHeuristicallyMatching = ( '` may be an ' + 'interface.\nA schema needs to be defined for this match to be deterministic, ' + 'otherwise the fragment will be matched heuristically!', - 16 + 16, + logger ); return ( @@ -192,7 +195,8 @@ export const makeSelectionIterator = ( fragment, typename, entityKey, - ctx.variables + ctx.variables, + ctx.store.logger )); if (isMatching) { if (process.env.NODE_ENV !== 'production') @@ -234,7 +238,8 @@ export const ensureLink = (store: Store, ref: Link): Link => { '\nYou have to pass an `id` or `_id` field or create a custom `keys` config for `' + ref.__typename + '`.', - 12 + 12, + store.logger ); } diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts index eaa61c9f1f..66f547dc98 100644 --- a/exchanges/graphcache/src/operations/write.ts +++ b/exchanges/graphcache/src/operations/write.ts @@ -147,7 +147,8 @@ export const _writeFragment = ( ' but could only find ' + Object.keys(fragments).join(', ') + '.', - 11 + 11, + store.logger ); return null; @@ -159,7 +160,8 @@ export const _writeFragment = ( warn( 'writeFragment(...) was called with an empty fragment.\n' + 'You have to call it with at least one fragment in your GraphQL document.', - 11 + 11, + store.logger ); return null; @@ -175,7 +177,8 @@ export const _writeFragment = ( 'You have to pass an `id` or `_id` field or create a custom `keys` config for `' + typename + '`.', - 12 + 12, + store.logger ); } @@ -223,7 +226,8 @@ const writeSelection = ( warn( "Couldn't find __typename when writing.\n" + "If you're writing to the cache manually have to pass a `__typename` property on each entity in your data.", - 14 + 14, + ctx.store.logger ); return; } else if (!isRoot && entityKey) { @@ -260,7 +264,12 @@ const writeSelection = ( if (process.env.NODE_ENV !== 'production') { if (ctx.store.schema && typename && fieldName !== '__typename') { - isFieldAvailableOnType(ctx.store.schema, typename, fieldName); + isFieldAvailableOnType( + ctx.store.schema, + typename, + fieldName, + ctx.store.logger + ); } } @@ -309,7 +318,8 @@ const writeSelection = ( '` is `undefined`, but the GraphQL query expects a ' + expected + ' for this field.', - 13 + 13, + ctx.store.logger ); } } @@ -426,7 +436,8 @@ const writeField = ( 'If this is intentional, create a `keys` config for `' + typename + '` that always returns null.', - 15 + 15, + ctx.store.logger ); } diff --git a/exchanges/graphcache/src/store/store.ts b/exchanges/graphcache/src/store/store.ts index e6115d824d..c7d26b7575 100644 --- a/exchanges/graphcache/src/store/store.ts +++ b/exchanges/graphcache/src/store/store.ts @@ -17,6 +17,7 @@ import type { Entity, CacheExchangeOpts, DirectivesConfig, + Logger, } from '../types'; import { invariant } from '../helpers/help'; @@ -48,6 +49,7 @@ export class Store< { data: InMemoryData.InMemoryData; + logger?: Logger; directives: DirectivesConfig; resolvers: ResolverConfig; updates: UpdatesConfig; @@ -62,6 +64,7 @@ export class Store< constructor(opts?: C) { if (!opts) opts = {} as C; + this.logger = opts.logger; this.resolvers = opts.resolvers || {}; this.directives = opts.directives || {}; this.optimisticMutations = opts.optimistic || {}; @@ -100,12 +103,13 @@ export class Store< this.data = InMemoryData.make(queryName); if (this.schema && process.env.NODE_ENV !== 'production') { - expectValidKeyingConfig(this.schema, this.keys); - expectValidUpdatesConfig(this.schema, this.updates); - expectValidResolversConfig(this.schema, this.resolvers); + expectValidKeyingConfig(this.schema, this.keys, this.logger); + expectValidUpdatesConfig(this.schema, this.updates, this.logger); + expectValidResolversConfig(this.schema, this.resolvers, this.logger); expectValidOptimisticMutationsConfig( this.schema, - this.optimisticMutations + this.optimisticMutations, + this.logger ); } } diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts index e98b4d21b3..b16255e164 100644 --- a/exchanges/graphcache/src/types.ts +++ b/exchanges/graphcache/src/types.ts @@ -541,8 +541,21 @@ export type ResolverResult = | null | undefined; +export type Logger = ( + severity: 'debug' | 'error' | 'warn', + message: string +) => void; + /** Input parameters for the {@link cacheExchange}. */ export type CacheExchangeOpts = { + /** Configure a custom-logger for graphcache, this function wll be called with a severity and a message. + * + * @remarks + * By default we will invoke `console.warn` for warnings during development, however you might want to opt + * out of this because you are re-using urql for a different library. This setting allows you to stub the logger + * function or filter to only logs you want. + */ + logger?: Logger; /** Configures update functions which are called when the mapped fields are written to the cache. * * @remarks