The Seneca framework provides a data entity API based loosely on the ActiveRecord style. Here’s how it works.
The Seneca framework is defined by a philosophy that actions are better than objects.
The only first-class citizens in the Seneca framework are actions. You register actions in Seneca by defining a set of key-value pairs that the action matches. When a JSON object is submitted to Seneca, it triggers an action if a matching set of key-value pairs is found. The action returns another JSON object.
Actions can call other actions, and wrap existing actions. Groups of actions can work
together to provide specific functionality, such as user management. Such groups are
called plugins. To keep things organized, a few conventions are used. A role
property identifies a specific area of functionality. A cmd
property identifies a
specific action.
For example:
seneca.act('role:entity,cmd:save', {ent:{...}}, (err,reply) => {...})
This action will save data entities, as part of the group of actions that perform the
role
of data persistence. The ent
property is an object containing the data of the
data entity to save.
In Seneca, data persistence is provided by a set of actions. These are:
save
, load
, list
, remove
. This provides a consistent interface
for all other actions that need to persist data.
As convenience, these data entity actions are also available in the form of data entity
objects, that expose the cmd
‘s as methods - just like the ActiveRecord pattern. However,
you cannot add business logic to these objects.
Business logic belongs inside actions!
First you need a Seneca instance:
var seneca = require('seneca')()
var entities = require('seneca-entity')
seneca.use(entities)
Then you can create data entity objects:
var foo = seneca.make('foo')
The entity name is foo
. If your underlying data store is
MongoDB, this data entity corresponds to the foo
collection. As a convenience, so you don’t have to hook up a database, Seneca provides a transient in-memory store out of the
box (so you can just start coding!).
Next, add some data fields:
foo.name = 'Apple'
foo.price = 1.99
The data fields are just ordinary JavaScript object properties.
Now, you need to save the data:
foo.save$(function(err,foo){
console.log(foo)
})
The save$
method invokes the role:entity,cmd:save
action, passing in the foo object as the value of ent
argument.
The reason for the $
suffix is to namespace the cmd
methods. You can always be 100% certain that vanilla property names
“just work”. Stick to alphanumeric characters and underscore and you’ll be fine.
The save$
method takes a callback, using the standard
Node.js idiom: The first parameter is an error object (if there was an
error), the second the result of the action. The save$
method provides
a new copy of the foo entity. This copy has been saved to persistent
storage, and includes a unique id
property.
Once you’ve saved the data entity, you’ll want to load it again at
some point. Use the load$
method to do this, passing in
the id
property.
var id = '...'
var foo_entity = seneca.make('foo')
foo_entity.load$( id, function(err,foo){
console.log(foo)
})
You can call the load$
method on any data entity object
to load another entity of the same type. The original entity does
not change - you get the loaded entity back via the callback.
To delete entities, you also use the id
property, with the
remove$
method:
var id = '...'
var foo_entity = seneca.make('foo')
foo_entity.remove$( id, function(err){ ... })
To get a list of entities that match a query, use
the list$
method:
var foo_entity = seneca.make('foo')
foo_entity.list$( {price:1.99}, function(err,list){
list.forEach(function( foo ){
console.log(foo)
})
})
The matching entities are returned as an array. The query is a set of
property values, all of which must match. This is equivalent to a SQL
query of the form: col1 = 'val1' AND col2 = 'val2' AND ...
.
Seneca provides a common query format that works
across all data stores. The trade-off is that these queries have
limited expressiveness (more on this later, including the get-out-of-jail options).
One thing you can do is sort the results:
foo_entity.list$( {price:1.99, sort$:{price:-1}}, function(err,list){
...
})
The sort$
meta argument takes a sub-object containing a single key, the field to sort. The value +1
means sort ascending,
and the value -1
means sort descending. The common query format only accepts a sort by one field.
You can also use queries with the load$
and remove$
methods. The first matching entity is selected.
Your data can live in many different places. It can be persistent or transient. It may have business rules that apply to it.It may be owned by different people.
Seneca lets you work with your data, without worrying about where it lives, or what rules should apply to it. This makes it easy to handle different types of data in different ways. To make this easier, Seneca provides a three layer namespace for data entities:
name
: the primary name of the entity. For example: product
base
: group name for entities that “belong together”. For example: shop
zone
: name for a data set belonging to a business entity, geography, or customer. For example: tenant001
The zone and base are optional. You can just use the name element in the same way you use ordinary
database tables, and you’ll be just fine. Here’s an example of creating a foo
entity (as seen
above):
var foo_entity = seneca.make('foo')
Often, a set of plugins that provide the related functions, will use
the same base
. This ensures that the entities used by these
plugins won’t interfere with your own entities.
For example, the user
and auth plugins,
which handle user accounts, and login/logout, use the sys
base,
and work with the following entities:
var sys_user = seneca.make('sys','user')
var sys_login = seneca.make('sys','login')
The underlying database needs to have a name for the table or
collection associated with an entity. The convention is to join the
base and name with an underscore, as '_'
is accepted by most database
systems as a valid name character. This means that name
, base
and
zone
values should only be alphanumeric, and to be completely safe,
should never start with a number.
For the above plugins, the table or collection names would be:
sys_user
and sys_login
.
The zone
element provides a higher level namespace that Seneca itself does not
use. It is merely a placeholder for your own needs. For example, you
may need to isolate customer data into separate physical databases.
The zone is never part of the database table name. You use it by registering multiple instances of the same database plugin, pointing at different physical databases. Seneca’s pattern matching makes this automatic for you (see the entity type mapping examples below).
You can also use the zone for custom business rules. The zone, base and name appear as action arguments - just pattern match the underlying actions! (and there are examples below).
The make
method is available on both the main Seneca object, and on each entity object (where it always has a $ suffix):
// the alias make$ will also work
var foo = seneca.make('foo')
// make() does not exist to avoid property clashes
var bar = foo.make$('bar')
It optionally accepts up to three string arguments, specifying the zone, base and name, always in that order:
var foo = seneca.make('foo')
var bar_foo = seneca.make('bar','foo')
var zen_bar_foo = seneca.make('zen','bar','foo')
When no arguments are given, calling make$
on an entity will create a new instance of the same kind (same zone, base and name):
var foo = seneca.make('foo')
var morefoo = foo.make$()
No data is copied, you get a completely new, empty, data entity (use clone$
instead to copy the data).
If you pass in an object as the last argument to make$
, it will be used to initialize the entity data fields:
var foo = seneca.make('foo', {price:1.99,color:'red'})
console.log('price is '+foo.price+' and color is '+foo.color)
If you call the toString
method on an entity, it will indicate the zone, base and name using the syntax zone/base/name
as a prefix to the entity data:
$zone/base/name:{id=...;prop=val,...}
If any of the namespace elements are not defined, a minus '-'
is used as placeholder:
$-/-/name:{id=...;prop=val,...}
The syntax zone/base/name
is also used a shorthand for an
entity type pattern. For example, -/bar/-
means any entities
that have base bar
.
entity.canon$([options])
Each entity has a canon$
method to extract or test equality of the zone/base/name
properties.
var apple = seneca.make('market','fruit');
// Get the properties
apple.canon$(); // -> '-/market/fruit'
apple.canon$({object: true}); // -> {zone: undefined, base: 'market', name: 'fruit'}
apple.canon$({array: true}); // -> [undefined, 'market', 'fruit']
// Test the properties by 'is a'
apple.canon$({isa: '-/market/fruit'}); // -> true
apple.canon$({isa: {base: 'market', name: 'fruit'}}); // -> true
apple.canon$({isa: '-/market/vegetable'}); // -> false
entity.data$([options])
Each entity also has a data$
method to read and write to the entity.
var apple = seneca.make('market','fruit');
apple.name = 'MacIntosh';
// Includes all $-properties
apple.data$(); // -> {'entity$': {zone: undefined, base: 'market', name: 'fruit'}, name: 'MacIntosh'}
// Exclude all $-properties
apple.data$(false); // -> {name: 'MacIntosh'}
// Update and add data
apple.data$({name: 'Golden Delicious', color: 'Yellow'});
To store persistent data, you’ll need to use an external database. Each database needs a plugin that understands how to talk to that database. The plugins normally use a specific driver module to do the actual talking.
For example, the seneca-mongo-driver plugin uses the mongoDB module.
Using a data store plugin is easy. Register with Seneca and supply the database connection details as options to the plugin:
var seneca = require('seneca')()
seneca.use('mongo-store',{
name:'dbname',
host:'127.0.0.1',
port:27017
})
The database connection will need to be established before you can
save data. Use the seneca.ready
function to supply a
callback that will be called once the database is good to go:
seneca.ready(function(err){
var apple = seneca.make$('fruit')
apple.name = 'Pink Lady'
apple.price = 1.99
apple.save$(function(err,apple){
if( err ) return console.log(err);
console.log( "apple = "+apple )
})
})
The seneca.ready
function works for any plugin that has a callback dependency
like this - it will only be triggered once all the plugins are ready.
To close any open database connections, use the seneca.close
method:
seneca.close(function(err){
console.log('database closed!')
})
To use a data store plugin, you’ll normally need to install the module via npm:
npm install seneca-mongo-store
The data store plugins use a naming convention of the form seneca-<database>-store
. The suffix db
is dropped. Here are some of the existing data store plugins:
Refer to their project pages for details on behaviour and configuration options. As a convenience, Seneca allows you to drop the seneca-
prefix when registering the plugin:
seneca.use('mongo-store',{ ... })
The default, built-in data store is mem-store
, which provides a
transient in-memory store. This is very useful for quick prototyping
and allows you to get started quickly. By sticking to the common
entity feature set (see below), you can easily swap over to a real database at a
later point.
If you’d like to add support for a database to Seneca, we are working on a guide to writing data store plugins, stay tuned!
One of the most useful features of the Seneca data entity model is the ability to transparently use different databases. This is enabled by the use of Seneca actions for all the underlying operations. This makes it easy to pattern match against specific entity zones, bases and names and send them to different data stores.
You can use the map
option when registering a data store plugin
to specify the data entity types that it should support. All others will be ignored.
The map is a set of key-value pairs, where the key is an entity type
pattern, and the value a list of entity cmd
s
(such as save
,load
,list
,remove
,…),
or '*'
, which means the mapping applies to all cmd
s.
The example mapping below means that all entities with the name tmp
,
regardless of zone or base, will use the transient mem-store
:
seneca.use('mem-store',{ map:{
'-/-/tmp':'*'
}})
To use different databases for different groups of data, use the base
element:
seneca.use('jsonfile-store',{
folder:'json-data', map:{'-/json/-':'*'}
})
seneca.use('level-store',{
folder:'level-data', map:{'-/level/-':'*'}
})
This mapping sends -/json/- entities to the jsonfile data store, and -/level/- entities to the leveldb data store.
Here it is in action:
seneca.ready(function(err,seneca){
;seneca
.make$('json','foo',{propA:'val1',propB:'val2'})
.save$(function(err,json_foo){
console.log(''+json_foo)
;seneca
.make$('level','bar',{propA:'val3',propB:'val4'})
.save$(function(err,level_bar){
console.log(''+level_bar)
}) })
})
The full source code is available in the data-entities folder of the seneca examples repository. (The ; prefix is just a marker to avoid excessive indentation)
You can track and debug the activity of data entities by reviewing the action log, and the plugin log for the datastore.
For example, run the example above, that uses both the jsonfile store and the leveldb store, using the --seneca.log=type:act
log filter, and you get the output:
$ node main.js --seneca.log=type:act
...
2013-04-18T10:05:45.818Z DEBUG act jsonfile-store BCL wa8xc5 In {cmd=save,role=entity,ent=$-/json/foo:{id=;propA=val1;propB=val2},name=foo,base=json} gx38qi
2013-04-18T10:05:45.821Z DEBUG act jsonfile-store BCL wa8xc5 OUT [$-/json/foo:{id=ulw8ew;propA=val1;propB=val2}] gx38qi
...
2013-04-18T10:05:45.822Z DEBUG act level-store GPN 8dnjyt IN {cmd=save,role=entity,ent=$-/level/bar:{id=;propA=val3;propB=val4},name=bar,base=level} 8ml1p7
2013-04-18T10:05:45.826Z DEBUG act level-store GPN 8dnjyt OUT [$-/level/bar:{id=7de92fc0-f402-411d-80ea-59e435a8c398;propA=val3;propB=val4}] 8ml1p7
...
This shows the role:entity, cmd:save
action of both data
stores. Seneca actions use a JSON-in/JSON-out model. You can trace
this using the IN
and OUT
markers in the log
entries. The IN
and OUT
entries are connected by an action identifier, such as wa8xc5
.
This lets you trace actions when they interleave asynchronously.
The IN
log entries show the action arguments, including the entity data, and the entity zone, base and name (if defined).
Once the action completes, the OUT
log entries show the returned data. In particular, notice that the entities now have generated id
s.
The data stores themselves also generate logging output. Try --seneca.log=type:plugin
to see this:
$ node main.js --seneca.log=type:plugin
2013-04-18T10:39:54.961Z DEBUG plugin jsonfile-store QSG cop6lx save/insert $-/json/foo:{id=nt7usm;propA=val1;propB=val2} jsonfile-store~QSG~-/json/-
2013-04-18T10:40:19.802Z DEBUG plugin level-store JNG save/insert $-/level/bar:{id=7166037e-112d-448c-9afa-84e69d84aa25;propA=val3;propB=val4} level-store~JNG~-/level/-
In this case, the data stores creates a log entry for each save operation that inserts data. The entity data is also shown.
Each plugin instance gets a three letter tag, such as QSG
, or JNG
. This helps you distinguish between multiple mappings that use the same data store.
Each data store plugin instance can be ths be described by the name of the data store plugin, the tag, and the associated mapping. This is the last element of the log entry. For example:
level-store~JNG~-/level/-
Issues? From spelling errors to broken tutorials and everything in between, report them here.