clues.js is a lean-mean-promisified-getter-machine that crunches through any javascript objects, including complex trees, functions, values and promises. Clues consists of a single getter function (just over 100 loc) that dynamically resolves dependency trees and memoizes resolutions (lets call them derived facts) along the way.
Prior versions of clues
were based on internal scaffolding holding separate logic and fact spaces within a clues
object. Clues 3.x is a major rewrite into a simple superpowered getter function. Clues apis might be backwards compatible - as long as you merge logic and facts into a single facts/logic object and use the new getter function directly for any resolutions
The basic function signature is simple and always returns a promise:
clues(obj,fn,[$global])
logic/facts (first argument)
The first argument of the clues function should be the object containing the logic/facts requested (or a function that delivers this object). The logic/facts object is any Javascript object, containing any mix of the following properties (directly and/or through prototype chains):
-
Static values (strings, numbers, booleans, etc)
-
Functions (i.e. logic) returning anything else in this list
-
Promises returning anything else in this list
-
Other javascript objects (child scopes)
-
Functions returning a function that returns anything in this list....
-
....etc
fn (second argument)
The second argument is a reference to the property/function requested, defined as any of the following:
- Name of the property to be resolved (or a path to the property, using dot notation)
- A custom function whose argument names will be resolved from the logic/facts prior to execution
- Array defined function (see below)
Here are a few examples:
clues(obj,'person').then(console.log);
clues(obj,function(person) { console.log(person); })
clues(obj,['person',console.log]);
global (optional third argument)
The third argument is an optional global object, whose properties are available from any scope. The global object itself is handled as a logic/facts object and will be traversed as required.
clues(obj,'person',{userid:'admin',res:res}).then(console.log);
caller and fullref (internal)
As clues traverses an object by recursively calling itself each step of the way it passes information about caller
, i.e. which reference is requesting each property and fullref
a dot delimited list of the traversed dependency path so far.
The full function signature (with the internal arguments) is therefore:
function clues(obj,fn,[$global],[caller],[fullref]) {...
and here is an advanced example:
clues(obj,'person',{userid:'admin',res:res},'__user__','top.child')
the mean function resolution machine
Whenever clues
hits a property that is an unresolved function it will parse the argument names (if any) and attempt to resolve the argument values (from properties within same scope that have the same name as each argument). Any property requested, either directly or indirectly, will be immediately morphed into a promise on its own resolution. If any requested unresolved function requires other properties as inputs, those required properties will also be replaced with promises on their resolution etc.. Once all dependencies of any given function have been resolved, the function will be evaluated and the corresponding promise resolved (or rejected) by the outcome.
var obj = {
miles : 220,
hours : Promise.fulfilled(2.3),
minutes : function(hours) {
return hours * 60;
},
mph : function(miles,hours) {
return miles / hours;
}
};
clues(obj,'mph').then(console.log);
clues(obj,function(minutes,mph) {
console.log('Drove for '+minutes+' min at '+mph+' mph');
});
There are only a few restrictions and conventions you must into account when defining property names.
- Any property name starting with a $ bypasses the main function cruncher (great for services)
$property
and $external
are special handlers for missing properties (if they are functions)$global
will always return the full global object provided, in any context.$caller
and $fullref
are reserved to provide access to the current state of the clues solver when it hits a function for the first time.- Property names really should never start with an underscore (see optional variables)
- Any array whose last element is a function will be evaluated as a function... Angular style
That's pretty much it.
reusing logic through prototypes
Since clues
modifies any functional property from being a reference to the function to a reference to the promise of their outcome, a logic/facts object lazily transforms from containing mostly logic functions to containing resolved set of facts (in the form of resolved promises).
An entirely fresh logic/facts object is required for a different context (i.e. different set known initial facts). A common pattern is to define all logic in a static object (as the prototype) and then only provide instances of this logic/facts object to the clues
function, each time a new context is required. This way, the original logic functions themselves are never "overwritten", as the references in the clones switch from pointing to a function in the prototype to a promise on its resolution as each property is lazily resolved on demand.
Here is a simple example:
var Logic = {
miles : function() {
return Promise.delay(2000)
.then(function() {
return 2.3;
});
},
mph : function(miles,hours) {
return miles / hours;
}
};
var obj = Object.create(Logic);
clues(obj,'mph')
.then(console.log,console.log);
var obj2 = Object.create(Logic);
obj2.hours = 3;
clues(obj2,'mph')
.then(console.log,console.log);
obj = Object.create(Logic,{
hours : { value : 3 }
});
...
handling rejection
Errors (i.e. rejected promises in clues
) will include a ref
property showing which logic function (by property name) is raising the error. If the thrown error is not an object (i.e. a string), the resulting error will be a (generic) object with message
showing the thrown message and ref
the logic function. If the erroring function was called by a named logic function, the name of that function will show up in the caller
property of the response. The rejection object will also contain fullref
a property that shows the full path of traversal (through arguments and dots) to the function that raised the error. The rejection handling by clues
will not force errors into formal Error Objects, which can be useful to distinguish between javascript errors (which are Error Object with .stack
) and customer 'string' error messages (which may not have .stack
).
Example: passing thrown string errors to the client while masking javascript error messages
.then(null,function(e) {
if (e.stack) res.send(500,'Internal Error');
else res.send(e.message);
});
making arguments optional with the underscore prefix
If any argument to a function resolves as a rejected promise (i.e. errored) then the function will not run and simply be rejected as well. But sometimes we want to continue nevertheless. Any argument to any function can be made optional by prefixing the argument name with an underscore. If the resolution of the optional argument returns a rejected promise (or the optional argument does not exist), then the value of this argument to the function will simply be undefined
.
var obj = {
value : 42,
badCall : function() { throw 'This is an error'; }
};
clues(obj,function(badCall,value) { console.log(badCall,value); })
.catch(console.log);
clues(obj,function(_badCall,value) { console.log(_badCall,value); });
clues(obj,function(__badCall,value) { console.log(JSON.stringify(__badCall),value); });
Please keep in mind that any variable that is referred to as optional in a function will look for the un-prefixed name in the logic object. A fact can therefore be optional in one function (name prefixed with underscore) and required in another (no prefix).
Bonus: If an argument is prefixed with a double underscore, the value of the argument will not be undefined
, but contain object representation of the error. In this case you'd better check if error === true
of any double-underscored argument to understand whether it's a value or an objectified error. The double underscore allows you to roll your own quasi- catch
inside a logic function to any of the input arguments.
Caveat: You should probably never define a property name that starts with an underscore. The only way to reach that argument is to request it with three prefixed underscores (as the first two are shaved off by the optional machinery), and even then it doesn't really make sense.
using special arrays to define functions
Functions can be defined in array form, where the function itself is placed in the last element, with the prior elements representing the full argument names required for the function to run. This allows the definition of more complex arguments (including argument names with dots in them) and also allows for code minification (angular style)
In the following example, the local variable a
stands for the input1
fact and b
is the input2
fact
var Logic = {
test : ['input1','input2',function(a,b) {
... function body ...
}]
};
Warning: Any array whose last element is a function, will be handled like an array-defined function, like it or not. Additionally, if the first element of the array is an (i) object or a (ii) function, the first element will be used as the context (fact/logic object) the function executes in (see private parts)
nesting and parenthood
Logic object can contain objects (or functions that return objects) providing separate children scopes. Trees of child scopes can be traversed using dot notation, either by requesting a string path directly from the clues
function or using dot notation for argument names in any array-defined function (see above).
Example:
var Cabinet = {
drawer : {
items : ['A','B','C','D' ]
},
fourthItem : ['drawer.items.3',String]
};
var obj = Object.create(Cabinet);
clues(obj,'drawer.items.0')
.then(console.log);
clues(obj,['drawer.items.1','drawer.items.2',function(a,b) {
console.log(a,b);
}]);
clues(obj,function(fourthItem) {
console.log(fourthItem);
});
It is worth noting that children do not inherit anything from parents. If you really want your children to listen to their parents (or their cousins) you have to get creative, passing variables down explicitly or providing a root reference in the globals (see appendix)
complex nesting? no problem!
In the previous example all the values of the nested tree were already determined. But clues
makes no distinction between resolved structures and unresolved when traversing down the tree. It crunches through any functions and promises along the way, without mercy.
The following logic supports the same outcomes as the previous example:
function obj() {
return {
drawer : function() {
return Promise.delay(2000)
.then(function() {
return {
items : [
'A','B','C',Promise.delay(1000).then(function() { return 'D';})
]
};
});
},
fourthItem : ['drawer.items.3',String]
};
}
Keep in mind that since the provided logic in this example is a function (creating an object), a new (fresh) object is generated each time clues is called.
global variables
The third parameter to the clues
function is an optional global object, whose properties are accessible n any scope (as a fallback). This makes it particularly easy to provide services or general inputs (from the user) without having to 'drag those properties manually' through the tree to ensure they exist in each scope where they are needed.
var Logic = {
repeat : ['input.verb',function(verb) { return 'I am '+verb; }],
parent : {
child : {
activity : ['input.childVerb',function(verb) {return 'Child is '+verb; }]
}
}
};
var obj = Object.create(Logic);
clues(obj,'repeat',{input:{verb:'coding'}})
.then(console.log) ;
clues(obj,'parent.child.activity',{input:{childVerb:'sleeping'}})
.then(console.log);
A bit of care is required if the same logic/facts object (not fresh new clone) is used to answer questions with different globals each time. As any function that uses a global value will be converted to a promise, a subsequent request for the same property and a different global variable will still result in the original answer. You need to determine when you need a new clone (i.e. new context) and when you can remain within the original context.
$ at your service
Any function whose property name starts with a $
will simply be resolved as the function itself, not the result of the function execution machine. This is a great method to pass global functions into any scope (as they won't be morphed into a promise on their value).
var Global = {
$emit : function(d) {
console.log('emitting ',d);
}
};
var Cabinet = {
drawer : {
open : function($emit) {
$emit('opened drawer');
return 'ok';
},
close : function($emit) {
$emit('closed drawer');
return 'ok';
}
}
};
var obj = Object.create(Cabinet);
clues(obj,['drawer.open','drawer.close',Object],Global);
It is worth noting that this functionality only applies to functions. If an object has a $ prefix, then any functions inside that object will be crunched by clues
as usual.
$property - lazily create children by missing reference
If a particular property can not be found in a given object, clues will try to locate a $property
function. If that function exists, it is executed with the missing property name as the first argument and the missing value is set to be the function outcome.
obj = {
users : {
all : function() {
return db.collection('users.all')
.find({},{user_id:true});
},
$property : function(ref) {
return db.collection('users')
.find({user_id:ref})
.then(function(user) {
if (!user) throw 'USER_NOT_FOUND';
else return user;
})
}
}
};
clues(obj,'users').then(console.log)
clues(obj,'user.3.name')
.then(console.log)
After 'user3.name' has been resolved, the obj
has three properties:
{
users : [resolved promise on the list of users],
$property : [function],
3 : [resolved promise on the database record for userid 3]
}
$external property for undefined paths
If an undefined property can not locate a $property
function it will look for an $external
function. The purpose of the $external
function is similar except that the argument passed to the function will be the full remaining reference (in dot notation), not just the next reference in the chain.
Here is an example of how the clues tree can be seamlessly extended to an external api using the $external
property:
var request = Promise.promisifyall(require('request'));
var Logic = {
myinfo : ['externalApi.user.info',Object],
externalApi : function(userid) {
return {
$external : function(ref) {
ref = ref.replace(/\./g,'/');
return request.getAsync({
url : 'http://api.vendor.com/api/'+ref,
json : {user_id : userid}
});
}
};
}
};
var obj = Object.create(Logic,{userid:{value:'admin'}});
clues(obj,['myinfo',console.log))
function that returns a function that returns a...
If the resolved value of any function is itself a function, that returned function will also be resolved (within the same scope). This allows for very powerful 'gateways' that constrains the tree traversal only to segments that are relevant for a particular solutions.
Logic = {
tree_a : {....},
tree_b : {....},
next_step: function(step) {
if (step === 'a')
return ['tree_a',Object]
else
return ['tree_b',Object];
}
}
}
clues(Logic,'continue',{step:'a'})
private parts
access control
Access to certain sub-trees of a fact/logic can be controlled through closures that evaluate user privileges before handing over the privileged parts.
Example, assuming a global object res
that has a user
record that defines whether a user has admin privileges or not:
var User = {
info : function(userid) {.....},
changePassword : [input.password,input.confirm,function() {.....},
admin : ['req.user.admin',function(admin) {
if (!admin) throw 'NO_ACCESS';
return {
delete : function() {...},
logs : function () {...}
}
Unauthorized users will get an error if they try to query any of the admin functions, while admins have unlimited access.
using first element to define private scope
But what if we want to hide certain parts of the tree from direct traversal, but still be able to use those hidden parts for logic? Array defined functions can be used to form gateways from one tree into a subtree of another. If the first element of an array-defined function is an object or a function, that object provides a separate scope the function will be evaluated in. The function can therefore act as a selector from this private scope.
Public/private Example:
var PrivateLogic = {
secret : 'Hidden secret',
hash : function(secret,password) {
return crypto.createHash('sha1').update(secret).update(password).digest('hex');
},
public : function(userid,hash) {
return db.find({user_id:user_id,password:hash})
.then(function(d) {
return {name:d.name,email:d.email};
});
}
};
var Logic = {
user : function(_userid,_password) {
var private = Object.create(PrivateLogic);
private.userid = _userid;
private.password = _password;
return [private,'public',Object];
}
};
var obj = Object.create(Logic,{
userid : {value: 'user123'},
password : {value: 'abc123'}
});
clues(obj,'user.name')
.then(console.log,console.log);
In this example user.info
points to an instance of PrivateLogic.public
without providing any access to the other properties or the private object, secret
or hash
. It is worth noting that names of private properties might still be exposed under fullref
of an error. For example if password is not provided above, the fullref of the error will be user.public.hash.password
with the message password not defined
The gateway function could have been with argument names inside or outside the function, even extra arguments. What really matters is what the gateway function returns:
return [private,function(public) { return public;}];
return [private,function(public,hash,secret) { return public;}];
return [private,'public','hash',function(a,b) { return a;}];
Similarly, a private scope can be generated in-line using a function:
{ answer: [function() { return { a : function(b) { return b+1;}, b:41};},'a',Number])}
where answer.a
= 42, but b
is unreachable
Cancellability
Each promise chain is cancellable, but the ability to cancel only reaches those logic functions that explicitly return cancellable promises. Any logic already resolved before a cancel is issued, will not be affected. Here is a pseudo example of how such cancellation could be incorporated with an expensive database query:
logic.transactions = function(userid) {
var connection = db.connect({host:...,});
return new Promise(function(resolve,reject) {
connection.get({'userid':userid}, function(err,d) {
if (err) return reject(err);
else resolve(JSON.stringify(d));
});
})
.cancellable()
.catch(Promise.CancellationError,function() {
connection.close();
});
}
...
express()
.get('/transactions/:userid',function(req,res) {
var facts = Object.create(logic);
facts.userid = req.param.userid;
var transactions = clues(facts,'transactions')
.then(res.end.bind(res));
req.on('abort',function() {
transactions.cancel();
});
moar stuff A: listening to your parents
The dot notation only works downwards from current scope. But what if you require variables from the parent scopes? There are few ways to accomplish this. One is to simply pass the required value through the functional scope of a peer or the required input:
var Logic = {
Charlie : ['Andy.mood',function(andyMood) {
return {
John : function() {
return 'John knows Andy is '+andyMood;
}
};
}],
Andy : { mood: 'happy' }
};
clues(Object.create(Logic),'Charlie.John')
.then(console.log);
Another way is to have the parent set property of the child to whatever should be passed down:
var ChildLogic = {
John : ['Andy.mood',function(andyMood) {
return 'John knows Andy is '+andyMood;
}]
};
var Logic = {
Charlie : function(Andy) {
return Object.create(ChildLogic, {Andy : {value: Andy}});
},
Andy : { mood : 'happy'}
};
clues(Object.create(Logic),'Charlie.John')
.then(console.log);
Yet another example, is to define global variable $root
pointing to the top parent, which allows us to navigate very easily from any child:
var Logic = {
Charlie : {
John : ['$root.Andy.mood',function(d) {
return 'John knows Andy is '+d;
}]
},
Andy : { mood: 23 }
};
var facts = Object.create(Logic);
clues(facts,'Charlie.John',{$root:facts})
.then(console.log);
The main reason why a $root
is not set automatically by clues
is that there is no real concept of a top-level object. You can run clues
on any subsection of a tree without knowing anything about possible parents (which could be multiple)