Sourced is a module designed to support event sourcing in javascript. It provides methods for storing revisions (collections of events that have occurred atomically within a resource's lifecycle) as well as building views of resources through the application of events.
It is designed to be highly configurable, supporting any reasonable form of storage through storage adapters.
Install Sourced by using npm:
npm install boco-sourced
This document is written in literate coffeescript. When executed via the coffee
command, the examples in this document act as high-level tests for the library itself. I've been calling this "Readme Driven Development", or RDD for short. I think it's pretty neat.
# test all the things
coffee -l docs/*.coffee.md
For the examples in this document, we will require the sourced
module:
Sourced = require 'boco-sourced'
In addition, the assert
library will be used to demonstrate the results of various methods:
assert = require 'assert'
We'll also use the async
library:
The following code is used to allow us to run asynchronous tests:
Async = require 'async'
steps = []
step = (name, fn) -> steps.push name: name, fn: fn
The dependencies necessary for Sourced are injected via a configuration object. Let's create a simple object and then step through each dependency one by one, adding them to the config
as we go.
config = {}
Sourced needs to know how to persist data. The core library comes with a simple MemoryStorage
adapter, which is useful for testing. Let's use that for now.
config.storage = new Sourced.MemoryStorage()
For real applications, you will want to use one of the persisted storage adapters or write your own.
Now that we have our configuration ready, let's get an instance of the service object we will use by calling the createService
method:
sourced = Sourced.createService config
Note that each of our configured dependencies appears as a property on the service object:
assert.equal config.storage, sourced.storage
A revision represents a collection of events that have occurred atomically at a given point within a resource's lifecycle. To create a revision, pass in the resource domain
, type
, identity
, and the version
of that resource to the service's createRevision
method:
userId = 'fcc6b227-3e52-40af-a1a3-b276ecf97daa'
revision = sourced.createRevision 'Users', 'User', userId, 5
The revision object should know about its associated resource:
assert.equal 'Users', revision.domain
assert.equal 'User', revision.resourceType
assert.equal 'fcc6b227-3e52-40af-a1a3-b276ecf97daa', revision.resourceId
assert.equal 5, revision.resourceVersion
You may omit both the identity
and version
parameters, and sane defaults will be applied.
By default, a uuid will be generated for the resource's identity.
You can override the default uuid generator by replacing the generateId
method on the revisionFactory
with your own.
sourced.revisionFactory.generateId = -> '83ff08d6-d23c-4431-8613-dd5aa3da5e4b'
revision = sourced.createRevision 'Users', 'User'
assert.equal '83ff08d6-d23c-4431-8613-dd5aa3da5e4b', revision.resourceId
We don't want to leave that stubbed generator method there, so let's restore the original. Since the original method was defined on the service's prototype
, we can just delete the overriden property:
delete sourced.revisionFactory.generateId
By default, the resource version
will be set to 0
, meaning that the revision applies to a resource that has had no previous revisions in its lifecycle.
assert.equal 0, revision.resourceVersion
A revision should contain one or more events. Let's add a single event to our initial revision by passing in the event's type
and a payload
hash:
event = sourced.createEvent 'Registered',
username: 'john.doe', email: '[email protected]'
revision.addEvent event
The revision maintains a collection of the events that have been added:
assert.equal 1, revision.events.length
You can access the events by their index, just like an array:
event = revision.events[0]
The revision's properties should be copied to the event:
assert.equal 'Users', event.domain
assert.equal 'User', event.resourceType
assert.equal '83ff08d6-d23c-4431-8613-dd5aa3da5e4b', event.resourceId
assert.equal 0, event.resourceVersion
They also contain their type
, and an index
of the order in which they were added:
assert.equal 'Registered', event.type
assert.equal 0, event.index
The payload that they were assigned upon creation is accessible via the payload
property:
assert.equal 'john.doe', event.payload.username
assert.equal '[email protected]', event.payload.email
Now that we have an initial revision for our User, it needs to be persisted to storage so that we can rebuild our resource at a later point. To store a revision, call the storeRevision
method, passing the revision
object and an optional callback
method:
step "storing a revision", (done) ->
sourced.storeRevision revision, done
If you attempt to store a revision of a resource with a version that has previously been stored, a revision conflict will be raised:
step "storing a conflicting revision", (done) ->
sourced.storeRevision revision, (error) ->
assert error instanceof Sourced.RevisionConflict
done()
If you attempt to store a revision with a version that is not in sequence (ie: it is not exactly 1 more than the previous version), a sequence error will be raised:
step "storing a revision out of sequence", (done) ->
rev2 = sourced.createRevision 'Users', 'User', revision.resourceId, 2
sourced.storeRevision rev2, (error) ->
assert error instanceof Sourced.RevisionOutOfSequence
done()
In order for Sourced to hydrate resources, you need to tell the service about the events for each resource type and how they are applied. Let's create a new schema by calling createSchema
and passing in the resource type
:
schema = sourced.createSchema 'User'
assert.equal 'User', schema.resourceType
The schema defines a method for constructing your resource, given its identity. By default, it creates a generic Resource
instance:
user = schema.constructResource '89e43652-9288-404e-a047-2fe94491ef29'
assert user instanceof Sourced.Resource
assert.equal '89e43652-9288-404e-a047-2fe94491ef29', user.id
assert.equal 0, user.version
You may want to define your own resource class instead:
class User extends Sourced.Resource
constructor: (props = {}) ->
@id = props.id
@version = props.version
@setDefaults()
setDefaults: ->
@version = 0 unless @version?
You can then override the constructResource
method to return an instance of that class:
schema.constructResource = (resourceId) ->
new User id: resourceId
The schema will now construct a new User
resource:
user = schema.constructResource '89e43652-9288-404e-a047-2fe94491ef29'
assert user instanceof User
assert.equal '89e43652-9288-404e-a047-2fe94491ef29', user.id
assert.equal 0, user.version
It is useful to maintain a property reflecting the current version of a resource on the model itself. As each revision is applied during hydration, the setResourceVersion
method of the schema will be called, passing in the resource
and version
. By default, it simply sets the version
property of the given resource
and returns it:
user = schema.setResourceVersion user, 1
assert.equal 1, user.version
You can override this method if you like, perhaps to use a different property name for maintaining the version. The default seems just fine for this example, and it's unlikely that you'll need to change this behavior.
Event handlers apply events to the resource during hydration. To define them, call the defineEventHandler
method, passing in the event type
, followed by a handler
method that accepts the resource
and event
. This method must return an object representing the resource after the event has been applied.
Let's go ahead and define how the Registered
and ProfileUpdated
events affect a user
model.
schema.defineEventHandler 'Registered', (user, event) ->
user.username = event.payload.username
user.email = event.payload.email
return user
schema.defineEventHandler 'ProfileUpdated', (user, event) ->
user.name = event.payload.name
user.title = event.payload.title
return user
The schema should know about the types of events it can handle:
assert schema.isEventHandlerDefined('Registered')
assert schema.isEventHandlerDefined('ProfileUpdated')
assert !schema.isEventHandlerDefined('SomeUndefinedEvent')
Let's test the behavior of the Registered
event with a mock object:
registered = type: 'Registered', payload:
username: 'foo.bar', email: '[email protected]'
user = schema.applyEvent user, registered
assert.equal 'foo.bar', user.username
assert.equal '[email protected]', user.email
As well as our handler for ProfileUpdated
:
profileUpdated = type: 'ProfileUpdated', payload:
name: 'Foo Bar', title: 'Foo Bar Baz'
user = schema.applyEvent user, profileUpdated
assert.equal 'Foo Bar', user.name
assert.equal 'Foo Bar Baz', user.title
Applying an event that has no defined handler should throw an EventHandlerUndefined
error:
undefinedEvent = type: 'UndefinedEvent', payload: { foo: 'bar' }
shouldThrow = ->
schema.applyEvent user, undefinedEvent
isCorrectError = (error) ->
error instanceof Sourced.EventHandlerUndefined
assert.throws shouldThrow, isCorrectError
Now that we have defined our schema, let's register it with the service:
sourced.registerSchema schema
The service should know about the resource types it has a registered schema for:
assert sourced.isSchemaRegisteredFor('User')
assert !sourced.isSchemaRegisteredFor('UndefinedResource')
Event sourcing allows us to rebuild views of our resources by applying events. In Sourced, we call this process hydration.
For these examples, we'll be working with a User
resource with the following id:
userId = '89e43652-9288-404e-a047-2fe94491ef29'
Let's say that at some point, the user has registered:
registerUser = (done) ->
rev0 = sourced.createRevision 'Users', 'User', userId
event = sourced.createEvent 'Registered',
username: 'john.doe', email: '[email protected]'
rev0.addEvent event
sourced.storeRevision rev0, done
Let's add another revision, in which the user updated their profile information:
updateProfile = (done) ->
rev1 = sourced.createRevision 'Users', 'User', userId, 1
event = sourced.createEvent 'ProfileUpdated',
name: 'John Doe', title: 'Software Developer'
rev1.addEvent event
sourced.storeRevision rev1, done
step "setup for hydration tests", (done) ->
Async.series [registerUser, updateProfile], done
To hydrate a resource, just call the hydrate
method, passing in the resource domain
, type
and identity
, followed by a callback
that accepts an error
and the hydrated resource
:
step "hydrating a resource", (done) ->
sourced.hydrate 'Users', 'User', userId, (error, user) ->
throw error if error?
The resource should have its id
and version
set:
assert.equal '89e43652-9288-404e-a047-2fe94491ef29', user.id
assert.equal 2, user.version
The first revision should have set the username
and email
by applying the Registered
event:
assert.equal 'john.doe', user.username
assert.equal '[email protected]', user.email
The second revision should have set the name
and title
by applying the ProfileUpdated
event:
assert.equal 'John Doe', user.name
assert.equal 'Software Developer', user.title
done()
The following code runs the asynchronous tests in this README
runStep = (step, done) ->
console.log "* #{step.name}"
step.fn done
cleanup = (error, done) ->
throw error if error?
console.log "Tests successful"
Async.eachSeries steps, runStep, cleanup