andvari
Version:
Event sourcing for node.js
176 lines (144 loc) • 6.18 kB
JavaScript
import levelup from 'level'
import now from 'nano-time'
const {keys, values, freeze} = Object
const PREVIOUS = 'PREVIOUS'
const NIGHTLY = 'NIGHTLY'
const msTillMidnight = () => {
const day = new Date()
day.setHours(24, 0, 0, 0)
const nextMd = day.getTime() - Date.now()
return nextMd > 1000 * 60 ? nextMd : 1000 * 60 * 60 * 24
}
const runNightly = (fn) => {
setTimeout(fn, msTillMidnight())
}
const SAFE_INT = '000000000000000'
const leftPad = (str = '', pad = SAFE_INT) => (pad + str).substring(str.length)
const since = (timestamp) => {
if (!timestamp) return
const left = timestamp.slice(0, -SAFE_INT.length)
const right = timestamp.slice(-SAFE_INT.length, timestamp.length)
return left + leftPad(parseInt(right) + 1 + '')
}
const pipeReducer = (acc, fn) => typeof fn === 'function' ? fn(acc) : acc
const pipe = (...fns) => (arg) => fns.reduce(pipeReducer, arg)
export default (path, initialProjectors, getEvents, REVISION = '1') => {
const projectors = initialProjectors || {}
const projections = {}
const snapshots = levelup(path, {valueEncoding: 'json'})
// Handle sync updates during initialization
const queue = []
let initialized = false
const buffer = (fn) => (...args) => new Promise((resolve) => {
if (initialized) return resolve(fn(...args))
queue.push([fn, args, resolve])
})
const flushQueue = () => queue.forEach(([fn, args, resolve]) => resolve(fn(...args)))
// Seeds
const getSeeded = () => new Promise((resolve) => {
snapshots.get('__seeded__', (err, seeded = []) => resolve(seeded))
})
const setSeeded = (newSeeds) => new Promise((resolve) => {
getSeeded().then((oldSeeds) =>
snapshots.put('__seeded__', [...oldSeeds, ...newSeeds], () => resolve(newSeeds)))
})
// Watchers
const watchers = {}
const cleanUpWatcher = (timestamp) =>
({keepWatching} = {}) =>
!keepWatching && delete watchers[timestamp]
const watchersFor = (projectionNamespace) =>
({namespace}) => projectionNamespace === namespace
const previousProjection = (namespace) =>
projections[`${namespace}:${PREVIOUS}`] && projections[`${namespace}:${PREVIOUS}`].projection
const updateWatchers = ({namespace, timestamp, projection}) => {
values(watchers).filter(watchersFor(namespace))
.forEach(({fn, watchTimestamp}) =>
fn(projection, timestamp, previousProjection(namespace)).then(cleanUpWatcher(watchTimestamp)))
}
const watch = (namespace, fn) => {
const watchTimestamp = now()
watchers[watchTimestamp] = {fn, namespace, watchTimestamp}
}
const matchProjection = (timestamp, condition, cb) => (projection, ssTimestamp) =>
new Promise((resolve) => {
if (ssTimestamp >= timestamp && condition(projection)) {
cb(projection)
resolve()
}
})
const when = (namespace, eTimestamp, condition = () => true) => new Promise((resolve) => {
watch(namespace, matchProjection(eTimestamp, condition, resolve))
})
// Manage Nightly builds
const getSnapshot = (namespace) => new Promise((resolve) => {
snapshots.get(`${namespace}:${NIGHTLY}:${REVISION}`, (err, {timestamp, projection} = {}) =>
resolve({timestamp, projection, namespace}))
})
const buildProjectionsFromSnapshots = (snapshots) => snapshots.reduce((acc, snapshot) => ({
...acc,
[snapshot.namespace]: snapshot
}), {})
const restoreSnapshots = (projectors, updateWatchers = false) =>
Promise.all(projectors.map(getSnapshot))
.then(buildProjectionsFromSnapshots)
.then(applyProjections(updateWatchers))
.then(getDaysEvents)
.then(createProjections)
.then(applyProjections(updateWatchers))
.then(() => {
initialized = true
flushQueue()
})
const getDaysEvents = () => new Promise((resolve) => {
snapshots.get(`__nightlyTimestamp__:${REVISION}`, (err, timestamp) => {
getEvents(since(timestamp)).then(resolve)
})
})
const updateLastNightly = (events) => new Promise((resolve) => {
snapshots.put(`__nightlyTimestamp__:${REVISION}`, events[events.length - 1].timestamp, () =>
resolve(events))
})
const persistProjections = (newProjections) =>
snapshots.batch(values(newProjections).map((value) => ({
type: 'put',
key: `${value.namespace}:${NIGHTLY}:${REVISION}`,
value
})))
// Projections
const getProjection = (namespace) => Promise.resolve(projections[namespace] && projections[namespace].projection)
const projectEvents = (events = [], lens, {timestamp, projection, namespace} = {}) => ({
namespace,
timestamp: events.length ? events[events.length - 1].timestamp : timestamp,
projection: events.reduce(lens, projection)
})
const createProjections = (events) =>
keys(projectors).reduce((acc, namespace) => ({
...acc,
[namespace]: projectEvents(events, projectors[namespace], projections[namespace])
}), {})
const applyProjections = (shouldUpdateWatchers) => (newProjections = {}) =>
values(newProjections).forEach(({namespace, timestamp, projection} = {}) => {
if (!namespace || projections[namespace] && projection === projections[namespace].projection) return
projections[`${namespace}:${PREVIOUS}`] = projections[namespace]
projections[namespace] = {namespace, timestamp, projection}
shouldUpdateWatchers && updateWatchers(projections[namespace])
})
const project = pipe(createProjections, applyProjections(true))
const projectNightly = () => {
getDaysEvents().then(updateLastNightly)
.then(pipe(createProjections, persistProjections))
runNightly(projectNightly)
}
runNightly(projectNightly)
restoreSnapshots(keys(projectors))
return freeze({
watch: buffer(watch),
when: buffer(when),
project: buffer(project),
getProjection: buffer(getProjection),
getSeeded: buffer(getSeeded),
setSeeded: buffer(setSeeded),
close: () => new Promise((resolve) => snapshots.close(resolve))
})
}