Home Reference Source Repository

src/listeners/Logger.js

import uuid from "uuid";
import autobind from "autobind-decorator";
import has from "lodash.has";
import invariant from "invariant";
import isFunction from "lodash.isfunction";
import isEqual from "lodash.isequal";
import isString from "lodash.isstring"
import sortBy from "lodash.sortby";
import result from "lodash.result";


const HelpMsg = `f.lux logger commands:
\tback          - moves backward in time by one store state frame
\tclear         - removes all logs
\tforward       - moves forward in time by one store state frame
\thelp          - f.lux logger commands
\tindex         - active index of store state frames
\tmaxFrames     - # of store updates to cache (default=50)
\nnext          - alias for forward
\tprint         - print logs to console
\tprintNoState  - print logs to console without state objects
\tsize          - # of store state frames available
\tstore         - gets the f.lux store
\nFunctions:
\tclearTrap(name)                    - clears a trap set by 'setTrap()'
\tgoto(idx)                          - move to a specific store state frame
\tsetMaxFrames(maxFrames)            - set the maximum number of store states to maintain (default=50)
\tsetTrap(cond, value, name=uuid)    - sets a debugger trap and returns name. condition argument may be
\t                                     a function taking next state or a string path to get a value
\ttail(count=10, printState=true)    - prints last 'count' store updates
\n
f.lux log available at window.`;


export default class Logger {
	constructor(store, name="flog") {
		this.store = store;
		this.name = name;
		this.nextFrameId = 1;
		this.filter = null;
		this.frames = [];
		this.maxFrames = 50;
		this.traps = null;

		this.activeFrame = new LogFrame(store, this.nextFrameId++);

		this.activeFrame.captureState();
		this.frames.push(this.activeFrame);

		this.currFrame = new LogFrame(store, this.nextFrameId++);

		window[name] = this.console = createConsoleLogger(this);

		// print help message to console
		this.console.help;
	}

	clear() {
		this.activeFrame = new LogFrame(this.store, this.nextFrameId++);
		this.activeFrame.captureState();

		this.frames = [ this.activeFrame ];
		this.currFrame = new LogFrame(this.store, this.nextFrameId++);
	}

	clearTrap(name) {
		if (!this.traps) { return }

		delete this.traps[name];

		if (Object.keys(this.traps).length === 0) {
			this.traps = null;
		}
	}

	print(printState=true) {
		const frames = this.frames;

		console.log(`f.lux log`);

		for (let i=0, frame; frame=frames[i]; i++) {
			frame.print(this.filter, frames[i-1], printState);
		}

		if (this.currFrame.actions.length) {
			console.log(`\nf.lux log current frame:`);
			this.currFrame.print(null, null, printState);
		}

		console.log("\n\n");
	}

	setActionFilter(filter) {
		if (filter instanceof RegExp) {
			this.filter = af => filter.test(`${af.impl.dotPath()}::${af.action.name}`);
		} else {
			this.filter = filter;
		}
	}

	setMaxFrames(maxFrames) {
		this.maxFrames = maxFrames;

		this.truncateFrames();
	}

	setTrap(condition, value, name=uuid()) {
		invariant(isFunction(condition) || isString(condition), "Traps must be either a regular expression or function");

		this.traps = this.traps || {};

		this.traps[name] = {
			eval: condition,
			name: name,
			value: value
		}

		return name;
	}

	tail(count=10, printState=true) {
		const frames = this.frames;
		const start = frames.length>count ?frames.length - count :0;
		var prevFrame;

		console.log(`f.lux tail - count=${count}, printState=${printState}`);

		for (let i=start, frame; frame=this.frames[i]; i++) {
			frame.print(this.filter, prevFrame, printState);
			prevFrame = frame;
		}
	}

	//******************************************************************************************************************
	//  Store listener methods
	//******************************************************************************************************************

	@autobind
	onError(store, msg, error) {
		// mark current active frame as not active
		this.activeFrame.active = false;

		this.currFrame.onError(msg, error);
		this.frames.push(this.currFrame);
		this.truncateFrames();

		this.activeFrame = this.currFrame;
		this.currFrame = new LogFrame(store, this.nextFrameId++);

		console.warn(`Store error: msg=${msg}, error=${error}`);
		error && error.stack && console.warn(error.stack);
	}

	@autobind
	onPostStateUpdate(store, action, impl) {
		this.currFrame.addAction(action, impl);

		if (this.traps) {
			const traps = Object.values(this.traps);
			const nextState = store.shadow.__().nextState();

			for (let i=0, t; t=traps[i]; i++) {
				var value = isString(t.eval)
					?result(nextState, t.eval)
					:t.eval(nextState)

				if (isEqual(value, t.value)) {
					debugger;

					this.clearTrap(t.name);
				}
			}
		}
	}

	@autobind
	onPostUpdate(store, time, currState, prevState) {
		const activeIdx = this.frames.indexOf(this.activeFrame);

		// remove all frames after current active frame (time travel occured)
		if (activeIdx < this.frames.length - 1) {
			this.frames = this.frames.slice(0, activeIdx + 1);
		}

		// mark current active frame as not actives
		this.activeFrame.active = false;

		// complete the current frame and mark active then create a new current frame
		this.currFrame.completed(time, currState);
		this.frames.push(this.currFrame);
		this.truncateFrames();

		this.activeFrame = this.currFrame;
		this.currFrame = new LogFrame(store, this.nextFrameId++);
	}


	//******************************************************************************************************************
	//  private methods
	//******************************************************************************************************************

	truncateFrames() {
		if (this.maxFrames > 0 && this.maxFrames < this.frames.length) {
			this.frames = this.frames.slice(this.frames.length - this.maxFrames);
		}
	}


	//******************************************************************************************************************
	//  time travel
	//******************************************************************************************************************

	get index() {
		return this.frames.indexOf(this.activeFrame);
	}

	get size() {
		return this.frames.length;
	}

	back() {
		if (this.index === 0) { return }

		this.goto(this.index - 1);
	}

	forward() {
		if (this.index + 1 === this.size) { return }

		this.goto(this.index + 1);
	}

	goto(idx) {
		if (idx === this.idx) {
			return
		} else if (this.size <= 1) {
			return console.log(`Time travel error - no log frames`);
		} else if (idx < 0 || idx >= this.size) {
			return console.log(`Time travel error - index=${idx}, expected index between 0 - ${this.size-1}`);
		}

		const currActiveFrame = this.activeFrame;
		const nextActiveFrame = this.frames[idx];
		const store = this.store;

		store.changeState(nextActiveFrame.state, true, nextActiveFrame.time);

		currActiveFrame.active = false;
		nextActiveFrame.active = true;

		this.activeFrame = nextActiveFrame;
	}
}

/** @ignore */
export class LogFrame {
	constructor(store, id) {
		this.id = id;
		this.store = store;
		this.actions = [];
		this.active = false;
	}

	activate() {
		invariant(has(this, state), "LogFrame does not have a state");

		this.store.changeState(this.state, true);
		this.active = true;
	}

	addAction(action, impl) {
		this.actions.push(new FrameAction(action, impl));
	}

	captureState() {
		this.state = this.store.state;
		this.active = true;
		this.time = this.store.updateTime;
		this.captureTime = new Date();
	}

	completed(time, currState) {
		this.time = time;
		this.state = currState;
		this.active = true;
		this.captureTime = new Date();
	}

	inactivate() {
		this.active = false;
	}

	onError(msg, error) {
		this.state = this.store.state;
		this.active = true;
		this.msg = msg;
		this.error = error;
		this.captureTime = new Date();
	}

	print(filter=null, prevFrame, printState=true) {
		var when = !this.captureTime
			?"n/a"
			:!prevFrame ?this.captureTime.toLocaleString() :`+${this.captureTime - prevFrame.captureTime} ms`

		if (printState) {
			console.log(`\tID=${this.id}, active=${this.active}, completed=${when}, final state:`, this.state);
		} else {
			console.log(`\tID=${this.id}, active=${this.active}, completed=${when}`);
		}

		var actions = sortBy(this.actions, a => {
				const path = a.impl.dotPath();

				return path=="root" ?0 :path;
			});

		if (filter) {
			actions = actions.filter(filter);
		}

		if (actions.length == 0) {
			console.log(`\t\tNo actions ${ filter ?"using filter" :""}`)
		}

		for (let i=0, action; action=actions[i]; i++) {
			action.print(printState);
		}

		if (this.error) {
			const stack = this.error.stack ?this.error.stack :"n/a";

			console.log(`\t\tEnded in error: msg=${this.msg}, error=%O, stack=%O`, this.error, stack);
		}
	}
}

/** @ignore */
export class FrameAction {
	constructor(action, impl) {
		this.action = action;
		this.impl = impl;
	}

	print(printState=true) {
		const { action, impl } = this;

		if (printState) {
			console.log(`\t\t${impl.dotPath()}::${action.name}, replace=${!!action.replace} nextState=%O, startState=%O`,
					action.nextState, impl.state());
		} else {
			console.log(`\t\t${impl.dotPath()}::${action.name}, replace=${!!action.replace}`);
		}
	}
}


export function createConsoleLogger(logger) {
	return {
		get back() {
			return logger.back();
		},

		get clear() {
			logger.clear();
		},

		get forward() {
			return logger.forward();
		},

		get help() {
			console.log(HelpMsg+logger.name+"\n\n");
		},

		get index() {
			return logger.index;
		},

		get maxFrames() {
			return logger.maxFrames;
		},

		get next() {
			return this.forward();
		},

		get print() {
			logger.print();
		},

		get printNoState() {
			logger.print(false);
		},

		get size() {
			return logger.size;
		},

		get store() {
			return logger.store;
		},

		clearTrap(name) {
			logger.clearTrap(name);
		},

		setActionFilter(filter) {
			logger.setActionFilter(filter);
		},

		setMaxFrames(maxFrames) {
			logger.setMaxFrames(maxFrames);
		},

		setTrap(condition, value, name) {
			return logger.setTrap(condition, value, name);
		},

		tail(count, printState) {
			logger.tail(count, printState);
		},

		goto(idx) {
			logger.goto(idx);
		},
	}
}