Repository: parse-community/Parse-SDK-JS
Pull request #734 (2019-02-13 00:44:36)

LocalDatastore can update from server
Allows for updating LocalDatastore with server data

Any advice would be helpful

codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/734?src=pr&el=h1) Report
> Merging [#734](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/734?src=pr&el=desc) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/7da04e240e0a536a9295491bda4822292f4e8fa6?src=pr&el=desc) will **increase** coverage by `0.04%`.
> The diff coverage is `93.18%`.

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

```diff
@@            Coverage Diff             @@
##           master     #734      +/-   ##
==========================================
+ Coverage   88.74%   88.78%   +0.04%     
==========================================
  Files          53       53              
  Lines        4584     4628      +44     
  Branches     1064     1069       +5     
==========================================
+ Hits         4068     4109      +41     
- Misses        516      519       +3
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/734?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/LocalDatastore.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/734/diff?src=pr&el=tree#diff-c3JjL0xvY2FsRGF0YXN0b3JlLmpz) | `98.98% <100%> (+0.23%)` | :arrow_up: |
| [src/RESTController.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/734/diff?src=pr&el=tree#diff-c3JjL1JFU1RDb250cm9sbGVyLmpz) | `79.59% <57.14%> (-1.13%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/734?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/734?src=pr&el=footer). Last update [7da04e2...da3672f](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/734?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

flovilmart: Did you check how the other SDKs implémented those features? I’m not sure the server is properly equipped to ‘sync’ data when it’s been off sync? 

<sub>Sent with <a href="http://githawk.com">GitHawk</a></sub>
dplewis: > Did you check how the other SDKs implémented those features?

If by other SDK's you mean iOS and android LDS, I don't believe they have this kind of feature. They do have `saveEventually` for syncing

> I’m not sure the server is properly equipped to ‘sync’ data when it’s been off sync?

I'm not looking for a true sync feature with this PR. Just when network reconnect perform a query.
flovilmart: That’s not what I meant, when you will get network connection again, you will attempt to save object, and may override previous operations. But I guess you already knew that.

<sub>Sent with <a href="http://githawk.com">GitHawk</a></sub>
flovilmart: The REstController also is not the best spot to get this feature in, as it’s responsibility is solely to connect to the API. It has nothing to do with the LDS.

<sub>Sent with <a href="http://githawk.com">GitHawk</a></sub>
dplewis: > you will attempt to save object, and may override previous operations

This attempt doesn't happen on ie9 and when it does happen it only tries for 4 seconds max

> The REstController also is not the best spot to get this feature in

When do you believe the best implementation is? I would like to the SDK to handle it. Any advice would be appreciated
dplewis: @flovilmart How does this look?
flovilmart: @dplewis ask @acinader :) 

<sub>Sent with <a href="http://githawk.com">GitHawk</a></sub>

Patch:
diff --git a/src/LocalDatastore.js b/src/LocalDatastore.js
index 3b34c1a..3c6e3a1 100644
--- a/src/LocalDatastore.js
+++ b/src/LocalDatastore.js
@@ -12,6 +12,7 @@
 import CoreManager from './CoreManager';
 
 import type ParseObject from './ParseObject';
+import ParseQuery from './ParseQuery';
 
 const DEFAULT_PIN = '_default';
 const PIN_PREFIX = 'parsePin_';
@@ -259,6 +260,65 @@ const LocalDatastore = {
     }
   },
 
+  /**
+   * Updates Local Datastore from Server
+   *
+   * <pre>
+   * await Parse.LocalDatastore.updateFromServer();
+   * </pre>
+   *
+   * @static
+   */
+  async updateFromServer() {
+    if (!this.checkIfEnabled() || this.isSyncing) {
+      return;
+    }
+    const localDatastore = await this._getAllContents();
+    const keys = [];
+    for (const key in localDatastore) {
+      if (key !== DEFAULT_PIN && !key.startsWith(PIN_PREFIX)) {
+        keys.push(key);
+      }
+    }
+    if (keys.length === 0) {
+      return;
+    }
+    this.isSyncing = true;
+    const pointersHash = {};
+    for (const key of keys) {
+      const [className, objectId] = key.split('_');
+      if (!(className in pointersHash)) {
+        pointersHash[className] = new Set();
+      }
+      pointersHash[className].add(objectId);
+    }
+    const queryPromises = Object.keys(pointersHash).map(className => {
+      const objectIds = Array.from(pointersHash[className]);
+      const query = new ParseQuery(className);
+      query.limit(objectIds.length);
+      if (objectIds.length === 1) {
+        query.equalTo('objectId', objectIds[0]);
+      } else {
+        query.containedIn('objectId', objectIds);
+      }
+      return query.find();
+    });
+    try {
+      const responses = await Promise.all(queryPromises);
+      const objects = [].concat.apply([], responses);
+      const pinPromises = objects.map((object) => {
+        const objectKey = this.getKeyForObject(object);
+        return this.pinWithName(objectKey, object._toFullJSON());
+      });
+      await Promise.all(pinPromises);
+      this.isSyncing = false;
+    } catch(error) {
+      console.log('Error syncing LocalDatastore'); // eslint-disable-line
+      console.log(error); // eslint-disable-line
+      this.isSyncing = false;
+    }
+  },
+
   getKeyForObject(object: any) {
     const objectId = object.objectId || object._getId();
     return `${object.className}_${objectId}`;
@@ -282,6 +342,7 @@ const LocalDatastore = {
 LocalDatastore.DEFAULT_PIN = DEFAULT_PIN;
 LocalDatastore.PIN_PREFIX = PIN_PREFIX;
 LocalDatastore.isEnabled = false;
+LocalDatastore.isSyncing = false;
 
 module.exports = LocalDatastore;
 


Test patch:
diff --git a/integration/test/ParseLocalDatastoreTest.js b/integration/test/ParseLocalDatastoreTest.js
index 4d7251c..9b01139 100644
--- a/integration/test/ParseLocalDatastoreTest.js
+++ b/integration/test/ParseLocalDatastoreTest.js
@@ -802,6 +802,29 @@ function runTest(controller) {
       assert.equal(localDatastore[`Item_${fetchedItems[0].id}`].foo, 'changed');
       assert.equal(localDatastore[`Item_${fetchedItems[1].id}`].foo, 'changed');
     });
+
+    it(`${controller.name} can update Local Datastore from network`, async () => {
+      const parent = new TestObject();
+      const child = new Item();
+      const grandchild = new Item();
+      child.set('grandchild', grandchild);
+      parent.set('field', 'test');
+      parent.set('child', child);
+      await Parse.Object.saveAll([parent, child, grandchild]);
+      await parent.pin();
+
+      // Updates child with { foo: 'changed' }
+      const params = { id: child.id };
+      await Parse.Cloud.run('TestFetchFromLocalDatastore', params);
+
+      Parse.LocalDatastore.isSyncing = false;
+
+      await Parse.LocalDatastore.updateFromServer();
+
+      const updatedLDS = await Parse.LocalDatastore._getAllContents();
+      const childJSON = updatedLDS[`${child.className}_${child.id}`];
+      assert.equal(childJSON.foo, 'changed');
+    });
   });
 
   describe(`Parse Query Pinning (${controller.name})`, () => {
@@ -2344,7 +2367,7 @@ function runTest(controller) {
       });
     });
 
-    it('supports withinPolygon', async () => {
+    it(`${controller.name} supports withinPolygon`, async () => {
       const sacramento = new TestObject();
       sacramento.set('location', new Parse.GeoPoint(38.52, -121.50));
       sacramento.set('name', 'Sacramento');
@@ -2373,7 +2396,7 @@ function runTest(controller) {
       assert.equal(results.length, 1);
     });
 
-    it('supports polygonContains', async () => {
+    it(`${controller.name} supports polygonContains`, async () => {
       const p1 = [[0,0], [0,1], [1,1], [1,0]];
       const p2 = [[0,0], [0,2], [2,2], [2,0]];
       const p3 = [[10,10], [10,15], [15,15], [15,10], [10,10]];
diff --git a/src/__tests__/LocalDatastore-test.js b/src/__tests__/LocalDatastore-test.js
index 8ca859e..debdbf9 100644
--- a/src/__tests__/LocalDatastore-test.js
+++ b/src/__tests__/LocalDatastore-test.js
@@ -82,9 +82,21 @@ const mockLocalStorageController = {
   clear: jest.fn(),
 };
 jest.setMock('../ParseObject', MockObject);
+
+const mockQueryFind = jest.fn();
+jest.mock('../ParseQuery', () => {
+  return jest.fn().mockImplementation(function () {
+    this.equalTo = jest.fn();
+    this.containedIn = jest.fn();
+    this.limit = jest.fn();
+    this.find = mockQueryFind;
+  });
+});
+
 const CoreManager = require('../CoreManager');
 const LocalDatastore = require('../LocalDatastore');
 const ParseObject = require('../ParseObject');
+const ParseQuery = require('../ParseQuery');
 const RNDatastoreController = require('../LocalDatastoreController.react-native');
 const BrowserDatastoreController = require('../LocalDatastoreController.browser');
 const DefaultDatastoreController = require('../LocalDatastoreController.default');
@@ -572,6 +584,139 @@ describe('LocalDatastore', () => {
     LocalDatastore._traverse(object, encountered);
     expect(encountered).toEqual({ 'Item_1234': object });
   });
+
+  it('do not sync if disabled', async () => {
+    LocalDatastore.isEnabled = false;
+    jest.spyOn(mockLocalStorageController, 'getAllContents');
+
+    await LocalDatastore.updateFromServer();
+    expect(LocalDatastore.isSyncing).toBe(false);
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(0);
+  });
+
+  it('do not sync if syncing', async () => {
+    LocalDatastore.isEnabled = true;
+    LocalDatastore.isSyncing = true;
+
+    jest.spyOn(mockLocalStorageController, 'getAllContents');
+    await LocalDatastore.updateFromServer();
+
+    expect(LocalDatastore.isSyncing).toBe(true);
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(0);
+  });
+
+  it('updateFromServer empty LDS', async () => {
+    LocalDatastore.isEnabled = true;
+    LocalDatastore.isSyncing = false;
+    const LDS = {};
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    jest.spyOn(mockLocalStorageController, 'pinWithName');
+    await LocalDatastore.updateFromServer();
+
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(0);
+  });
+
+  it('updateFromServer on one object', async () => {
+    LocalDatastore.isEnabled = true;
+    LocalDatastore.isSyncing = false;
+    const object = new ParseObject('Item');
+    const LDS = {
+      [`Item_${object.id}`]: object._toFullJSON(),
+      [`${LocalDatastore.PIN_PREFIX}_testPinName`]: [`Item_${object.id}`],
+      [LocalDatastore.DEFAULT_PIN]: [`Item_${object.id}`],
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    object.set('updatedField', 'foo');
+    mockQueryFind.mockImplementationOnce(() => Promise.resolve([object]));
+
+    await LocalDatastore.updateFromServer();
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+    expect(ParseQuery).toHaveBeenCalledTimes(1);
+    const mockQueryInstance = ParseQuery.mock.instances[0];
+
+    expect(mockQueryInstance.equalTo.mock.calls.length).toBe(1);
+    expect(mockQueryFind).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(1);
+  });
+
+  it('updateFromServer handle error', async () => {
+    LocalDatastore.isEnabled = true;
+    LocalDatastore.isSyncing = false;
+    const object = new ParseObject('Item');
+    const LDS = {
+      [`Item_${object.id}`]: object._toFullJSON(),
+      [`${LocalDatastore.PIN_PREFIX}_testPinName`]: [`Item_${object.id}`],
+      [LocalDatastore.DEFAULT_PIN]: [`Item_${object.id}`],
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    object.set('updatedField', 'foo');
+    mockQueryFind.mockImplementationOnce(() => {
+      expect(LocalDatastore.isSyncing).toBe(true);
+      return Promise.reject('Unable to connect to the Parse API')
+    });
+
+    jest.spyOn(console, 'log');
+    await LocalDatastore.updateFromServer();
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+    expect(ParseQuery).toHaveBeenCalledTimes(1);
+    const mockQueryInstance = ParseQuery.mock.instances[0];
+
+    expect(mockQueryInstance.equalTo.mock.calls.length).toBe(1);
+    expect(mockQueryFind).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(0);
+    expect(console.log).toHaveBeenCalledTimes(2);
+    expect(LocalDatastore.isSyncing).toBe(false);
+  });
+
+  it('updateFromServer on mixed object', async () => {
+    LocalDatastore.isEnabled = true;
+    LocalDatastore.isSyncing = false;
+    const obj1 = new ParseObject('Item');
+    const obj2 = new ParseObject('Item');
+    const obj3 = new ParseObject('TestObject');
+    const LDS = {
+      [`Item_${obj1.id}`]: obj1._toFullJSON(),
+      [`Item_${obj2.id}`]: obj2._toFullJSON(),
+      [`TestObject_${obj3.id}`]: obj3._toFullJSON(),
+      [`${LocalDatastore.PIN_PREFIX}_testPinName`]: [`Item_${obj1.id}`],
+      [LocalDatastore.DEFAULT_PIN]: [`Item_${obj1.id}`],
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    mockQueryFind
+      .mockImplementationOnce(() => Promise.resolve([obj1, obj2]))
+      .mockImplementationOnce(() => Promise.resolve([obj3]));
+
+    await LocalDatastore.updateFromServer();
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+    expect(ParseQuery).toHaveBeenCalledTimes(2);
+
+    const mockQueryInstance1 = ParseQuery.mock.instances[0];
+    const mockQueryInstance2 = ParseQuery.mock.instances[1];
+
+    expect(mockQueryInstance1.containedIn.mock.calls.length).toBe(1);
+    expect(mockQueryInstance2.equalTo.mock.calls.length).toBe(1);
+    expect(mockQueryFind).toHaveBeenCalledTimes(2);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(3);
+  });
 });
 
 describe('BrowserDatastoreController', async () => {
diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js
index b4232fb..891d988 100644
--- a/src/__tests__/ParseObject-test.js
+++ b/src/__tests__/ParseObject-test.js
@@ -119,6 +119,7 @@ const mockLocalDatastore = {
   _updateObjectIfPinned: jest.fn(),
   _destroyObjectIfPinned: jest.fn(),
   _updateLocalIdForObject: jest.fn(),
+  updateFromServer: jest.fn(),
   _clear: jest.fn(),
   getKeyForObject: jest.fn(),
   checkIfEnabled: jest.fn(() => {