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

feat(graphcache): track types in the data-structure (#3501)

Co-authored-by: Phil Pluckthun <phil@kitten.sh>

Changed files
+103 -11
.changeset
docs
graphcache
exchanges
+5
.changeset/tall-buttons-fetch.md
···
+
---
+
'@urql/exchange-graphcache': minor
+
---
+
+
Track list of entity keys for a given type name. This enables enumerating and invalidating all entities of a given type within the normalized cache.
+19
docs/graphcache/cache-updates.md
···
mutation by enumerating all `todos` listing fields using `cache.inspectFields` and targetedly
invalidate only these fields, which causes all queries using these listing fields to be refetched.
+
### Invalidating a type
+
+
We can also invalidate all the entities of a given type, this could be handy in the case of a
+
list update or when you aren't sure what entity is affected.
+
+
This can be done by only passing the relevant `__typename` to the `invalidate` function.
+
+
```js
+
cacheExchange({
+
updates: {
+
Mutation: {
+
deleteTodo(_result, args, cache, _info) {
+
cache.invalidate('Todo');
+
},
+
},
+
},
+
});
+
```
+
## Optimistic updates
If we know what result a mutation may return, why wait for the GraphQL API to fulfill our mutations?
+7
exchanges/graphcache/src/operations/invalidate.ts
···
}
}
};
+
+
export const invalidateType = (typename: string) => {
+
const types = InMemoryData.getEntitiesForType(typename);
+
for (const entity of types) {
+
invalidateEntity(entity);
+
}
+
};
+1
exchanges/graphcache/src/operations/write.ts
···
return;
} else if (!isRoot && entityKey) {
InMemoryData.writeRecord(entityKey, '__typename', typename);
+
InMemoryData.writeType(typename, entityKey);
}
const updates = ctx.store.updates[typename];
+19
exchanges/graphcache/src/store/data.test.ts
···
InMemoryData.writeRecord('Todo:2', '__typename', 'Todo');
InMemoryData.writeRecord('Query', '__typename', 'Query');
InMemoryData.writeLink('Query', 'todo', 'Todo:1');
+
InMemoryData.writeType('Todo', 'Todo:1');
InMemoryData.gc();
expect(InMemoryData.readLink('Query', 'todo')).toBe('Todo:1');
+
expect(InMemoryData.getEntitiesForType('Todo')).toEqual(
+
new Set(['Todo:1'])
+
);
InMemoryData.writeLink('Query', 'todo', undefined);
InMemoryData.gc();
expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined);
expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
+
expect(InMemoryData.getEntitiesForType('Todo')).toEqual(new Set());
expect(InMemoryData.getCurrentDependencies()).toEqual(
new Set(['Todo:1', 'Todo:2', 'Query.todo'])
···
InMemoryData.writeLink('Query', 'todo', 'Todo:1');
InMemoryData.writeLink('Query', 'todo', undefined);
InMemoryData.writeLink('Query', 'newTodo', 'Todo:1');
+
InMemoryData.writeType('Todo', 'Todo:1');
InMemoryData.gc();
expect(InMemoryData.readLink('Query', 'newTodo')).toBe('Todo:1');
expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined);
expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1');
+
expect(InMemoryData.getEntitiesForType('Todo')).toEqual(
+
new Set(['Todo:1'])
+
);
expect(InMemoryData.getCurrentDependencies()).toEqual(
new Set(['Todo:1', 'Query.todo', 'Query.newTodo'])
···
InMemoryData.writeRecord('Todo:1', '__typename', 'Todo');
InMemoryData.writeRecord('Todo:1', 'id', '1');
InMemoryData.writeLink('Query', 'todo', 'Todo:1');
+
InMemoryData.writeType('Todo', 'Todo:1');
+
InMemoryData.writeType('Author', 'Author:1');
InMemoryData.writeLink('Query', 'todo', undefined);
+
expect(InMemoryData.getEntitiesForType('Todo')).toEqual(
+
new Set(['Todo:1'])
+
);
+
expect(InMemoryData.getEntitiesForType('Author')).toEqual(
+
new Set(['Author:1'])
+
);
InMemoryData.gc();
expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
expect(InMemoryData.readRecord('Author:1', 'id')).toBe(undefined);
+
expect(InMemoryData.getEntitiesForType('Todo')).toEqual(new Set());
+
expect(InMemoryData.getEntitiesForType('Author')).toEqual(new Set());
expect(InMemoryData.getCurrentDependencies()).toEqual(
new Set(['Author:1', 'Todo:1', 'Query.todo'])
+26
exchanges/graphcache/src/store/data.ts
···
records: NodeMap<EntityField>;
/** A map of entity links which are connections from one entity to another (key-value entries per entity) */
links: NodeMap<Link>;
+
/** A map of typename to a list of entity-keys belonging to said type */
+
types: Map<string, Set<string>>;
/** A set of Query operation keys that are in-flight and deferred/streamed */
deferredKeys: Set<number>;
/** A set of Query operation keys that are in-flight and awaiting a result */
···
return currentDependencies;
};
+
const DEFAULT_EMPTY_SET = new Set<string>();
export const make = (queryRootKey: string): InMemoryData => ({
hydrating: false,
defer: false,
gc: new Set(),
+
types: new Map(),
persist: new Set(),
queryRootKey,
refCount: new Map(),
···
const rc = currentData!.refCount.get(entityKey) || 0;
if (rc > 0) continue;
+
const record = currentData!.records.base.get(entityKey);
// Delete the reference count, and delete the entity from the GC batch
currentData!.refCount.delete(entityKey);
currentData!.records.base.delete(entityKey);
+
+
const typename = (record && record.__typename) as string | undefined;
+
if (typename) {
+
const type = currentData!.types.get(typename);
+
if (type) type.delete(entityKey);
+
}
+
const linkNode = currentData!.links.base.get(entityKey);
if (linkNode) {
currentData!.links.base.delete(entityKey);
···
): Link | undefined => {
updateDependencies(entityKey, fieldKey);
return getNode(currentData!.links, entityKey, fieldKey);
+
};
+
+
export const getEntitiesForType = (typename: string): Set<string> =>
+
currentData!.types.get(typename) || DEFAULT_EMPTY_SET;
+
+
export const writeType = (typename: string, entityKey: string) => {
+
const existingTypes = currentData!.types.get(typename);
+
if (!existingTypes) {
+
const typeSet = new Set<string>();
+
typeSet.add(entityKey);
+
currentData!.types.set(typename, typeSet);
+
} else {
+
existingTypes.add(entityKey);
+
}
};
/** Writes an entity's field (a "record") to data */
+9
exchanges/graphcache/src/store/store.test.ts
···
expect(data).toBe(null);
});
});
+
+
describe('Invalidating a type', () => {
+
it('removes an entity from a list.', () => {
+
InMemoryData.initDataState('write', store.data, null);
+
store.invalidate('Todo');
+
const { data } = query(store, { query: Todos });
+
expect(data).toBe(null);
+
});
+
});
});
describe('Store with storage', () => {
+17 -11
exchanges/graphcache/src/store/store.ts
···
import { contextRef, ensureLink } from '../operations/shared';
import { _query, _queryFragment } from '../operations/query';
import { _write, _writeFragment } from '../operations/write';
-
import { invalidateEntity } from '../operations/invalidate';
+
import { invalidateEntity, invalidateType } from '../operations/invalidate';
import { keyOfField } from './keys';
import * as InMemoryData from './data';
···
invalidate(entity: Entity, field?: string, args?: FieldArgs) {
const entityKey = this.keyOfEntity(entity);
+
const shouldInvalidateType =
+
entity && typeof entity === 'string' && !field && !args;
-
invariant(
-
entityKey,
-
"Can't generate a key for invalidate(...).\n" +
-
'You have to pass an id or _id field or create a custom `keys` field for `' +
-
(typeof entity === 'object'
-
? (entity as Data).__typename
-
: entity + '`.'),
-
19
-
);
+
if (shouldInvalidateType) {
+
invalidateType(entity);
+
} else {
+
invariant(
+
entityKey,
+
"Can't generate a key for invalidate(...).\n" +
+
'You have to pass an id or _id field or create a custom `keys` field for `' +
+
(typeof entity === 'object'
+
? (entity as Data).__typename
+
: entity + '`.'),
+
19
+
);
-
invalidateEntity(entityKey, field, args);
+
invalidateEntity(entityKey, field, args);
+
}
}
inspectFields(entity: Entity): FieldInfo[] {