diff --git a/package-lock.json b/package-lock.json index d60c5ef7..7f650d90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -444,6 +444,15 @@ "@types/node": "*" } }, + "@types/uuid": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-3.4.3.tgz", + "integrity": "sha512-5fRLCYhLtDb3hMWqQyH10qtF+Ud2JnNCXTCZ+9ktNdCcgslcuXkDTkFcJNk++MT29yDntDnlF1+jD+uVGumsbw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "JSONStream": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.3.tgz", diff --git a/package.json b/package.json index dcdd8d44..5def08cc 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "statements": 95 } }, - "collectCoverage": true + "collectCoverage": false }, "prettier": { "semi": false, @@ -79,6 +79,7 @@ "devDependencies": { "@types/jest": "^22.0.0", "@types/node": "^10.0.3", + "@types/uuid": "^3.4.3", "colors": "^1.1.2", "commitizen": "^2.9.6", "coveralls": "^3.0.0", @@ -106,6 +107,7 @@ "tslint-config-standard": "^7.0.0", "typedoc": "^0.11.0", "typescript": "^2.6.2", + "uuid": "^3.2.1", "validate-commit-msg": "^2.12.2" }, "dependencies": {} diff --git a/rollup.config.ts b/rollup.config.ts index 486edde4..aca1c1be 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -13,12 +13,12 @@ export default { input: `src/${libraryName}.ts`, output: [ { file: pkg.main, name: camelCase(libraryName), format: 'umd', sourcemap: true }, - { file: pkg.module, format: 'es', sourcemap: true }, + { file: pkg.module, format: 'es', sourcemap: true } ], // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') external: [], watch: { - include: 'src/**', + include: 'src/**' }, plugins: [ // Allow json resolution @@ -33,6 +33,6 @@ export default { resolve(), // Resolve source maps to the original source - sourceMaps(), - ], + sourceMaps() + ] } diff --git a/src/EventData.ts b/src/EventData.ts new file mode 100644 index 00000000..7f93ad1b --- /dev/null +++ b/src/EventData.ts @@ -0,0 +1,13 @@ +import { Guard } from './errors/Guard' + +class EventData { + constructor( + public readonly eventId: string, + public readonly body: any, + public readonly metaData: any = undefined + ) { + Guard.againstNull('body', body) + } +} + +export { EventData } diff --git a/src/EventStore.ts b/src/EventStore.ts index 9ee1473f..a806bfe6 100644 --- a/src/EventStore.ts +++ b/src/EventStore.ts @@ -1,6 +1,7 @@ import { Guard } from './errors/Guard' -import { EventData, StorageEvent } from './StorageEvent' +import { StorageEvent } from './StorageEvent' import { IStorageEngine } from './IStorageEngine' +import { EventData } from './EventData' class EventStore { constructor(private readonly engine: IStorageEngine) { @@ -31,3 +32,5 @@ class EventStore { return this.engine.readStreamForwards(streamId, startPosition, numberOfEvents) } } + +export { EventStore } diff --git a/src/StorageEvent.ts b/src/StorageEvent.ts index 3abcd98c..1dec80a0 100644 --- a/src/StorageEvent.ts +++ b/src/StorageEvent.ts @@ -1,21 +1,20 @@ -import { Guard } from './errors/Guard' +import { EventData } from './EventData' +// TODO: Rename as clashes with native type class StorageEvent { + public eventBody: any + public metaData: any + public eventId: string + constructor( public readonly streamId: string, - public readonly data: EventData, + eventData: EventData, public readonly eventNumber: number - ) {} -} - -class EventData { - constructor( - public readonly eventId: string, - public readonly body: any, - public readonly metaData: any = undefined ) { - Guard.againstNull('body', body) + this.eventBody = eventData.body + this.metaData = eventData.metaData + this.eventId = eventData.eventId } } -export { StorageEvent, EventData } +export { StorageEvent } diff --git a/src/errors/ArgumentError.ts b/src/errors/ArgumentError.ts new file mode 100644 index 00000000..f6c578f7 --- /dev/null +++ b/src/errors/ArgumentError.ts @@ -0,0 +1,7 @@ +class ArgumentError extends Error { + constructor(message: string) { + super(message) + } +} + +export { ArgumentError } diff --git a/src/errors/ConcurrencyError.ts b/src/errors/ConcurrencyError.ts index ff8b42e9..37bd2f80 100644 --- a/src/errors/ConcurrencyError.ts +++ b/src/errors/ConcurrencyError.ts @@ -1 +1,3 @@ class ConcurrencyError extends Error {} + +export { ConcurrencyError } diff --git a/src/errors/Guard.ts b/src/errors/Guard.ts index 9532d0e7..96383166 100644 --- a/src/errors/Guard.ts +++ b/src/errors/Guard.ts @@ -1,9 +1,12 @@ import { NullableString } from '../types' +import { ArgumentError } from './ArgumentError' class Guard { public static againstNullOrEmpty(paramName: string, value: NullableString) { if (this.isNull(value) || value!.trim().length === 0) { - throw new Error(`${paramName} can not be null, empty string or contain only whitespace`) + throw new ArgumentError( + `${paramName} can not be null, empty string or contain only whitespace` + ) } } diff --git a/src/inMemory/InMemoryStorageEngine.ts b/src/inMemory/InMemoryStorageEngine.ts index a8013647..639d0342 100644 --- a/src/inMemory/InMemoryStorageEngine.ts +++ b/src/inMemory/InMemoryStorageEngine.ts @@ -1,8 +1,13 @@ import { IStorageEngine } from '../IStorageEngine' import { StorageEvent } from '../StorageEvent' +import { ConcurrencyError } from '../errors/ConcurrencyError' class InMemoryStorageEngine implements IStorageEngine { - constructor(private readonly streams: Map) {} + private readonly streams: Map + + constructor() { + this.streams = new Map() + } public async appendToStream(streamId: string, events: StorageEvent[]): Promise { if (!this.streams.has(streamId)) { @@ -17,8 +22,8 @@ class InMemoryStorageEngine implements IStorageEngine { } : Actual revision ${this.streams.get(streamId)!.length}"` ) } - - this.streams.set(streamId, events) + const stream = this.streams.get(streamId)!.concat(events) + this.streams.set(streamId, stream) } public async readStreamForwards( @@ -29,11 +34,13 @@ class InMemoryStorageEngine implements IStorageEngine { if (!this.streams.has(streamId)) { return [] } - - return this.streams.get(streamId)!.slice(startPosition, startPosition + numberOfEvents) + const index = startPosition <= 0 ? 0 : startPosition - 1 + return this.streams.get(streamId)!.slice(index, startPosition + numberOfEvents) } public async initialise(): Promise { return this } } + +export { InMemoryStorageEngine } diff --git a/test/EventData.test.ts b/test/EventData.test.ts new file mode 100644 index 00000000..1c855777 --- /dev/null +++ b/test/EventData.test.ts @@ -0,0 +1,25 @@ +import { EventData } from '../src/EventData' +import * as uuid from 'uuid' + +describe('When the body is not provided', () => { + it('It should throw an error', () => { + expect(() => new EventData(uuid.v4(), undefined)).toThrowError('body can not be null') + }) +}) +describe('When creating an instance of an EventData object', () => { + const expectedEventId = uuid.v4() + const expectedMetaData = 'METADATA' + const expectedBody = 'BODY' + + const eventData = new EventData(expectedEventId, expectedBody, expectedMetaData) + + it('It should map the event Id correctly', () => { + expect(eventData.eventId).toEqual(expectedEventId) + }) + it('It should map the body correctly', () => { + expect(eventData.body).toEqual(expectedBody) + }) + it('It should map the meta data correctly', () => { + expect(eventData.metaData).toEqual(expectedMetaData) + }) +}) diff --git a/test/EventStore.Appending.test.ts b/test/EventStore.Appending.test.ts new file mode 100644 index 00000000..a93877e6 --- /dev/null +++ b/test/EventStore.Appending.test.ts @@ -0,0 +1,144 @@ +import { InMemoryStorageEngine } from '../src/inMemory/InMemoryStorageEngine' +import { EventStore } from '../src/EventStore' +import { IStorageEngine } from '../src/IStorageEngine' +import { StorageEvent } from '../src/StorageEvent' +import * as uuid from 'uuid' +import { EventData } from '../src/EventData' +import { OrderCreated } from './Events/OrderCreated' +import { OrderDispatched } from './Events/OrderDispatched' + +describe('Given a set of engines to test against', () => { + const engineFactories: (() => IStorageEngine)[] = [() => new InMemoryStorageEngine()] + + const newGuid = () => uuid.v4() + + engineFactories.forEach(getEngine => { + const engine = getEngine() + + const getStore = async () => { + await engine.initialise() + return new EventStore(engine) + } + describe('When appending to a new stream', () => { + describe('And the stream id is invalid', () => { + const invalidStreamIds = [undefined, null, '', ' '] + invalidStreamIds.forEach(invalidStreamId => { + it(`It should throw an error for stream id: '${invalidStreamId}'`, async () => { + const eventStore = await getStore() + const event = new EventData(newGuid(), 'BODY') + try { + await eventStore.AppendToStream(invalidStreamId as string, 0, event) + } catch (e) { + expect(e.message).toEqual( + 'streamId can not be null, empty string or contain only whitespace' + ) + } + }) + }) + }) + describe('And we have multiple events to save', () => { + const streamId = newGuid() + const firstEvent = new EventData(newGuid(), new OrderCreated(streamId)) + const secondEvent = new EventData(newGuid(), new OrderDispatched(streamId)) + const eventsToSave = [firstEvent, secondEvent] + + it('It should save both events and allow them to be retrievable', async () => { + const sut = await getStore() + await sut.AppendToStream(streamId, 0, ...eventsToSave) + + const savedEvents = await sut.readStreamForwards(streamId) + + expect(savedEvents.length).toEqual(2) + const firstSavedEvent = savedEvents.shift() as StorageEvent + const secondSavedEvent = savedEvents.shift() as StorageEvent + + expect(firstSavedEvent.streamId).toEqual(streamId) + expect(firstSavedEvent.eventNumber).toEqual(1) + + expect(secondSavedEvent.streamId).toEqual(streamId) + expect(secondSavedEvent.eventNumber).toEqual(2) + }) + }) + it('It should save the event', async () => { + const streamId = newGuid() + const sut = await getStore() + const event = new EventData(newGuid(), new OrderCreated(streamId)) + + await sut.AppendToStream(streamId, 0, event) + + const stream = await sut.readStreamForwards(streamId) + expect(stream.length).toEqual(1) + const savedEvent = stream[0] + expect(savedEvent.streamId).toEqual(streamId) + expect(savedEvent.eventId).toEqual(event.eventId) + expect(savedEvent.eventNumber).toEqual(1) + }) + it('It should save the meta data correctly', async () => { + interface SomeMetaData { + value: string + } + + const metaData: SomeMetaData = { + value: 'foo' + } + + const streamId = newGuid() + const sut = await getStore() + const event = new EventData(newGuid(), new OrderCreated(streamId), metaData) + + await sut.AppendToStream(streamId, 0, event) + const stream = await sut.readStreamForwards(streamId) + const savedEvent = stream.pop() as StorageEvent + expect(savedEvent.metaData as SomeMetaData).toEqual(metaData) + }) + }) + describe('When appending to an existing stream', () => { + it('It should save the event', async () => { + const streamId = newGuid() + const sut = await getStore() + const firstEvent = new EventData(newGuid(), new OrderCreated(streamId)) + const secondEvent = new EventData(newGuid(), new OrderDispatched(streamId)) + await sut.AppendToStream(streamId, 0, firstEvent) + + await sut.AppendToStream(streamId, 1, secondEvent) + + const stream = await sut.readStreamForwards(streamId) + + expect(stream.length).toEqual(2) + const lastEvent = stream.pop() as StorageEvent + expect(lastEvent.eventId).toEqual(secondEvent.eventId) + expect(lastEvent.eventNumber).toEqual(2) + }) + }) + describe('When appending to a new stream with an unexpected version', () => { + const invalidRevisions = [-1, 1, 2, 99] + invalidRevisions.forEach(invalidRevision => { + it(`It should throw a concurrency error with revision number: '${invalidRevision}'`, async () => { + const streamId = newGuid() + const sut = await getStore() + const event = new EventData(newGuid(), new OrderDispatched(streamId)) + + await expect(sut.AppendToStream(streamId, invalidRevision, event)).rejects.toThrow( + 'Concurrency conflict' + ) + }) + }) + }) + describe('When appending to an existing stream with an unexpected version', () => { + const invalidRevisions = [0, 2] + invalidRevisions.forEach(invalidRevision => { + it(`It should throw a concurrency error with revision number: '${invalidRevision}'`, async () => { + const streamId = newGuid() + const sut = await getStore() + + const existingEvent = new EventData(newGuid(), new OrderCreated(streamId)) + const newEvent = new EventData(newGuid(), new OrderDispatched(streamId)) + await sut.AppendToStream(streamId, 0, existingEvent) + await expect(sut.AppendToStream(streamId, invalidRevision, newEvent)).rejects.toThrow( + 'Concurrency conflict' + ) + }) + }) + }) + }) +}) diff --git a/test/EventStore.Reading.test.ts b/test/EventStore.Reading.test.ts new file mode 100644 index 00000000..ae318e95 --- /dev/null +++ b/test/EventStore.Reading.test.ts @@ -0,0 +1,77 @@ +import { InMemoryStorageEngine } from '../src/inMemory/InMemoryStorageEngine' +import { IStorageEngine } from '../src/IStorageEngine' +import * as uuid from 'uuid' +import { EventStore } from '../src/EventStore' +import { EventData } from '../src/EventData' +import { OrderCreated } from './Events/OrderCreated' +import { OrderDispatched } from './Events/OrderDispatched' + +describe('Given a set of engines to test against', () => { + const engineFactories: (() => IStorageEngine)[] = [() => new InMemoryStorageEngine()] + const newGuid = () => uuid.v4() + engineFactories.forEach(getEngine => { + const engine = getEngine() + + const getStore = async () => { + await engine.initialise() + return new EventStore(engine) + } + describe('When reading any stream', () => { + describe('And the stream id is dodgy', () => { + const invalidStreamIds = [null, undefined, '', ' '] + invalidStreamIds.forEach(invalidStreamId => { + it(`It should throw an error for stream id: '${invalidStreamId}'`, async () => { + const sut = await getStore() + try { + await sut.readStreamForwards(invalidStreamId as string) + } catch (e) { + expect(e.message).toEqual( + 'streamId can not be null, empty string or contain only whitespace' + ) + } + }) + }) + }) + }) + describe('When reading an empty stream', () => { + it('It should return an empty array', async () => { + const streamId = newGuid() + const sut = await getStore() + const events = await sut.readStreamForwards(streamId) + + expect(events.length).toEqual(0) + }) + }) + describe('When reading a stream with multiple events', () => { + it('It should return all events', async () => { + const streamId = newGuid() + const sut = await getStore() + const firstEvent = new EventData(newGuid(), new OrderCreated(streamId)) + const secondEvent = new EventData(newGuid(), new OrderDispatched(streamId)) + + await sut.AppendToStream(streamId, 0, firstEvent) + await sut.AppendToStream(streamId, 1, secondEvent) + + const stream = await sut.readStreamForwards(streamId) + + expect(stream.length).toEqual(2) + expect(stream.shift()!.eventBody).toBeInstanceOf(OrderCreated) + expect(stream.shift()!.eventBody).toBeInstanceOf(OrderDispatched) + }) + it('It should return a subset of events', async () => { + const streamId = newGuid() + const sut = await getStore() + const firstEvent = new EventData(newGuid(), new OrderCreated(streamId)) + const secondEvent = new EventData(newGuid(), new OrderDispatched(streamId)) + + await sut.AppendToStream(streamId, 0, firstEvent) + await sut.AppendToStream(streamId, 1, secondEvent) + + const subsetOfStream = await sut.readStreamForwards(streamId, 2, 1) + + expect(subsetOfStream.length).toEqual(1) + expect(subsetOfStream[0].eventBody).toBeInstanceOf(OrderDispatched) + }) + }) + }) +}) diff --git a/test/Events/OrderCreated.ts b/test/Events/OrderCreated.ts new file mode 100644 index 00000000..acdb9338 --- /dev/null +++ b/test/Events/OrderCreated.ts @@ -0,0 +1,5 @@ +class OrderCreated { + constructor(public readonly orderId: string) {} +} + +export { OrderCreated } diff --git a/test/Events/OrderDispatched.ts b/test/Events/OrderDispatched.ts new file mode 100644 index 00000000..69fa1154 --- /dev/null +++ b/test/Events/OrderDispatched.ts @@ -0,0 +1,5 @@ +class OrderDispatched { + constructor(public readonly orderId: string) {} +} + +export { OrderDispatched } diff --git a/test/Guard.test.ts b/test/Guard.test.ts new file mode 100644 index 00000000..0fa36b4a --- /dev/null +++ b/test/Guard.test.ts @@ -0,0 +1,12 @@ +import { Guard } from '../src/errors/Guard' + +describe('Given a variety of invalid string values', () => { + const invalidStringValues = ['', ' ', undefined, null] + invalidStringValues.forEach(invalidStringValue => + it('It should throw an error when guarding against these', () => { + expect(() => Guard.againstNullOrEmpty('foo', invalidStringValue)).toThrow( + 'foo can not be null, empty string or contain only whitespace' + ) + }) + ) +}) diff --git a/test/StorageEvent.test.ts b/test/StorageEvent.test.ts new file mode 100644 index 00000000..cbac5411 --- /dev/null +++ b/test/StorageEvent.test.ts @@ -0,0 +1,31 @@ +import * as uuid from 'uuid' +import { EventData } from '../src/EventData' +import { StorageEvent } from '../src/StorageEvent' + +describe('When creating an instance of a StorageEvent', () => { + const expected = { + eventId: uuid.v4(), + eventBody: 'BODY', + eventMetaData: 'METADATA', + streamId: 'STREAMID', + eventNumber: 1 + } + const event = new EventData(expected.eventId, expected.eventBody, expected.eventMetaData) + const sut = new StorageEvent(expected.streamId, event, expected.eventNumber) + + it('It should map the stream id correctly', () => { + expect(sut.streamId).toEqual(expected.streamId) + }) + it('It should map the event body correctly', () => { + expect(sut.eventBody).toEqual(expected.eventBody) + }) + it('It should map the event meta data correctly', () => { + expect(sut.metaData).toEqual(expected.eventMetaData) + }) + it('It should map the event number correctly', () => { + expect(sut.eventNumber).toEqual(expected.eventNumber) + }) + it('It should map the event id correctly', () => { + expect(sut.eventId).toEqual(expected.eventId) + }) +})