Repository: FormidableLabs/urql
Pull request #715 (2020-04-19 00:56:14)

(fix) - Remove special character from persistence keys and sanitize
## Summary

- ~Avoid illegal character \t which seems to be sometimes stripped in IDB~ (This turned out to be untrue and our example was simply broken. That being said, I think this is still a safer alternative)
- Move deserialization to keys.ts
- Add string sanitization


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

Latest commit: ed5f1e63f5b0086e89c8b261622026eb27b9a16b

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/fix/safer-keys?filename=.changeset/mean-crews-guess.md&value=---%0A%22%40urql%2Fexchange-graphcache%22%3A%20patch%0A---%0A%0A(fix)%20-%20Remove%20special%20character%20from%20persistence%20keys%20and%20sanitize%0A)



Patch:
diff --git a/.changeset/honest-mangos-rule.md b/.changeset/honest-mangos-rule.md
new file mode 100644
index 00000000..2160984f
--- /dev/null
+++ b/.changeset/honest-mangos-rule.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-graphcache': patch
+---
+
+Fix persistence using special tab character in serialized keys and add sanitization to persistence key serializer.
diff --git a/.changeset/stale-onions-check.md b/.changeset/stale-onions-check.md
new file mode 100644
index 00000000..733b757a
--- /dev/null
+++ b/.changeset/stale-onions-check.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': patch
+---
+
+Fix stringifyVariables breaking on x.toJSON scalars.
diff --git a/docs/api/core.md b/docs/api/core.md
index 78517e03..a639b846 100644
--- a/docs/api/core.md
+++ b/docs/api/core.md
@@ -73,7 +73,7 @@ be conveniently converted to a promise.
 ```js
 import { pipe, subscribe } from 'wonka';
 
-const { subscribe } = pipe(
+const { unsubscribe } = pipe(
   client.query('{ test }', {
     /* vars */
   }),
@@ -87,6 +87,7 @@ client
   .query('{ test }', {
     /* vars */
   })
+  .toPromise()
   .then(result => {
     console.log(result); // OperationResult
   });
diff --git a/exchanges/graphcache/examples/persisted-store/src/app/index.tsx b/exchanges/graphcache/examples/persisted-store/src/app/index.tsx
index 3512aebb..4754a4fe 100644
--- a/exchanges/graphcache/examples/persisted-store/src/app/index.tsx
+++ b/exchanges/graphcache/examples/persisted-store/src/app/index.tsx
@@ -12,8 +12,16 @@ const storage = {
     db = await openDB('myApplication', 1, {
       upgrade: db => db.createObjectStore('keyval'),
     });
-    const result = (await db.getAll('keyval')) as SerializedEntries;
-    return result;
+
+    return Promise.all([
+      db.getAllKeys("keyval"),
+      db.getAll("keyval"),
+    ]).then(([keys, values]) => {
+      return keys.reduce((acc: SerializedEntries, key, i) => {
+        acc[key] = values[i];
+        return acc;
+      }, {});
+    });
   },
   write: async batch => {
     for (const key in batch) {
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index aac6a40d..92a60819 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -9,10 +9,10 @@ import {
 } from '../types';
 
 import {
+  serializeKeys,
+  deserializeKeyInfo,
   fieldInfoOfKey,
   joinKeys,
-  serializeKeys,
-  indexOfSeparator,
 } from './keys';
 
 import { makeDict } from '../helpers/dict';
@@ -543,18 +543,14 @@ export const persistData = () => {
     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;
-        }
+      const { entityKey, fieldKey } = deserializeKeyInfo(key);
+      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;
       }
     });
 
@@ -573,10 +569,8 @@ export const hydrateData = (
 
   for (const key in entries) {
     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 !== undefined) {
+      const { entityKey, fieldKey } = deserializeKeyInfo(key);
       if (value[0] === ':') {
         writeLink(entityKey, fieldKey, JSON.parse(value.slice(1)));
       } else {
diff --git a/exchanges/graphcache/src/store/keys.ts b/exchanges/graphcache/src/store/keys.ts
index e07ab9c0..bb6942b3 100644
--- a/exchanges/graphcache/src/store/keys.ts
+++ b/exchanges/graphcache/src/store/keys.ts
@@ -1,9 +1,12 @@
 import { stringifyVariables } from '@urql/core';
-import { Variables, FieldInfo } from '../types';
+import { Variables, FieldInfo, KeyInfo } from '../types';
 
 export const keyOfField = (fieldName: string, args?: null | Variables) =>
   args ? `${fieldName}(${stringifyVariables(args)})` : fieldName;
 
+export const joinKeys = (parentKey: string, key: string) =>
+  `${parentKey}.${key}`;
+
 export const fieldInfoOfKey = (fieldKey: string): FieldInfo => {
   const parenIndex = fieldKey.indexOf('(');
   if (parenIndex > -1) {
@@ -21,9 +24,12 @@ export const fieldInfoOfKey = (fieldKey: string): FieldInfo => {
   }
 };
 
-export const joinKeys = (parentKey: string, key: string) =>
-  `${parentKey}.${key}`;
-
 export const serializeKeys = (entityKey: string, fieldKey: string) =>
-  `${entityKey}\t${fieldKey}`;
-export const indexOfSeparator = (key: string) => key.indexOf('\t');
+  `${entityKey.replace(/\./g, '\\.')}.${fieldKey}`;
+
+export const deserializeKeyInfo = (key: string): KeyInfo => {
+  const dotIndex = key.indexOf('.');
+  const entityKey = key.slice(0, dotIndex).replace(/\\\./g, '.');
+  const fieldKey = key.slice(dotIndex + 1);
+  return { entityKey, fieldKey };
+};
diff --git a/exchanges/graphcache/src/types.ts b/exchanges/graphcache/src/types.ts
index be57845a..b16fea07 100644
--- a/exchanges/graphcache/src/types.ts
+++ b/exchanges/graphcache/src/types.ts
@@ -45,6 +45,11 @@ export interface FieldInfo {
   arguments: Variables | null;
 }
 
+export interface KeyInfo {
+  entityKey: string;
+  fieldKey: string;
+}
+
 // This is an input operation
 export interface OperationRequest {
   query: DocumentNode;
diff --git a/packages/core/src/utils/stringifyVariables.ts b/packages/core/src/utils/stringifyVariables.ts
index 493cb42c..d8ce554e 100644
--- a/packages/core/src/utils/stringifyVariables.ts
+++ b/packages/core/src/utils/stringifyVariables.ts
@@ -7,7 +7,7 @@ const stringify = (x: any): string => {
   } else if (typeof x !== 'object') {
     return JSON.stringify(x) || '';
   } else if (x.toJSON) {
-    return x.toJSON();
+    return stringify(x.toJSON());
   } else if (Array.isArray(x)) {
     let out = '[';
     for (let i = 0, l = x.length; i < l; i++) {


Test patch:
diff --git a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
index 9fdebaf7..414ef8dc 100644
--- a/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
+++ b/exchanges/graphcache/src/store/__snapshots__/store.test.ts.snap
@@ -2,9 +2,9 @@
 
 exports[`Store with storage should be able to store and rehydrate data 1`] = `
 Object {
-  "Appointment:1	__typename": "\\"Appointment\\"",
-  "Appointment:1	id": "\\"1\\"",
-  "Appointment:1	info": "\\"urql meeting\\"",
-  "Query	appointment({\\"id\\":\\"1\\"})": ":\\"Appointment:1\\"",
+  "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/store.test.ts b/exchanges/graphcache/src/store/store.test.ts
index f95ada5c..6f221e28 100644
--- a/exchanges/graphcache/src/store/store.test.ts
+++ b/exchanges/graphcache/src/store/store.test.ts
@@ -568,7 +568,7 @@ describe('Store with storage', () => {
     const serialisedStore = (storage.write as any).mock.calls[0][0];
 
     expect(serialisedStore).toEqual({
-      'Query\tbase': 'true',
+      'Query.base': 'true',
     });
 
     store = new Store();
diff --git a/packages/core/src/utils/stringifyVariables.test.ts b/packages/core/src/utils/stringifyVariables.test.ts
index 6e6c6ae4..2d592e4e 100644
--- a/packages/core/src/utils/stringifyVariables.test.ts
+++ b/packages/core/src/utils/stringifyVariables.test.ts
@@ -29,7 +29,7 @@ it('returns null for circular structures', () => {
 
 it('stringifies dates correctly', () => {
   const date = new Date('2019-12-11T04:20:00');
-  expect(stringifyVariables(date)).toBe(date.toJSON());
+  expect(stringifyVariables(date)).toBe(`"${date.toJSON()}"`);
 });
 
 it('stringifies dictionaries (Object.create(null)) correctly', () => {