Source: realtime/query/Stream.js

'use strict';

const Metadata = require('../../lib/util/Metadata');
const lib = require('../../lib/baqend');
const uuid = require('../../lib/util/uuid').uuid;

/**
 * @typedef {object} StreamOptions
 * @property {boolean} initial          Indicates whether or not the initial result set should be delivered on
 *                                      creating the subscription.
 * @property {Array<string>} matchTypes A list of match types.
 * @property {Array<string>} operations A list of operations.
 * @property {number} reconnects        The number of reconnects.
 */

/**
 * @alias query.Stream
 */
class Stream {
  /**
   * Creates a live updating object stream for a query
   *
   * @alias query.Stream.createStream<T>
   * @param {EntityManager} entityManager The owning entity manager of this query
   * @param {string} query The query options
   * @param {string} query.query The serialized query
   * @param {string} query.bucket The Bucket on which the streaming query is performed
   * @param {string=} query.sort the sort string
   * @param {number=} query.limit the count, i.e. the number of items in the result
   * @param {number=} query.offset offset, i.e. the number of items to skip
   * @param {boolean=} query.initial Indicates if the initial result should be returned
   * @param {Partial<StreamOptions>} options an object containing parameters
   * @return {Observable<RealtimeEvent<T>>} The query result as a live updating stream of objects
   */
  static createEventStream(entityManager, query, options) {
    const opt = options || {};
    opt.reconnects = 0;
    return Stream.streamObservable(entityManager, query, opt, (msg, next) => {
      const messageType = msg.type;
      delete msg.type;
      if (messageType === 'result') {
        msg.data.forEach((obj, index) => {
          const event = Object.assign({
            matchType: 'add',
            operation: 'none',
            initial: true,
          }, msg);

          event.data = Stream.resolveObject(entityManager, obj);
          if (query.sort) { event.index = index; }

          next(event);
        });
      }

      if (messageType === 'match') {
        msg.data = Stream.resolveObject(entityManager, msg.data);
        next(msg);
      }
    });
  }

  /**
   * Creates a live updating result stream for a query
   *
   * @alias query.Stream.createStreamResult<T>
   * @param {EntityManager} entityManager The owning entity manager of this query
   * @param {string} query The query options
   * @param {string} query.query The serialized query
   * @param {string} query.bucket The Bucket on which the streaming query is performed
   * @param {string=} query.sort the sort string
   * @param {number=} query.limit the count, i.e. the number of items in the result
   * @param {number=} query.offset offset, i.e. the number of items to skip
   * @param {Partial<StreamOptions>} options an object containing parameters
   * @return {Observable<Array<T>>} The query result as a live updating query result
   */
  static createResultStream(entityManager, query, options) {
    const opt = options || {};
    opt.initial = true;
    opt.matchTypes = 'all';
    opt.operations = 'any';

    let result;
    const ordered = !!query.sort;
    return Stream.streamObservable(entityManager, query, opt, (event, next) => {
      if (event.type === 'result') {
        result = event.data.map(obj => Stream.resolveObject(entityManager, obj));
        next(result.slice());
      }

      if (event.type === 'match') {
        const obj = Stream.resolveObject(entityManager, event.data);

        if (event.matchType === 'remove' || event.matchType === 'changeIndex') {
          // if we have removed the instance our self, we do not have the cached instances anymore
          // therefore we can't find it anymore in the result by identity
          for (let i = 0, len = result.length; i < len; i += 1) {
            if (result[i].id === event.data.id) {
              result.splice(i, 1);
              break;
            }
          }
        }

        if (event.matchType === 'add' || event.matchType === 'changeIndex') {
          if (ordered) {
            result.splice(event.index, 0, obj);
          } else {
            result.push(obj);
          }
        }

        next(result.slice());
      }
    });
  }

  static streamObservable(entityManager, query, options, mapper) {
    const opt = Stream.parseOptions(options);

    const socket = entityManager.entityManagerFactory.websocket;
    const observable = new lib.Observable((subscriber) => {
      const id = uuid();
      const stream = socket.openStream(entityManager.tokenStorage, id);

      stream.send(Object.assign({
        type: 'subscribe',
      }, query, opt));

      let closed = false;
      const next = subscriber.next.bind(subscriber);
      const subscription = stream.subscribe({
        complete() {
          closed = true;
          subscriber.complete();
        },
        error(e) {
          closed = true;
          subscriber.error(e);
        },
        next(msg) {
          mapper(msg, next);
        },
      });

      return () => {
        if (!closed) { // send unsubscribe only when we aren't completed by the socket and call it only once
          stream.send({ type: 'unsubscribe' });
          subscription.unsubscribe();
          closed = true;
        }
      };
    });

    return Stream.cachedObservable(observable, opt);
  }

  static cachedObservable(observable, options) {
    let subscription = null;
    const observers = [];
    return new lib.Observable((observer) => {
      if (!subscription) {
        let remainingRetries = options.reconnects;
        let backoff = 1;
        const subscriptionObserver = {
          next(msg) {
            // reset the backoff if we get a message
            backoff = 1;
            observers.forEach(o => o.next(msg));
          },
          error(e) {
            observers.forEach(o => o.error(e));
          },
          complete() {
            if (remainingRetries !== 0) {
              remainingRetries = remainingRetries < 0 ? -1 : remainingRetries - 1;

              setTimeout(() => {
                subscription = observable.subscribe(subscriptionObserver);
              }, backoff * 1000);

              backoff *= 2;
            } else {
              observers.forEach(o => o.complete());
            }
          },
        };
        subscription = observable.subscribe(subscriptionObserver);
      }
      observers.push(observer);
      return () => {
        observers.splice(observers.indexOf(observer), 1);
        if (!observers.length) {
          subscription.unsubscribe();
          subscription = null;
        }
      };
    });
  }

  /**
   * Parses the StreamOptions
   *
   * @param {Partial<StreamOptions>=} [options] object containing partial options
   * @returns {StreamOptions} an object containing VALID options
   */
  static parseOptions(options) {
    const opt = options || {};

    const verified = {
      initial: opt.initial === undefined || !!opt.initial,
      matchTypes: Stream.normalizeMatchTypes(opt.matchTypes),
      operations: Stream.normalizeOperations(opt.operations),
      reconnects: Stream.normalizeReconnects(opt.reconnects),
    };

    if (verified.matchTypes.indexOf('all') === -1 && verified.operations.indexOf('any') === -1) {
      throw new Error('Only subscriptions for either operations or matchTypes are allowed. You cannot subscribe to a query using matchTypes and operations at the same time!');
    }

    return verified;
  }

  static normalizeMatchTypes(list) {
    return Stream.normalizeSortedSet(list, 'all', 'match types', ['add', 'change', 'changeIndex', 'match', 'remove']);
  }

  static normalizeReconnects(reconnects) {
    if (reconnects === undefined) {
      return -1;
    }
    return reconnects < 0 ? -1 : Number(reconnects);
  }

  static normalizeOperations(list) {
    return Stream.normalizeSortedSet(list, 'any', 'operations', ['delete', 'insert', 'none', 'update']);
  }

  static normalizeSortedSet(list, wildcard, itemType, allowedItems) {
    if (!list) {
      return [wildcard];
    }

    const li = Array.isArray(list) ? list : [list];

    if (li.length === 0) { // undefined or empty list --> default value
      return [wildcard];
    }

    // sort, remove duplicates and check whether all values are allowed
    li.sort();
    let item;
    let lastItem;
    for (let i = li.length - 1; i >= 0; i -= 1) {
      item = li[i];
      if (!item) { // undefined and null item in the list --> invalid!
        throw new Error('undefined and null not allowed!');
      }
      if (item === lastItem) { // remove duplicates
        li.splice(i, 1);
      }
      if (item === wildcard) {
        return [wildcard];
      }
      if (allowedItems.indexOf(item) === -1) { // raise error on invalid elements
        throw new Error(item + ' not allowed for ' + itemType + '! (permitted: ' + allowedItems + '.)');
      }
      lastItem = item;
    }

    return li;
  }

  static resolveObject(entityManager, object) {
    const entity = entityManager.getReference(object.id);
    const metadata = Metadata.get(entity);
    if (!object.version) {
      metadata.setRemoved();
      entityManager.removeReference(entity);
    } else if (entity.version <= object.version) {
      metadata.setJson(object, { persisting: true });
    }
    return entity;
  }
}

module.exports = Stream;