Repository: parse-community/Parse-SDK-JS
Pull request #1297 (2021-02-13 00:14:54)

feat: Add IndexedDB Storage Controller
Closes: https://github.com/parse-community/Parse-SDK-JS/issues/1270

```js
Parse.CoreManager.setStorageController(Parse.IndexedDB);
```

The goal of this PR is to provide a default indexedDB implementation. Users are more than welcome to create their own storage controllers if they need transactions, indexes or schema migration etc...

Uses the lightweight, [idb-keyval](https://github.com/jakearchibald/idb-keyval) package instead of the full [idb](https://www.npmjs.com/package/idb)

@FunMiles Can you give it a try?

peril-parse-community[bot]: 
<!--
  0 failure: 
  0 warning: 
  
  1 markdown notices
  DangerID: danger-id-Peril;
-->



Danger run resulted in 1 warning; to find out more, see the [checks page](https://github.com/parse-community/Parse-SDK-JS/runs/1891293014).
<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a>
</p>

codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297?src=pr&el=h1) Report
> Merging [#1297](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297?src=pr&el=desc) (c7f1e82) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/4caede2e4948f9eff8cf9e174d4d3b2c3b842c98?el=desc) (4caede2) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297/graphs/tree.svg?width=650&height=150&src=pr&token=8nCENM0QHj)](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master     #1297   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           59        60    +1     
  Lines         5800      5815   +15     
  Branches      1305      1305           
=========================================
+ Hits          5800      5815   +15     
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/IndexedDBStorageController.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297/diff?src=pr&el=tree#diff-c3JjL0luZGV4ZWREQlN0b3JhZ2VDb250cm9sbGVyLmpz) | `100.00% <100.00%> (ø)` | |
| [src/Parse.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297/diff?src=pr&el=tree#diff-c3JjL1BhcnNlLmpz) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297?src=pr&el=footer). Last update [4caede2...3494fde](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1297?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

peril-parse-community[bot]: 
<!--
  0 failure: 
  0 warning: 
  
  1 markdown notices
  DangerID: danger-id-Peril;
-->



Danger run resulted in 1 warning; to find out more, see the [checks page](https://github.com/parse-community/Parse-SDK-JS/runs/1891547024).
<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a>
</p>


Patch:
diff --git a/README.md b/README.md
index 88e2e84..339fc5a 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,12 @@ const Parse = require('parse');
 import Parse from 'parse/dist/parse.min.js';
 ```
 
+For web worker or browser applications, indexedDB storage is available:
+
+```js
+Parse.CoreManager.setStorageController(Parse.IndexedDB);
+```
+
 For server-side applications or Node.js command line tools, include `'parse/node'`:
 
 ```js
diff --git a/package-lock.json b/package-lock.json
index 70c8714..027d917 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7594,6 +7594,11 @@
         "safer-buffer": ">= 2.1.2 < 3"
       }
     },
+    "idb-keyval": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-5.0.2.tgz",
+      "integrity": "sha512-1DYjY/nX2U9pkTkwFoAmKcK1ZWmkNgO32Oon9tp/9+HURizxUQ4fZRxMJZs093SldP7q6dotVj03kIkiqOILyA=="
+    },
     "ieee754": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
diff --git a/package.json b/package.json
index 91a8804..6fe1654 100644
--- a/package.json
+++ b/package.json
@@ -29,8 +29,9 @@
     "react-native": false
   },
   "dependencies": {
-    "@babel/runtime-corejs3": "7.12.5",
     "@babel/runtime": "7.12.5",
+    "@babel/runtime-corejs3": "7.12.5",
+    "idb-keyval": "5.0.2",
     "react-native-crypto-js": "1.0.0",
     "uuid": "3.4.0",
     "ws": "7.4.2",
diff --git a/src/IndexedDBStorageController.js b/src/IndexedDBStorageController.js
new file mode 100644
index 0000000..9c963d6
--- /dev/null
+++ b/src/IndexedDBStorageController.js
@@ -0,0 +1,32 @@
+/**
+ * @flow
+ */
+
+import { createStore, del, set, get, clear, keys } from 'idb-keyval';
+
+try {
+  const ParseStore = createStore('parseDB', 'parseStore');
+
+  const IndexedDBStorageController = {
+    async: 1,
+    getItemAsync(path: string) {
+      return get(path, ParseStore);
+    },
+    setItemAsync(path: string, value: string) {
+      return set(path, value, ParseStore);
+    },
+    removeItemAsync(path: string) {
+      return del(path, ParseStore);
+    },
+    getAllKeysAsync() {
+      return keys(ParseStore);
+    },
+    clear() {
+      return clear(ParseStore);
+    },
+  };
+
+  module.exports = IndexedDBStorageController;
+} catch (e) {
+  // IndexedDB not supported
+}
diff --git a/src/Parse.js b/src/Parse.js
index e132b27..41f9c87 100644
--- a/src/Parse.js
+++ b/src/Parse.js
@@ -226,6 +226,7 @@ Parse.Storage = require('./Storage');
 Parse.User = require('./ParseUser').default;
 Parse.LiveQuery = require('./ParseLiveQuery').default;
 Parse.LiveQueryClient = require('./LiveQueryClient').default;
+Parse.IndexedDB = require('./IndexedDBStorageController');
 
 Parse._request = function (...args) {
   return CoreManager.getRESTController().request.apply(null, args);
diff --git a/src/ParseUser.js b/src/ParseUser.js
index 9ea7f14..69d702f 100644
--- a/src/ParseUser.js
+++ b/src/ParseUser.js
@@ -308,6 +308,16 @@ class ParseUser extends ParseObject {
     return !!current && current.id === this.id;
   }
 
+  /**
+   * Returns true if <code>current</code> would return this user.
+   *
+   * @returns {Promise<boolean>} true if user is cached on disk
+   */
+  async isCurrentAsync(): Promise<boolean> {
+    const current = await ParseUser.currentAsync();
+    return !!current && current.id === this.id;
+  }
+
   /**
    * Returns get("username").
    *
@@ -458,13 +468,13 @@ class ParseUser extends ParseObject {
    * @param {...any} args
    * @returns {Promise}
    */
-  save(...args: Array<any>): Promise<ParseUser> {
-    return super.save.apply(this, args).then(() => {
-      if (this.isCurrent()) {
-        return CoreManager.getUserController().updateUserOnDisk(this);
-      }
-      return this;
-    });
+  async save(...args: Array<any>): Promise<ParseUser> {
+    await super.save.apply(this, args);
+    const current = await this.isCurrentAsync();
+    if (current) {
+      return CoreManager.getUserController().updateUserOnDisk(this);
+    }
+    return this;
   }
 
   /**
@@ -474,13 +484,13 @@ class ParseUser extends ParseObject {
    * @param {...any} args
    * @returns {Parse.User}
    */
-  destroy(...args: Array<any>): Promise<ParseUser> {
-    return super.destroy.apply(this, args).then(() => {
-      if (this.isCurrent()) {
-        return CoreManager.getUserController().removeUserFromDisk();
-      }
-      return this;
-    });
+  async destroy(...args: Array<any>): Promise<ParseUser> {
+    await super.destroy.apply(this, args);
+    const current = await this.isCurrentAsync();
+    if (current) {
+      return CoreManager.getUserController().removeUserFromDisk();
+    }
+    return this;
   }
 
   /**
@@ -490,13 +500,13 @@ class ParseUser extends ParseObject {
    * @param {...any} args
    * @returns {Parse.User}
    */
-  fetch(...args: Array<any>): Promise<ParseUser> {
-    return super.fetch.apply(this, args).then(() => {
-      if (this.isCurrent()) {
-        return CoreManager.getUserController().updateUserOnDisk(this);
-      }
-      return this;
-    });
+  async fetch(...args: Array<any>): Promise<ParseUser> {
+    await super.fetch.apply(this, args);
+    const current = await this.isCurrentAsync();
+    if (current) {
+      return CoreManager.getUserController().updateUserOnDisk(this);
+    }
+    return this;
   }
 
   /**
@@ -506,13 +516,13 @@ class ParseUser extends ParseObject {
    * @param {...any} args
    * @returns {Parse.User}
    */
-  fetchWithInclude(...args: Array<any>): Promise<ParseUser> {
-    return super.fetchWithInclude.apply(this, args).then(() => {
-      if (this.isCurrent()) {
-        return CoreManager.getUserController().updateUserOnDisk(this);
-      }
-      return this;
-    });
+  async fetchWithInclude(...args: Array<any>): Promise<ParseUser> {
+    await super.fetchWithInclude.apply(this, args);
+    const current = await this.isCurrentAsync();
+    if (current) {
+      return CoreManager.getUserController().updateUserOnDisk(this);
+    }
+    return this;
   }
 
   /**
@@ -1161,7 +1171,7 @@ const DefaultController = {
     return RESTController.request('POST', 'requestPasswordReset', { email: email }, options);
   },
 
-  upgradeToRevocableSession(user: ParseUser, options: RequestOptions) {
+  async upgradeToRevocableSession(user: ParseUser, options: RequestOptions) {
     const token = user.getSessionToken();
     if (!token) {
       return Promise.reject(
@@ -1172,15 +1182,15 @@ const DefaultController = {
     options.sessionToken = token;
 
     const RESTController = CoreManager.getRESTController();
-    return RESTController.request('POST', 'upgradeToRevocableSession', {}, options).then(result => {
-      const session = new ParseSession();
-      session._finishFetch(result);
-      user._finishFetch({ sessionToken: session.getSessionToken() });
-      if (user.isCurrent()) {
-        return DefaultController.setCurrentUser(user);
-      }
-      return Promise.resolve(user);
-    });
+    const result = await RESTController.request('POST', 'upgradeToRevocableSession', {}, options);
+    const session = new ParseSession();
+    session._finishFetch(result);
+    user._finishFetch({ sessionToken: session.getSessionToken() });
+    const current = await user.isCurrentAsync();
+    if (current) {
+      return DefaultController.setCurrentUser(user);
+    }
+    return Promise.resolve(user);
   },
 
   linkWith(user: ParseUser, authData: AuthData, options: FullOptions) {


Test patch:
diff --git a/src/__tests__/Parse-test.js b/src/__tests__/Parse-test.js
index c38c956..9e6d548 100644
--- a/src/__tests__/Parse-test.js
+++ b/src/__tests__/Parse-test.js
@@ -200,4 +200,12 @@ describe('Parse module', () => {
   it('_encode', () => {
     expect(Parse._encode(12)).toBe(12);
   });
+
+  it('can get IndexedDB storage', () => {
+    console.log(Parse.IndexedDB);
+    expect(Parse.IndexedDB).toBeDefined();
+    CoreManager.setStorageController(Parse.IndexedDB);
+    const currentStorage = CoreManager.getStorageController();
+    expect(currentStorage).toEqual(Parse.IndexedDB);
+  });
 });
diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js
index 1e85375..828b14b 100644
--- a/src/__tests__/ParseUser-test.js
+++ b/src/__tests__/ParseUser-test.js
@@ -1280,6 +1280,54 @@ describe('ParseUser', () => {
     });
   });
 
+  it('can linkWith async', async () => {
+    const currentStorage = CoreManager.getStorageController();
+    CoreManager.setStorageController(mockAsyncStorage);
+    ParseUser.enableUnsafeCurrentUser();
+    ParseUser._clearCache();
+    CoreManager.setRESTController({
+      request() {
+        return Promise.resolve(
+          {
+            objectId: 'uid5Async',
+            sessionToken: 'r:123abc',
+            authData: {
+              test: {
+                id: 'id',
+                access_token: 'access_token',
+              },
+            },
+          },
+          200
+        );
+      },
+      ajax() {},
+    });
+    const provider = {
+      authenticate(options) {
+        if (options.success) {
+          options.success(this, {
+            id: 'id',
+            access_token: 'access_token',
+          });
+        }
+      },
+      restoreAuthentication() {},
+      getAuthType() {
+        return 'test';
+      },
+      deauthenticate() {},
+    };
+
+    const user = new ParseUser();
+    await user.linkWith(provider, null, { useMasterKey: true });
+
+    expect(user.get('authData')).toEqual({
+      test: { id: 'id', access_token: 'access_token' },
+    });
+    CoreManager.setStorageController(currentStorage);
+  });
+
   it('handle linkWith authentication failure', async () => {
     const provider = {
       authenticate(options) {
diff --git a/src/__tests__/Storage-test.js b/src/__tests__/Storage-test.js
index e3030b0..356956e 100644
--- a/src/__tests__/Storage-test.js
+++ b/src/__tests__/Storage-test.js
@@ -10,43 +10,16 @@
 jest.autoMockOff();
 
 const mockRNStorageInterface = require('./test_helpers/mockRNStorage');
+const mockStorageInterface = require('./test_helpers/mockStorageInteface');
+const mockIndexedDB = require('./test_helpers/mockIndexedDB');
 const mockWeChat = require('./test_helpers/mockWeChat');
 const CoreManager = require('../CoreManager');
 
 global.wx = mockWeChat;
-
-let mockStorage = {};
-const mockStorageInterface = {
-  getItem(path) {
-    return mockStorage[path] || null;
-  },
-
-  getItemAsync(path) {
-    return Promise.resolve(mockStorageInterface.getItem(path));
-  },
-
-  setItem(path, value) {
-    mockStorage[path] = value;
-  },
-
-  setItemAsync(path, value) {
-    return Promise.resolve(mockStorageInterface.setItem(path, value));
-  },
-
-  removeItem(path) {
-    delete mockStorage[path];
-  },
-
-  removeItemAsync(path) {
-    return Promise.resolve(mockStorageInterface.removeItem(path));
-  },
-
-  clear() {
-    mockStorage = {};
-  },
-};
-
 global.localStorage = mockStorageInterface;
+jest.mock('idb-keyval', () => {
+  return mockIndexedDB;
+});
 
 const BrowserStorageController = require('../StorageController.browser');
 
@@ -192,6 +165,46 @@ describe('React Native StorageController', () => {
   });
 });
 
+const IndexedDBStorageController = require('../IndexedDBStorageController');
+
+describe('React Native StorageController', () => {
+  beforeEach(() => {
+    IndexedDBStorageController.clear();
+  });
+
+  it('is asynchronous', () => {
+    expect(IndexedDBStorageController.async).toBe(1);
+    expect(typeof IndexedDBStorageController.getItemAsync).toBe('function');
+    expect(typeof IndexedDBStorageController.setItemAsync).toBe('function');
+    expect(typeof IndexedDBStorageController.removeItemAsync).toBe('function');
+  });
+
+  it('can store and retrieve values', async () => {
+    let result = await IndexedDBStorageController.getItemAsync('myKey');
+    expect(result).toBe(null);
+    await IndexedDBStorageController.setItemAsync('myKey', 'myValue');
+    result = await IndexedDBStorageController.getItemAsync('myKey');
+    expect(result).toBe('myValue');
+  });
+
+  it('can remove values', async () => {
+    await IndexedDBStorageController.setItemAsync('myKey', 'myValue');
+    let result = await IndexedDBStorageController.getItemAsync('myKey');
+    expect(result).toBe('myValue');
+    await IndexedDBStorageController.removeItemAsync('myKey');
+    result = await IndexedDBStorageController.getItemAsync('myKey');
+    expect(result).toBe(null);
+  });
+
+  it('can getAllKeys', async () => {
+    await IndexedDBStorageController.setItemAsync('myKey', 'myValue');
+    const result = await IndexedDBStorageController.getItemAsync('myKey');
+    expect(result).toBe('myValue');
+    const keys = await IndexedDBStorageController.getAllKeysAsync();
+    expect(keys[0]).toBe('myKey');
+  });
+});
+
 const DefaultStorageController = require('../StorageController.default');
 
 describe('Default StorageController', () => {
diff --git a/src/__tests__/test_helpers/mockIndexedDB.js b/src/__tests__/test_helpers/mockIndexedDB.js
new file mode 100644
index 0000000..3a01218
--- /dev/null
+++ b/src/__tests__/test_helpers/mockIndexedDB.js
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2015-present, Parse, LLC.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ */
+let mockStorage = {};
+const mockStorageInterface = {
+  createStore() {},
+
+  async get(path) {
+    return mockStorage[path] || null;
+  },
+
+  async set(path, value) {
+    mockStorage[path] = value;
+  },
+
+  async del(path) {
+    delete mockStorage[path];
+  },
+
+  async keys() {
+    return Object.keys(mockStorage);
+  },
+
+  async clear() {
+    mockStorage = {};
+  },
+};
+
+module.exports = mockStorageInterface;
diff --git a/src/__tests__/test_helpers/mockStorageInteface.js b/src/__tests__/test_helpers/mockStorageInteface.js
new file mode 100644
index 0000000..a633b45
--- /dev/null
+++ b/src/__tests__/test_helpers/mockStorageInteface.js
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) 2015-present, Parse, LLC.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ */
+let mockStorage = {};
+const mockStorageInterface = {
+  getItem(path) {
+    return mockStorage[path] || null;
+  },
+
+  getItemAsync(path) {
+    return Promise.resolve(mockStorageInterface.getItem(path));
+  },
+
+  setItem(path, value) {
+    mockStorage[path] = value;
+  },
+
+  setItemAsync(path, value) {
+    return Promise.resolve(mockStorageInterface.setItem(path, value));
+  },
+
+  removeItem(path) {
+    delete mockStorage[path];
+  },
+
+  removeItemAsync(path) {
+    return Promise.resolve(mockStorageInterface.removeItem(path));
+  },
+
+  clear() {
+    mockStorage = {};
+  },
+};
+module.exports = mockStorageInterface;