Source: models/helpers/revision.js

'use strict';
const thinky = require('../../db');
const r = thinky.r;
const type = thinky.type;

/**
 * Common handler functions for managing revisions. These are typically attached
 * to models as static or instance methods, see models/thing.js for examples.
 *
 * @namespace Revision
 */

const revision = {

  /**
   * Get a function that lets us create a new revision (including saving a copy
   * of the current revision) for the given Model
   *
   * @param {Model} Model
   *  the Model we need a handler for
   * @returns {Function}
   *  function that can be attached as an instance method to the Model via
   *  `Model.define`. See {@link Revision~_newRevision}.
   * @memberof Revision
   */
  getNewRevisionHandler(Model) {

    /**
     * Function obtained via {@link Revision.getNewRevisionHandler}.
     * Save a copy of the current revision as an old revision and create (but
     * do not save) a new revision object.
     *
     * @param {User} user
     *  the user to associate with this revision
     * @param {Object} [options]
     *  revision options
     * @param {String[]} options.tags
     *  set of tags to associate with this revision
     * @returns {Model}
     *  new revision of the given Model
     * @memberof Revision
     * @inner
     * @this model
     */
    const _newRevision = async function(user, { tags } = {}) {
      let newRev = this;
      // Archive current revision
      let oldRev = new Model(newRev);
      oldRev._oldRevOf = newRev.id;
      oldRev.id = undefined;
      await oldRev.save();
      const uuid = await r.uuid();
      newRev._revID = uuid;
      newRev._revUser = user.id;
      newRev._revDate = new Date();
      newRev._revTags = tags;
      return newRev;
    };
    return _newRevision;
  },

  /**
   * Shortcut for a filter operation that narrows a table to current revisions,
   * commonly chained with other filters.
   *
   * @param {Model} Model
   *  the table to filter
   * @returns {Function}
   *  function that returns a Query object for this Model
   * @memberof Revision
   */
  getNotStaleOrDeletedFilterHandler(Model) {

    /**
     * Function obtained via {@link Revision.getNotStaleOrDeletedFilterHandler}.
     *
     * @returns {Query}
     *  revisions that are not flagged as outdated or deleted
     * @memberof Revision
     * @inner
     */
    const _filterNotStaleOrDeleted = () => Model
      .filter({ _oldRevOf: false }, { default: true })
      .filter({ _revDeleted: false }, { default: true });
    return _filterNotStaleOrDeleted;
  },

  /**
   * Shortcut for a getAll/filter operation that returns objects matching a
   * given list of IDs.
   *
   * @param {Model} Model
   *  the table to filter
   * @returns {Function}
   *  function that returns a Query object for this Model
   * @memberof Revision
   */
  getMultipleNotStaleOrDeletedHandler(Model) {

    /**
     * Function obtained via {@link Revision.getMultipleNotStaleOrDeletedHandler}.
     *
     * @returns {Query}
     *  objects that are not flagged as outdated or deleted
     * @param {String[]} idArray
     *  an array of primary key IDs
     * @memberof Revision
     * @inner
     */
    const getMultipleNotStaleOrDeleted = idArray => Model
      .getAll(...idArray, { index: 'id' })
      .filter({ _revDeleted: false }, { default: true })
      .filter({ _oldRevOf: false }, { default: true });
    return getMultipleNotStaleOrDeleted;
  },

  /**
   * Get handler to `.get()` an object by its ID and reject with standardized
   * error if it is an old or deleted revision.
   *
   * @param {Model} Model
   *  the table to query with this handler
   * @returns {Function}
   *  function we can attach as a static method to the Model via
   * `Model.getNotStaleOrDeleted = fn`. See
   * {@link Revision~_getNotStaleOrDeleted}.
   * @memberof Revision
   */
  getNotStaleOrDeletedGetHandler(Model) {

    /**
     * Function obtained via {@link Revision.getNotStaleOrDeletedGetHandler}.
     *
     * @param {String} id
     *  the ID to look up
     * @param {Object} [join]
     *  an object specifying a join to another table
     * @returns {Model}
     *  an object of the specified Model
     * @memberof Revision
     * @inner
     */
    const _getNotStaleOrDeleted = async (id, join) => {
      let data;
      if (typeof join == 'object')
        data = await Model.get(id).getJoin(join);
      else
        data = await Model.get(id);

      if (data._revDeleted)
        throw revision.deletedError;
      else if (data._oldRevOf)
        throw revision.staleError;
      else
        return data;
    };
    return _getNotStaleOrDeleted;
  },


  /**
   * Get a function that lets us create the first revision of a given model.
   * Does not save.
   *
   * @param {Model} Model
   *  the Model we want to attach the handler to
   * @returns {Function}
   *  function we can attach as a static method to the Model via
   *  `Model.createFirstRevision = fn`. See
   *  {@link Revision~_createFirstRevision}.
   * @memberof Revision
   */
  getFirstRevisionHandler(Model) {

    /**
     * Function obtained via {@link Revision.getFirstRevisionHandler}.
     * Create (but don't save) the initial revision for an object of this Model.
     * Asynchronously obtains UUID.
     *
     * @param {User} user
     *  the user to associate with this revision
     * @param {Object} [options]
     *  revision options
     * @param {String[]} options.tags
     *  set of tags to associate with this revision
     * @returns {Model}
     *  first revision
     * @memberof Revision
     * @inner
     */
    const _createFirstRevision = async function(user, { tags } = {}) {
      let firstRev = new Model({});
      const uuid = await r.uuid();
      firstRev._revID = uuid;
      firstRev._revUser = user.id;
      firstRev._revDate = new Date();
      firstRev._revTags = tags;
      return firstRev;
    };
    return _createFirstRevision;
  },


  /**
   * Get a function that lets us mark all revisions of a given object as
   * deleted (they are not actually removed) and save the deletion metadata
   * as a new revision.
   *
   * @param {Model} Model
   *  the Model we want to attach the handler to
   * @returns {Function}
   *  function we can attach as an instance method via `Model.define`. See
   *  {@link Revision~_deleteAllRevisions}.
   * @memberof Revision
   */
  getDeleteAllRevisionsHandler(Model) {


    /**
     * Function obtained via {@link Revision.getDeleteAllRevisionsHandler}.
     * Creates and saves a new revision with deletion metadata as well.
     *
     * @param {User} user
     *  the user we want to associate with this deletion action
     * @param {Object} [options]
     *  revision options
     * @param {String[]} options.tags
     *  set of tags to associate with the deletion revision. The first tag will
     *  always be 'delete', but you can specify, e.g., the method by which
     *  the deletion occurred.
     * @returns {Model}
     *  revision with deletion metadata
     * @memberof Revision
     * @inner
     */
    const _deleteAllRevisions = async function(user, {
      tags = []
    } = {}) {
      const id = this.id;
      tags.unshift('delete');

      // A deletion results in a new revision which contains
      // information about the deletion itself (date, user who
      // performed it, tags, etc.)
      const rev = await this.newRevision(user, { tags });
      rev._revDeleted = true;
      await rev.save();

      // Update all other rows
      await Model.filter({ _oldRevOf: id }).update({ _revDeleted: true });
      return rev;
    };
    return _deleteAllRevisions;
  },


  /**
   * Obtain a copy of the standard revision schema.
   *
   * @returns {Object}
   *  object that can be assigned via Object.assign to the schema object
   *  for your Model
   * @memberof Revision
   */
  getSchema() {
    return {
      _revUser: type
        .string()
        .required(true),
      _revDate: type
        .date()
        .required(true),
      _revID: type
        .string()
        .uuid(4)
        .required(true), // Set this for all revisions, including current
      _oldRevOf: type.string(), // Only set if it's an old revision of an existing thing
      _revDeleted: type.boolean(), // Set to true for all deleted revisions (not all revisions have to be deleted)
      _revTags: [type.string()] // Optional tags to describe action performed through this revision, e.g. edit, delete, etc.
    };
  }
};

revision.deletedError = new Error('Revision has been deleted.');
revision.staleError = new Error('Outdated revision.');
revision.deletedError.name = 'RevisionDeletedError';
revision.staleError.name = 'RevisionStaleError';

module.exports = revision;