Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
915 lines
29 KiB
JavaScript
Executable file
915 lines
29 KiB
JavaScript
Executable file
/**
|
|
* Logic is a structured logging system with bonus features for tracking
|
|
* asynchronous code flow and simple unit testing.
|
|
*
|
|
* This docstring is a quick tutorial.
|
|
*
|
|
*******************************************************************************
|
|
* SCOPES
|
|
*
|
|
* Every log must be associated with a Scope.
|
|
*
|
|
* A Scope is just a wrapper around a namespace and a set of default arguments.
|
|
* When you hear "Scope", think "Logger". You could create a scope like so:
|
|
*
|
|
* var scope = logic.scope('Animal');
|
|
*
|
|
* Then, you'd do this to log events (see below for more on logging):
|
|
*
|
|
* logic(scope, 'createdAnimal'); // shorthand for logic.event(...)
|
|
*
|
|
* However, it's inconvenient to pass around scopes, just like it's inconvenient
|
|
* to pass around loggers. So Logic allows you to associate a Scope with an
|
|
* object, like a class instance, and use that object in place of the Scope:
|
|
*
|
|
* function Animal(name) {
|
|
* logic.defineScope(this, 'Animal');
|
|
* logic(this, 'createdAnimal');
|
|
* }
|
|
*
|
|
* Scopes have two properties: a namespace and default details. When you log
|
|
* an event, it absorbs these things from its associated scope.
|
|
*
|
|
* More about Scopes later; let's talk Events.
|
|
*
|
|
*******************************************************************************
|
|
* EVENT BASICS
|
|
*
|
|
* Logic operates under the principle that "everything is an event", and your
|
|
* program's execution flow can be encoded into a big list of events. Rather
|
|
* than tracking hierarchical relationships in logged events itself, Logic
|
|
* stores relevant information (such as a namespace) on each and every event.
|
|
*
|
|
* Every event, when serialized, is just a simple self-describing JSON payload.
|
|
*
|
|
* By distilling program execution into a linear sequence of Events, we can
|
|
* later reconstruct additional hierarchy or metadata by analyzing the resulting
|
|
* stream, rather than pushing the "burden of understanding" onto the logging
|
|
* framework itself.
|
|
*
|
|
* While you can log events with "logic.event(...)", we also expose "logic(...)"
|
|
* as a convenient shorthand.
|
|
*
|
|
* Events consist of the following:
|
|
*
|
|
* scope:
|
|
* The associated scope, which lends a 'namespace' and default details.
|
|
* type:
|
|
* The type of the event. You define what it means.
|
|
* details:
|
|
* A key-value object of additional details. Any default details associated
|
|
* with the scope are merged into these details.
|
|
*
|
|
* So given the following code:
|
|
*
|
|
* function Animal(name) {
|
|
* logic.defineScope('Animal', { name: name });
|
|
* logic(this, 'animalCreated', { why: 'because' });
|
|
* }
|
|
* Animal.prototype.say = function(what) {
|
|
* logic(this, 'said', { what: what });
|
|
* }
|
|
* new Animal('Riker').say('sup');
|
|
*
|
|
* Logic would output something like the following:
|
|
*
|
|
* [
|
|
* { namespace: 'Animal',
|
|
* type: 'animalCreated',
|
|
* details: { name: 'Riker', why: 'because' } },
|
|
* { namespace: 'Animal',
|
|
* type: 'said',
|
|
* details: { name: 'Riker', what: 'sup' } }
|
|
* ]
|
|
*
|
|
* Notice how every event receives a copy of the details it has been passed?
|
|
* This makes events self-describing. Note that the 'name' detail, passed in
|
|
* logic.defineScope, is also copied to each event.
|
|
*
|
|
* It's often useful to log several things with a set of additional details, and
|
|
* for that, we have subscopes:
|
|
*
|
|
* var subscope = logic.subscope(animal, { color: 'brown' })
|
|
* logic(subscope, 'run') // ==> { details: { color: 'brown', name: 'Riker' }}
|
|
*
|
|
* There is no explicit concept of hierarchy. Rather, we expect to reconstruct
|
|
* anything we need when viewing the logs later (i.e. in logic-inspector).
|
|
*
|
|
* There is also no concept of log levels. In practice, the logs we want
|
|
* bug-reporters to see are console logs, not logic events, and only we can
|
|
* understand what the chain of complex events means in context. For instance,
|
|
* errors are often expected in unit tests, where it doesn't make sense to
|
|
* categorically treat them as bright-red errors. (The distinction between
|
|
* log/info/warn events is often unclear, but perhaps a case could be made for
|
|
* distinguishing errors.)
|
|
*
|
|
* In general, our logs should just objectively report what happens, leaving
|
|
* logic-inspector to decide what's important.
|
|
*
|
|
*******************************************************************************
|
|
* ASYNC and AWAIT
|
|
*
|
|
* Tracking events _within_ an individual scope is nice, but often we need to
|
|
* track asynchronous events that get passed around. For that, Logic provides
|
|
* 'logic.async' and 'logic.await', two primitives to annotate async behavior.
|
|
*
|
|
* var promise = logic.async(this, 'does a job', (resolve) => {...})
|
|
*
|
|
* logic.await(otherScope, 'waiting for job done', promise)
|
|
* .then(...)
|
|
*
|
|
* Logic will then log events corresponding to the Promise's resolution and
|
|
* state (such as which events depend on other events) so that we can later
|
|
* reconstruct graphs of the code flow and dependencies. With those two
|
|
* primitives, we could construct a graph like the following:
|
|
*
|
|
* [ Animal ] [ Owner ]
|
|
* __________________
|
|
* ________________ | ASYNC |
|
|
* | AWAIT dog bowl | | Filling dog bowl |
|
|
* | | | |
|
|
* | | | |
|
|
* |________________| |__________________|
|
|
* done <--------------------/
|
|
*
|
|
* Unfortunately, it's hard to display all that information such that it doesn't
|
|
* get in the way. :mcav attempted to add digraph-like views to logic-inspector,
|
|
* but didn't have much success making it usable yet.
|
|
*
|
|
*******************************************************************************
|
|
* TESTING
|
|
*
|
|
* To write tests against your logic logs, Logic provides the 'logic.match'
|
|
* function.
|
|
*
|
|
* var promise = logic
|
|
* .match('Animal', 'animalCreated', { name: 'Riker' })
|
|
* .match('Animal', 'say')
|
|
* .failIfMatched('Animal', 'died');
|
|
*
|
|
* In the snippet above, the first logic.match call returns an object that has
|
|
* `.then` and `.match`, so that you can treat it like a Promise as well as
|
|
* easily chain further expectations. The promise chain will resolve after all
|
|
* of those conditions have matched, or a timeout has been reached.
|
|
*
|
|
* See test_disaster_recovery.js for an example test using these primitives.
|
|
*/
|
|
define(function(require) {
|
|
var evt = require('evt');
|
|
// Use a relative path here to avoid having consumers need special require
|
|
// configs (we already specify 'evt' in the frontend).
|
|
var equal = require('./ext/equal');
|
|
|
|
/**
|
|
* The `logic` module is callable, as a shorthand for `logic.event()`.
|
|
*/
|
|
function logic() {
|
|
return logic.event.apply(logic, arguments);
|
|
}
|
|
|
|
evt.mix(logic);
|
|
|
|
/**
|
|
* Create a new Scope with the given namespace and default details.
|
|
*
|
|
* @param {string} namespace
|
|
* @param {object|null} defaultDetails
|
|
*/
|
|
logic.scope = function(namespace, defaultDetails) {
|
|
return new Scope(namespace, defaultDetails);
|
|
};
|
|
|
|
var objectToScope = new WeakMap();
|
|
|
|
function toScope(scope) {
|
|
if (!(scope instanceof Scope)) {
|
|
scope = objectToScope.get(scope);
|
|
if (!scope) {
|
|
throw new Error('Invalid scope ' + scope +
|
|
' passed to logic.event(); ' +
|
|
'did you remember to call logic.defineScope()? ' +
|
|
new Error().stack);
|
|
}
|
|
}
|
|
return scope;
|
|
}
|
|
|
|
/**
|
|
* Most often, scopes and namespaces map one-to-one with class instances. With
|
|
* defineScope(), you can associate a Scope with an object, and then use that
|
|
* object in place of the scope. For instance:
|
|
*
|
|
* function MyClass() {
|
|
* logic.defineScope(this, 'MyClass');
|
|
* logic.event(this, 'initialized');
|
|
* }
|
|
*/
|
|
logic.defineScope = function(obj, namespace, defaultDetails) {
|
|
// Default to the object's class name, if available.
|
|
if (!namespace && obj && obj.constructor && obj.constructor.name) {
|
|
namespace = obj.constructor.name;
|
|
}
|
|
var scope = new Scope(namespace, defaultDetails);
|
|
objectToScope.set(obj, scope);
|
|
return scope;
|
|
};
|
|
|
|
/**
|
|
* Sometimes, you may want to log several events, each with shared
|
|
* details. With logic.subscope(), you can create a child scope that
|
|
* shares the same namespace, but adds additional default details
|
|
* onto each message. For instance:
|
|
*
|
|
* logic.defineScope(this, 'Account', { accountId: 1 });
|
|
* var scope = logic.subscope(this, { action: 'move' });
|
|
* logic.log(scope, 'start');
|
|
* // event: Account/start { accountId: 1, action: 'move' }
|
|
*/
|
|
logic.subscope = function(scope, defaultDetails) {
|
|
scope = toScope(scope);
|
|
return new Scope(scope.namespace, into(shallowClone(scope.defaultDetails),
|
|
shallowClone(defaultDetails)));
|
|
};
|
|
|
|
/**
|
|
* Emit an event. `logic(...)` is shorthand for `logic.event(...)`.
|
|
* See the module docs for more about events.
|
|
*
|
|
* @param {Scope} scope
|
|
* The scope (i.e. "namespace") for this event.
|
|
* @param {string} type
|
|
* A string, typically camelCased, describing the event taking place.
|
|
* @param {object} details
|
|
* Optional details about this event, such as identifiers or parameters.
|
|
* These details will be mixed in with any default details specified
|
|
* by the Scope.
|
|
*/
|
|
logic.event = function(scope, type, details) {
|
|
scope = toScope(scope);
|
|
|
|
// Give others a chance to intercept this event before we do lots of hard
|
|
// JSON object work.
|
|
var isDefaultPrevented = false;
|
|
var preprocessEvent = {
|
|
scope: scope,
|
|
namespace: scope.namespace,
|
|
type: type,
|
|
details: details,
|
|
preventDefault: function() {
|
|
isDefaultPrevented = true;
|
|
}
|
|
};
|
|
logic.emit('preprocessEvent', preprocessEvent);
|
|
|
|
if (isDefaultPrevented) {
|
|
return { id: 0 }; // async/await require a return object regardless.
|
|
}
|
|
|
|
type = preprocessEvent.type;
|
|
details = preprocessEvent.details;
|
|
|
|
if (typeof type !== 'string') {
|
|
throw new Error('Invalid "type" passed to logic.event(); ' +
|
|
'expected a string, got "' + type + '"');
|
|
}
|
|
|
|
if (scope.defaultDetails) {
|
|
if(isPlainObject(details)) {
|
|
details = into(shallowClone(scope.defaultDetails),
|
|
shallowClone(details));
|
|
} else {
|
|
details = shallowClone(scope.defaultDetails);
|
|
}
|
|
} else {
|
|
details = shallowClone(details);
|
|
}
|
|
|
|
var event = new LogicEvent(scope, type, details);
|
|
logic.emit('censorEvent', event);
|
|
logic.emit('event', event);
|
|
|
|
if (logic.realtimeLogEverything) {
|
|
dump('logic: ' + event.toString() + '\n');
|
|
}
|
|
|
|
return event;
|
|
};
|
|
|
|
|
|
// True when being run within a test.
|
|
logic.underTest = false;
|
|
logic._currentTestRejectFunction = null;
|
|
|
|
/**
|
|
* Immediately fail the current test with the given exception. If no test is
|
|
* in progress, an error is logged, but no exception is thrown. In other
|
|
* words, logic.fail() will NOT throw on you.
|
|
*
|
|
* @param {object} ex
|
|
* Exception object, as with Promise.reject()
|
|
*/
|
|
logic.fail = function(ex) {
|
|
if (logic.underTest) {
|
|
if (logic._currentTestRejectFunction) {
|
|
logic._currentTestRejectFunction(ex);
|
|
} else {
|
|
throw ex;
|
|
}
|
|
} else {
|
|
console.error('Logic fail:', ex);
|
|
}
|
|
};
|
|
|
|
|
|
var nextId = 1;
|
|
|
|
/**
|
|
* Return a sequential unique identifier, unique for users of this module
|
|
* instance.
|
|
*/
|
|
logic.uniqueId = function() {
|
|
return nextId++;
|
|
};
|
|
|
|
// Hacky way to pass around a global config:
|
|
logic.isCensored = false;
|
|
logic.realtimeLogEverything = false;
|
|
|
|
var interceptions = {};
|
|
|
|
/**
|
|
* Provide a named hook which can be intercepted by tests.
|
|
*/
|
|
logic.interceptable = function(type, fn) {
|
|
if (interceptions[type]) {
|
|
return interceptions[type]();
|
|
} else {
|
|
return fn();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Intercept a named logic.interceptable by calling your function instead.
|
|
*/
|
|
logic.interceptOnce = function(type, replacementFn) {
|
|
var prevFn = interceptions[type];
|
|
interceptions[type] = function() {
|
|
interceptions[type] = prevFn;
|
|
return replacementFn();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a Promise-like object that is fulfilled when an event
|
|
* matching the given details is logged. Chainable.
|
|
*
|
|
* detailPredicate is optional and can be any of the following:
|
|
*
|
|
* an object:
|
|
* Checks to see if the given object is a SUBSET of the event's details.
|
|
*
|
|
* a function:
|
|
* The event matches if detailPredicate(event.details) returns true.
|
|
*
|
|
* @param {string} ns
|
|
* @param {string} type
|
|
* @param {object|function} detailPredicate
|
|
*/
|
|
logic.match = function(ns, type, detailPredicate) {
|
|
return new LogicMatcher(
|
|
LogicMatcher.normalizeMatchArgs(ns, type, detailPredicate));
|
|
}
|
|
|
|
|
|
function MismatchError(matcher, event) {
|
|
this.matcher = matcher;
|
|
this.event = event;
|
|
}
|
|
|
|
MismatchError.prototype = Object.create(Error.prototype, {
|
|
constructor: { value: MismatchError },
|
|
toString: { value: function() {
|
|
if (this.matcher.not) {
|
|
return 'MismatchError: expected ' + this.event +
|
|
' to not occur (failIfMatched ' + this.matcher + ').';
|
|
} else {
|
|
return 'MismatchError: expected ' + this.event +
|
|
' to match ' + JSON.stringify(this.matcher.detailPredicate) + '.';
|
|
}
|
|
}}
|
|
});
|
|
|
|
|
|
/**
|
|
* This is the object returned from `logic.match`. It acts as a Promise that
|
|
* resolves when a matching event has been logged.
|
|
*/
|
|
function LogicMatcher(opts) {
|
|
this.matchedLogs = opts.prevMatcher ? opts.prevMatcher.matchedLogs : [];
|
|
this.capturedLogs = [];
|
|
this.ns = opts.ns;
|
|
this.type = opts.type;
|
|
this.detailPredicate = opts.detailPredicate;
|
|
this.failOnMismatchedDetails = true;
|
|
this.not = opts.not;
|
|
this.timeoutMS = 2000;
|
|
this.resolved = false;
|
|
this.anotherMatcherNeedsMyLogs = false;
|
|
|
|
if (opts.prevMatcher) {
|
|
// Tell the previous matcher to not remove its event listener until we've
|
|
// had a chance to pull out any logs which occured between its resolution
|
|
// and our start.
|
|
opts.prevMatcher.anotherMatcherNeedsMyLogs = true;
|
|
}
|
|
|
|
logic.defineScope(this, 'LogicMatcher');
|
|
|
|
var hasPrevPromise = !!opts.prevPromise;
|
|
var normalizedPrevPromise = opts.prevPromise || Promise.resolve();
|
|
|
|
if (this.not) {
|
|
// XXX this should probably bind instantly like the next case.
|
|
this.promise = normalizedPrevPromise.then(() => {
|
|
this.capturedLogs.some((event) => {
|
|
if ((!this.ns || event.namespace === this.ns) &&
|
|
event.matches(this.type, this.detailPredicate)) {
|
|
throw new MismatchError(this, event);
|
|
}
|
|
});
|
|
});
|
|
} else if (this.type) {
|
|
this.promise = new Promise((resolve, reject) => {
|
|
// Once any previous match has been resolved,
|
|
// subscribe to a following match.
|
|
var subscribeToNextMatch = () => {
|
|
var timeoutId = setTimeout(() => {
|
|
logic(this, 'failedMatch',
|
|
{
|
|
ns: this.ns,
|
|
type: this.type,
|
|
detailPredicate: this.detailPredicate,
|
|
capturedLogs: this.capturedLogs
|
|
});
|
|
reject(new Error('LogicMatcherTimeout: ' + this));
|
|
}, this.timeoutMS);
|
|
|
|
// Promise chains have "dead spots" in between resolution
|
|
// callbacks. For instance:
|
|
// [promise1.then] [promise2.then]
|
|
// other events could be logged --^
|
|
//
|
|
// We could miss those events in the middle by just setting
|
|
// up a new listener for each LogicMatcher. Instead, since
|
|
// every matcher has a pointer to its prevMatcher, we can
|
|
// just grab the missing logs from there.
|
|
var resolveThisMatcher = (event) => {
|
|
this.resolved = true;
|
|
this.capturedLogs = []; // Extra events will go here.
|
|
if (!this.anotherMatcherNeedsMyLogs) {
|
|
this.removeMatchListener();
|
|
}
|
|
};
|
|
|
|
var matchFn = (event) => {
|
|
this.capturedLogs.push(event);
|
|
if (this.resolved) {
|
|
return;
|
|
}
|
|
|
|
if (this.ns && event.namespace !== this.ns ||
|
|
event.type !== this.type) {
|
|
return false; // did not match
|
|
}
|
|
if (event.matches(this.type, this.detailPredicate)) {
|
|
resolveThisMatcher(event);
|
|
this.matchedLogs.push(event);
|
|
clearTimeout(timeoutId);
|
|
logic(this, 'match', { ns: this.ns,
|
|
type: this.type,
|
|
event: event });
|
|
resolve(event);
|
|
return true;
|
|
} else {
|
|
if (this.failOnMismatchedDetails) {
|
|
resolveThisMatcher(event);
|
|
reject(new MismatchError(this, event));
|
|
return true; // matched
|
|
} else {
|
|
// Ignore mismatched events; maybe we'll match later.
|
|
}
|
|
}
|
|
return false; // not done yet, didn't find a match
|
|
};
|
|
|
|
this.removeMatchListener = () => {
|
|
logic.removeListener('event', matchFn);
|
|
};
|
|
|
|
logic.on('event', matchFn);
|
|
|
|
if (opts.prevMatcher) {
|
|
var prevLogs = opts.prevMatcher.capturedLogs;
|
|
// Run matchFn on prevLogs until one of them matches.
|
|
var matchIndex = prevLogs.findIndex(matchFn);
|
|
// Then, we get to start by capturing all logs that have occured in
|
|
// the intervening time:
|
|
if (matchIndex !== -1) {
|
|
this.capturedLogs = prevLogs.slice(matchIndex + 1);
|
|
}
|
|
// Now that we're done with the previous matcher, it doesn't need to
|
|
// listen to events any more.
|
|
opts.prevMatcher.removeMatchListener();
|
|
}
|
|
}
|
|
|
|
if (hasPrevPromise) {
|
|
normalizedPrevPromise.then(subscribeToNextMatch, (e) => reject(e) );
|
|
} else {
|
|
try {
|
|
subscribeToNextMatch();
|
|
} catch(e) {
|
|
reject(e);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
// This is the '.then()' case; we still want to return a
|
|
// LogicMatcher so they can chain, but without any further expectations.
|
|
this.promise = normalizedPrevPromise;
|
|
}
|
|
}
|
|
|
|
LogicMatcher.normalizeMatchArgs = function(ns, type, details) {
|
|
// 'ns' is optional
|
|
if (typeof type === 'object') {
|
|
details = type;
|
|
type = ns;
|
|
ns = null;
|
|
}
|
|
return { ns: ns, type: type, detailPredicate: details };
|
|
}
|
|
|
|
LogicMatcher.prototype = {
|
|
|
|
/**
|
|
* Same as `logic.match`.
|
|
*/
|
|
match(ns, type, details) {
|
|
var args = LogicMatcher.normalizeMatchArgs(ns, type, details);
|
|
args.prevMatcher = this;
|
|
args.prevPromise = this.promise;
|
|
return new LogicMatcher(args);
|
|
},
|
|
|
|
/**
|
|
* Look at THE LOGS ALREADY CAPTURED by this LogicMatcher, and fail if any
|
|
* of them match this one.
|
|
*/
|
|
failIfMatched(ns, type, details) {
|
|
var args = LogicMatcher.normalizeMatchArgs(ns, type, details);
|
|
args.not = true;
|
|
args.prevMatcher = this;
|
|
args.prevPromise = this.promise;
|
|
return new LogicMatcher(args);
|
|
},
|
|
|
|
/**
|
|
* Like Promise.then(); resolves with an array of matched logs.
|
|
*/
|
|
then(fn, catchFn) {
|
|
return new LogicMatcher({
|
|
prevPromise: this.promise.then(() => {
|
|
var ret = fn(this.matchedLogs.slice());
|
|
if (ret instanceof Promise) {
|
|
ret = new LogicMatcher({
|
|
prevPromise: ret
|
|
});
|
|
}
|
|
return ret;
|
|
}, catchFn)
|
|
});
|
|
},
|
|
|
|
toString() {
|
|
return '<LogicMatcher ' + (this.ns ? this.ns + '/' : '') +
|
|
this.type + ' ' + new ObjectSimplifier().simplify(this.detailPredicate)
|
|
+ '>';
|
|
}
|
|
}
|
|
|
|
function Scope(namespace, defaultDetails) {
|
|
this.namespace = namespace;
|
|
|
|
if (defaultDetails && !isPlainObject(defaultDetails)) {
|
|
throw new Error('Invalid defaultDetails; expected a plain-old object: ' +
|
|
defaultDetails);
|
|
}
|
|
this.defaultDetails = defaultDetails;
|
|
}
|
|
|
|
function ObjectSimplifier(opts) {
|
|
opts = opts || {};
|
|
this.maxDepth = opts.maxDepth || 10;
|
|
this.maxStringLength = opts.maxStringLength || 1000;
|
|
this.maxArrayLength = opts.maxArrayLength || 1000;
|
|
this.maxObjectLength = opts.maxObjectLength || 10;
|
|
}
|
|
|
|
ObjectSimplifier.prototype = {
|
|
simplify: function(x) {
|
|
return this._simplify(x, 0, new WeakSet());
|
|
},
|
|
|
|
_simplify: function(x, depth, cacheSet) {
|
|
if (cacheSet.has(x)) {
|
|
return '(cycle)';
|
|
}
|
|
if (typeof x === 'number') {
|
|
return x;
|
|
} else if (typeof x === 'string') {
|
|
return x.slice(0, this.maxStringLength);
|
|
} else if (x && x.BYTES_PER_ELEMENT) {
|
|
// TypedArray
|
|
return x.slice(0, this.maxArrayLength);
|
|
} else if (Array.isArray(x)) {
|
|
if (depth < this.maxDepth) {
|
|
return x.slice(0, this.maxArrayLength)
|
|
.map((element) => this._simplify(element, depth + 1, cacheSet));
|
|
} else {
|
|
return '[Array length=' + x.length + ']';
|
|
}
|
|
} else if (x && typeof x === 'object') {
|
|
cacheSet.add(x);
|
|
if (!isPlainObject(x)) {
|
|
if (x.toJSON) {
|
|
return this._simplify(x.toJSON(), depth, cacheSet);
|
|
} else if (x.toString) {
|
|
return this._simplify(x.toString(), depth, cacheSet);
|
|
} else {
|
|
return '(?)';
|
|
}
|
|
} else {
|
|
if (depth < this.maxDepth) {
|
|
var retObj = {};
|
|
var idx = 0;
|
|
for (var key in x) {
|
|
if (idx > this.maxObjectLength) {
|
|
break;
|
|
}
|
|
retObj[key] = this._simplify(x[key], depth + 1, cacheSet);
|
|
idx++;
|
|
}
|
|
return retObj;
|
|
} else if (x.toString) {
|
|
return this._simplify(x.toString(), depth, cacheSet);
|
|
} else {
|
|
return '(object?)';
|
|
}
|
|
}
|
|
} else if (typeof x === 'function') {
|
|
return '(function)';
|
|
} else {
|
|
return x;
|
|
}
|
|
}
|
|
}
|
|
|
|
function LogicEvent(scope, type, details) {
|
|
if (!(scope instanceof Scope)) {
|
|
throw new Error('Invalid "scope" passed to LogicEvent(); ' +
|
|
'did you remember to call logic.defineScope()?');
|
|
}
|
|
|
|
this.scope = scope;
|
|
this.type = type;
|
|
this.details = details;
|
|
this.time = Date.now();
|
|
this.id = logic.uniqueId();
|
|
this.jsonRepresentation = {
|
|
namespace: this.scope.namespace,
|
|
type: this.type,
|
|
details: new ObjectSimplifier().simplify(this.details),
|
|
time: this.time,
|
|
id: this.id
|
|
};
|
|
}
|
|
|
|
LogicEvent.fromJSON = function(data) {
|
|
var event = new LogicEvent(new Scope(data.namespace),
|
|
data.type,
|
|
data.details);
|
|
event.time = data.time;
|
|
event.id = data.id;
|
|
return event;
|
|
}
|
|
|
|
LogicEvent.prototype = {
|
|
get namespace() {
|
|
return this.scope.namespace;
|
|
},
|
|
|
|
toJSON: function() {
|
|
return this.jsonRepresentation;
|
|
},
|
|
|
|
toString: function() {
|
|
return '<LogicEvent ' + this.namespace + '/' + this.type + ' ' +
|
|
JSON.stringify(this.jsonRepresentation.details) + '>';
|
|
},
|
|
|
|
/**
|
|
* Return true if this event matches the given predicate, using the same
|
|
* rules as `logic.match()`.
|
|
*
|
|
* @param {string} type
|
|
* @param {object|function|null} detailPredicate
|
|
*/
|
|
matches: function(type, detailPredicate) {
|
|
if (this.type !== type) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof detailPredicate === 'function') {
|
|
return !!detailPredicate(this.details);
|
|
} else if (isPlainObject(detailPredicate)) {
|
|
for (var key in detailPredicate) {
|
|
var expected = detailPredicate && detailPredicate[key];
|
|
var actual = this.details && this.details[key];
|
|
if (actual === undefined) {
|
|
actual = null; // For actual comparison, undefined equates to null.
|
|
}
|
|
|
|
if (expected === undefined) {
|
|
continue; // We don't care about these.
|
|
} else if (!this.details ||
|
|
!equal(expected, actual)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
} else if (detailPredicate != null) {
|
|
return equal(this.details, detailPredicate);
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
};
|
|
|
|
function isPlainObject(obj) {
|
|
if (!obj || typeof obj !== 'object') {
|
|
return false;
|
|
}
|
|
// Object.create(null) has no .toString().
|
|
if (obj.toString && (obj.toString() !== '[object Object]')) {
|
|
return false;
|
|
}
|
|
for (var k in obj) {
|
|
if (typeof k === 'function') {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
logic.isPlainObject = isPlainObject;
|
|
|
|
//----------------------------------------------------------------
|
|
// Promises
|
|
|
|
var promiseToStartEventMap = new WeakMap();
|
|
var promiseToResultEventMap = new WeakMap();
|
|
|
|
/**
|
|
* For those cases when your logic starts in one place but ends in
|
|
* another, logic.async is slightly inconvenient. This function
|
|
* tracks an async event much like `logic.async`, except that this
|
|
* helper pulls out 'resolve' and 'reject' to allow you to log
|
|
* completion elsewhere.
|
|
*
|
|
* @return An object with 'resolve' and 'reject' properties.
|
|
*/
|
|
logic.startAsync = function(scope, type, details) {
|
|
var resolve, reject;
|
|
var promise = logic.async(scope, type, details, (_resolve, _reject) => {
|
|
resolve = _resolve;
|
|
reject = _reject;
|
|
});
|
|
return {
|
|
resolve: resolve,
|
|
reject: reject
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A tracked version of `new Promise()`, where `fn` here is your promise
|
|
* executor function. As with `logic.event()`, details is optional, but type
|
|
* is required. Events will be logged to track the promise's resolution.
|
|
*/
|
|
logic.async = function(scope, type, details, fn) {
|
|
if (!fn && typeof details === 'function') {
|
|
fn = details;
|
|
details = null;
|
|
}
|
|
|
|
scope = logic.subscope(scope, details);
|
|
|
|
var startEvent;
|
|
var promise = new Promise((resolve, reject) => {
|
|
startEvent = logic(scope, 'begin ' + type, {
|
|
asyncStatus: 0, // 'pending', as per Promise's private 'status' property.
|
|
asyncName: type
|
|
});
|
|
|
|
fn((result) => {
|
|
promiseToResultEventMap.set(promise, logic(scope, type, {
|
|
asyncStatus: 1, // 'resolved'
|
|
sourceEventIds: [startEvent.id],
|
|
result: result
|
|
}));
|
|
resolve(result);
|
|
}, (error) => {
|
|
promiseToResultEventMap.set(promise, logic(scope, type, {
|
|
asyncStatus: 2, // 'rejected'
|
|
sourceEventIds: [startEvent.id],
|
|
error: error
|
|
}));
|
|
reject(error);
|
|
});
|
|
});
|
|
|
|
promiseToStartEventMap.set(promise, startEvent);
|
|
return promise;
|
|
};
|
|
|
|
/**
|
|
* Wraps a Promise, logging events that say "I'm waiting for this Promise" and
|
|
* "I finally got this Promise's result". If the originating promise was
|
|
* created with `logic.async`, we can link the two semantically.
|
|
*/
|
|
logic.await = function(scope, type, details, promise) {
|
|
if (!promise && details.then) {
|
|
promise = details;
|
|
details = null;
|
|
}
|
|
|
|
scope = logic.subscope(scope, details).subscope(scope);
|
|
|
|
var startEvent = promiseToStartEventMap.get(promise);
|
|
var awaitEvent = logic.event(scope, 'await ' + type, {
|
|
awaitStatus: 0, // 'pending', as per Promise's private 'status' property.
|
|
sourceEventIds: startEvent ? [startEvent.id] : null,
|
|
awaitName: type
|
|
});
|
|
|
|
return promise.then((result) => {
|
|
var resultEvent = promiseToResultEventMap.get(promise);
|
|
logic(scope, type, {
|
|
awaitStatus: 1, // 'resolved'
|
|
result: result,
|
|
sourceEventIds: (resultEvent
|
|
? [resultEvent.id, awaitEvent.id]
|
|
: [awaitEvent.id])
|
|
});
|
|
return result;
|
|
}, (error) => {
|
|
var resultEvent = promiseToResultEventMap.get(promise);
|
|
logic(scope, type, {
|
|
awaitStatus: 2, // 'rejected'
|
|
error: error,
|
|
sourceEventIds: (resultEvent
|
|
? [resultEvent.id, awaitEvent.id]
|
|
: [awaitEvent.id])
|
|
});
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
function shallowClone(x) {
|
|
if (isPlainObject(x)) {
|
|
var ret = {};
|
|
for (var key in x) {
|
|
ret[key] = x[key];
|
|
}
|
|
return ret;
|
|
} else {
|
|
return x;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Merge `source` into `target`.
|
|
*/
|
|
function into(target, source) {
|
|
if (!target) {
|
|
target = {};
|
|
}
|
|
for (var key in source) {
|
|
target[key] = source[key];
|
|
}
|
|
return target;
|
|
}
|
|
|
|
|
|
return logic;
|
|
});
|