From 2344277f604825c0673c329e9835d951cf174731 Mon Sep 17 00:00:00 2001 From: Vinicius Gomes Date: Mon, 29 Aug 2016 08:15:06 -0400 Subject: [PATCH] simple abstractions for functional event sourcing --- .babelrc | 1 + .gitignore | 1 + .npmignore | 1 + README.md | 3 ++ package.json | 31 +++++++++++++ test/upshot.test.js | 111 ++++++++++++++++++++++++++++++++++++++++++++ upshot.js | 43 +++++++++++++++++ 7 files changed, 191 insertions(+) create mode 100644 .babelrc create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 README.md create mode 100644 package.json create mode 100644 test/upshot.test.js create mode 100644 upshot.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..9d8d516 --- /dev/null +++ b/.babelrc @@ -0,0 +1 @@ +{ "presets": ["es2015"] } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a022512 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Upshot + +TBD. diff --git a/package.json b/package.json new file mode 100644 index 0000000..b86ba53 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "upshot", + "version": "0.0.1", + "description": "functional event sourcing", + "main": "index.js", + "author": "vvgomes", + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/vvgomes/upshot.git" + }, + "dependencies": { + "ramda": "^0.19.1", + "data.maybe": "1.2.0", + "data.validation": "^1.2.0" + }, + "devDependencies": { + "babel-cli": "^6.6.5", + "babel-preset-es2015": "^6.6.0", + "mocha": "^2.4.5" + }, + "babel": { + "presets": [ + "es2015" + ] + }, + "scripts": { + "test": "mocha --compilers js:babel-register --recursive" + } +} + diff --git a/test/upshot.test.js b/test/upshot.test.js new file mode 100644 index 0000000..a36f30b --- /dev/null +++ b/test/upshot.test.js @@ -0,0 +1,111 @@ +import assert from "assert"; +import R from "ramda"; +import Validation from "data.validation" +import Upshot from "../upshot"; + +describe("an event sourcing app", () => { + + // just a simple counter + const app = Upshot.createApp({ + initialState: { counter: 0 }, + + eventHandlers: { + counterIncremented: (state, event) => R.evolve({ counter: R.inc }, state), + counterDecremented: (state, event) => R.evolve({ counter: R.dec }, state) + }, + + commandHandlers: { + incrementCounter: (state, command) => [ { type: "counterIncremented" } ], + decrementCounter: (state, command) => [ { type: "counterDecremented" } ] + } + }); + + describe("state", () => { + it("is the initial state by default", () => { + assert.deepEqual( + app([]).state(), + { counter: 0 } + ); + }); + + it("applies previous events", () => { + const events = [ + { type: "counterIncremented" }, + { type: "counterDecremented" }, + { type: "counterIncremented" }, + { type: "counterIncremented" } + ]; + + assert.deepEqual( + app(events).state(), + { counter: 2 } + ); + }); + + it("applies only known events", () => { + const events = [ + { type: "counterIncremented" }, + { type: "unknown" }, + { type: "counterDecremented" }, + { type: "counterIncremented" } + ]; + + assert.deepEqual( + app(events).state(), + { counter: 1 } + ); + }); + + it("ignores events without a type", () => { + const events = [ + { type: "counterIncremented" }, + {}, + { type: "counterDecremented" }, + { type: "counterIncremented" } + ]; + + assert.deepEqual( + app(events).state(), + { counter: 1 } + ); + }); + + }); + + describe("dispatch", () => { + it("produces events after command handled successfully", () => { + const firstCommand = { type: "incrementCounter" }; + + assert.deepEqual( + app([]).dispatch(firstCommand), + [{ type: "counterIncremented" }] + ); + + const secondCommand = { type: "decrementCounter" }; + + assert.deepEqual( + app([]).dispatch(secondCommand), + [{ type: "counterDecremented" }] + ); + }); + + it("produces error when the command is unknown", () => { + const command = { type: "unknown" }; + + assert.deepEqual( + app([]).dispatch(command), + Validation.Failure(["Cannot handle command of type 'unknown'."]) + ); + }); + + it("produces error when the command has no type", () => { + const command = {}; + + assert.deepEqual( + app([]).dispatch(command), + Validation.Failure(["Cannot handle command of type 'undefined'."]) + ); + }); + }); +}); + diff --git a/upshot.js b/upshot.js new file mode 100644 index 0000000..2f3ac40 --- /dev/null +++ b/upshot.js @@ -0,0 +1,43 @@ +import R from "ramda"; +import Maybe from "data.maybe" +import Validation from "data.validation" + +const Success = Validation.Success; +const Failure = Validation.Failure; + +const defineHandle = (commandHandlers) => + (state, command) => + Maybe + .fromNullable(commandHandlers[command.type]) + .map(R.flip(R.apply)([state, command])) + .getOrElse(Failure([`Cannot handle command of type '${command.type}'.`])); + +const defineApply = (eventHandlers) => + (state, event) => + Maybe + .fromNullable(eventHandlers[event.type]) + .map((handler) => handler(state, event)) + .getOrElse(state); + +const Upshot = {}; + +Upshot.createApp = (config) => { + const commandHandlers = config.commandHandlers || {}; + const eventHandlers = config.eventHandlers || {}; + const initialState = config.initialState || {}; + + const handle = defineHandle(commandHandlers); + const apply = defineApply(eventHandlers); + + return (events) => { + const state = R.reduce(apply, initialState, events); + + return { + state: () => state, + dispatch: (command) => handle(state, command) + }; + }; +}; + +export default Upshot; +