cowbell/splittypie

View on GitHub
app/services/syncer.js

Summary

Maintainability
A
0 mins
Test Coverage
import { alias } from "@ember/object/computed";
import { run, scheduleOnce } from "@ember/runloop";
import { reject, resolve, allSettled } from "rsvp";
import EmberObject, {
  getProperties,
  set,
  get,
  observer
} from "@ember/object";
import Service, { inject as service } from "@ember/service";
import Evented, { on } from "@ember/object/evented";
import Ember from "ember";

const {
    Logger: { debug }
} = Ember;

export default Service.extend(Evented, {
    // Events
    // syncStarted: synchronization started
    // syncCompleted: synchronization completed
    // conflict: conflict found

    store: service(),
    onlineStore: service(),
    connection: service(),
    syncQueue: service(),

    eventListeners: null,

    isOnline: alias("connection.isOnline"),
    isOnlineStateDidChange: on("init", observer("isOnline", function () {
        const isOnline = get(this, "isOnline");

        if (isOnline) {
            this.syncOnline();
        } else {
            this._removeAllListeners();
        }
    })),

    init() {
        this._super(...arguments);
        set(this, "eventListeners", EmberObject.create({}));
    },

    syncOnline() {
        debug("Syncer: Starting online full sync");
        set(this, "isSyncing", true);
        this.trigger("syncStarted");
        return this._reloadOnlineStore()
            .then(this._flushSyncQueue.bind(this))
            .then(this._updateOfflineStore.bind(this))
            .finally(() => {
                debug("Syncer: Full sync has been completed");
                set(this, "isSyncing", false);
                this.trigger("syncCompleted");
            });
    },

    pushEventOffline(onlineEvent) {
        debug(`Syncer: Syncing online event ${get(onlineEvent, "name")} into offline store`);

        return this._pushToStore(get(this, "store"), onlineEvent);
    },

    pushEventOnline(offlineEvent) {
        return this._pushToStore(get(this, "onlineStore"), offlineEvent).then((onlineEvent) => {
            set(offlineEvent, "isOffline", false);
            this._listenForChanges(onlineEvent);
            return offlineEvent.save();
        });
    },

    _reloadOnlineStore() {
        get(this, "onlineStore").unloadAll();
        this._removeAllListeners();

        return get(this, "store")
            .findAll("event")
            .then(events => events.map(this._fetchOnlineEvent.bind(this)))
            .then(fetchOperations => allSettled(fetchOperations));
    },

    _flushSyncQueue() {
        return get(this, "syncQueue").flush();
    },

    _updateOfflineStore() {
        debug("Syncer: Updating Offline Store");

        return get(this, "store")
            .findAll("event")
            .then(events => events.rejectBy("isOffline", true))
            .then(events => events.map(this._replaceOfflineEvent.bind(this)))
            .then(operations => allSettled(operations));
    },

    _fetchOnlineEvent(offlineEvent) {
        const { id, isOffline } = getProperties(offlineEvent, "id", "isOffline");

        if (isOffline) {
            return resolve();
        }

        this._unloadOnlineEvent(id);

        return get(this, "onlineStore")
            .findRecord("event", id)
            .catch(this._onlineEventNotFound.bind(this, offlineEvent));
    },

    _replaceOfflineEvent(offlineEvent) {
        return get(this, "onlineStore")
            .findRecord("event", get(offlineEvent, "id"))
            .then((onlineEvent) => {
                this.pushEventOffline(onlineEvent);
                this._listenForChanges(onlineEvent);
            });
    },

    _syncOneEvent(offlineEvent) {
        this._fetchOnlineEvent(offlineEvent).then(() => {
            this._replaceOfflineEvent(offlineEvent);
        }).catch(() => { });
    },

    _onlineEventNotFound(offlineEvent, error) {
        const { id, name } = getProperties(offlineEvent, "id", "name");

        debug(`Syncer: Event ${name} not found online`);
        debug("Syncer: Setting event as offline - it will be available to manual sync");
        set(offlineEvent, "isOffline", true);
        this._removeListenerFor(id);
        this.trigger("conflict", {
            modelName: "event",
            type: "not-found-online",
            model: {
                id,
                name,
            },
        });

        return offlineEvent.save().then(() => reject(error));
    },

    _pushToStore(store, model) {
        const normalized = this._normalizeModel(model);

        return store.push(normalized).save();
    },

    _normalizeModel(model) {
        const store = get(this, "store");
        const snapshot = model._createSnapshot();
        const serializer = store.serializerFor(snapshot.modelName);
        const serialized = serializer.serialize(snapshot, { includeId: true });

        return store.normalize(snapshot.modelName, serialized);
    },

    _unloadOnlineEvent(id) {
        const event = get(this, "onlineStore").peekRecord("event", id);

        if (event) {
            get(this, "onlineStore").unloadRecord(event);
            this._removeListenerFor(id);
        }
    },

    // workaround to keep firebase realtime function
    _listenForChanges(onlineEvent) {
        const eventListeners = get(this, "eventListeners");
        const eventId = get(onlineEvent, "id");
        let isInitial = true;

        if (!eventListeners[eventId]) {
            const ref = onlineEvent.ref();

            onlineEvent.ref().on("value", (snapshot) => {
                run(() => {
                    // don't listen for initial on value
                    if (isInitial) {
                        isInitial = false;
                        return;
                    }
                    if (get(this, "isSyncing") || get(this, "syncQueue.isProcessing")) {
                        return;
                    }

                    const onlineEventId = snapshot.key;

                    // some changes in firebase not coming from this application instance
                    // schedule sync
                    scheduleOnce("actions", () => {
                        get(this, "store")
                            .findRecord("event", onlineEventId)
                            .then(this._syncOneEvent.bind(this));
                    });
                });
            });
            eventListeners[eventId] = ref;
            set(this, "eventListeners", eventListeners);
        }
    },

    _removeAllListeners() {
        Object.keys(get(this, "eventListeners")).forEach(this._removeListenerFor.bind(this));
    },

    _removeListenerFor(eventId) {
        const eventListeners = get(this, "eventListeners");
        const ref = eventListeners[eventId];

        if (ref) {
            ref.off("value");
            delete eventListeners[eventId];
        }
    },
});