Repository: urql-graphql/urql
Pull request #3501 (2024-02-03 09:48:05)

feat(graphcache): track types in the data-structure
Related to #3470

## Summary

This starts tracking a mapping of `__typename` to a list of `entityKey`, this enables us to perform operations like `invalidate('Todo')` which would invalidate every `entityKey` of the `Todo` type.

We add non-root typenames when going through the `write` operation and we clean up entities in the list when we are going through `gc` runs.

In the future this can instruct us for operations where we can't derive an entity i.e. a create-mutation will invalidate all entities of its own type to ensure we don't run into stale values which leaves us with the scalar return case which won't automatically be handled.

The PR explicitly does not add the `updater` logic yet so we can see that the explicit API surface does not grow, we support more cases instead.

## Set of changes

- Add Map of `__typename` to a list of keys
- Add support for `invalidate('Type')` and `inavlidate({ __typename: 'Type' })`

## Questions

Should we support invalidating a full type with field and arguments i.e. `invalidate('Todo', 'author')` would go through every `Todo` key and explicitly invalidate the `Author` field.

## Follow-ups

- After this PR we can provide logic where we check the presence of the entity in cahce when there's no updater. When we can't find the entity we perform `invalidate({__typename})`

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 7524deb75044d4157e95816b58d912f541bd3ad4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/urql-graphql/urql/new/track-types?filename=.changeset/seven-maps-wash.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0Afeat(graphcache)%3A%20track%20types%20in%20the%20data-structure%0A)



Patch:
diff --git a/.changeset/tall-buttons-fetch.md b/.changeset/tall-buttons-fetch.md
new file mode 100644
index 00000000..2946d9be
--- /dev/null
+++ b/.changeset/tall-buttons-fetch.md
@@ -0,0 +1,5 @@
+---
+'@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.
diff --git a/.changeset/wicked-kids-joke.md b/.changeset/wicked-kids-joke.md
new file mode 100644
index 00000000..ab01de50
--- /dev/null
+++ b/.changeset/wicked-kids-joke.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Fix `store.resolve()` returning the exact link array that’s used by the cache. This can lead to subtle bugs when a user mutates the result returned by `cache.resolve()`, since this directly mutates what’s in the cache at that layer.
diff --git a/docs/graphcache/cache-updates.md b/docs/graphcache/cache-updates.md
index 464b5004..174bb4cf 100644
--- a/docs/graphcache/cache-updates.md
+++ b/docs/graphcache/cache-updates.md
@@ -490,6 +490,25 @@ In this example we've attached an updater to a `Mutation.updateTodo` field. We r
 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?
diff --git a/exchanges/graphcache/src/operations/invalidate.ts b/exchanges/graphcache/src/operations/invalidate.ts
index 22ca5b2c..02497b12 100644
--- a/exchanges/graphcache/src/operations/invalidate.ts
+++ b/exchanges/graphcache/src/operations/invalidate.ts
@@ -24,3 +24,10 @@ export const invalidateEntity = (
     }
   }
 };
+
+export const invalidateType = (typename: string) => {
+  const types = InMemoryData.getEntitiesForType(typename);
+  for (const entity of types) {
+    invalidateEntity(entity);
+  }
+};
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index 66f547dc..ed3b4539 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -232,6 +232,7 @@ const writeSelection = (
     return;
   } else if (!isRoot && entityKey) {
     InMemoryData.writeRecord(entityKey, '__typename', typename);
+    InMemoryData.writeType(typename, entityKey);
   }
 
   const updates = ctx.store.updates[typename];
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index 174610a1..99881ff8 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -47,6 +47,8 @@ export interface InMemoryData {
   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 */
@@ -234,10 +236,12 @@ export const getCurrentDependencies = (): Dependencies => {
   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(),
@@ -409,9 +413,17 @@ export const gc = () => {
     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);
@@ -452,6 +464,20 @@ export const readLink = (
   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 */
 export const writeRecord = (
   entityKey: string,
diff --git a/exchanges/graphcache/src/store/store.ts b/exchanges/graphcache/src/store/store.ts
index c7d26b75..92c5e050 100644
--- a/exchanges/graphcache/src/store/store.ts
+++ b/exchanges/graphcache/src/store/store.ts
@@ -24,7 +24,7 @@ import { invariant } from '../helpers/help';
 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';
 
@@ -153,15 +153,15 @@ export class Store<
     field: string,
     args?: FieldArgs
   ): DataField | undefined {
-    let fieldValue: DataField | undefined = null;
     const entityKey = this.keyOfEntity(entity);
     if (entityKey) {
       const fieldKey = keyOfField(field, args);
-      fieldValue = InMemoryData.readRecord(entityKey, fieldKey);
-      if (fieldValue === undefined)
-        fieldValue = InMemoryData.readLink(entityKey, fieldKey);
+      const fieldValue = InMemoryData.readRecord(entityKey, fieldKey);
+      if (fieldValue !== undefined) return fieldValue;
+      let fieldLink = InMemoryData.readLink(entityKey, fieldKey);
+      if (fieldLink !== undefined) fieldLink = ensureLink(this, fieldLink);
+      return fieldLink;
     }
-    return fieldValue;
   }
 
   resolveFieldByKey(entity: Entity, field: string, args?: FieldArgs) {
@@ -170,18 +170,24 @@ export class Store<
 
   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[] {


Test patch:
diff --git a/exchanges/graphcache/src/store/data.test.ts b/exchanges/graphcache/src/store/data.test.ts
index 03f5441e..a7dcad93 100644
--- a/exchanges/graphcache/src/store/data.test.ts
+++ b/exchanges/graphcache/src/store/data.test.ts
@@ -16,16 +16,21 @@ describe('garbage collection', () => {
     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'])
@@ -39,12 +44,16 @@ describe('garbage collection', () => {
     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'])
@@ -101,12 +110,22 @@ describe('garbage collection', () => {
     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'])
diff --git a/exchanges/graphcache/src/store/store.test.ts b/exchanges/graphcache/src/store/store.test.ts
index 40222efe..f73793ae 100644
--- a/exchanges/graphcache/src/store/store.test.ts
+++ b/exchanges/graphcache/src/store/store.test.ts
@@ -844,6 +844,15 @@ describe('Store with OptimisticMutationConfig', () => {
       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', () => {