state-log.js

import { MerkleJson } from "merkle-json/index.js"

/**
 * StateLog is a logger that uses a compact JSON data representation
 * for applications requiring periodic logging.
 * StateLog instances only grow when state changes.
 * 
 * ### Discrete Time Axis
 * StateLog relies on an infinite discrete time axis to log 
 * periodic events separated by a client-defined interval. 
 * The client must therefore provide exactly one state update 
 * for each interval. Logged states increases in age until
 * they change. State change is determined by a Merkle tree hash.
 * The default state is *undefined*.
 *
 * ### Syncronization
 * Synchronization of updates is a responsibility shared between
 * client and StateLog. The client is responsible for periodic
 * logging--StateLog is NOT designed for intermittent ad-hoc logging.
 * Although periodic logging is required, strict adherence to 
 * discrete time points is not required. StateLog will deduce the
 * discrete time point correspoinding to each update.
 * Clients should therefore be aware that the time of any logged event
 * is recorded at the granularity of the logging interval, not the actual
 * time of the event. Updates made at the beginning of an interval
 * will have the *same discrete timestamp* as updates made at the
 * end of an interval.
 * 
 * Because discrete time points are used for logging, StateLog 
 * will handle delayed logging provided the delay does not
 * exceed a full interval. 
 *
 * Finally, if logging is interrupted for more than an interval,
 * Statelog will automatically an *undefined* state for the duration
 * of the logging gap as soon as the next update arrives.
 */
export class StateLog {
  /**
   * Create a StateLog for a given interval.
   * Create a StateLog from its serialized JSON representation.
   * @param {object} opts - REQUIRED: named options
   * @param {milliseconds} opts.interval - REQUIRED: sampling period in millisecods (1000)
   * @param {Date} opts.date - JSON: current sample state date (current Date)
   * @param {JSON} opts.state - JSON: sample state (undefined)
   * @param {array} opts.history - JSON: past states ([])
   * @param {guid} opts.hash - JSON: Merkle hash of state
   * @param {numberOfIntervals} opts.age - JSON: state duration (1)
   */
  constructor(opts={}) {
    const msg = 'StateLog.ctor()';
    if (typeof opts === 'string') {
      opts = JSON.parse(opts);
    }
    let { 
      interval=1000, 
      date=new Date(),
      state, 
      history,
      hash,
      age=1,
    } = opts;

    if (history == null) {
      history = [];
    }
    if (typeof date === 'string') {
      date = new Date(date);
    }

    let ml = new MerkleJson();
    Object.defineProperty(this, "ml", {
      value: ml,
    });
    hash = hash || ml.hash(state);

    Object.assign(this, {
      interval, date, state, history, hash, age, 
    });
  }

  /**
   * Update StateLog with new state for given date/time.
   * For proper synchronization, update the StateLog
   * at the interval defined for the StateLog.
   * 
   * @param {serializable} newState - state at given date
   * @param {Date} newDate - date of given state
   * @returns this
   */
  update(newState, newDate=new Date()) {
    const msg = 'StateLog.update() ';
    let { interval, age, ml, hash, date, } = this;
    let state = newState;
    let newHash = ml.hash(state);
    if (newDate < date) {
      let emsg = `${msg} newDate must be after ${date}`;
      throw new Error(emsg);
    }
    let dtime = newDate.getTime() - date.getTime();
    let dinterval = Math.floor(dtime / interval);
    let dAge = Math.max(1, dinterval);
    
    if (hash !== newHash) {
      this.history.push({ age, state:this.state, });
    }

    if (hash !== newHash) {
      if (dAge > 1) {
        this.history.push({ age: dAge-1, state: undefined, });
        this.age = 1;
      }
      this.state = state;
      this.hash = newHash;
      this.age = 1;
    } else {
      if (dAge > 1) {
        this.history.push({ age: dAge-1, state: undefined });
        this.age = 1;
      } else {
        this.age += dAge;
      }
    }

    // synchronized to interval date
    let syncDate = new Date(date.getTime() + dAge * interval);

    this.date = syncDate;

    return this;
  }

  /**
   * Generate an iterator over logged states
   * @param {object} opts - options
   * @param {Date} opts.endDate - do not show states older than endDate
   * @returns {Iterator} 
   */
  stateGenerator(opts={}) {
    const msg = 'StateLog.stateGenerator() ';
    let { history, interval, date, age, state } = this;
    let { endDate } = opts;
    let length = history.length;
    let age_ms = age * interval;
    let startDate = new Date(date.getTime() - age_ms + 1);
    let iHistory = length;

    return (function* () {
      if (iHistory === length) {
        iHistory--;
        yield  {
          age_ms, 
          date, 
          state,
        }
      }
     while (0 <= iHistory && (!endDate || endDate < startDate)) {
        let hist = history[iHistory];
        let age_ms = hist.age * interval;
        let date = new Date(startDate.getTime() - 1);
        startDate = new Date(startDate.getTime() - age_ms);
        iHistory--;
        yield {
          age_ms: hist.age * interval,
          state: hist.state,
          date,
        }
      }
    })();
  }

}