UNPKG

evtstore

Version:

Event Sourcing with Node.JS

278 lines (236 loc) 7.54 kB
import * as neo from 'neo4j-driver' import { ErrorCallback, Event, Provider } from '../src/types' import { VersionError } from './error' import { createEventsMapper, toArray } from './util' export type Bookmark = { bookmark: string /** datetime.realtime() */ position: string } export type Options = { limit?: number client: neo.Driver | Promise<neo.Driver> onError?: ErrorCallback /** Bookmarks label */ bookmarks: string /** Events label */ events: string } export type MigrateOptions = { client: neo.Driver | Promise<neo.Driver> bookmarks: string events: string } export function createProvider<E extends Event>(opts: Options): Provider<E> { const onError = opts.onError || noop const client = opts.client const run = <T = unknown>(query: string, params?: {}) => cypher<T>(client, query, params) return { limit: opts.limit, driver: 'neo4j', onError, getPosition: async (bm) => { const [pos] = await run<Bookmark>( `MATCH (bm: ${opts.bookmarks} { bookmark: $bm }) RETURN bm`, { bm } ) if (pos === undefined) return 0 return toInternalPosition(pos.position) }, setPosition: async (bm, pos) => { const position = toNeoPosition(pos) await run( ` MERGE (bm: ${opts.bookmarks} { bookmark: $bm }) ON CREATE SET bm.position = $position ON MATCH SET bm.position = $position `, { bm, position } ) }, getEventsFor: async (stream, id, from) => { const params: any = { stream, id, from: toNeoPosition(from) } let query = ` MATCH (ev: ${opts.events}) WHERE ev.aggregateId = $id AND ev.position > datetime($from) AND ev.stream = $stream ` const limit = opts.limit ? `LIMIT ${opts.limit}` : '' const events = await run<any>(`${query} RETURN ev ORDER BY ev.position ASC ${limit}`, params) const parsed = events.map((ev) => ({ stream: ev.stream, position: toInternalPosition(ev.position), version: toVersion(ev.version), timestamp: new Date(ev.timestamp), aggregateId: ev.aggregateId, event: JSON.parse(ev.event), })) return parsed }, getLastEventFor: async (stream, id) => { const streams = toArray(stream).map((stream) => `'${stream}'`) const params: any = {} let query = ` MATCH (ev: ${opts.events}) WHERE ev.stream IN [${streams.join(', ')}]` if (id) { params.id = id query += ` AND ev.aggregateId = $id` } const events = await run<any>(`${query} RETURN ev ORDER BY ev.position DESC LIMIT 1`, params) const parsed = events.map((ev) => ({ stream: ev.stream, position: toInternalPosition(ev.position), version: toVersion(ev.version), timestamp: new Date(ev.timestamp), aggregateId: ev.aggregateId, event: JSON.parse(ev.event), })) return parsed[0] }, getEventsFrom: async (stream, pos, lim) => { const streams = toArray(stream).map((stream) => `'${stream}'`) const params: any = { pos: toNeoPosition(pos) } const query = ` MATCH (ev: ${opts.events}) WHERE ev.stream IN [${streams.join(', ')}] AND ev.position > datetime($pos) ` const limit = lim ?? opts.limit ? `LIMIT ${opts.limit}` : '' const events = await run<any>(`${query} RETURN ev ORDER BY ev.position ASC ${limit}`, params) const parsed = events.map((ev) => ({ stream: ev.stream, position: toInternalPosition(ev.position), version: toVersion(ev.version), timestamp: new Date(ev.timestamp), aggregateId: ev.aggregateId, event: JSON.parse(ev.event), })) return parsed }, createEvents: createEventsMapper<E>(0), append: async (stream, id, _version, newEvents) => { const client = await opts.client for (const event of newEvents) { try { await cypher( client, ` WITH datetime.transaction() as curr, $stream + "_" + toString(datetime.transaction()) as streampos CREATE (ev: ${opts.events} { stream: $stream, position: curr, version: $version, timestamp: datetime($timestamp), aggregateId: $id, event: $event, _streamPosition: streampos, _streamIdVersion: $streamIdVersion }) RETURN ev `, { stream, id, version: event.version, timestamp: event.timestamp.toISOString(), event: JSON.stringify(event.event), streamIdVersion: `${stream}_${id}_${event.version}`, } ) } catch (ex: any) { if (ex instanceof neo.Neo4jError === false) throw ex if (ex.code === 'Neo.ClientError.Schema.ConstraintValidationFailed') { throw new VersionError(ex.message) } throw ex } } return newEvents }, } } export async function migrate(opts: MigrateOptions) { const cli = await opts.client const session = await cli.session({ defaultAccessMode: 'WRITE' }) const trx = session.beginTransaction() await trx.run(` CREATE INDEX ${opts.events}_stream_position IF NOT EXISTS FOR (ev: ${opts.events}) ON (ev.stream, ev.position) `) await trx.run(` CREATE INDEX ${opts.events}_stream_id_pos IF NOT EXISTS FOR (ev: ${opts.events}) ON (ev.stream, ev.aggregateId, ev.position) `) await trx.run(` CREATE CONSTRAINT ${opts.events}_streampos_unique IF NOT EXISTS ON (ev: ${opts.events}) ASSERT ev._streamPos IS UNIQUE `) await trx.run(` CREATE CONSTRAINT ${opts.events}_streamidversion_unique IF NOT EXISTS ON (ev: ${opts.events}) ASSERT ev._streamIdVersion IS UNIQUE `) await trx.commit() await session.close() } export async function cypher<T = unknown>( client: neo.Driver | Promise<neo.Driver>, query: string, params?: {} ) { const cli = await client const session = cli.session({ defaultAccessMode: 'WRITE' }) const response = await session.run(query, params) await session.close() // Unfortunately the type definitions in neo4j-driver are weak and don't // allow us to do any better here const objects: any[] = response.records.map((record) => record.toObject()) const results: T[] = [] for (const row of objects) { let obj: any = {} for (const key in row) { if (row[key]?.properties === undefined) { obj[sanitise(key)] = row[key] continue } Object.assign(obj, row[key].properties) } results.push(obj) } return results } function sanitise(key: string) { const last = key.split('.').slice(-1)[0] return last } function noop() {} function toVersion(value: any) { return neo.isInt(value) ? value.toInt() : value } function toNeoPosition(position: any) { if (!position) { return new Date(0).toISOString() } if (typeof position === 'number') { return new Date(position).toISOString() } if (position instanceof Date) { return position.toISOString() } return position } function toInternalPosition(position: any) { if (neo.isDateTime(position) || typeof position === 'string') { return new Date(position.toString()).valueOf() } if (isNaN(position)) return 0 return position }