Skip to content

bocodigitalmedia/boco-sourced

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

39 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

boco-sourced

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.

Installation

Install Sourced by using npm:

npm install boco-sourced

README Driven Development

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

Usage

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

Configuration

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 = {}

config.storage

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.

Create the service object

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

Creating revisions

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

Defaults

You may omit both the identity and version parameters, and sane defaults will be applied.

revision.resourceId

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

revision.resourceVersion

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

Adding events to a revision

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

Storing revisions

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

Revision conflicts

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()

Sequence errors

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()

Defining a schema

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

schema.constructResource

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

schema.setResourceVersion

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.

schema.defineEventHandler

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

Registering a schema

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')

Hydrating a resource

Event sourcing allows us to rebuild views of our resources by applying events. In Sourced, we call this process hydration.

Setup

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

Hydrate a resource

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

About

Support library for the event source pattern in javascript.

Resources

Stars

Watchers

Forks

Packages

No packages published