Source: types/state.js

'use strict';

// Constants
const {
  MAX_MESSAGE_SIZE
} = require('../constants');

// Dependencies
const crypto = require('crypto');
const monitor = require('fast-json-patch');
const pointer = require('json-pointer');

// Fabric Types
const Actor = require('./actor');

// Local Services
const json = require('../functions/json');

/**
 * The {@link State} is the core of most {@link User}-facing interactions.  To
 * interact with the {@link User}, simply propose a change in the state by
 * committing to the outcome.  This workflow keeps app design quite simple!
 * @access protected
 * @augments EventEmitter
 * @property {Number} size Size of state in bytes.
 * @property {Buffer} @buffer Byte-for-byte memory representation of state.
 * @property {String} @type Named type.
 * @property {Mixed} @data Local instance of the state.
 * @property {String} @id Unique identifier for this data.
 */
class State extends Actor {
  /**
   * Creates a snapshot of some information.
   * @param  {Mixed} data Input data.
   * @return {State}      Resulting state.
   */
  constructor (data = {}) {
    super(data);

    this['@input'] = data || null;
    this['@data'] = data || {};
    this['@meta'] = {};
    this['@encoding'] = 'json';

    // Literal Entity Structure
    this['@entity'] = {
      '@type': 'State',
      '@data': data
    };

    // TODO: test and document memory alignment
    // this['@buffer'] = Buffer.alloc(Constants.MAX_MESSAGE_SIZE);
    this['@allocation'] = Buffer.alloc(MAX_MESSAGE_SIZE);
    this['@buffer'] = Buffer.from(this.serialize(this['@entity']['@data']));

    // if not destined to be an object...
    if (typeof this['@data'] === 'string') {
      this['@entity']['@type'] = 'String';
      this['@entity']['@data'] = this['@data'].split('').map(x => x.charCodeAt(0));
    } else if (this['@data'] instanceof Array) {
      this['@entity']['@type'] = 'Array';
    } else if (this['@data'] instanceof Buffer) {
      this['@entity']['@type'] = 'Buffer';
    } else if (
      this['@data'] &&
      this['@data']['@type'] &&
      this['@data']['@data']
    ) {
      switch (this['@data']['@type']) {
        default:
          this['@entity']['@type'] = this['@data']['@type'];
          this['@entity']['@data'] = this['@data']['@data'];
          break;
      }
    } else {
      this['@entity']['@type'] = 'Object';
      this['@entity']['@data'] = data;
    }

    // start at zero
    this._clock = 0;

    // set various #meta
    this['@type'] = this['@entity']['@type'];
    // this['@id'] = null;
    // this['@id'] = this.id;

    // set internal data
    this.services = { json };
    this.name = this['@entity'].name || this.id;

    if (this['@entity']['@data']) {
      try {
        this.observer = monitor.observe(this['@entity']['@data']);
      } catch (E) {
        console.error('Could not create observer:', E, this['@entity']['@data']);
      }
    }

    this.value = {};

    // TODO: document hidden properties
    // Remove various undesired clutter from output
    Object.defineProperty(this, '@allocation', { enumerable: false });
    Object.defineProperty(this, '@buffer', { enumerable: false });
    Object.defineProperty(this, '@encoding', { enumerable: false });
    Object.defineProperty(this, 'key', { enumerable: false });
    Object.defineProperty(this, 'services', { enumerable: false });

    Object.defineProperty(this, 'size', {
      enumerable: true,
      get: function count () {
        return this['@buffer'].length;
      }
    });

    Object.defineProperty(this, 'domain', {
      enumerable: false
    });

    Object.defineProperty(this, '_events', {
      enumerable: false
    });

    Object.defineProperty(this, '_eventsCount', {
      enumerable: false
    });

    Object.defineProperty(this, '_maxListeners', {
      enumerable: false
    });

    return this;
  }

  static get json () {
    return json;
  }

  static get html () {
    return json;
  }

  static get pointer () {
    return pointer;
  }

  get path () {
    return `/entities/${this.id}`;
  }

  get state () {
    return this.value;
    // TODO: re-enable the below, map security considerations
    // return Object.assign({}, this.value);
  }

  set path (value) {
    return this.path;
  }

  set state (value) {
    this.value = value;
  }

  /**
   * Marshall an input into an instance of a {@link State}.  States have
   * absolute authority over their own domain, so choose your States wisely.
   * @param  {String} input Arbitrary input.
   * @return {State}       Resulting instance of the {@link State}.
   */
  static fromJSON (input) {
    let result = null;

    if (typeof input === 'string') {
      try {
        result = JSON.parse(input);
      } catch (E) {
        console.error('Failure in fromJSON:', E);
      }
    }

    return result;
  }

  static fromHex (input) {
    if (typeof input !== 'string') return null;
    return this.fromJSON(Buffer.from(input, 'hex').toString('utf8'));
  }

  static fromString (input) {
    if (typeof input !== 'string') return null;
    return this.fromJSON(input);
  }

  sha256 (value) {
    return crypto.createHash('sha256').update(value).digest('hex');
  }

  async _applyChanges (ops) {
    try {
      monitor.applyPatch(this['@data'], ops);

      await this.commit();
    } catch (E) {
      this.error('Error applying changes:', E);
    }

    return this;
  }

  fingerprint () {
    const map = {};
    map['@method'] = 'sha256';
    map['@input'] = this.serialize(this['@entity']['@data']);
    map['@buffer'] = crypto.createHash('sha256').update(map['@input'], 'utf8');
    map['@output'] = map['@buffer'].digest('hex');
    return map['@output'];
  }

  isRoot () {
    return this['@parent'] === this.id;
  }

  toBuffer () {
    if (this['@data'] instanceof Buffer) return this['@data'];
    if (this['@data']) return this.serialize();

    return Buffer.from(this['@data']['@data']);
  }

  /** Converts the State to an HTML document. */
  toHTML () {
    const state = this;
    const solution = state['@output'].toString('utf8');
    const confirmed = String(solution);
    const raw = `<html>X-Claim-ID: ${this.id}
X-Claim-Integrity: sha256
X-Claim-Type: Response
X-Claim-Result: ${state.id}
Body:
# STOP!
Here is your opportunity to read the documentation: https://dev.fabric.pub

Document ID: ${this.id}
Document Type (local JSON): ${this.constructor.name}
Document Path: ${this.path}
Document Name: ${this.name}
Document Integrity: sha256:${this.id}
Document Data (local JSON, <${confirmed.length}> bytes: ${confirmed}
Document Source:
\`\`\`
${confirmed}
\`\`\`

## Source Code
### Free as in _freedom_.
Labs: https://github.com/FabricLabs

To edit this message, visit this URL: https://github.com/FabricLabs/fabric/edit/master/types/state.js

## Onboarding
When you're ready to continue, visit the following URL: https://dev.fabric.pub/WELCOME.html</html>
`;

    return raw;
  }

  /**
   * Unmarshall an existing state to an instance of a {@link Blob}.
   * @return {String} Serialized {@link Blob}.
   */
  toString () {
    return this.serialize();
  }

  overlay (data) {
    let state = new State(data);
    this['@parent'] = this['@id'];
    this['@data'] = Object.assign({}, this['@data'], state['@data']);
    this['@did'] = `did:fabric:${this.id}`;
    this['@id'] = this.id;
    return this;
  }

  pack (data) {
    if (!data) data = this['@data'];
    return json(data);
  }

  /**
   * Convert to {@link Buffer}.
   * @param  {Mixed} [input] Input to serialize.
   * @return {Buffer}       {@link Store}-able blob.
   */
  serialize (input = this.state, encoding = 'json') {
    const state = {};
    let result = null;

    if (typeof input === 'string') {
      return Buffer.from(`${json(input)}`, 'utf8');
    } else if (input instanceof Array) {
      result = Buffer.from(`${JSON.stringify(input)}`, 'utf8');
    } else if (input instanceof Buffer) {
      result = input;
    } else if (input['@type'] && input['@data']) {
      return this.serialize(input['@data']);
    } else {
      switch (input.constructor.name) {
        case 'Function':
          result = Buffer.from(input.toString('utf8'));
          break;
        case 'Boolean':
          result = Buffer.from(JSON.stringify(input));
          break;
        case 'Buffer':
          result = Buffer.from(JSON.stringify(input.toString('utf8')));
          break;
        case 'Object':
          result = Buffer.from(JSON.stringify(input));
          break;
        default:
          result = input.toString('utf8');
          break;
      }

      // strip special fields
      // TODO: order?
      for (const name in input) {
        if (name.charAt(0) === '@') {
          continue;
        } else {
          state[name] = input[name];
        }
      }
    }

    return JSON.parse(json(result));
  }

  /**
   * Take a hex-encoded input and convert to a {@link State} object.
   * @param  {String} input [description]
   * @return {State}       [description]
   */
  deserialize (input) {
    let output = null;

    if (typeof input === 'string') {
      // Let's create a state object...
      try {
        let state = new State(input);
        // Assign our output to the state data
        output = state['@data'];
      } catch (E) {
        this.error('Could not parse string as Buffer:', E);
      }

      return output;
    } else {
      this.log('WARNING:', `input not a string`, input);
    }

    if (!output) return null;

    switch (output['@type']) {
      case 'String':
        output = output['@buffer'].toString(output['@encoding']);
        break;
    }

    return output;
  }

  flatten () {
    let map = {};

    for (let k in this['@data']) {
      map[k] = this.serialize(this['@data'][k]);
    }

    return map;
  }

  /**
   * Creates a new child {@link State}, with `@parent` set to
   * the current {@link State} by immutable identifier.
   * @returns {State}
   */
  fork () {
    let data = Object.assign({
      '@parent': this.id
    }, this['@data']);
    return new State(data);
  }

  /**
   * Retrieve a key from the {@link State}.
   * @param {Path} path Key to retrieve.
   * @returns {Mixed}
   */
  get (path = '') {
    // return pointer.get(this.state, path);
    let result = null;
    try {
      result = pointer.get(this['@entity']['@data'], path);
    } catch (exception) {
      console.error('[FABRIC:STATE]', 'Could not retrieve path:', path, pointer.get(this['@entity']['@data'], '/'), exception);
    }
    return result;
  }

  /**
   * Set a key in the {@link State} to a particular value.
   * @param {Path} path Key to retrieve.
   * @returns {Mixed}
   */
  set (path, value) {
    // console.log('setting:', path, value);
    pointer.set(this.value, path, value);
    pointer.set(this['@entity']['@data'], path, value);
    const result = pointer.set(this.value, path, value);
    this.commit();
    return result;
  }

  /**
   * Increment the vector clock, broadcast all changes as a transaction.
   */
  commit () {
    ++this._clock;

    this['@parent'] = this.id;
    this['@preimage'] = this.toString();
    this['@constructor'] = this.constructor;

    if (this.observer) {
      this['@changes'] = monitor.generate(this.observer);
    }

    this['@id'] = this.id;

    if (this['@changes'] && this['@changes'].length) {
      this.emit('changes', this['@changes']);
      this.emit('state', this['@state']);
      this.emit('message', {
        '@type': 'Transaction',
        '@data': {
          'changes': this['@changes'],
          'state': this['@changes']
        }
      });
    }

    return this;
  }

  /**
   * Compose a JSON string for network consumption.
   * @return {String} JSON-encoded {@link String}.
   */
  render () {
    this['@id'] = this.id;
    this['@encoding'] = 'json';
    this['@output'] = this.serialize(this.state, 'json');
    this['@commit'] = this.commit();

    return this['@output'].toString('utf8');
  }
}

module.exports = State;