Repository: FormidableLabs/urql
Pull request #674 (2020-03-30 15:34:56)

Implement new persistence logic compatible with commutativity
## Summary

This contains a stable implementation of persistence that combines its run with the garbage collection run and takes commutativity into account using a new approach that squashes layers on-the-fly.

## Set of changes

- [x] Refactor to remove the old persistence logic
- [x] Refactor GC code to use data state
- [x] Add joined key batch to flag persistence
- [x] Implement new persistence deferred run next to `gc`
- [x] Implement new rehydration logic


changeset-bot[bot]: ###  💥  No Changeset

Latest commit: 310997b775cd46e6a2d00639b0d32fb9b1c810b0

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, 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/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/feat/persistence-stable?filename=.changeset/fifty-jobs-accept.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0AWIP%3A%20Implement%20new%20persistence%20logic%0A)



Patch:
diff --git a/.changeset/sweet-ghosts-share.md b/.changeset/sweet-ghosts-share.md
new file mode 100644
index 00000000..e7095411
--- /dev/null
+++ b/.changeset/sweet-ghosts-share.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Reimplement persistence support to take commutative layers into account.
diff --git a/exchanges/graphcache/benchmark/suite.js b/exchanges/graphcache/benchmark/suite.js
index 461802b4..0ba64f12 100644
--- a/exchanges/graphcache/benchmark/suite.js
+++ b/exchanges/graphcache/benchmark/suite.js
@@ -1,6 +1,6 @@
 const gql = require('graphql-tag');
 const { InMemoryCache } = require('@apollo/client');
-const { Store, write, query } = require('../dist/urql-exchange-graphcache.cjs.min');
+const { Store, write, query } = require('../dist/urql-exchange-graphcache.min');
 
 const countries = ['UK', 'BE', 'ES', 'US'];
 
diff --git a/exchanges/graphcache/src/operations/invalidate.ts b/exchanges/graphcache/src/operations/invalidate.ts
index 26767dc3..b88d9c43 100644
--- a/exchanges/graphcache/src/operations/invalidate.ts
+++ b/exchanges/graphcache/src/operations/invalidate.ts
@@ -3,24 +3,6 @@ import { Variables, OperationRequest } from '../types';
 import { Store, keyOfField } from '../store';
 import { read } from './query';
 
-export const invalidate = (store: Store, request: OperationRequest) => {
-  const dependencies = InMemoryData.forkDependencies();
-  read(store, request);
-
-  dependencies.forEach(dependency => {
-    if (dependency.startsWith(`${store.data.queryRootKey}.`)) {
-      const fieldKey = dependency.slice(`${store.data.queryRootKey}.`.length);
-      InMemoryData.writeLink(store.data.queryRootKey, fieldKey);
-      InMemoryData.writeRecord(store.data.queryRootKey, fieldKey);
-    } else {
-      store.invalidate(dependency);
-    }
-  });
-
-  InMemoryData.unforkDependencies();
-  InMemoryData.gc(store.data);
-};
-
 interface PartialFieldInfo {
   fieldKey: string;
 }
@@ -43,3 +25,21 @@ export const invalidateEntity = (
     }
   }
 };
+
+export const invalidate = (store: Store, request: OperationRequest) => {
+  const dependencies = InMemoryData.forkDependencies();
+  read(store, request);
+  InMemoryData.unforkDependencies();
+
+  dependencies.forEach(dependency => {
+    if (dependency.startsWith(`${store.data.queryRootKey}.`)) {
+      const fieldKey = dependency.slice(`${store.data.queryRootKey}.`.length);
+      InMemoryData.writeLink(store.data.queryRootKey, fieldKey);
+      InMemoryData.writeRecord(store.data.queryRootKey, fieldKey);
+    } else {
+      invalidateEntity(dependency);
+    }
+  });
+
+  InMemoryData.gc();
+};
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index 1e776f0c..aac6a40d 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -1,3 +1,5 @@
+import { stringifyVariables } from '@urql/core';
+
 import {
   Link,
   EntityField,
@@ -6,10 +8,16 @@ import {
   SerializedEntries,
 } from '../types';
 
+import {
+  fieldInfoOfKey,
+  joinKeys,
+  serializeKeys,
+  indexOfSeparator,
+} from './keys';
+
 import { makeDict } from '../helpers/dict';
 import { invariant, currentDebugStack } from '../helpers/help';
-import { fieldInfoOfKey, joinKeys, prefixKey } from './keys';
-import { defer } from './timing';
+import { scheduleTask } from './defer';
 
 type Dict<T> = Record<string, T>;
 type KeyMap<T> = Map<string, T>;
@@ -21,14 +29,12 @@ interface NodeMap<T> {
 }
 
 export interface InMemoryData {
-  /** Ensure persistence is never scheduled twice at a time */
-  persistenceScheduled: boolean;
-  /** Batches changes to the data layer that'll be written to `storage` */
-  persistenceBatch: SerializedEntries;
-  /** Ensure garbage collection is never scheduled twice at a time */
-  gcScheduled: boolean;
+  /** Flag for whether deferred tasks have been scheduled yet */
+  defer: boolean;
   /** A list of entities that have been flagged for gargabe collection since no references to them are left */
-  gcBatch: Set<string>;
+  gc: Set<string>;
+  /** A list of entity+field keys that will be persisted */
+  persist: Set<string>;
   /** The API's "Query" typename which is needed to filter dependencies */
   queryRootKey: string;
   /** Number of references to each entity (except "Query") */
@@ -51,6 +57,7 @@ let currentData: null | InMemoryData = null;
 let currentDependencies: null | Set<string> = null;
 let previousDependencies: null | Set<string> = null;
 let currentOptimisticKey: null | number = null;
+let currentIgnoreOptimistic = false;
 
 const makeNodeMap = <T>(): NodeMap<T> => ({
   optimistic: makeDict(),
@@ -66,6 +73,7 @@ export const initDataState = (
   currentData = data;
   previousDependencies = currentDependencies;
   currentDependencies = new Set();
+  currentIgnoreOptimistic = false;
   if (process.env.NODE_ENV !== 'production') {
     currentDebugStack.length = 0;
   }
@@ -99,6 +107,11 @@ export const initDataState = (
 
 /** Reset the data state after read/write is complete */
 export const clearDataState = () => {
+  // NOTE: This is only called to check for the invariant to pass
+  if (process.env.NODE_ENV !== 'production') {
+    getCurrentDependencies();
+  }
+
   const data = currentData!;
   const layerKey = currentOptimisticKey;
   currentOptimisticKey = null;
@@ -117,27 +130,23 @@ export const clearDataState = () => {
     }
   }
 
+  // Schedule deferred tasks if we haven't already
+  if (process.env.NODE_ENV !== 'test' && !data.defer) {
+    data.defer = true;
+    scheduleTask(() => {
+      initDataState(data, null);
+      gc();
+      persistData();
+      clearDataState();
+      data.defer = false;
+    });
+  }
+
   currentData = null;
   currentDependencies = null;
   if (process.env.NODE_ENV !== 'production') {
     currentDebugStack.length = 0;
   }
-
-  if (!data.gcScheduled && data.gcBatch.size > 0) {
-    data.gcScheduled = true;
-    defer(() => {
-      gc(data);
-    });
-  }
-
-  if (data.storage && !data.persistenceScheduled) {
-    data.persistenceScheduled = true;
-    defer(() => {
-      data.storage!.write(data.persistenceBatch);
-      data.persistenceScheduled = false;
-      data.persistenceBatch = makeDict();
-    });
-  }
 };
 
 /** Initialises then resets the data state, which may squash this layer if necessary */
@@ -174,11 +183,10 @@ export const unforkDependencies = () => {
 };
 
 export const make = (queryRootKey: string): InMemoryData => ({
-  persistenceScheduled: false,
-  persistenceBatch: makeDict(),
-  gcScheduled: false,
+  defer: false,
+  gc: new Set(),
+  persist: new Set(),
   queryRootKey,
-  gcBatch: new Set(),
   refCount: makeDict(),
   refLock: makeDict(),
   links: makeNodeMap(),
@@ -227,10 +235,13 @@ const getNode = <T>(
 
   // This first iterates over optimistic layers (in order)
   for (let i = 0, l = currentData!.optimisticOrder.length; i < l; i++) {
-    const optimistic = map.optimistic[currentData!.optimisticOrder[i]];
+    const layerKey = currentData!.optimisticOrder[i];
+    const optimistic = map.optimistic[layerKey];
     // If the node and node value exists it is returned, including undefined
     if (
       optimistic &&
+      (!currentIgnoreOptimistic ||
+        currentData!.commutativeKeys.has(layerKey)) &&
       (node = optimistic.get(entityKey)) !== undefined &&
       fieldKey in node
     ) {
@@ -243,9 +254,9 @@ const getNode = <T>(
   return node !== undefined ? node[fieldKey] : undefined;
 };
 
-/** Adjusts the reference count of an entity on a refCount dict by "by" and updates the gcBatch */
+/** Adjusts the reference count of an entity on a refCount dict by "by" and updates the gc */
 const updateRCForEntity = (
-  gcBatch: void | Set<string>,
+  gc: void | Set<string>,
   refCount: Dict<number>,
   entityKey: string,
   by: number
@@ -256,26 +267,26 @@ const updateRCForEntity = (
   const newCount = (refCount[entityKey] = (count + by) | 0);
   // Add it to the garbage collection batch if it needs to be deleted or remove it
   // from the batch if it needs to be kept
-  if (gcBatch !== undefined) {
-    if (newCount <= 0) gcBatch.add(entityKey);
-    else if (count <= 0 && newCount > 0) gcBatch.delete(entityKey);
+  if (gc !== undefined) {
+    if (newCount <= 0) gc.add(entityKey);
+    else if (count <= 0 && newCount > 0) gc.delete(entityKey);
   }
 };
 
-/** Adjusts the reference counts of all entities of a link on a refCount dict by "by" and updates the gcBatch */
+/** Adjusts the reference counts of all entities of a link on a refCount dict by "by" and updates the gc */
 const updateRCForLink = (
-  gcBatch: void | Set<string>,
+  gc: void | Set<string>,
   refCount: Dict<number>,
   link: Link | undefined,
   by: number
 ): void => {
   if (typeof link === 'string') {
-    updateRCForEntity(gcBatch, refCount, link, by);
+    updateRCForEntity(gc, refCount, link, by);
   } else if (Array.isArray(link)) {
     for (let i = 0, l = link.length; i < l; i++) {
       const entityKey = link[i];
       if (entityKey) {
-        updateRCForEntity(gcBatch, refCount, entityKey, by);
+        updateRCForEntity(gc, refCount, entityKey, by);
       }
     }
   }
@@ -319,64 +330,38 @@ const extractNodeMapFields = <T>(
 };
 
 /** Garbage collects all entities that have been marked as having no references */
-export const gc = (data: InMemoryData) => {
-  // Reset gcScheduled flag
-  data.gcScheduled = false;
+export const gc = () => {
   // Iterate over all entities that have been marked for deletion
   // Entities have been marked for deletion in `updateRCForEntity` if
   // their reference count dropped to 0
-  data.gcBatch.forEach(entityKey => {
+  currentData!.gc.forEach((entityKey: string, _, batch: Set<string>) => {
     // Check first whether the reference count is still 0
-    const rc = data.refCount[entityKey] || 0;
-    if (rc <= 0) {
-      // Each optimistic layer may also still contain some references to marked entities
-      for (const layerKey in data.refLock) {
-        const refCount = data.refLock[layerKey];
-        const locks = refCount[entityKey] || 0;
-        // If the optimistic layer has any references to the entity, don't GC it,
-        // otherwise delete the reference count from the optimistic layer
-        if (locks > 0) return;
-        delete refCount[entityKey];
-      }
+    const rc = currentData!.refCount[entityKey] || 0;
+    if (rc > 0) {
+      batch.delete(entityKey);
+      return;
+    }
 
-      // All conditions are met: The entity can be deleted
-
-      // Delete the reference count, and delete the entity from the GC batch
-      delete data.refCount[entityKey];
-      data.gcBatch.delete(entityKey);
-
-      // Delete the record and for each of its fields, delete them on the persistence
-      // layer if one is present
-      // No optimistic data needs to be deleted, as the entity is not being referenced by
-      // anything and optimistic layers will eventually be deleted anyway
-      const recordsNode = data.records.base.get(entityKey);
-      if (recordsNode !== undefined) {
-        data.records.base.delete(entityKey);
-        if (data.storage) {
-          for (const fieldKey in recordsNode) {
-            const key = prefixKey('r', joinKeys(entityKey, fieldKey));
-            data.persistenceBatch[key] = undefined;
-          }
-        }
-      }
+    // Each optimistic layer may also still contain some references to marked entities
+    for (const layerKey in currentData!.refLock) {
+      const refCount = currentData!.refLock[layerKey];
+      const locks = refCount[entityKey] || 0;
+      // If the optimistic layer has any references to the entity, don't GC it,
+      // otherwise delete the reference count from the optimistic layer
+      if (locks > 0) return;
+      delete refCount[entityKey];
+    }
 
-      // Delete all the entity's links, but also update the reference count
-      // for those links (which can lead to an unrolled recursive GC of the children)
-      const linkNode = data.links.base.get(entityKey);
-      if (linkNode !== undefined) {
-        data.links.base.delete(entityKey);
-        for (const fieldKey in linkNode) {
-          // Delete all links from the persistence layer if one is present
-          if (data.storage) {
-            const key = prefixKey('l', joinKeys(entityKey, fieldKey));
-            data.persistenceBatch[key] = undefined;
-          }
-
-          updateRCForLink(data.gcBatch, data.refCount, linkNode[fieldKey], -1);
-        }
+    // Delete the reference count, and delete the entity from the GC batch
+    delete currentData!.refCount[entityKey];
+    batch.delete(entityKey);
+    currentData!.records.base.delete(entityKey);
+    const linkNode = currentData!.links.base.get(entityKey);
+    if (linkNode) {
+      currentData!.links.base.delete(entityKey);
+      for (const fieldKey in linkNode) {
+        updateRCForLink(batch, currentData!.refCount, linkNode[fieldKey], -1);
       }
-    } else {
-      data.gcBatch.delete(entityKey);
     }
   });
 };
@@ -391,6 +376,11 @@ const updateDependencies = (entityKey: string, fieldKey?: string) => {
   }
 };
 
+const updatePersist = (entityKey: string, fieldKey: string) => {
+  if (currentData!.storage)
+    currentData!.persist.add(serializeKeys(entityKey, fieldKey));
+};
+
 /** Reads an entity's field (a "record") from data */
 export const readRecord = (
   entityKey: string,
@@ -416,11 +406,8 @@ export const writeRecord = (
   value?: EntityField
 ) => {
   updateDependencies(entityKey, fieldKey);
+  updatePersist(entityKey, fieldKey);
   setNode(currentData!.records, entityKey, fieldKey, value);
-  if (currentData!.storage && !currentOptimisticKey) {
-    const key = prefixKey('r', joinKeys(entityKey, fieldKey));
-    currentData!.persistenceBatch[key] = value;
-  }
 };
 
 export const hasField = (entityKey: string, fieldKey: string): boolean =>
@@ -439,7 +426,7 @@ export const writeLink = (
   // Retrive the link NodeMap from either an optimistic or the base layer
   let links: KeyMap<Dict<Link | undefined>> | undefined;
   // Set the GC batch if we're not optimistically updating
-  let gcBatch: void | Set<string>;
+  let gc: void | Set<string>;
   if (currentOptimisticKey) {
     // The refLock counters are also reference counters, but they prevent
     // garbage collection instead of being used to trigger it
@@ -448,27 +435,24 @@ export const writeLink = (
       (data.refLock[currentOptimisticKey] = makeDict());
     links = data.links.optimistic[currentOptimisticKey];
   } else {
-    if (data.storage) {
-      const key = prefixKey('l', joinKeys(entityKey, fieldKey));
-      data.persistenceBatch[key] = link;
-    }
     refCount = data.refCount;
     links = data.links.base;
-    gcBatch = data.gcBatch;
+    gc = data.gc;
   }
 
   // Retrieve the previous link for this field
   const prevLinkNode = links && links.get(entityKey);
   const prevLink = prevLinkNode && prevLinkNode[fieldKey];
 
-  // Update dependencies
+  // Update persistence batch and dependencies
   updateDependencies(entityKey, fieldKey);
+  updatePersist(entityKey, fieldKey);
   // Update the link
   setNode(data.links, entityKey, fieldKey, link);
   // First decrease the reference count for the previous link
-  updateRCForLink(gcBatch, refCount, prevLink, -1);
+  updateRCForLink(gc, refCount, prevLink, -1);
   // Then increase the reference count for the new link
-  updateRCForLink(gcBatch, refCount, link, 1);
+  updateRCForLink(gc, refCount, link, 1);
 };
 
 /** Reserves an optimistic layer and preorders it */
@@ -554,25 +538,53 @@ export const inspectFields = (entityKey: string): FieldInfo[] => {
   return fieldInfos;
 };
 
+export const persistData = () => {
+  if (currentData!.storage) {
+    const entries: SerializedEntries = makeDict();
+    currentIgnoreOptimistic = true;
+    currentData!.persist.forEach(key => {
+      const sepIndex = indexOfSeparator(key);
+      if (sepIndex > -1) {
+        const entityKey = key.slice(0, sepIndex);
+        const fieldKey = key.slice(sepIndex + 1);
+        let x: void | Link | EntityField;
+        if ((x = readLink(entityKey, fieldKey)) !== undefined) {
+          entries[key] = `:${stringifyVariables(x)}`;
+        } else if ((x = readRecord(entityKey, fieldKey)) !== undefined) {
+          entries[key] = stringifyVariables(x);
+        } else {
+          entries[key] = undefined;
+        }
+      }
+    });
+
+    currentIgnoreOptimistic = false;
+    currentData!.storage.write(entries);
+    currentData!.persist.clear();
+  }
+};
+
 export const hydrateData = (
   data: InMemoryData,
   storage: StorageAdapter,
   entries: SerializedEntries
 ) => {
   initDataState(data, null);
+
   for (const key in entries) {
-    const dotIndex = key.indexOf('.');
-    const entityKey = key.slice(2, dotIndex);
-    const fieldKey = key.slice(dotIndex + 1);
-    switch (key.charCodeAt(0)) {
-      case 108:
-        writeLink(entityKey, fieldKey, entries[key] as Link);
-        break;
-      case 114:
-        writeRecord(entityKey, fieldKey, entries[key]);
-        break;
+    const value = entries[key];
+    const sepIndex = indexOfSeparator(key);
+    if (value && sepIndex > -1) {
+      const entityKey = key.slice(0, sepIndex);
+      const fieldKey = key.slice(sepIndex + 1);
+      if (value[0] === ':') {
+        writeLink(entityKey, fieldKey, JSON.parse(value.slice(1)));
+      } else {
+        writeRecord(entityKey, fieldKey, JSON.parse(value));
+      }
     }
   }
+
   clearDataState();
   data.storage = storage;
 };
diff --git a/exchanges/graphcache/src/store/timing.ts b/exchanges/graphcache/src/store/defer.ts
similarity index 74%
rename from exchanges/graphcache/src/store/timing.ts
rename to exchanges/graphcache/src/store/defer.ts
index 62042627..61f7e841 100644
--- a/exchanges/graphcache/src/store/timing.ts
+++ b/exchanges/graphcache/src/store/defer.ts
@@ -1,4 +1,4 @@
-export const defer: (fn: () => void) => void =
+export const scheduleTask: (fn: () => void) => void =
   process.env.NODE_ENV === 'production' && typeof Promise !== 'undefined'
     ? Promise.prototype.then.bind(Promise.resolve())
     : fn => setTimeout(fn, 0);
diff --git a/exchanges/graphcache/src/store/keys.ts b/exchanges/graphcache/src/store/keys.ts
index cef476a0..e07ab9c0 100644
--- a/exchanges/graphcache/src/store/keys.ts
+++ b/exchanges/graphcache/src/store/keys.ts
@@ -24,5 +24,6 @@ export const fieldInfoOfKey = (fieldKey: string): FieldInfo => {
 export const joinKeys = (parentKey: string, key: string) =>
   `${parentKey}.${key}`;
 
-/** Prefix key with its owner type Link / Record */
-export const prefixKey = (owner: 'l' | 'r', key: string) => `${owner}|${key}`;
+export const serializeKeys = (entityKey: string, fieldKey: string) =>
+  `${entityKey}\t${fieldKey}`;
+export const indexOfSeparator = (key: string) => key.indexOf('\t');
diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts
index 3e60b2b5..be57845a 100644
--- a/exchanges/graphcache/src/types.ts
+++ b/exchanges/graphcache/src/types.ts
@@ -169,7 +169,7 @@ export interface KeyingConfig {
 export type SerializedEntry = EntityField | Connection[] | Link;
 
 export interface SerializedEntries {
-  [key: string]: SerializedEntry;
+  [key: string]: string | undefined;
 }
 
 export interface StorageAdapter {


Test patch:
diff --git a/exchanges/graphcache/src/operations/invalidate.test.ts b/exchanges/graphcache/src/operations/invalidate.test.ts
index 9068a15e..ea675f0e 100644
--- a/exchanges/graphcache/src/operations/invalidate.test.ts
+++ b/exchanges/graphcache/src/operations/invalidate.test.ts
@@ -42,7 +42,6 @@ describe('Query', () => {
       }
     );
 
-    InMemoryData.initDataState(store.data, null);
     jest.clearAllMocks();
   });
 
@@ -56,10 +55,17 @@ describe('Query', () => {
         }
       }
     `;
+
+    InMemoryData.initDataState(store.data, null);
     invalidate(store, { query: INVALID_TODO_QUERY });
+    InMemoryData.clearDataState();
     expect(console.warn).toHaveBeenCalledTimes(1);
+
+    InMemoryData.initDataState(store.data, null);
     invalidate(store, { query: INVALID_TODO_QUERY });
+    InMemoryData.clearDataState();
     expect(console.warn).toHaveBeenCalledTimes(1);
+
     expect((console.warn as any).mock.calls[0][0]).toMatch(/incomplete/);
   });
 
@@ -75,10 +81,17 @@ describe('Query', () => {
         }
       }
     `;
+
+    InMemoryData.initDataState(store.data, null);
     invalidate(store, { query: INVALID_TODO_QUERY });
+    InMemoryData.clearDataState();
     expect(console.warn).toHaveBeenCalledTimes(1);
+
+    InMemoryData.initDataState(store.data, null);
     invalidate(store, { query: INVALID_TODO_QUERY });
+    InMemoryData.clearDataState();
     expect(console.warn).toHaveBeenCalledTimes(1);
+
     expect((console.warn as any).mock.calls[0][0]).toMatch(/writer/);
   });
 });
diff --git a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
index 7efbf046..9fdebaf7 100644
--- a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
+++ b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
@@ -2,18 +2,9 @@
 
 exports[`Store with storage should be able to store and rehydrate data 1`] = `
 Object {
-  "l|Query.appointment({\\"id\\":\\"1\\"})": "Appointment:1",
-  "r|Appointment:1.__typename": "Appointment",
-  "r|Appointment:1.id": "1",
-  "r|Appointment:1.info": "urql meeting",
-}
-`;
-
-exports[`Store with storage writes removals based on GC to storage 1`] = `
-Object {
-  "l|Query.appointment({\\"id\\":\\"1\\"})": undefined,
-  "r|Appointment:1.__typename": undefined,
-  "r|Appointment:1.id": undefined,
-  "r|Appointment:1.info": undefined,
+  "Appointment:1	__typename": "\\"Appointment\\"",
+  "Appointment:1	id": "\\"1\\"",
+  "Appointment:1	info": "\\"urql meeting\\"",
+  "Query	appointment({\\"id\\":\\"1\\"})": ":\\"Appointment:1\\"",
 }
 `;
diff --git a/exchanges/graphcache/src/store/data.test.ts b/exchanges/graphcache/src/store/data.test.ts
index 5c74b2bb..361f0ffc 100644
--- a/exchanges/graphcache/src/store/data.test.ts
+++ b/exchanges/graphcache/src/store/data.test.ts
@@ -15,12 +15,12 @@ describe('garbage collection', () => {
     InMemoryData.writeRecord('Query', '__typename', 'Query');
     InMemoryData.writeLink('Query', 'todo', 'Todo:1');
 
-    InMemoryData.gc(data);
+    InMemoryData.gc();
 
     expect(InMemoryData.readLink('Query', 'todo')).toBe('Todo:1');
 
     InMemoryData.writeLink('Query', 'todo', undefined);
-    InMemoryData.gc(data);
+    InMemoryData.gc();
 
     expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined);
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
@@ -39,7 +39,7 @@ describe('garbage collection', () => {
     InMemoryData.writeLink('Query', 'todo', undefined);
     InMemoryData.writeLink('Query', 'newTodo', 'Todo:1');
 
-    InMemoryData.gc(data);
+    InMemoryData.gc();
 
     expect(InMemoryData.readLink('Query', 'newTodo')).toBe('Todo:1');
     expect(InMemoryData.readLink('Query', 'todo')).toBe(undefined);
@@ -60,7 +60,7 @@ describe('garbage collection', () => {
     InMemoryData.writeLink('Query', 'todoB', 'Todo:1');
     InMemoryData.writeLink('Query', 'todoA', undefined);
 
-    InMemoryData.gc(data);
+    InMemoryData.gc();
 
     expect(InMemoryData.readLink('Query', 'todoA')).toBe(undefined);
     expect(InMemoryData.readLink('Query', 'todoB')).toBe('Todo:1');
@@ -83,12 +83,12 @@ describe('garbage collection', () => {
     InMemoryData.initDataState(data, 0, true);
 
     InMemoryData.writeLink('Query', 'todo', undefined);
-    InMemoryData.gc(data);
+    InMemoryData.gc();
 
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe('1');
 
     InMemoryData.clearLayer(data, 1);
-    InMemoryData.gc(data);
+    InMemoryData.gc();
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
 
     expect([...InMemoryData.getCurrentDependencies()]).toEqual([
@@ -106,7 +106,7 @@ describe('garbage collection', () => {
     InMemoryData.writeLink('Query', 'todo', 'Todo:1');
 
     InMemoryData.writeLink('Query', 'todo', undefined);
-    InMemoryData.gc(data);
+    InMemoryData.gc();
 
     expect(InMemoryData.readRecord('Todo:1', 'id')).toBe(undefined);
     expect(InMemoryData.readRecord('Author:1', 'id')).toBe(undefined);
diff --git a/exchanges/graphcache/src/store/store.test.ts b/exchanges/graphcache/src/store/store.test.ts
index 912c80be..0810beb1 100644
--- a/exchanges/graphcache/src/store/store.test.ts
+++ b/exchanges/graphcache/src/store/store.test.ts
@@ -454,6 +454,8 @@ describe('Store with OptimisticMutationConfig', () => {
 });
 
 describe('Store with storage', () => {
+  let store: Store;
+
   const expectedData = {
     __typename: 'Query',
     appointment: {
@@ -464,7 +466,7 @@ describe('Store with storage', () => {
   };
 
   beforeEach(() => {
-    jest.useFakeTimers();
+    store = new Store();
   });
 
   it('should be able to store and rehydrate data', () => {
@@ -473,8 +475,7 @@ describe('Store with storage', () => {
       write: jest.fn(),
     };
 
-    let store = new Store();
-    InMemoryData.hydrateData(store.data, storage, Object.create(null));
+    store.data.storage = storage;
 
     write(
       store,
@@ -485,9 +486,10 @@ describe('Store with storage', () => {
       expectedData
     );
 
-    expect(storage.write).not.toHaveBeenCalled();
+    InMemoryData.initDataState(store.data, null);
+    InMemoryData.persistData();
+    InMemoryData.clearDataState();
 
-    jest.runAllTimers();
     expect(storage.write).toHaveBeenCalled();
 
     const serialisedStore = (storage.write as any).mock.calls[0][0];
@@ -504,37 +506,41 @@ describe('Store with storage', () => {
     expect(data).toEqual(expectedData);
   });
 
-  it('writes removals based on GC to storage', () => {
+  it('persists commutative layers and ignores optimistic layers', () => {
     const storage: StorageAdapter = {
       read: jest.fn(),
       write: jest.fn(),
     };
 
-    const store = new Store();
-    InMemoryData.hydrateData(store.data, storage, Object.create(null));
+    store.data.storage = storage;
 
-    write(
-      store,
-      {
-        query: Appointment,
-        variables: { id: '1' },
-      },
-      expectedData
-    );
+    InMemoryData.reserveLayer(store.data, 1);
 
-    InMemoryData.initDataState(store.data, null);
-    InMemoryData.writeLink(
-      'Query',
-      store.keyOfField('appointment', { id: '1' }),
-      undefined
-    );
-    InMemoryData.gc(store.data);
+    InMemoryData.initDataState(store.data, 1);
+    InMemoryData.writeRecord('Query', 'base', true);
     InMemoryData.clearDataState();
 
-    jest.runAllTimers();
+    InMemoryData.initDataState(store.data, 2, true);
+    InMemoryData.writeRecord('Query', 'base', false);
+    InMemoryData.clearDataState();
+
+    InMemoryData.initDataState(store.data, null);
+    expect(InMemoryData.readRecord('Query', 'base')).toBe(false);
+    InMemoryData.persistData();
+    InMemoryData.clearDataState();
 
     expect(storage.write).toHaveBeenCalled();
     const serialisedStore = (storage.write as any).mock.calls[0][0];
-    expect(serialisedStore).toMatchSnapshot();
+
+    expect(serialisedStore).toEqual({
+      'Query\tbase': 'true',
+    });
+
+    store = new Store();
+    InMemoryData.hydrateData(store.data, storage, serialisedStore);
+
+    InMemoryData.initDataState(store.data, null);
+    expect(InMemoryData.readRecord('Query', 'base')).toBe(true);
+    InMemoryData.clearDataState();
   });
 });