Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

feat(graphcache): add logger interface (#3444)

Changed files
+143 -59
.changeset
docs
exchanges
+5
.changeset/tender-files-tie.md
···
+
---
+
'@urql/exchange-graphcache': minor
+
---
+
+
Add optional `logger` to the options, this allows you to filter out warnings or disable them all together
+1
docs/api/graphcache.md
···
| `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.
+10 -5
exchanges/graphcache/src/ast/schemaPredicates.test.ts
···
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();
});
···
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'
);
···
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);
+43 -25
exchanges/graphcache/src/ast/schemaPredicates.ts
···
UpdatesConfig,
ResolverConfig,
OptimisticMutationConfig,
+
Logger,
} from '../types';
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;
···
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,
···
const getField = (
schema: SchemaIntrospector,
typename: string,
-
fieldName: string
+
fieldName: string,
+
logger: Logger | undefined
) => {
if (
fieldName.indexOf(BUILTIN_NAME) === 0 ||
···
'`, ' +
'but the GraphQL document expects it to exist.\n' +
'Traversal will continue, however this may lead to undefined behavior!',
-
4
+
4,
+
logger
);
}
···
export function expectValidKeyingConfig(
schema: SchemaIntrospector,
-
keys: KeyingConfig
+
keys: KeyingConfig,
+
logger: Logger | undefined
): void {
if (process.env.NODE_ENV !== 'production') {
for (const key in keys) {
···
'Invalid Object type: The type `' +
key +
'` is not an object in the defined schema, but the `keys` option is referencing it.',
-
20
+
20,
+
logger
);
}
}
···
export function expectValidUpdatesConfig(
schema: SchemaIntrospector,
-
updates: UpdatesConfig
+
updates: UpdatesConfig,
+
logger: Logger | undefined
): void {
if (process.env.NODE_ENV === 'production') {
return;
···
typename +
'` is not an object in the defined schema, but the `updates` config is referencing it.' +
addition,
-
21
+
21,
+
logger
);
}
···
'` 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;
···
).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 = (
···
).fields();
for (const resolverProperty in resolvers[key] || {}) {
if (!validTypeProperties[resolverProperty]) {
-
warnAboutResolver(key + '.' + resolverProperty);
+
warnAboutResolver(key + '.' + resolverProperty, logger);
}
}
}
···
export function expectValidOptimisticMutationsConfig(
schema: SchemaIntrospector,
-
optimisticMutations: OptimisticMutationConfig
+
optimisticMutations: OptimisticMutationConfig,
+
logger: Logger | undefined
): void {
if (process.env.NODE_ENV === 'production') {
return;
···
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
);
}
}
+11 -2
exchanges/graphcache/src/helpers/help.ts
···
ExecutableDefinitionNode,
InlineFragmentNode,
} from '@0no-co/graphql.web';
+
import type { Logger } from '../types';
import { Kind } from '@0no-co/graphql.web';
export type ErrorCode =
···
}
}
-
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);
}
}
+25 -12
exchanges/graphcache/src/operations/query.ts
···
' but could only find ' +
Object.keys(fragments).join(', ') +
'.',
-
6
+
6,
+
store.logger
);
return null;
···
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;
···
'You have to pass an `id` or `_id` field or create a custom `keys` config for `' +
typename +
'`.',
-
7
+
7,
+
store.logger
);
return null;
···
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
);
}
···
ctx.store.rootFields.subscription +
'` types are special ' +
'Operation Root Types and cannot be read back from the cache.',
-
25
+
25,
+
store.logger
);
}
···
entityKey +
'` returned an ' +
'invalid typename that could not be reconciled with the cache.',
-
8
+
8,
+
store.logger
);
return;
···
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
···
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
···
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;
···
// 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);
···
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;
···
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;
+9 -4
exchanges/graphcache/src/operations/shared.ts
···
Link,
Entity,
Data,
+
Logger,
} from '../types';
export interface Context {
···
node: FormattedNode<InlineFragmentNode | FragmentDefinitionNode>,
typename: void | string,
entityKey: string,
-
vars: Variables
+
vars: Variables,
+
logger?: Logger
) => {
if (!typename) return false;
const typeCondition = getTypeCondition(node);
···
'` 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 (
···
fragment,
typename,
entityKey,
-
ctx.variables
+
ctx.variables,
+
ctx.store.logger
));
if (isMatching) {
if (process.env.NODE_ENV !== 'production')
···
'\nYou have to pass an `id` or `_id` field or create a custom `keys` config for `' +
ref.__typename +
'`.',
-
12
+
12,
+
store.logger
);
}
+18 -7
exchanges/graphcache/src/operations/write.ts
···
' but could only find ' +
Object.keys(fragments).join(', ') +
'.',
-
11
+
11,
+
store.logger
);
return null;
···
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;
···
'You have to pass an `id` or `_id` field or create a custom `keys` config for `' +
typename +
'`.',
-
12
+
12,
+
store.logger
);
}
···
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) {
···
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
+
);
}
}
···
'` is `undefined`, but the GraphQL query expects a ' +
expected +
' for this field.',
-
13
+
13,
+
ctx.store.logger
);
}
}
···
'If this is intentional, create a `keys` config for `' +
typename +
'` that always returns null.',
-
15
+
15,
+
ctx.store.logger
);
}
+8 -4
exchanges/graphcache/src/store/store.ts
···
Entity,
CacheExchangeOpts,
DirectivesConfig,
+
Logger,
} from '../types';
import { invariant } from '../helpers/help';
···
{
data: InMemoryData.InMemoryData;
+
logger?: Logger;
directives: DirectivesConfig;
resolvers: ResolverConfig;
updates: UpdatesConfig;
···
constructor(opts?: C) {
if (!opts) opts = {} as C;
+
this.logger = opts.logger;
this.resolvers = opts.resolvers || {};
this.directives = opts.directives || {};
this.optimisticMutations = opts.optimistic || {};
···
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
);
}
}
+13
exchanges/graphcache/src/types.ts
···
| 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