orbitjs / orbit

Composable data framework for ambitious web applications.
https://orbitjs.com
MIT License
2.33k stars 134 forks source link

Sync strategy triggered by external events #887

Open ef4 opened 3 years ago

ef4 commented 3 years ago

I have an @orbit/local-storage source and I wrote a strategy for keeping this source updated in response to changes made by other browser tabs (which fires the 'storage' event on window). This is working, but my question is whether there is a better way to do this that's less manual and that preserves operation IDs instead of creating new ones.

Here is the relevant bit that we run each time we hear the event about a record:

 async _update(type, id) {
    // local is `@orbitjs/local-storage`
    // store is ember-orbit's default in-memory store
    let [local, store] = this.sources;
    let dbRecord = await local.query((q) => q.findRecord({ type, id }));
    let memoryRecord = store.cache.query((q) => q.findRecord({ type, id }));
    await store.sync((t) => {
      if (dbRecord && memoryRecord) {
        return t.updateRecord(dbRecord);
      } else if (dbRecord) {
        return t.addRecord(dbRecord);
      } else if (memoryRecord) {
        return t.removeRecord({ type, id });
      }
    });
  }

It seems quite manual and I suspect I'm missing a better solution.

The full strategy:

import { Strategy } from '@orbit/coordinator';

export default class extends Strategy {
  constructor() {
    super({
      name: 'local-store-sync',
      sources: ['local', 'store'],
    });
  }
  static create() {
    return new this();
  }

  async activate(...args) {
    await super.activate(...args);
    await this._initialSync();
    this._listener = (event) => {
      let [local] = this.sources;
      if (event.key?.startsWith(local.namespace + '/')) {
        let [type, id] = event.key.slice(local.namespace.length + 1).split('/');
        this._update(type, id);
      }
    };
    window.addEventListener('storage', this._listener);
  }

  async deactivate() {
    window.removeEventListener('storage', this._listener);
    await super.deactivate();
  }

  async _initialSync() {
    let [local, store] = this.sources;
    let dbRecords = await local.query((q) => q.findRecords());
    await store.sync((t) => dbRecords.map((r) => t.addRecord(r)));
  }

  async _update(type, id) {
    let [local, store] = this.sources;
    let dbRecord = await local.query((q) => q.findRecord({ type, id }));
    let memoryRecord = store.cache.query((q) => q.findRecord({ type, id }));
    await store.sync((t) => {
      if (dbRecord && memoryRecord) {
        return t.updateRecord(dbRecord);
      } else if (dbRecord) {
        return t.addRecord(dbRecord);
      } else if (memoryRecord) {
        return t.removeRecord({ type, id });
      }
    });
  }
}
dgeb commented 3 years ago

I've wanted to add a storage listener to the LocalStorageSource for some time. In this way, the source could just transform browser storage events into transform events. You could then use a simple SyncStrategy from your local source back to your store to keep them in sync.

The implementation of the storage listener would look quite similar to what you have. However, instead of inspecting what's already in the store, it could look at the newValue and oldValue properties on the storage event to decipher what change occurred.

I think the one catch here, which you mention, is that you may wish to maintain transform IDs across browser tabs. I think this could be achieved by storing IDs as some hidden metadata (e.g. __transform_id__) inside each record. But it gets tricky because you'd need to wait a "tick" to collect all the changes per transform 🤔

ef4 commented 3 years ago

You could then use a simple SyncStrategy from your local source back to your store to keep them in sync.

Yeah, that is the first thing I tried.

the source could just transform browser storage events into transform events

Ah, this is what I was missing. That seems like a good simplification, even without also addressing the question of stable transform ids.