bucket/Actions.js

/**
 *  S3 is NOT a DB
 *  Simple interface to using Amazon S3 as a database.
 *
 *  Copyright 2023, Marc S. Brooks (https://mbrooks.info)
 *  Licensed under the MIT license:
 *  http://www.opensource.org/licenses/mit-license.php
 */

'use strict';

// Local modules.
const Client = require('../Client');
const Utils  = require('../Utils');

const {
  throwError
} = require('../Errors');

/**
 * Provides bucket actions.
 */
class BucketActions {
  #client;
  #name;
  #dataFields;
  #outputType;
  #prefixPath;
  #lockOwner;

  /**
   * @param {Object} bucket
   *   S3 Bucket name.
   *
   * @param {Object} region
   *   S3 Region name.
   *
   * @example
   * const actions = new Actions('s3-is-not-a-db', 'us-east-1');
   */
  constructor(bucket, region) {
    this.#client = new Client(bucket, region);
  }

  // Getters.
  get name() {
    return this.#name;
  }

  get dataFields() {
    return this.#dataFields;
  }

  get outputType() {
    return this.#outputType;
  }

  get prefixPath() {
    return this.#prefixPath;
  }

  // Setters.
  set name(value) {
    this.#name = value;
  }

  set dataFields(value) {
    this.#dataFields = value;
  }

  set outputType(value) {
    this.#outputType = value;
  }

  set prefixPath(value) {
    this.#prefixPath = value;
  }

  /**
   * List objects.
   *
   * @example
   * actions.prefix = 'path/to/objects';
   *
   * const objects = await actions.list();
   * // ['foo.ext', 'bar.ext', 'biz.ext', 'baz.ext']
   *
   * @return {Promise<Object|Error>}
   */
  async list() {
    return await this.#client.list(this.#prefixPath);
  }

  /**
   * Delete object.
   *
   * @param {String} keyName
   *   Object name.
   *
   * @return {Promise<Object|Error>}
   *
   * @example
   * actions.prefix = 'path/to/object';
   *
   * await actions.delete('keyName');
   */
  async delete(keyName) {
    if (!this.lockOwner && await this.isLocked(keyName)) {
      throwError('OBJECT_LOCK_EXISTS', keyName);
    }

    return await this.#client.delete(`${this.#prefixPath}/${keyName}`);
  }

  /**
   * Fetch object.
   *
   * @param {String} keyName
   *   Object name.
   *
   * @return {Promise<Object|Error>}
   *
   * @example
   * actions.prefix = 'path/to/object';
   *
   * const data = await actions.fetch('keyName');
   */
  async fetch(keyName) {
    if (!this.lockOwner && await this.isLocked(keyName)) {
      throwError('OBJECT_LOCK_EXISTS', keyName);
    }

    const data = await this.#client.fetch(`${this.#prefixPath}/${keyName}`);

    if (data) {
      switch (this.#outputType) {
        case 'base64':
          return await data.transformToString('base64');

        case 'blob':
          return await data.transformToByteArray();

        case 'json':
          return JSON.parse(await data.transformToString());

        default:
          return await data.transformToString();
      }
    }
  }

  /**
   * Write object.
   *
   * @param {String} keyName
   *   Object name.
   *
   * @param {String|Buffer} data
   *   Object data.
   *
   * @param {String} contentType
   *   Object content type (default: 'text/plain')
   *
   * @return {Promise<Object|Error>}
   *
   * @example
   * actions.prefix = 'path/to/object';
   *
   * await actions.write('keyName', 'foo');
   *   ..
   *
   * await actions.write('keyName', {foo1: 'bar'});
   *   ..
   *
   * await actions.write('keyName', <Buffer>, 'image/jpeg; charset=utf-8');
   */
  async write(keyName, data, contentType = 'text/plain') {
    if (!this.lockOwner && await this.isLocked(keyName)) {
      throwError('OBJECT_LOCK_EXISTS', keyName);
    }

    if (this.#dataFields && Utils.isObject(data)) {

      // Validate object keys.
      if (!this.isValidData(data)) {
        const keyDiff = `${Object.keys(data)} <> ${this.#dataFields}`;

        throwError('INVALID_MODEL_FIELDS', keyDiff, this.#name);
      }

      contentType = 'application/json';

      // Convert object to JSON
      data = JSON.stringify(data);
    }

    return await this.#client.write(`${this.#prefixPath}/${keyName}`, data, contentType);
  }

  /**
   * Rename object.
   *
   * @param {String} oldKeyName
   *   Old Object as string.
   *
   * @param {String} newKeyName
   *   New object as string.
   *
   * @return {Promise<Object|Error>}
   *
   * @example
   * actions.prefix = 'path/to/object';
   *
   * await actions.rename('keyName1', 'keyName2');
   */
  async rename(oldKeyName, newKeyName) {
    if (!this.lockOwner && await this.isLocked(oldKeyName)) {
      throwError('OBJECT_LOCK_EXISTS', oldKeyName);
    }

    return await this.#client.rename(
      `${this.#prefixPath}/${oldKeyName}`, `${this.#prefixPath}/${newKeyName}`
    );
  }

  /**
   * Check object exists.
   *
   * @param {String} keyName
   *   Object name.
   *
   * @return {Promise<Object|Boolean|Error>}
   *
   * @example
   * actions.prefix = 'path/to/object';
   *
   * const exists = await actions.exists('keyName');
   */
  async exists(keyName) {
    return await this.#client.exists(`${this.#prefixPath}/${keyName}`);
  }

  /**
   * Execute a batch operation in sequential order.
   * Use "Pessimistic Locking" for data integrity.
   *
   * @param {String} keyName
   *   Object name.
   *
   * @param {Array<Promise>} actions
   *   Array of promised Actions.
   *
   * @return {Promise|Error}
   *
   * @example
   * const keyName = 'file.json';
   * const operations = [];
   *
   * // Fetch the object.
   * operations.push(() => {
   *   return actions.fetch(keyName);
   * });
   *
   * // Update existing data.
   * operations.push(data => {
   *   return actions.write(keyName, {...data, foo: 'bar'}));
   * });
   *
   * actions.batch(keyName, operations)
   *   .catch(function(err) {
   *     console.warn(err.message);
   *   });
   */
  async batch(keyName, actions) {

    // Set exclusive lock (first operation).
    actions.unshift(() => {
      return this.lockObject(keyName);
    });

    // Remove exclusive lock (last operation).
    actions.push(() => {
      return this.unlockObject(keyName)
        .then(() => {
          this.lockOwner = null;
        });
    });

    return actions.reduce(function(current, next) {
      return current.then(next);
    }, Promise.resolve([]))
      .catch(function(err) {
        throw new Error(err.message);
      });
  }

  /**
   * Check object lock exists.
   *
   * @param {String} keyName
   *   Object name.
   *
   * @return {Promise<Boolean|Error>}
   *
   * @example
   * actions.prefix = 'path/to/object';
   *
   * const result = await actions.isLocked('keyName');
   */
  async isLocked(keyName) {
    const data = await this.exists(`${keyName}.lock`);

    const ownerId = data?.Metadata?.ownerId;

    if (ownerId && ownerId !== this.#lockOwner) {
      return false;
    }

    return !!data;
  }

  /**
   * Create object lock.
   *
   * @param {String} keyName
   *   Object name.
   *
   * @return {Promise<undefined|Error>}
   *
   * @example
   * actions.prefix = 'path/to/object';
   *
   * await actions.lockObject('keyName');
   */
  async lockObject(keyName) {
    const data = await this.isLocked(keyName);

    if (data) {
      throwError('OBJECT_LOCK_EXISTS', keyName);
    }

    const ownerId = Utils.genRandomStr();

    await this.write(keyName, '', {metaData: {ownerId}});

    this.lockOwner = ownerId;
  }

  /**
   * Remove object lock.
   *
   * @param {String} keyName
   *   Object name.
   *
   * @return {Promise<undefined|Error>}
   *
   * @example
   * actions.prefix = 'path/to/object';
   *
   * await actions.unlockObject('keyName');
   */
  async unlockObject(keyName) {
    const data = await this.isLocked(keyName);

    if (data) {
      await this.delete(keyName);
    }
  }

  /**
   * Check object keys match Model fields.
   *
   * @param {Object} obj
   *   Data as object.
   *
   * @return {Boolean}
   *
   * @example
   * const result = actions.isValidData({foo: true, bar: false});
   */
  isValidData(obj) {
    return Utils.compareArrays(this.#dataFields, Object.keys(obj));
  }
}

module.exports = BucketActions;