├── src ├── index.ts ├── features │ ├── get-account │ │ ├── index.ts │ │ ├── types.ts │ │ ├── README.md │ │ └── shell.ts │ ├── deposit-money │ │ ├── index.ts │ │ ├── events.ts │ │ ├── types.ts │ │ ├── core.ts │ │ └── shell.ts │ ├── transfer-money │ │ ├── index.ts │ │ ├── events.ts │ │ ├── types.ts │ │ ├── core.ts │ │ └── shell.ts │ ├── withdraw-money │ │ ├── index.ts │ │ ├── events.ts │ │ ├── types.ts │ │ ├── core.ts │ │ └── shell.ts │ └── open-bank-account │ │ ├── index.ts │ │ ├── events.ts │ │ ├── types.ts │ │ ├── shell.ts │ │ └── core.ts ├── eventstore │ ├── index.ts │ ├── types.ts │ ├── filter.ts │ └── postgres.ts └── cli.ts ├── .prettierrc ├── .npmignore ├── jest.config.js ├── .eslintrc.js ├── tsconfig.json ├── LICENSE ├── package.json ├── .gitignore ├── test-unique-customer.js ├── tests ├── eventstore.test.ts ├── withdraw-money.test.ts ├── deposit-money.test.ts ├── transfer-money.test.ts ├── open-bank-account.test.ts └── optimistic-locking.test.ts ├── test-all-operations.js └── README.md /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './eventstore'; -------------------------------------------------------------------------------- /src/features/get-account/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './shell'; -------------------------------------------------------------------------------- /src/features/deposit-money/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './core'; 3 | export * from './shell'; -------------------------------------------------------------------------------- /src/features/transfer-money/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './core'; 3 | export * from './shell'; -------------------------------------------------------------------------------- /src/features/withdraw-money/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './core'; 3 | export * from './shell'; -------------------------------------------------------------------------------- /src/features/open-bank-account/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './core'; 3 | export * from './shell'; -------------------------------------------------------------------------------- /src/eventstore/index.ts: -------------------------------------------------------------------------------- 1 | export { PostgresEventStore as EventStore } from './postgres'; 2 | export { EventFilter } from './filter'; 3 | export * from './types'; -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false 8 | } -------------------------------------------------------------------------------- /src/features/get-account/types.ts: -------------------------------------------------------------------------------- 1 | export interface BankAccount { 2 | accountId: string; 3 | customerName: string; 4 | accountType: 'checking' | 'savings'; 5 | balance: number; 6 | currency: string; 7 | openedAt: Date; 8 | } 9 | 10 | export interface GetAccountQuery { 11 | accountId: string; 12 | } 13 | 14 | export type GetAccountResult = BankAccount | null; -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | tests/ 4 | coverage/ 5 | 6 | # Development files 7 | .eslintrc.js 8 | .prettierrc 9 | jest.config.js 10 | tsconfig.json 11 | .gitignore 12 | 13 | # Development dependencies 14 | node_modules/ 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # Test and coverage 20 | *.test.ts 21 | *.spec.ts 22 | coverage/ 23 | .nyc_output 24 | 25 | # Editor files 26 | .vscode/ 27 | .idea/ 28 | *.swp 29 | *.swo 30 | 31 | # OS files 32 | .DS_Store 33 | Thumbs.db 34 | 35 | # Documentation (keep README.md) 36 | # docs/ -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/src', '/tests'], 5 | testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'], 6 | transform: { 7 | '^.+\\.ts$': 'ts-jest', 8 | }, 9 | collectCoverageFrom: [ 10 | 'src/**/*.ts', 11 | '!src/**/*.d.ts', 12 | '!src/index.ts', 13 | ], 14 | coverageDirectory: 'coverage', 15 | coverageReporters: ['text', 'lcov', 'html'], 16 | moduleFileExtensions: ['ts', 'js', 'json'], 17 | setupFilesAfterEnv: [], 18 | }; -------------------------------------------------------------------------------- /src/features/deposit-money/events.ts: -------------------------------------------------------------------------------- 1 | import { HasEventType } from '../../eventstore/types'; 2 | 3 | export class MoneyDepositedEvent implements HasEventType { 4 | constructor( 5 | public readonly accountId: string, 6 | public readonly amount: number, 7 | public readonly currency: string, 8 | public readonly depositId: string, 9 | public readonly timestamp: Date = new Date() 10 | ) {} 11 | 12 | eventType(): string { 13 | return 'MoneyDeposited'; 14 | } 15 | 16 | eventVersion(): string { 17 | return '1.0'; 18 | } 19 | } -------------------------------------------------------------------------------- /src/features/withdraw-money/events.ts: -------------------------------------------------------------------------------- 1 | import { HasEventType } from '../../eventstore/types'; 2 | 3 | export class MoneyWithdrawnEvent implements HasEventType { 4 | constructor( 5 | public readonly accountId: string, 6 | public readonly amount: number, 7 | public readonly currency: string, 8 | public readonly withdrawalId: string, 9 | public readonly timestamp: Date = new Date() 10 | ) {} 11 | 12 | eventType(): string { 13 | return 'MoneyWithdrawn'; 14 | } 15 | 16 | eventVersion(): string { 17 | return '1.0'; 18 | } 19 | } -------------------------------------------------------------------------------- /src/features/get-account/README.md: -------------------------------------------------------------------------------- 1 | # Get Account Feature 2 | 3 | This feature demonstrates account state reconstruction from events for read operations. 4 | 5 | **Note**: This implementation rebuilds the account state from the event store on every query, which is done here for demonstration purposes only. In a real-world production system, account data should be maintained in a dedicated read model table (projection) that gets updated whenever relevant events are processed. This would provide much better performance for read operations while maintaining eventual consistency. 6 | -------------------------------------------------------------------------------- /src/features/transfer-money/events.ts: -------------------------------------------------------------------------------- 1 | import { HasEventType } from '../../eventstore/types'; 2 | 3 | export class MoneyTransferredEvent implements HasEventType { 4 | constructor( 5 | public readonly fromAccountId: string, 6 | public readonly toAccountId: string, 7 | public readonly amount: number, 8 | public readonly currency: string, 9 | public readonly transferId: string, 10 | public readonly timestamp: Date = new Date() 11 | ) {} 12 | 13 | eventType(): string { 14 | return 'MoneyTransferred'; 15 | } 16 | 17 | eventVersion(): string { 18 | return '1.0'; 19 | } 20 | } -------------------------------------------------------------------------------- /src/features/open-bank-account/events.ts: -------------------------------------------------------------------------------- 1 | import { HasEventType } from '../../eventstore/types'; 2 | 3 | export class BankAccountOpenedEvent implements HasEventType { 4 | constructor( 5 | public readonly accountId: string, 6 | public readonly customerName: string, 7 | public readonly accountType: 'checking' | 'savings', 8 | public readonly initialDeposit: number, 9 | public readonly currency: string, 10 | public readonly openedAt: Date = new Date() 11 | ) {} 12 | 13 | eventType(): string { 14 | return 'BankAccountOpened'; 15 | } 16 | 17 | eventVersion(): string { 18 | return '1.0'; 19 | } 20 | } -------------------------------------------------------------------------------- /src/features/withdraw-money/types.ts: -------------------------------------------------------------------------------- 1 | export interface WithdrawMoneyCommand { 2 | accountId: string; 3 | amount: number; 4 | currency?: string; 5 | withdrawalId: string; 6 | } 7 | 8 | export interface MoneyWithdrawnEvent { 9 | type: 'MoneyWithdrawn'; 10 | accountId: string; 11 | amount: number; 12 | currency: string; 13 | withdrawalId: string; 14 | timestamp: Date; 15 | } 16 | 17 | export type WithdrawError = 18 | | { type: 'InvalidAmount'; message: string } 19 | | { type: 'InvalidCurrency'; message: string } 20 | | { type: 'InsufficientFunds'; message: string } 21 | | { type: 'DuplicateWithdrawal'; message: string }; 22 | 23 | export type WithdrawResult = 24 | | { success: true; event: MoneyWithdrawnEvent } 25 | | { success: false; error: WithdrawError }; -------------------------------------------------------------------------------- /src/features/deposit-money/types.ts: -------------------------------------------------------------------------------- 1 | export interface DepositMoneyCommand { 2 | accountId: string; 3 | amount: number; 4 | currency?: string; 5 | depositId: string; 6 | } 7 | 8 | export interface MoneyDepositedEvent { 9 | type: 'MoneyDeposited'; 10 | accountId: string; 11 | amount: number; 12 | currency: string; 13 | depositId: string; 14 | timestamp: Date; 15 | } 16 | 17 | export interface AccountBalance { 18 | accountId: string; 19 | balance: number; 20 | currency: string; 21 | } 22 | 23 | export type DepositError = 24 | | { type: 'InvalidAmount'; message: string } 25 | | { type: 'InvalidCurrency'; message: string } 26 | | { type: 'DuplicateDeposit'; message: string }; 27 | 28 | export type DepositResult = 29 | | { success: true; event: MoneyDepositedEvent } 30 | | { success: false; error: DepositError }; -------------------------------------------------------------------------------- /src/features/open-bank-account/types.ts: -------------------------------------------------------------------------------- 1 | export interface OpenBankAccountCommand { 2 | customerName: string; 3 | accountType?: 'checking' | 'savings'; 4 | initialDeposit?: number; 5 | currency?: string; 6 | } 7 | 8 | export interface BankAccountOpenedEvent { 9 | type: 'BankAccountOpened'; 10 | accountId: string; 11 | customerName: string; 12 | accountType: 'checking' | 'savings'; 13 | initialDeposit: number; 14 | currency: string; 15 | openedAt: Date; 16 | } 17 | 18 | 19 | export type OpenAccountError = 20 | | { type: 'InvalidCustomerName'; message: string } 21 | | { type: 'InvalidInitialDeposit'; message: string } 22 | | { type: 'InvalidCurrency'; message: string }; 23 | 24 | export type OpenAccountResult = 25 | | { success: true; event: BankAccountOpenedEvent } 26 | | { success: false; error: OpenAccountError }; -------------------------------------------------------------------------------- /src/features/transfer-money/types.ts: -------------------------------------------------------------------------------- 1 | export interface TransferMoneyCommand { 2 | fromAccountId: string; 3 | toAccountId: string; 4 | amount: number; 5 | currency?: string; 6 | transferId: string; 7 | } 8 | 9 | export interface MoneyTransferredEvent { 10 | type: 'MoneyTransferred'; 11 | fromAccountId: string; 12 | toAccountId: string; 13 | amount: number; 14 | currency: string; 15 | transferId: string; 16 | timestamp: Date; 17 | } 18 | 19 | export type TransferError = 20 | | { type: 'InvalidAmount'; message: string } 21 | | { type: 'InvalidCurrency'; message: string } 22 | | { type: 'InsufficientFunds'; message: string } 23 | | { type: 'SameAccount'; message: string } 24 | | { type: 'DuplicateTransfer'; message: string }; 25 | 26 | export type TransferResult = 27 | | { success: true; event: MoneyTransferredEvent } 28 | | { success: false; error: TransferError }; -------------------------------------------------------------------------------- /src/eventstore/types.ts: -------------------------------------------------------------------------------- 1 | export interface HasEventType { 2 | eventType(): string; 3 | eventVersion?(): string; 4 | } 5 | 6 | export interface EventRecord { 7 | sequenceNumber: number; 8 | occurredAt: Date; 9 | eventType: string; 10 | payload: Record; 11 | metadata: Record; 12 | } 13 | 14 | export interface EventFilter { 15 | eventTypes: string[]; 16 | payloadPredicates?: Record; 17 | payloadPredicateOptions?: Record[]; 18 | } 19 | 20 | export interface QueryResult { 21 | events: T[]; 22 | maxSequenceNumber: number; 23 | } 24 | 25 | export interface IEventStore { 26 | query(filter: EventFilter): Promise>; 27 | append(filter: EventFilter, events: T[], expectedMaxSequence: number): Promise; 28 | close(): Promise; 29 | } 30 | 31 | export interface EventStoreOptions { 32 | connectionString?: string; 33 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaVersion: 2020, 5 | sourceType: 'module', 6 | project: './tsconfig.json', 7 | }, 8 | plugins: ['@typescript-eslint', 'prettier'], 9 | extends: [ 10 | 'eslint:recommended', 11 | '@typescript-eslint/recommended', 12 | '@typescript-eslint/recommended-requiring-type-checking', 13 | 'prettier', 14 | ], 15 | rules: { 16 | 'prettier/prettier': 'error', 17 | '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 18 | '@typescript-eslint/explicit-function-return-type': 'warn', 19 | '@typescript-eslint/no-explicit-any': 'error', 20 | '@typescript-eslint/no-unsafe-assignment': 'error', 21 | '@typescript-eslint/no-unsafe-member-access': 'error', 22 | '@typescript-eslint/no-unsafe-call': 'error', 23 | '@typescript-eslint/no-unsafe-return': 'error', 24 | }, 25 | env: { 26 | node: true, 27 | es6: true, 28 | }, 29 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "sourceMap": true, 15 | "removeComments": false, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "strictFunctionTypes": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "exactOptionalPropertyTypes": true, 23 | "resolveJsonModule": true, 24 | "moduleResolution": "node", 25 | "allowSyntheticDefaultImports": true, 26 | "experimentalDecorators": true, 27 | "emitDecoratorMetadata": true 28 | }, 29 | "include": [ 30 | "src/**/*" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "dist", 35 | "**/*.test.ts", 36 | "**/*.spec.ts" 37 | ] 38 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Rico Fritzsche 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/eventstore/filter.ts: -------------------------------------------------------------------------------- 1 | import { EventFilter as IEventFilter } from './types'; 2 | 3 | export class EventFilter implements IEventFilter { 4 | public readonly eventTypes: string[]; 5 | public readonly payloadPredicates?: Record; 6 | public readonly payloadPredicateOptions?: Record[]; 7 | 8 | constructor(eventTypes: string[], payloadPredicates?: Record, payloadPredicateOptions?: Record[]) { 9 | this.eventTypes = eventTypes; 10 | if (payloadPredicates !== undefined) { 11 | this.payloadPredicates = payloadPredicates; 12 | } 13 | if (payloadPredicateOptions !== undefined) { 14 | this.payloadPredicateOptions = payloadPredicateOptions; 15 | } 16 | } 17 | 18 | static new(eventTypes: string[]): EventFilter { 19 | return new EventFilter(eventTypes); 20 | } 21 | 22 | static createFilter(eventTypes: string[], payloadPredicateOptions?: Record[]): EventFilter { 23 | return new EventFilter(eventTypes, undefined, payloadPredicateOptions); 24 | } 25 | 26 | withPayloadPredicate(key: string, value: unknown): EventFilter { 27 | const predicates = { ...this.payloadPredicates, [key]: value }; 28 | return new EventFilter(this.eventTypes, predicates, this.payloadPredicateOptions); 29 | } 30 | 31 | withPayloadPredicates(predicates: Record): EventFilter { 32 | const mergedPredicates = { ...this.payloadPredicates, ...predicates }; 33 | return new EventFilter(this.eventTypes, mergedPredicates, this.payloadPredicateOptions); 34 | } 35 | } -------------------------------------------------------------------------------- /src/features/open-bank-account/shell.ts: -------------------------------------------------------------------------------- 1 | import { EventFilter } from '../../eventstore'; 2 | import { IEventStore } from '../../eventstore/types'; 3 | import { OpenBankAccountCommand, OpenAccountResult } from './types'; 4 | import { processOpenAccountCommand } from './core'; 5 | import { BankAccountOpenedEvent } from './events'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | 8 | export async function execute( 9 | eventStore: IEventStore, 10 | command: OpenBankAccountCommand 11 | ): Promise { 12 | const accountId = uuidv4(); 13 | 14 | const openAccountState = await getOpenAccountState(eventStore, command.customerName); 15 | 16 | const result = processOpenAccountCommand(command, accountId, openAccountState.state.existingCustomerNames); 17 | 18 | if (!result.success) { 19 | return result; 20 | } 21 | 22 | try { 23 | const appendFilter = EventFilter.createFilter(['BankAccountOpened']); 24 | 25 | const event = new BankAccountOpenedEvent( 26 | result.event.accountId, 27 | result.event.customerName, 28 | result.event.accountType, 29 | result.event.initialDeposit, 30 | result.event.currency, 31 | result.event.openedAt 32 | ); 33 | 34 | await eventStore.append(appendFilter, [event], openAccountState.maxSequenceNumber); 35 | 36 | return result; 37 | } catch (error) { 38 | return { 39 | success: false, 40 | error: { type: 'InvalidCustomerName', message: 'Failed to save account opening event' } 41 | }; 42 | } 43 | } 44 | 45 | async function getOpenAccountState(eventStore: IEventStore, customerName: string): Promise<{ 46 | state: { 47 | existingCustomerNames: string[]; 48 | }; 49 | maxSequenceNumber: number; 50 | }> { 51 | const filter = EventFilter.createFilter(['BankAccountOpened']); 52 | 53 | const result = await eventStore.query(filter); 54 | 55 | const existingCustomerNames = result.events.map(e => e.customerName); 56 | 57 | return { 58 | state: { 59 | existingCustomerNames 60 | }, 61 | maxSequenceNumber: result.maxSequenceNumber 62 | }; 63 | } 64 | 65 | -------------------------------------------------------------------------------- /src/features/withdraw-money/core.ts: -------------------------------------------------------------------------------- 1 | import { WithdrawMoneyCommand, MoneyWithdrawnEvent, WithdrawError, WithdrawResult } from './types'; 2 | 3 | const SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP']; 4 | const MIN_WITHDRAWAL_AMOUNT = 0.01; 5 | const MAX_WITHDRAWAL_AMOUNT = 10000; 6 | 7 | export function validateWithdrawCommand(command: WithdrawMoneyCommand): WithdrawError | null { 8 | if (command.amount <= 0) { 9 | return { type: 'InvalidAmount', message: 'Withdrawal amount must be positive' }; 10 | } 11 | 12 | if (command.amount < MIN_WITHDRAWAL_AMOUNT) { 13 | return { type: 'InvalidAmount', message: `Minimum withdrawal amount is ${MIN_WITHDRAWAL_AMOUNT}` }; 14 | } 15 | 16 | if (command.amount > MAX_WITHDRAWAL_AMOUNT) { 17 | return { type: 'InvalidAmount', message: `Maximum withdrawal amount is ${MAX_WITHDRAWAL_AMOUNT}` }; 18 | } 19 | 20 | if (command.currency && !SUPPORTED_CURRENCIES.includes(command.currency)) { 21 | return { type: 'InvalidCurrency', message: `Currency ${command.currency} is not supported` }; 22 | } 23 | 24 | return null; 25 | } 26 | 27 | export function processWithdrawCommand( 28 | command: WithdrawMoneyCommand, 29 | currentBalance: number, 30 | existingWithdrawalIds: string[] 31 | ): WithdrawResult { 32 | const validationError = validateWithdrawCommand(command); 33 | if (validationError) { 34 | return { success: false, error: validationError }; 35 | } 36 | 37 | if (existingWithdrawalIds.includes(command.withdrawalId)) { 38 | return { 39 | success: false, 40 | error: { type: 'DuplicateWithdrawal', message: 'Withdrawal ID already exists' } 41 | }; 42 | } 43 | 44 | if (currentBalance < command.amount) { 45 | return { 46 | success: false, 47 | error: { type: 'InsufficientFunds', message: 'Insufficient funds for withdrawal' } 48 | }; 49 | } 50 | 51 | const event: MoneyWithdrawnEvent = { 52 | type: 'MoneyWithdrawn', 53 | accountId: command.accountId, 54 | amount: command.amount, 55 | currency: command.currency || 'USD', 56 | withdrawalId: command.withdrawalId, 57 | timestamp: new Date() 58 | }; 59 | 60 | return { success: true, event }; 61 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aggregateless-eventstore", 3 | "version": "0.1.0", 4 | "description": "A TypeScript implementation of an aggregateless eventstore", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist/**/*", 9 | "README.md", 10 | "LICENSE" 11 | ], 12 | "scripts": { 13 | "build": "tsc", 14 | "build:watch": "tsc --watch", 15 | "clean": "rm -rf dist", 16 | "dev": "ts-node src/index.ts", 17 | "cli": "ts-node src/cli.ts", 18 | "bank": "ts-node src/cli.ts", 19 | "test": "jest", 20 | "test:watch": "jest --watch", 21 | "test:coverage": "jest --coverage", 22 | "lint": "eslint src/**/*.ts", 23 | "lint:fix": "eslint src/**/*.ts --fix", 24 | "format": "prettier --write src/**/*.ts", 25 | "format:check": "prettier --check src/**/*.ts", 26 | "prepublishOnly": "npm run clean && npm run build && npm run test", 27 | "prepack": "npm run build" 28 | }, 29 | "keywords": [ 30 | "eventstore", 31 | "eventsourcing", 32 | "aggregateless", 33 | "typescript", 34 | "events" 35 | ], 36 | "author": "Rico Fritzsche", 37 | "license": "MIT", 38 | "devDependencies": { 39 | "@types/jest": "^29.5.0", 40 | "@types/node": "^20.0.0", 41 | "@types/pg": "^8.10.0", 42 | "@types/uuid": "^9.0.0", 43 | "@typescript-eslint/eslint-plugin": "^6.0.0", 44 | "@typescript-eslint/parser": "^6.0.0", 45 | "eslint": "^8.0.0", 46 | "eslint-config-prettier": "^9.0.0", 47 | "eslint-plugin-prettier": "^5.0.0", 48 | "jest": "^29.5.0", 49 | "prettier": "^3.0.0", 50 | "ts-jest": "^29.1.0", 51 | "ts-node": "^10.9.0", 52 | "typescript": "^5.0.0" 53 | }, 54 | "dependencies": { 55 | "dotenv": "^17.0.1", 56 | "pg": "^8.11.0", 57 | "uuid": "^9.0.0" 58 | }, 59 | "engines": { 60 | "node": ">=16.0.0" 61 | }, 62 | "repository": { 63 | "type": "git", 64 | "url": "git+https://github.com/ricofritzsche/eventstore-typescript" 65 | }, 66 | "bugs": { 67 | "url": "https://github.com/ricofritzsche/eventstore-typescript/issues" 68 | }, 69 | "homepage": "https://github.com/ricofritzsche/eventstore-typescript#readme" 70 | } 71 | -------------------------------------------------------------------------------- /src/features/deposit-money/core.ts: -------------------------------------------------------------------------------- 1 | import { DepositMoneyCommand, MoneyDepositedEvent, AccountBalance, DepositError, DepositResult } from './types'; 2 | 3 | const SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP']; 4 | const MIN_DEPOSIT_AMOUNT = 0.01; 5 | const MAX_DEPOSIT_AMOUNT = 1000000; 6 | 7 | export function validateDepositCommand(command: DepositMoneyCommand): DepositError | null { 8 | if (command.amount <= 0) { 9 | return { type: 'InvalidAmount', message: 'Deposit amount must be positive' }; 10 | } 11 | 12 | if (command.amount < MIN_DEPOSIT_AMOUNT) { 13 | return { type: 'InvalidAmount', message: `Minimum deposit amount is ${MIN_DEPOSIT_AMOUNT}` }; 14 | } 15 | 16 | if (command.amount > MAX_DEPOSIT_AMOUNT) { 17 | return { type: 'InvalidAmount', message: `Maximum deposit amount is ${MAX_DEPOSIT_AMOUNT}` }; 18 | } 19 | 20 | if (command.currency && !SUPPORTED_CURRENCIES.includes(command.currency)) { 21 | return { type: 'InvalidCurrency', message: `Currency ${command.currency} is not supported` }; 22 | } 23 | 24 | return null; 25 | } 26 | 27 | export function processDepositCommand( 28 | command: DepositMoneyCommand, 29 | existingDepositIds: string[] 30 | ): DepositResult { 31 | const validationError = validateDepositCommand(command); 32 | if (validationError) { 33 | return { success: false, error: validationError }; 34 | } 35 | 36 | if (existingDepositIds.includes(command.depositId)) { 37 | return { 38 | success: false, 39 | error: { type: 'DuplicateDeposit', message: 'Deposit ID already exists' } 40 | }; 41 | } 42 | 43 | const event: MoneyDepositedEvent = { 44 | type: 'MoneyDeposited', 45 | accountId: command.accountId, 46 | amount: command.amount, 47 | currency: command.currency || 'USD', 48 | depositId: command.depositId, 49 | timestamp: new Date() 50 | }; 51 | 52 | return { success: true, event }; 53 | } 54 | 55 | export function foldMoneyDepositedEvents(events: MoneyDepositedEvent[]): AccountBalance | null { 56 | if (events.length === 0) { 57 | return null; 58 | } 59 | 60 | const firstEvent = events[0]!; 61 | const totalAmount = events.reduce((sum, event) => sum + event.amount, 0); 62 | 63 | return { 64 | accountId: firstEvent.accountId, 65 | balance: totalAmount, 66 | currency: firstEvent.currency 67 | }; 68 | } -------------------------------------------------------------------------------- /src/features/transfer-money/core.ts: -------------------------------------------------------------------------------- 1 | import { TransferMoneyCommand, MoneyTransferredEvent, TransferError, TransferResult } from './types'; 2 | 3 | const SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP']; 4 | const MIN_TRANSFER_AMOUNT = 0.01; 5 | const MAX_TRANSFER_AMOUNT = 50000; 6 | 7 | export function validateTransferCommand(command: TransferMoneyCommand): TransferError | null { 8 | if (command.fromAccountId === command.toAccountId) { 9 | return { type: 'SameAccount', message: 'Cannot transfer to the same account' }; 10 | } 11 | 12 | if (command.amount <= 0) { 13 | return { type: 'InvalidAmount', message: 'Transfer amount must be positive' }; 14 | } 15 | 16 | if (command.amount < MIN_TRANSFER_AMOUNT) { 17 | return { type: 'InvalidAmount', message: `Minimum transfer amount is ${MIN_TRANSFER_AMOUNT}` }; 18 | } 19 | 20 | if (command.amount > MAX_TRANSFER_AMOUNT) { 21 | return { type: 'InvalidAmount', message: `Maximum transfer amount is ${MAX_TRANSFER_AMOUNT}` }; 22 | } 23 | 24 | if (command.currency && !SUPPORTED_CURRENCIES.includes(command.currency)) { 25 | return { type: 'InvalidCurrency', message: `Currency ${command.currency} is not supported` }; 26 | } 27 | 28 | return null; 29 | } 30 | 31 | export function processTransferCommand( 32 | command: TransferMoneyCommand, 33 | fromAccountBalance: number, 34 | existingTransferIds: string[] 35 | ): TransferResult { 36 | const validationError = validateTransferCommand(command); 37 | if (validationError) { 38 | return { success: false, error: validationError }; 39 | } 40 | 41 | if (existingTransferIds.includes(command.transferId)) { 42 | return { 43 | success: false, 44 | error: { type: 'DuplicateTransfer', message: 'Transfer ID already exists' } 45 | }; 46 | } 47 | 48 | if (fromAccountBalance < command.amount) { 49 | return { 50 | success: false, 51 | error: { type: 'InsufficientFunds', message: 'Insufficient funds for transfer' } 52 | }; 53 | } 54 | 55 | const event: MoneyTransferredEvent = { 56 | type: 'MoneyTransferred', 57 | fromAccountId: command.fromAccountId, 58 | toAccountId: command.toAccountId, 59 | amount: command.amount, 60 | currency: command.currency || 'USD', 61 | transferId: command.transferId, 62 | timestamp: new Date() 63 | }; 64 | 65 | return { success: true, event }; 66 | } -------------------------------------------------------------------------------- /src/features/open-bank-account/core.ts: -------------------------------------------------------------------------------- 1 | import { OpenBankAccountCommand, BankAccountOpenedEvent, OpenAccountError, OpenAccountResult } from './types'; 2 | 3 | const SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP']; 4 | const MAX_INITIAL_DEPOSIT = 1000000; 5 | 6 | export function validateOpenAccountCommand(command: OpenBankAccountCommand): OpenAccountError | null { 7 | if (!command.customerName || command.customerName.trim().length === 0) { 8 | return { type: 'InvalidCustomerName', message: 'Customer name is required' }; 9 | } 10 | 11 | if (command.customerName.trim().length < 2) { 12 | return { type: 'InvalidCustomerName', message: 'Customer name must be at least 2 characters' }; 13 | } 14 | 15 | const initialDeposit = command.initialDeposit ?? 0; 16 | 17 | if (initialDeposit < 0) { 18 | return { type: 'InvalidInitialDeposit', message: 'Initial deposit cannot be negative' }; 19 | } 20 | 21 | if (initialDeposit > MAX_INITIAL_DEPOSIT) { 22 | return { type: 'InvalidInitialDeposit', message: `Initial deposit cannot exceed ${MAX_INITIAL_DEPOSIT}` }; 23 | } 24 | 25 | if (command.currency && !SUPPORTED_CURRENCIES.includes(command.currency)) { 26 | return { type: 'InvalidCurrency', message: `Currency ${command.currency} is not supported` }; 27 | } 28 | 29 | return null; 30 | } 31 | 32 | export function processOpenAccountCommand( 33 | command: OpenBankAccountCommand, 34 | accountId: string, 35 | existingCustomerNames?: string[] 36 | ): OpenAccountResult { 37 | const commandWithDefaults = { 38 | ...command, 39 | accountType: command.accountType || 'checking', 40 | currency: command.currency || 'USD' 41 | }; 42 | 43 | const validationError = validateOpenAccountCommand(commandWithDefaults); 44 | if (validationError) { 45 | return { success: false, error: validationError }; 46 | } 47 | 48 | if (existingCustomerNames && existingCustomerNames.includes(commandWithDefaults.customerName.trim())) { 49 | return { 50 | success: false, 51 | error: { type: 'InvalidCustomerName', message: 'Customer name already exists' } 52 | }; 53 | } 54 | 55 | const event: BankAccountOpenedEvent = { 56 | type: 'BankAccountOpened', 57 | accountId: accountId, 58 | customerName: commandWithDefaults.customerName.trim(), 59 | accountType: commandWithDefaults.accountType, 60 | initialDeposit: commandWithDefaults.initialDeposit ?? 0, 61 | currency: commandWithDefaults.currency, 62 | openedAt: new Date() 63 | }; 64 | 65 | return { success: true, event }; 66 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Build outputs 8 | dist/ 9 | build/ 10 | *.tsbuildinfo 11 | 12 | # Coverage reports 13 | coverage/ 14 | *.lcov 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Logs 23 | logs 24 | *.log 25 | 26 | # Runtime data 27 | pids 28 | *.pid 29 | *.seed 30 | *.pid.lock 31 | 32 | # Directory for instrumented libs generated by jscoverage/JSCover 33 | lib-cov 34 | 35 | # Coverage directory used by tools like istanbul 36 | coverage 37 | *.lcov 38 | 39 | # nyc test coverage 40 | .nyc_output 41 | 42 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | bower_components 47 | 48 | # node-waf configuration 49 | .lock-wscript 50 | 51 | # Compiled binary addons (https://nodejs.org/api/addons.html) 52 | build/Release 53 | 54 | # Dependency directories 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Snowpack dependency directory (https://snowpack.dev/) 59 | web_modules/ 60 | 61 | # TypeScript cache 62 | *.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | .npm 66 | 67 | # Optional eslint cache 68 | .eslintcache 69 | 70 | # Microbundle cache 71 | .rpt2_cache/ 72 | .rts2_cache_cjs/ 73 | .rts2_cache_es/ 74 | .rts2_cache_umd/ 75 | 76 | # Optional REPL history 77 | .node_repl_history 78 | 79 | # Output of 'npm pack' 80 | *.tgz 81 | 82 | # Yarn Integrity file 83 | .yarn-integrity 84 | 85 | # dotenv environment variables file 86 | .env 87 | .env.test 88 | .env.production 89 | 90 | # parcel-bundler cache (https://parceljs.org/) 91 | .cache 92 | .parcel-cache 93 | 94 | # Next.js build output 95 | .next 96 | 97 | # Nuxt.js build / generate output 98 | .nuxt 99 | dist 100 | 101 | # Gatsby files 102 | .cache/ 103 | public 104 | 105 | # Storybook build outputs 106 | .out 107 | .storybook-out 108 | 109 | # Temporary folders 110 | tmp/ 111 | temp/ 112 | 113 | # Editor directories and files 114 | .vscode/* 115 | !.vscode/extensions.json 116 | .idea 117 | .claude 118 | .DS_Store 119 | *.suo 120 | *.ntvs* 121 | *.njsproj 122 | *.sln 123 | *.sw? 124 | 125 | # OS generated files 126 | .DS_Store 127 | .DS_Store? 128 | ._* 129 | .Spotlight-V100 130 | .Trashes 131 | ehthumbs.db 132 | Thumbs.db 133 | /load-test.js 134 | /load-test-detailed.js 135 | /load-test-millions.js 136 | /LOAD_TEST_RESULTS.md 137 | /tests/load-test.ts 138 | -------------------------------------------------------------------------------- /src/features/get-account/shell.ts: -------------------------------------------------------------------------------- 1 | import { EventFilter } from '../../eventstore'; 2 | import { IEventStore } from '../../eventstore'; 3 | import { GetAccountQuery, GetAccountResult, BankAccount } from './types'; 4 | 5 | export async function execute( 6 | eventStore: IEventStore, 7 | query: GetAccountQuery 8 | ): Promise { 9 | const accountViewStateResult = await getAccountViewState(eventStore, query.accountId); 10 | 11 | return accountViewStateResult.state.account; 12 | } 13 | 14 | async function getAccountViewState(eventStore: IEventStore, accountId: string): Promise<{ 15 | state: { 16 | account: BankAccount | null; 17 | }; 18 | maxSequenceNumber: number; 19 | }> { 20 | // Single optimized query using payloadPredicateOptions for multiple account relationships 21 | const filter = EventFilter.createFilter( 22 | ['BankAccountOpened', 'MoneyDeposited', 'MoneyWithdrawn', 'MoneyTransferred'], 23 | [ 24 | { accountId: accountId }, 25 | { fromAccountId: accountId }, 26 | { toAccountId: accountId } 27 | ] 28 | ); 29 | 30 | const result = await eventStore.query(filter); 31 | const allEvents = result.events; 32 | 33 | const openingEvent = allEvents.find(e => 34 | (e.event_type || (e.eventType && e.eventType())) === 'BankAccountOpened' 35 | ); 36 | 37 | if (!openingEvent) { 38 | return { 39 | state: { account: null }, 40 | maxSequenceNumber: result.maxSequenceNumber 41 | }; 42 | } 43 | 44 | let currentBalance = openingEvent.initialDeposit; 45 | 46 | for (const event of allEvents) { 47 | const eventType = event.event_type || (event.eventType && event.eventType()); 48 | 49 | if (eventType === 'MoneyDeposited' && event.currency === openingEvent.currency) { 50 | currentBalance += event.amount; 51 | } else if (eventType === 'MoneyWithdrawn' && event.currency === openingEvent.currency) { 52 | currentBalance -= event.amount; 53 | } else if (eventType === 'MoneyTransferred' && event.currency === openingEvent.currency) { 54 | if (event.fromAccountId === accountId) { 55 | currentBalance -= event.amount; 56 | } else if (event.toAccountId === accountId) { 57 | currentBalance += event.amount; 58 | } 59 | } 60 | } 61 | 62 | const maxSequenceNumber = result.maxSequenceNumber; 63 | 64 | return { 65 | state: { 66 | account: { 67 | accountId: openingEvent.accountId, 68 | customerName: openingEvent.customerName, 69 | accountType: openingEvent.accountType, 70 | balance: currentBalance, 71 | currency: openingEvent.currency, 72 | openedAt: openingEvent.openedAt 73 | } 74 | }, 75 | maxSequenceNumber 76 | }; 77 | } -------------------------------------------------------------------------------- /src/features/deposit-money/shell.ts: -------------------------------------------------------------------------------- 1 | import { EventFilter } from '../../eventstore'; 2 | import { IEventStore } from '../../eventstore'; 3 | import { DepositMoneyCommand, DepositResult } from './types'; 4 | import { processDepositCommand } from './core'; 5 | import { MoneyDepositedEvent } from './events'; 6 | 7 | export async function execute( 8 | eventStore: IEventStore, 9 | command: DepositMoneyCommand 10 | ): Promise { 11 | const depositStateResult = await getDepositState(eventStore, command.accountId); 12 | 13 | if (!depositStateResult.state.account) { 14 | return { 15 | success: false, 16 | error: { type: 'InvalidAmount', message: 'Account not found' } 17 | }; 18 | } 19 | 20 | const effectiveCommand = { 21 | ...command, 22 | currency: command.currency || depositStateResult.state.account.currency 23 | }; 24 | 25 | const result = processDepositCommand(effectiveCommand, depositStateResult.state.existingDepositIds); 26 | 27 | if (!result.success) { 28 | return result; 29 | } 30 | 31 | try { 32 | const filter = EventFilter.createFilter(['BankAccountOpened', 'MoneyDeposited']) 33 | .withPayloadPredicates({ accountId: command.accountId }); 34 | 35 | const event = new MoneyDepositedEvent( 36 | result.event.accountId, 37 | result.event.amount, 38 | result.event.currency, 39 | result.event.depositId, 40 | result.event.timestamp 41 | ); 42 | 43 | await eventStore.append(filter, [event], depositStateResult.maxSequenceNumber); 44 | 45 | return result; 46 | } catch (error) { 47 | return { 48 | success: false, 49 | error: { type: 'InvalidAmount', message: 'Failed to save deposit event' } 50 | }; 51 | } 52 | } 53 | 54 | async function getDepositState(eventStore: IEventStore, accountId: string): Promise<{ 55 | state: { 56 | account: { currency: string } | null; 57 | existingDepositIds: string[]; 58 | }; 59 | maxSequenceNumber: number; 60 | }> { 61 | const filter = EventFilter.createFilter(['BankAccountOpened', 'MoneyDeposited']) 62 | .withPayloadPredicates({ accountId }); 63 | 64 | const result = await eventStore.query(filter); 65 | 66 | const openingEvent = result.events.find(e => 67 | (e.event_type || (e.eventType && e.eventType())) === 'BankAccountOpened' 68 | ); 69 | 70 | const account = openingEvent ? { currency: openingEvent.currency } : null; 71 | const existingDepositIds = result.events 72 | .filter(e => (e.event_type || (e.eventType && e.eventType())) === 'MoneyDeposited') 73 | .map(e => e.depositId); 74 | 75 | return { 76 | state: { 77 | account, 78 | existingDepositIds 79 | }, 80 | maxSequenceNumber: result.maxSequenceNumber 81 | }; 82 | } -------------------------------------------------------------------------------- /test-unique-customer.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { EventStore } = require('./dist/eventstore'); 3 | const OpenBankAccount = require('./dist/features/open-bank-account'); 4 | 5 | async function testUniqueCustomerName() { 6 | const eventStore = new EventStore(); 7 | 8 | try { 9 | await eventStore.migrate(); 10 | console.log('🏦 Testing Unique Customer Name Validation\n'); 11 | 12 | // 1. Create first account with "Alice" 13 | console.log('1. Creating account for Alice...'); 14 | const firstResult = await OpenBankAccount.execute(eventStore, { 15 | customerName: 'Alice', 16 | accountType: 'checking', 17 | initialDeposit: 500, 18 | currency: 'USD' 19 | }); 20 | 21 | if (firstResult.success) { 22 | console.log(`✅ First Alice account created: ${firstResult.event.accountId}`); 23 | } else { 24 | console.log('❌ Failed to create first Alice account:', firstResult.error.message); 25 | return; 26 | } 27 | 28 | // 2. Try to create second account with same name "Alice" 29 | console.log('\n2. Attempting to create another account for Alice...'); 30 | const secondResult = await OpenBankAccount.execute(eventStore, { 31 | customerName: 'Alice', 32 | accountType: 'savings', 33 | initialDeposit: 300, 34 | currency: 'EUR' 35 | }); 36 | 37 | if (!secondResult.success) { 38 | console.log(`✅ Correctly rejected duplicate name: ${secondResult.error.message}`); 39 | } else { 40 | console.log('❌ Should have failed due to duplicate customer name!'); 41 | } 42 | 43 | // 3. Create account with different name "Bob" 44 | console.log('\n3. Creating account for Bob...'); 45 | const bobResult = await OpenBankAccount.execute(eventStore, { 46 | customerName: 'Bob', 47 | accountType: 'checking', 48 | initialDeposit: 1000, 49 | currency: 'USD' 50 | }); 51 | 52 | if (bobResult.success) { 53 | console.log(`✅ Bob's account created successfully: ${bobResult.event.accountId}`); 54 | } else { 55 | console.log('❌ Failed to create Bob account:', bobResult.error.message); 56 | } 57 | 58 | // 4. Test case sensitivity - try "alice" (lowercase) 59 | console.log('\n4. Testing case sensitivity with "alice" (lowercase)...'); 60 | const caseTestResult = await OpenBankAccount.execute(eventStore, { 61 | customerName: 'alice', 62 | accountType: 'savings', 63 | initialDeposit: 200, 64 | currency: 'GBP' 65 | }); 66 | 67 | if (caseTestResult.success) { 68 | console.log(`✅ Lowercase "alice" allowed: ${caseTestResult.event.accountId}`); 69 | } else { 70 | console.log(`❌ Lowercase "alice" rejected: ${caseTestResult.error.message}`); 71 | } 72 | 73 | console.log('\n✅ Unique customer name validation test completed!'); 74 | 75 | } catch (error) { 76 | console.error('Test failed:', error); 77 | } finally { 78 | await eventStore.close(); 79 | } 80 | } 81 | 82 | testUniqueCustomerName(); -------------------------------------------------------------------------------- /tests/eventstore.test.ts: -------------------------------------------------------------------------------- 1 | import { EventStore, EventFilter } from '../src'; 2 | import { HasEventType, IEventStore } from '../src'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | 7 | class TestEvent implements HasEventType { 8 | constructor( 9 | public readonly id: string, 10 | public readonly data: Record, 11 | public readonly timestamp: Date = new Date() 12 | ) {} 13 | 14 | eventType(): string { 15 | return 'TestEvent'; 16 | } 17 | 18 | eventVersion(): string { 19 | return '1.0'; 20 | } 21 | } 22 | 23 | describe('EventStore', () => { 24 | let eventStore: IEventStore; 25 | 26 | beforeEach(() => { 27 | eventStore= new EventStore( 28 | { connectionString: process.env.DATABASE_TEST_URL || 'postgres://postgres:postgres@localhost:5432/eventstore_test' } 29 | ); 30 | }); 31 | 32 | afterEach(async () => { 33 | await eventStore.close(); 34 | }); 35 | 36 | it('should create an instance', () => { 37 | expect(eventStore).toBeInstanceOf(EventStore); 38 | }); 39 | 40 | it('should create filter', () => { 41 | const filter = EventFilter.createFilter(['TestEvent']); 42 | expect(filter.eventTypes).toEqual(['TestEvent']); 43 | }); 44 | 45 | it('should create filter with payload predicates', () => { 46 | const filter = EventFilter 47 | .createFilter(['TestEvent']) 48 | .withPayloadPredicate('id', '123'); 49 | 50 | expect(filter.eventTypes).toEqual(['TestEvent']); 51 | expect(filter.payloadPredicates).toEqual({ id: '123' }); 52 | }); 53 | 54 | describe('pure functions', () => { 55 | interface AssetState { 56 | exists: boolean; 57 | } 58 | 59 | function foldAssetState(events: TestEvent[]): AssetState { 60 | return { exists: events.length > 0 }; 61 | } 62 | 63 | function decideAssetRegistration( 64 | state: AssetState, 65 | id: string, 66 | data: Record 67 | ): TestEvent[] { 68 | if (state.exists) { 69 | throw new Error('AssetAlreadyExists'); 70 | } 71 | return [new TestEvent(id, data)]; 72 | } 73 | 74 | it('should fold empty state correctly', () => { 75 | const state = foldAssetState([]); 76 | expect(state.exists).toBe(false); 77 | }); 78 | 79 | it('should fold existing state correctly', () => { 80 | const events = [new TestEvent('1', { test: 'data' })]; 81 | const state = foldAssetState(events); 82 | expect(state.exists).toBe(true); 83 | }); 84 | 85 | it('should decide with empty state', () => { 86 | const state = { exists: false }; 87 | const events = decideAssetRegistration(state, '1', { test: 'data' }); 88 | expect(events).toHaveLength(1); 89 | expect(events[0]?.id).toBe('1'); 90 | }); 91 | 92 | it('should reject with existing state', () => { 93 | const state = { exists: true }; 94 | expect(() => decideAssetRegistration(state, '1', { test: 'data' })) 95 | .toThrow('AssetAlreadyExists'); 96 | }); 97 | }); 98 | }); -------------------------------------------------------------------------------- /src/features/withdraw-money/shell.ts: -------------------------------------------------------------------------------- 1 | import { EventFilter } from '../../eventstore'; 2 | import { IEventStore } from '../../eventstore/types'; 3 | import { WithdrawMoneyCommand, WithdrawResult } from './types'; 4 | import { processWithdrawCommand } from './core'; 5 | import { MoneyWithdrawnEvent } from './events'; 6 | 7 | export async function execute( 8 | eventStore: IEventStore, 9 | command: WithdrawMoneyCommand 10 | ): Promise { 11 | const withdrawStateResult = await getWithdrawState(eventStore, command.accountId); 12 | 13 | if (!withdrawStateResult.state.account) { 14 | return { 15 | success: false, 16 | error: { type: 'InsufficientFunds', message: 'Account not found' } 17 | }; 18 | } 19 | 20 | const effectiveCommand = { 21 | ...command, 22 | currency: command.currency || withdrawStateResult.state.account.currency 23 | }; 24 | 25 | const result = processWithdrawCommand(effectiveCommand, withdrawStateResult.state.account.balance, withdrawStateResult.state.existingWithdrawalIds); 26 | 27 | if (!result.success) { 28 | return result; 29 | } 30 | 31 | try { 32 | // Use a filter that captures all events that affect the account balance 33 | // This matches the scope of events considered in getWithdrawState 34 | const filter = EventFilter.createFilter( 35 | ['BankAccountOpened', 'MoneyDeposited', 'MoneyWithdrawn', 'MoneyTransferred'], 36 | [ 37 | { accountId: command.accountId }, 38 | { fromAccountId: command.accountId }, 39 | { toAccountId: command.accountId } 40 | ] 41 | ); 42 | 43 | const event = new MoneyWithdrawnEvent( 44 | result.event.accountId, 45 | result.event.amount, 46 | result.event.currency, 47 | result.event.withdrawalId, 48 | result.event.timestamp 49 | ); 50 | 51 | await eventStore.append(filter, [event], withdrawStateResult.maxSequenceNumber); 52 | 53 | return result; 54 | } catch (error) { 55 | return { 56 | success: false, 57 | error: { type: 'InsufficientFunds', message: 'Failed to save withdrawal event' } 58 | }; 59 | } 60 | } 61 | 62 | 63 | async function getWithdrawState(eventStore: IEventStore, accountId: string): Promise<{ 64 | state: { 65 | account: { balance: number; currency: string } | null; 66 | existingWithdrawalIds: string[]; 67 | }; 68 | maxSequenceNumber: number; 69 | }> { 70 | // Single optimized query using payloadPredicateOptions for multiple account relationships 71 | const filter = EventFilter.createFilter( 72 | ['BankAccountOpened', 'MoneyDeposited', 'MoneyWithdrawn', 'MoneyTransferred'], 73 | [ 74 | { accountId: accountId }, 75 | { fromAccountId: accountId }, 76 | { toAccountId: accountId } 77 | ] 78 | ); 79 | 80 | const result = await eventStore.query(filter); 81 | const allEvents = result.events; 82 | const account = buildAccountState(allEvents, accountId); 83 | const existingWithdrawalIds = allEvents 84 | .filter(e => (e.event_type || (e.eventType && e.eventType())) === 'MoneyWithdrawn') 85 | .map(e => e.withdrawalId); 86 | 87 | const maxSequenceNumber = result.maxSequenceNumber; 88 | 89 | return { 90 | state: { 91 | account, 92 | existingWithdrawalIds 93 | }, 94 | maxSequenceNumber 95 | }; 96 | } 97 | 98 | function buildAccountState(events: any[], accountId: string): { balance: number; currency: string } | null { 99 | const openingEvent = events.find(e => 100 | (e.event_type || (e.eventType && e.eventType())) === 'BankAccountOpened' && e.accountId === accountId 101 | ); 102 | 103 | if (!openingEvent) { 104 | return null; 105 | } 106 | 107 | let currentBalance = openingEvent.initialDeposit; 108 | 109 | for (const event of events) { 110 | const eventType = event.event_type || (event.eventType && event.eventType()); 111 | 112 | if (eventType === 'MoneyDeposited' && event.accountId === accountId && event.currency === openingEvent.currency) { 113 | currentBalance += event.amount; 114 | } else if (eventType === 'MoneyWithdrawn' && event.accountId === accountId && event.currency === openingEvent.currency) { 115 | currentBalance -= event.amount; 116 | } else if (eventType === 'MoneyTransferred' && event.currency === openingEvent.currency) { 117 | if (event.fromAccountId === accountId) { 118 | currentBalance -= event.amount; 119 | } else if (event.toAccountId === accountId) { 120 | currentBalance += event.amount; 121 | } 122 | } 123 | } 124 | 125 | return { 126 | balance: currentBalance, 127 | currency: openingEvent.currency 128 | }; 129 | } -------------------------------------------------------------------------------- /src/features/transfer-money/shell.ts: -------------------------------------------------------------------------------- 1 | import { EventFilter } from '../../eventstore'; 2 | import { IEventStore } from '../../eventstore/types'; 3 | import { TransferMoneyCommand, TransferResult } from './types'; 4 | import { processTransferCommand } from './core'; 5 | import { MoneyTransferredEvent } from './events'; 6 | 7 | export async function execute( 8 | eventStore: IEventStore, 9 | command: TransferMoneyCommand 10 | ): Promise { 11 | const transferStateResult = await getTransferState(eventStore, command.fromAccountId, command.toAccountId); 12 | 13 | if (!transferStateResult.state.fromAccount) { 14 | return { 15 | success: false, 16 | error: { type: 'InsufficientFunds', message: 'From account not found' } 17 | }; 18 | } 19 | 20 | if (!transferStateResult.state.toAccount) { 21 | return { 22 | success: false, 23 | error: { type: 'InsufficientFunds', message: 'To account not found' } 24 | }; 25 | } 26 | 27 | const effectiveCommand = { 28 | ...command, 29 | currency: command.currency || transferStateResult.state.fromAccount.currency 30 | }; 31 | 32 | const result = processTransferCommand(effectiveCommand, transferStateResult.state.fromAccount.balance, transferStateResult.state.existingTransferIds); 33 | 34 | if (!result.success) { 35 | return result; 36 | } 37 | 38 | try { 39 | const filter = EventFilter.createFilter( 40 | ['BankAccountOpened', 'MoneyDeposited', 'MoneyWithdrawn', 'MoneyTransferred'], 41 | [ 42 | { accountId: command.fromAccountId }, 43 | { accountId: command.toAccountId }, 44 | { toAccountId: command.fromAccountId }, 45 | { fromAccountId: command.toAccountId } 46 | ] 47 | ); 48 | 49 | const event = new MoneyTransferredEvent( 50 | result.event.fromAccountId, 51 | result.event.toAccountId, 52 | result.event.amount, 53 | result.event.currency, 54 | result.event.transferId, 55 | result.event.timestamp 56 | ); 57 | 58 | await eventStore.append(filter, [event], transferStateResult.maxSequenceNumber); 59 | 60 | return result; 61 | } catch (error) { 62 | return { 63 | success: false, 64 | error: { type: 'InsufficientFunds', message: 'Failed to save transfer event' } 65 | }; 66 | } 67 | } 68 | 69 | 70 | async function getTransferState(eventStore: IEventStore, fromAccountId: string, toAccountId: string): Promise<{ 71 | state: { 72 | fromAccount: { balance: number; currency: string } | null; 73 | toAccount: { balance: number; currency: string } | null; 74 | existingTransferIds: string[]; 75 | }; 76 | maxSequenceNumber: number; 77 | }> { 78 | const filter = EventFilter.createFilter( 79 | ['BankAccountOpened', 'MoneyDeposited', 'MoneyWithdrawn', 'MoneyTransferred'], 80 | [ 81 | { accountId: fromAccountId }, 82 | { accountId: toAccountId }, 83 | { toAccountId: fromAccountId }, 84 | { fromAccountId: toAccountId } 85 | ] 86 | ); 87 | 88 | const result = await eventStore.query(filter); 89 | 90 | const fromAccount = buildAccountState(result.events, fromAccountId); 91 | const toAccount = buildAccountState(result.events, toAccountId); 92 | const existingTransferIds = result.events 93 | .filter(e => (e.event_type || (e.eventType && e.eventType())) === 'MoneyTransferred') 94 | .map(e => e.transferId); 95 | 96 | return { 97 | state: { 98 | fromAccount, 99 | toAccount, 100 | existingTransferIds 101 | }, 102 | maxSequenceNumber: result.maxSequenceNumber 103 | }; 104 | } 105 | 106 | function buildAccountState(events: any[], accountId: string): { balance: number; currency: string } | null { 107 | const openingEvent = events.find(e => 108 | (e.event_type || (e.eventType && e.eventType())) === 'BankAccountOpened' && e.accountId === accountId 109 | ); 110 | 111 | if (!openingEvent) { 112 | return null; 113 | } 114 | 115 | let currentBalance = openingEvent.initialDeposit; 116 | 117 | for (const event of events) { 118 | const eventType = event.event_type || (event.eventType && event.eventType()); 119 | 120 | if (eventType === 'MoneyDeposited' && event.accountId === accountId && event.currency === openingEvent.currency) { 121 | currentBalance += event.amount; 122 | } else if (eventType === 'MoneyWithdrawn' && event.accountId === accountId && event.currency === openingEvent.currency) { 123 | currentBalance -= event.amount; 124 | } else if (eventType === 'MoneyTransferred' && event.currency === openingEvent.currency) { 125 | if (event.fromAccountId === accountId) { 126 | currentBalance -= event.amount; 127 | } else if (event.toAccountId === accountId) { 128 | currentBalance += event.amount; 129 | } 130 | } 131 | } 132 | 133 | return { 134 | balance: currentBalance, 135 | currency: openingEvent.currency 136 | }; 137 | } -------------------------------------------------------------------------------- /test-all-operations.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { EventStore } = require('./dist/eventstore'); 3 | const OpenBankAccount = require('./dist/features/open-bank-account'); 4 | const GetAccount = require('./dist/features/get-account'); 5 | const DepositMoney = require('./dist/features/deposit-money'); 6 | const WithdrawMoney = require('./dist/features/withdraw-money'); 7 | const TransferMoney = require('./dist/features/transfer-money'); 8 | 9 | async function testAllOperations() { 10 | const eventStore = new EventStore(); 11 | 12 | try { 13 | await eventStore.migrate(); 14 | console.log('🏦 Testing All Banking Operations\n'); 15 | 16 | // 1. Create two accounts 17 | console.log('1. Creating two accounts...'); 18 | const timestamp = Date.now(); 19 | const account1Result = await OpenBankAccount.execute(eventStore, { 20 | customerName: `Alice_${timestamp}`, 21 | accountType: 'checking', 22 | initialDeposit: 500, 23 | currency: 'EUR' 24 | }); 25 | 26 | const account2Result = await OpenBankAccount.execute(eventStore, { 27 | customerName: `Bob_${timestamp}`, 28 | accountType: 'savings', 29 | initialDeposit: 300, 30 | currency: 'EUR' 31 | }); 32 | 33 | if (!account1Result.success || !account2Result.success) { 34 | console.log('❌ Failed to create accounts'); 35 | return; 36 | } 37 | 38 | const account1Id = account1Result.event.accountId; 39 | const account2Id = account2Result.event.accountId; 40 | 41 | console.log(`✅ Alice_${timestamp}'s account: ${account1Id} (500 EUR)`); 42 | console.log(`✅ Bob_${timestamp}'s account: ${account2Id} (300 EUR)\n`); 43 | 44 | // 2. Deposit money to Alice's account 45 | console.log('2. Depositing 200 EUR to Alice\'s account...'); 46 | const depositResult = await DepositMoney.execute(eventStore, { 47 | accountId: account1Id, 48 | amount: 200, 49 | depositId: `deposit-${Date.now()}` 50 | }); 51 | 52 | if (depositResult.success) { 53 | console.log(`✅ Deposit successful: ${depositResult.event.amount} ${depositResult.event.currency}`); 54 | 55 | const alice = await GetAccount.execute(eventStore, { accountId: account1Id }); 56 | console.log(` Alice's new balance: ${alice.balance} ${alice.currency}\n`); 57 | } else { 58 | console.log('❌ Deposit failed:', depositResult.error.message); 59 | } 60 | 61 | // 3. Withdraw money from Alice's account 62 | console.log('3. Withdrawing 150 EUR from Alice\'s account...'); 63 | const withdrawResult = await WithdrawMoney.execute(eventStore, { 64 | accountId: account1Id, 65 | amount: 150, 66 | withdrawalId: `withdrawal-${Date.now()}` 67 | }); 68 | 69 | if (withdrawResult.success) { 70 | console.log(`✅ Withdrawal successful: ${withdrawResult.event.amount} ${withdrawResult.event.currency}`); 71 | 72 | const alice = await GetAccount.execute(eventStore, { accountId: account1Id }); 73 | console.log(` Alice's new balance: ${alice.balance} ${alice.currency}\n`); 74 | } else { 75 | console.log('❌ Withdrawal failed:', withdrawResult.error.message); 76 | } 77 | 78 | // 4. Transfer money from Alice to Bob 79 | console.log('4. Transferring 250 EUR from Alice to Bob...'); 80 | const transferResult = await TransferMoney.execute(eventStore, { 81 | fromAccountId: account1Id, 82 | toAccountId: account2Id, 83 | amount: 250, 84 | transferId: `transfer-${Date.now()}` 85 | }); 86 | 87 | if (transferResult.success) { 88 | console.log(`✅ Transfer successful: ${transferResult.event.amount} ${transferResult.event.currency}`); 89 | console.log(` From: ${transferResult.event.fromAccountId}`); 90 | console.log(` To: ${transferResult.event.toAccountId}\n`); 91 | 92 | const alice = await GetAccount.execute(eventStore, { accountId: account1Id }); 93 | const bob = await GetAccount.execute(eventStore, { accountId: account2Id }); 94 | 95 | console.log('Final balances:'); 96 | console.log(` Alice: ${alice.balance} ${alice.currency}`); 97 | console.log(` Bob: ${bob.balance} ${bob.currency}\n`); 98 | 99 | // Expected: Alice = 500 + 200 - 150 - 250 = 300 EUR 100 | // Expected: Bob = 300 + 250 = 550 EUR 101 | console.log('Expected balances:'); 102 | console.log(' Alice: 300 EUR (500 + 200 - 150 - 250)'); 103 | console.log(' Bob: 550 EUR (300 + 250)'); 104 | 105 | if (alice.balance === 300 && bob.balance === 550) { 106 | console.log('✅ All operations completed successfully!'); 107 | } else { 108 | console.log('❌ Balance calculations are incorrect!'); 109 | } 110 | } else { 111 | console.log('❌ Transfer failed:', transferResult.error.message); 112 | } 113 | 114 | // 5. Test insufficient funds 115 | console.log('\n5. Testing insufficient funds (trying to withdraw 1000 EUR from Alice)...'); 116 | const insufficientResult = await WithdrawMoney.execute(eventStore, { 117 | accountId: account1Id, 118 | amount: 1000, 119 | withdrawalId: `withdrawal-${Date.now() + 1}` 120 | }); 121 | 122 | if (!insufficientResult.success) { 123 | console.log(`✅ Correctly rejected: ${insufficientResult.error.message}`); 124 | } else { 125 | console.log('❌ Should have failed due to insufficient funds!'); 126 | } 127 | 128 | } catch (error) { 129 | console.error('Test failed:', error); 130 | } finally { 131 | await eventStore.close(); 132 | } 133 | } 134 | 135 | testAllOperations(); -------------------------------------------------------------------------------- /tests/withdraw-money.test.ts: -------------------------------------------------------------------------------- 1 | import { validateWithdrawCommand, processWithdrawCommand } from '../src/features/withdraw-money/core'; 2 | import { WithdrawMoneyCommand } from '../src/features/withdraw-money/types'; 3 | 4 | describe('Withdraw Money Core Functions', () => { 5 | describe('validateWithdrawCommand', () => { 6 | it('should return null for valid withdrawal command', () => { 7 | const command: WithdrawMoneyCommand = { 8 | accountId: 'test-account', 9 | amount: 100, 10 | currency: 'USD', 11 | withdrawalId: 'withdrawal-1' 12 | }; 13 | 14 | const result = validateWithdrawCommand(command); 15 | 16 | expect(result).toBeNull(); 17 | }); 18 | 19 | it('should return error for zero amount', () => { 20 | const command: WithdrawMoneyCommand = { 21 | accountId: 'test-account', 22 | amount: 0, 23 | currency: 'USD', 24 | withdrawalId: 'withdrawal-1' 25 | }; 26 | 27 | const result = validateWithdrawCommand(command); 28 | 29 | expect(result).toEqual({ 30 | type: 'InvalidAmount', 31 | message: 'Withdrawal amount must be positive' 32 | }); 33 | }); 34 | 35 | it('should return error for negative amount', () => { 36 | const command: WithdrawMoneyCommand = { 37 | accountId: 'test-account', 38 | amount: -50, 39 | currency: 'USD', 40 | withdrawalId: 'withdrawal-1' 41 | }; 42 | 43 | const result = validateWithdrawCommand(command); 44 | 45 | expect(result).toEqual({ 46 | type: 'InvalidAmount', 47 | message: 'Withdrawal amount must be positive' 48 | }); 49 | }); 50 | 51 | it('should return error for amount below minimum', () => { 52 | const command: WithdrawMoneyCommand = { 53 | accountId: 'test-account', 54 | amount: 0.005, 55 | currency: 'USD', 56 | withdrawalId: 'withdrawal-1' 57 | }; 58 | 59 | const result = validateWithdrawCommand(command); 60 | 61 | expect(result).toEqual({ 62 | type: 'InvalidAmount', 63 | message: 'Minimum withdrawal amount is 0.01' 64 | }); 65 | }); 66 | 67 | it('should return error for amount above maximum', () => { 68 | const command: WithdrawMoneyCommand = { 69 | accountId: 'test-account', 70 | amount: 15000, 71 | currency: 'USD', 72 | withdrawalId: 'withdrawal-1' 73 | }; 74 | 75 | const result = validateWithdrawCommand(command); 76 | 77 | expect(result).toEqual({ 78 | type: 'InvalidAmount', 79 | message: 'Maximum withdrawal amount is 10000' 80 | }); 81 | }); 82 | 83 | it('should return error for unsupported currency', () => { 84 | const command: WithdrawMoneyCommand = { 85 | accountId: 'test-account', 86 | amount: 100, 87 | currency: 'JPY', 88 | withdrawalId: 'withdrawal-1' 89 | }; 90 | 91 | const result = validateWithdrawCommand(command); 92 | 93 | expect(result).toEqual({ 94 | type: 'InvalidCurrency', 95 | message: 'Currency JPY is not supported' 96 | }); 97 | }); 98 | }); 99 | 100 | describe('processWithdrawCommand', () => { 101 | it('should create event for valid withdrawal with sufficient balance', () => { 102 | const command: WithdrawMoneyCommand = { 103 | accountId: 'test-account', 104 | amount: 100, 105 | currency: 'USD', 106 | withdrawalId: 'withdrawal-1' 107 | }; 108 | 109 | const result = processWithdrawCommand(command, 500, []); 110 | 111 | expect(result.success).toBe(true); 112 | if (result.success) { 113 | expect(result.event.accountId).toBe('test-account'); 114 | expect(result.event.amount).toBe(100); 115 | expect(result.event.currency).toBe('USD'); 116 | expect(result.event.withdrawalId).toBe('withdrawal-1'); 117 | expect(result.event.type).toBe('MoneyWithdrawn'); 118 | expect(result.event.timestamp).toBeInstanceOf(Date); 119 | } 120 | }); 121 | 122 | it('should return error for insufficient funds', () => { 123 | const command: WithdrawMoneyCommand = { 124 | accountId: 'test-account', 125 | amount: 100, 126 | currency: 'USD', 127 | withdrawalId: 'withdrawal-1' 128 | }; 129 | 130 | const result = processWithdrawCommand(command, 50, []); 131 | 132 | expect(result.success).toBe(false); 133 | if (!result.success) { 134 | expect(result.error.type).toBe('InsufficientFunds'); 135 | expect(result.error.message).toBe('Insufficient funds for withdrawal'); 136 | } 137 | }); 138 | 139 | it('should return error for duplicate withdrawal ID', () => { 140 | const command: WithdrawMoneyCommand = { 141 | accountId: 'test-account', 142 | amount: 100, 143 | currency: 'USD', 144 | withdrawalId: 'withdrawal-1' 145 | }; 146 | 147 | const result = processWithdrawCommand(command, 500, ['withdrawal-1']); 148 | 149 | expect(result.success).toBe(false); 150 | if (!result.success) { 151 | expect(result.error.type).toBe('DuplicateWithdrawal'); 152 | expect(result.error.message).toBe('Withdrawal ID already exists'); 153 | } 154 | }); 155 | 156 | it('should return error for invalid amount', () => { 157 | const command: WithdrawMoneyCommand = { 158 | accountId: 'test-account', 159 | amount: -50, 160 | currency: 'USD', 161 | withdrawalId: 'withdrawal-1' 162 | }; 163 | 164 | const result = processWithdrawCommand(command, 500, []); 165 | 166 | expect(result.success).toBe(false); 167 | if (!result.success) { 168 | expect(result.error.type).toBe('InvalidAmount'); 169 | } 170 | }); 171 | 172 | it('should allow withdrawal of exact balance', () => { 173 | const command: WithdrawMoneyCommand = { 174 | accountId: 'test-account', 175 | amount: 100, 176 | currency: 'USD', 177 | withdrawalId: 'withdrawal-1' 178 | }; 179 | 180 | const result = processWithdrawCommand(command, 100, []); 181 | 182 | expect(result.success).toBe(true); 183 | }); 184 | }); 185 | }); -------------------------------------------------------------------------------- /tests/deposit-money.test.ts: -------------------------------------------------------------------------------- 1 | import { validateDepositCommand, processDepositCommand, foldMoneyDepositedEvents } from '../src/features/deposit-money'; 2 | import { DepositMoneyCommand, MoneyDepositedEvent } from '../src/features/deposit-money'; 3 | 4 | describe('Deposit Money Core Functions', () => { 5 | describe('validateDepositCommand', () => { 6 | it('should return null for valid deposit command', () => { 7 | const command: DepositMoneyCommand = { 8 | accountId: 'test-account', 9 | amount: 100, 10 | currency: 'USD', 11 | depositId: 'deposit-1' 12 | }; 13 | 14 | const result = validateDepositCommand(command); 15 | 16 | expect(result).toBeNull(); 17 | }); 18 | 19 | it('should return error for zero amount', () => { 20 | const command: DepositMoneyCommand = { 21 | accountId: 'test-account', 22 | amount: 0, 23 | currency: 'USD', 24 | depositId: 'deposit-1' 25 | }; 26 | 27 | const result = validateDepositCommand(command); 28 | 29 | expect(result).toEqual({ 30 | type: 'InvalidAmount', 31 | message: 'Deposit amount must be positive' 32 | }); 33 | }); 34 | 35 | it('should return error for negative amount', () => { 36 | const command: DepositMoneyCommand = { 37 | accountId: 'test-account', 38 | amount: -50, 39 | currency: 'USD', 40 | depositId: 'deposit-1' 41 | }; 42 | 43 | const result = validateDepositCommand(command); 44 | 45 | expect(result).toEqual({ 46 | type: 'InvalidAmount', 47 | message: 'Deposit amount must be positive' 48 | }); 49 | }); 50 | 51 | it('should return error for amount below minimum', () => { 52 | const command: DepositMoneyCommand = { 53 | accountId: 'test-account', 54 | amount: 0.005, 55 | currency: 'USD', 56 | depositId: 'deposit-1' 57 | }; 58 | 59 | const result = validateDepositCommand(command); 60 | 61 | expect(result).toEqual({ 62 | type: 'InvalidAmount', 63 | message: 'Minimum deposit amount is 0.01' 64 | }); 65 | }); 66 | 67 | it('should return error for amount above maximum', () => { 68 | const command: DepositMoneyCommand = { 69 | accountId: 'test-account', 70 | amount: 2000000, 71 | currency: 'USD', 72 | depositId: 'deposit-1' 73 | }; 74 | 75 | const result = validateDepositCommand(command); 76 | 77 | expect(result).toEqual({ 78 | type: 'InvalidAmount', 79 | message: 'Maximum deposit amount is 1000000' 80 | }); 81 | }); 82 | 83 | it('should return error for unsupported currency', () => { 84 | const command: DepositMoneyCommand = { 85 | accountId: 'test-account', 86 | amount: 100, 87 | currency: 'JPY', 88 | depositId: 'deposit-1' 89 | }; 90 | 91 | const result = validateDepositCommand(command); 92 | 93 | expect(result).toEqual({ 94 | type: 'InvalidCurrency', 95 | message: 'Currency JPY is not supported' 96 | }); 97 | }); 98 | }); 99 | 100 | describe('processDepositCommand', () => { 101 | it('should create event for valid deposit', () => { 102 | const command: DepositMoneyCommand = { 103 | accountId: 'test-account', 104 | amount: 100, 105 | currency: 'USD', 106 | depositId: 'deposit-1' 107 | }; 108 | 109 | const result = processDepositCommand(command, []); 110 | 111 | expect(result.success).toBe(true); 112 | if (result.success) { 113 | expect(result.event.accountId).toBe('test-account'); 114 | expect(result.event.amount).toBe(100); 115 | expect(result.event.currency).toBe('USD'); 116 | expect(result.event.depositId).toBe('deposit-1'); 117 | expect(result.event.type).toBe('MoneyDeposited'); 118 | expect(result.event.timestamp).toBeInstanceOf(Date); 119 | } 120 | }); 121 | 122 | it('should return error for duplicate deposit ID', () => { 123 | const command: DepositMoneyCommand = { 124 | accountId: 'test-account', 125 | amount: 100, 126 | currency: 'USD', 127 | depositId: 'deposit-1' 128 | }; 129 | 130 | const result = processDepositCommand(command, ['deposit-1']); 131 | 132 | expect(result.success).toBe(false); 133 | if (!result.success) { 134 | expect(result.error.type).toBe('DuplicateDeposit'); 135 | expect(result.error.message).toBe('Deposit ID already exists'); 136 | } 137 | }); 138 | 139 | it('should return error for invalid amount', () => { 140 | const command: DepositMoneyCommand = { 141 | accountId: 'test-account', 142 | amount: -50, 143 | currency: 'USD', 144 | depositId: 'deposit-1' 145 | }; 146 | 147 | const result = processDepositCommand(command, []); 148 | 149 | expect(result.success).toBe(false); 150 | if (!result.success) { 151 | expect(result.error.type).toBe('InvalidAmount'); 152 | } 153 | }); 154 | }); 155 | 156 | describe('foldMoneyDepositedEvents', () => { 157 | it('should return null for empty events array', () => { 158 | const result = foldMoneyDepositedEvents([]); 159 | 160 | expect(result).toBeNull(); 161 | }); 162 | 163 | it('should calculate balance for single deposit', () => { 164 | const events: MoneyDepositedEvent[] = [ 165 | { 166 | type: 'MoneyDeposited', 167 | accountId: 'test-account', 168 | amount: 100, 169 | currency: 'USD', 170 | depositId: 'deposit-1', 171 | timestamp: new Date() 172 | } 173 | ]; 174 | 175 | const result = foldMoneyDepositedEvents(events); 176 | 177 | expect(result).toEqual({ 178 | accountId: 'test-account', 179 | balance: 100, 180 | currency: 'USD' 181 | }); 182 | }); 183 | 184 | it('should calculate balance for multiple deposits', () => { 185 | const events: MoneyDepositedEvent[] = [ 186 | { 187 | type: 'MoneyDeposited', 188 | accountId: 'test-account', 189 | amount: 100, 190 | currency: 'USD', 191 | depositId: 'deposit-1', 192 | timestamp: new Date() 193 | }, 194 | { 195 | type: 'MoneyDeposited', 196 | accountId: 'test-account', 197 | amount: 50, 198 | currency: 'USD', 199 | depositId: 'deposit-2', 200 | timestamp: new Date() 201 | }, 202 | { 203 | type: 'MoneyDeposited', 204 | accountId: 'test-account', 205 | amount: 25, 206 | currency: 'USD', 207 | depositId: 'deposit-3', 208 | timestamp: new Date() 209 | } 210 | ]; 211 | 212 | const result = foldMoneyDepositedEvents(events); 213 | 214 | expect(result).toEqual({ 215 | accountId: 'test-account', 216 | balance: 175, 217 | currency: 'USD' 218 | }); 219 | }); 220 | }); 221 | }); -------------------------------------------------------------------------------- /tests/transfer-money.test.ts: -------------------------------------------------------------------------------- 1 | import { validateTransferCommand, processTransferCommand } from '../src/features/transfer-money/core'; 2 | import { TransferMoneyCommand } from '../src/features/transfer-money/types'; 3 | 4 | describe('Transfer Money Core Functions', () => { 5 | describe('validateTransferCommand', () => { 6 | it('should return null for valid transfer command', () => { 7 | const command: TransferMoneyCommand = { 8 | fromAccountId: 'account-1', 9 | toAccountId: 'account-2', 10 | amount: 100, 11 | currency: 'USD', 12 | transferId: 'transfer-1' 13 | }; 14 | 15 | const result = validateTransferCommand(command); 16 | 17 | expect(result).toBeNull(); 18 | }); 19 | 20 | it('should return error for same account transfer', () => { 21 | const command: TransferMoneyCommand = { 22 | fromAccountId: 'account-1', 23 | toAccountId: 'account-1', 24 | amount: 100, 25 | currency: 'USD', 26 | transferId: 'transfer-1' 27 | }; 28 | 29 | const result = validateTransferCommand(command); 30 | 31 | expect(result).toEqual({ 32 | type: 'SameAccount', 33 | message: 'Cannot transfer to the same account' 34 | }); 35 | }); 36 | 37 | it('should return error for zero amount', () => { 38 | const command: TransferMoneyCommand = { 39 | fromAccountId: 'account-1', 40 | toAccountId: 'account-2', 41 | amount: 0, 42 | currency: 'USD', 43 | transferId: 'transfer-1' 44 | }; 45 | 46 | const result = validateTransferCommand(command); 47 | 48 | expect(result).toEqual({ 49 | type: 'InvalidAmount', 50 | message: 'Transfer amount must be positive' 51 | }); 52 | }); 53 | 54 | it('should return error for negative amount', () => { 55 | const command: TransferMoneyCommand = { 56 | fromAccountId: 'account-1', 57 | toAccountId: 'account-2', 58 | amount: -50, 59 | currency: 'USD', 60 | transferId: 'transfer-1' 61 | }; 62 | 63 | const result = validateTransferCommand(command); 64 | 65 | expect(result).toEqual({ 66 | type: 'InvalidAmount', 67 | message: 'Transfer amount must be positive' 68 | }); 69 | }); 70 | 71 | it('should return error for amount below minimum', () => { 72 | const command: TransferMoneyCommand = { 73 | fromAccountId: 'account-1', 74 | toAccountId: 'account-2', 75 | amount: 0.005, 76 | currency: 'USD', 77 | transferId: 'transfer-1' 78 | }; 79 | 80 | const result = validateTransferCommand(command); 81 | 82 | expect(result).toEqual({ 83 | type: 'InvalidAmount', 84 | message: 'Minimum transfer amount is 0.01' 85 | }); 86 | }); 87 | 88 | it('should return error for amount above maximum', () => { 89 | const command: TransferMoneyCommand = { 90 | fromAccountId: 'account-1', 91 | toAccountId: 'account-2', 92 | amount: 60000, 93 | currency: 'USD', 94 | transferId: 'transfer-1' 95 | }; 96 | 97 | const result = validateTransferCommand(command); 98 | 99 | expect(result).toEqual({ 100 | type: 'InvalidAmount', 101 | message: 'Maximum transfer amount is 50000' 102 | }); 103 | }); 104 | 105 | it('should return error for unsupported currency', () => { 106 | const command: TransferMoneyCommand = { 107 | fromAccountId: 'account-1', 108 | toAccountId: 'account-2', 109 | amount: 100, 110 | currency: 'JPY', 111 | transferId: 'transfer-1' 112 | }; 113 | 114 | const result = validateTransferCommand(command); 115 | 116 | expect(result).toEqual({ 117 | type: 'InvalidCurrency', 118 | message: 'Currency JPY is not supported' 119 | }); 120 | }); 121 | }); 122 | 123 | describe('processTransferCommand', () => { 124 | it('should create event for valid transfer with sufficient balance', () => { 125 | const command: TransferMoneyCommand = { 126 | fromAccountId: 'account-1', 127 | toAccountId: 'account-2', 128 | amount: 100, 129 | currency: 'USD', 130 | transferId: 'transfer-1' 131 | }; 132 | 133 | const result = processTransferCommand(command, 500, []); 134 | 135 | expect(result.success).toBe(true); 136 | if (result.success) { 137 | expect(result.event.fromAccountId).toBe('account-1'); 138 | expect(result.event.toAccountId).toBe('account-2'); 139 | expect(result.event.amount).toBe(100); 140 | expect(result.event.currency).toBe('USD'); 141 | expect(result.event.transferId).toBe('transfer-1'); 142 | expect(result.event.type).toBe('MoneyTransferred'); 143 | expect(result.event.timestamp).toBeInstanceOf(Date); 144 | } 145 | }); 146 | 147 | it('should return error for insufficient funds', () => { 148 | const command: TransferMoneyCommand = { 149 | fromAccountId: 'account-1', 150 | toAccountId: 'account-2', 151 | amount: 100, 152 | currency: 'USD', 153 | transferId: 'transfer-1' 154 | }; 155 | 156 | const result = processTransferCommand(command, 50, []); 157 | 158 | expect(result.success).toBe(false); 159 | if (!result.success) { 160 | expect(result.error.type).toBe('InsufficientFunds'); 161 | expect(result.error.message).toBe('Insufficient funds for transfer'); 162 | } 163 | }); 164 | 165 | it('should return error for duplicate transfer ID', () => { 166 | const command: TransferMoneyCommand = { 167 | fromAccountId: 'account-1', 168 | toAccountId: 'account-2', 169 | amount: 100, 170 | currency: 'USD', 171 | transferId: 'transfer-1' 172 | }; 173 | 174 | const result = processTransferCommand(command, 500, ['transfer-1']); 175 | 176 | expect(result.success).toBe(false); 177 | if (!result.success) { 178 | expect(result.error.type).toBe('DuplicateTransfer'); 179 | expect(result.error.message).toBe('Transfer ID already exists'); 180 | } 181 | }); 182 | 183 | it('should return error for same account validation', () => { 184 | const command: TransferMoneyCommand = { 185 | fromAccountId: 'account-1', 186 | toAccountId: 'account-1', 187 | amount: 100, 188 | currency: 'USD', 189 | transferId: 'transfer-1' 190 | }; 191 | 192 | const result = processTransferCommand(command, 500, []); 193 | 194 | expect(result.success).toBe(false); 195 | if (!result.success) { 196 | expect(result.error.type).toBe('SameAccount'); 197 | } 198 | }); 199 | 200 | it('should allow transfer of exact balance', () => { 201 | const command: TransferMoneyCommand = { 202 | fromAccountId: 'account-1', 203 | toAccountId: 'account-2', 204 | amount: 100, 205 | currency: 'USD', 206 | transferId: 'transfer-1' 207 | }; 208 | 209 | const result = processTransferCommand(command, 100, []); 210 | 211 | expect(result.success).toBe(true); 212 | }); 213 | }); 214 | }); -------------------------------------------------------------------------------- /tests/open-bank-account.test.ts: -------------------------------------------------------------------------------- 1 | import { validateOpenAccountCommand, processOpenAccountCommand } from '../src/features/open-bank-account/core'; 2 | import { OpenBankAccountCommand } from '../src/features/open-bank-account/types'; 3 | 4 | describe('Open Bank Account Core Functions', () => { 5 | describe('validateOpenAccountCommand', () => { 6 | it('should return null for valid command with all fields', () => { 7 | const command: OpenBankAccountCommand = { 8 | customerName: 'John Doe', 9 | accountType: 'checking', 10 | initialDeposit: 100, 11 | currency: 'USD' 12 | }; 13 | 14 | const result = validateOpenAccountCommand(command); 15 | 16 | expect(result).toBeNull(); 17 | }); 18 | 19 | it('should return null for valid command with no initial deposit', () => { 20 | const command: OpenBankAccountCommand = { 21 | customerName: 'John Doe', 22 | accountType: 'savings', 23 | currency: 'EUR' 24 | }; 25 | 26 | const result = validateOpenAccountCommand(command); 27 | 28 | expect(result).toBeNull(); 29 | }); 30 | 31 | it('should return error for empty customer name', () => { 32 | const command: OpenBankAccountCommand = { 33 | customerName: '', 34 | accountType: 'checking', 35 | currency: 'USD' 36 | }; 37 | 38 | const result = validateOpenAccountCommand(command); 39 | 40 | expect(result).toEqual({ 41 | type: 'InvalidCustomerName', 42 | message: 'Customer name is required' 43 | }); 44 | }); 45 | 46 | it('should return error for customer name with only whitespace', () => { 47 | const command: OpenBankAccountCommand = { 48 | customerName: ' ', 49 | accountType: 'checking', 50 | currency: 'USD' 51 | }; 52 | 53 | const result = validateOpenAccountCommand(command); 54 | 55 | expect(result).toEqual({ 56 | type: 'InvalidCustomerName', 57 | message: 'Customer name is required' 58 | }); 59 | }); 60 | 61 | it('should return error for customer name too short', () => { 62 | const command: OpenBankAccountCommand = { 63 | customerName: 'A', 64 | accountType: 'checking', 65 | currency: 'USD' 66 | }; 67 | 68 | const result = validateOpenAccountCommand(command); 69 | 70 | expect(result).toEqual({ 71 | type: 'InvalidCustomerName', 72 | message: 'Customer name must be at least 2 characters' 73 | }); 74 | }); 75 | 76 | it('should return error for negative initial deposit', () => { 77 | const command: OpenBankAccountCommand = { 78 | customerName: 'John Doe', 79 | accountType: 'checking', 80 | initialDeposit: -10, 81 | currency: 'USD' 82 | }; 83 | 84 | const result = validateOpenAccountCommand(command); 85 | 86 | expect(result).toEqual({ 87 | type: 'InvalidInitialDeposit', 88 | message: 'Initial deposit cannot be negative' 89 | }); 90 | }); 91 | 92 | it('should return error for initial deposit exceeding maximum', () => { 93 | const command: OpenBankAccountCommand = { 94 | customerName: 'John Doe', 95 | accountType: 'checking', 96 | initialDeposit: 2000000, 97 | currency: 'USD' 98 | }; 99 | 100 | const result = validateOpenAccountCommand(command); 101 | 102 | expect(result).toEqual({ 103 | type: 'InvalidInitialDeposit', 104 | message: 'Initial deposit cannot exceed 1000000' 105 | }); 106 | }); 107 | 108 | it('should return error for unsupported currency', () => { 109 | const command: OpenBankAccountCommand = { 110 | customerName: 'John Doe', 111 | accountType: 'checking', 112 | currency: 'JPY' 113 | }; 114 | 115 | const result = validateOpenAccountCommand(command); 116 | 117 | expect(result).toEqual({ 118 | type: 'InvalidCurrency', 119 | message: 'Currency JPY is not supported' 120 | }); 121 | }); 122 | 123 | it('should accept all supported currencies', () => { 124 | const currencies = ['USD', 'EUR', 'GBP']; 125 | 126 | currencies.forEach(currency => { 127 | const command: OpenBankAccountCommand = { 128 | customerName: 'John Doe', 129 | accountType: 'checking', 130 | currency 131 | }; 132 | 133 | const result = validateOpenAccountCommand(command); 134 | expect(result).toBeNull(); 135 | }); 136 | }); 137 | }); 138 | 139 | describe('processOpenAccountCommand', () => { 140 | it('should create event for valid command with initial deposit', () => { 141 | const command: OpenBankAccountCommand = { 142 | customerName: 'John Doe', 143 | accountType: 'checking', 144 | initialDeposit: 100, 145 | currency: 'USD' 146 | }; 147 | const accountId = 'test-account-id'; 148 | 149 | const result = processOpenAccountCommand(command, accountId); 150 | 151 | expect(result.success).toBe(true); 152 | if (result.success) { 153 | expect(result.event.accountId).toBe(accountId); 154 | expect(result.event.customerName).toBe('John Doe'); 155 | expect(result.event.accountType).toBe('checking'); 156 | expect(result.event.initialDeposit).toBe(100); 157 | expect(result.event.currency).toBe('USD'); 158 | expect(result.event.type).toBe('BankAccountOpened'); 159 | expect(result.event.openedAt).toBeInstanceOf(Date); 160 | } 161 | }); 162 | 163 | it('should create event with default 0 deposit when not provided', () => { 164 | const command: OpenBankAccountCommand = { 165 | customerName: 'Jane Smith', 166 | accountType: 'savings', 167 | currency: 'EUR' 168 | }; 169 | const accountId = 'test-account-id-2'; 170 | 171 | const result = processOpenAccountCommand(command, accountId); 172 | 173 | expect(result.success).toBe(true); 174 | if (result.success) { 175 | expect(result.event.initialDeposit).toBe(0); 176 | expect(result.event.customerName).toBe('Jane Smith'); 177 | expect(result.event.accountType).toBe('savings'); 178 | expect(result.event.currency).toBe('EUR'); 179 | } 180 | }); 181 | 182 | it('should trim customer name whitespace', () => { 183 | const command: OpenBankAccountCommand = { 184 | customerName: ' John Doe ', 185 | accountType: 'checking', 186 | currency: 'USD' 187 | }; 188 | const accountId = 'test-account-id-3'; 189 | 190 | const result = processOpenAccountCommand(command, accountId); 191 | 192 | expect(result.success).toBe(true); 193 | if (result.success) { 194 | expect(result.event.customerName).toBe('John Doe'); 195 | } 196 | }); 197 | 198 | it('should return error for invalid command', () => { 199 | const command: OpenBankAccountCommand = { 200 | customerName: '', 201 | accountType: 'checking', 202 | currency: 'USD' 203 | }; 204 | const accountId = 'test-account-id-4'; 205 | 206 | const result = processOpenAccountCommand(command, accountId); 207 | 208 | expect(result.success).toBe(false); 209 | if (!result.success) { 210 | expect(result.error.type).toBe('InvalidCustomerName'); 211 | } 212 | }); 213 | }); 214 | }); -------------------------------------------------------------------------------- /src/eventstore/postgres.ts: -------------------------------------------------------------------------------- 1 | import { Pool } from 'pg'; 2 | import { IEventStore, EventFilter, HasEventType, EventRecord, EventStoreOptions } from './types'; 3 | import { EventFilter as EventFilterClass } from './filter'; 4 | 5 | export class PostgresEventStore implements IEventStore { 6 | private pool: Pool; 7 | private readonly dbName: string; 8 | 9 | constructor(options: EventStoreOptions = {}) { 10 | const connectionString = options.connectionString || process.env.DATABASE_URL; 11 | 12 | if (!connectionString) { 13 | throw new Error('DATABASE_URL environment variable is required'); 14 | } 15 | 16 | this.dbName = this.getDatabaseNameFromConnectionString(connectionString) || 'bank'; 17 | this.pool = new Pool({ connectionString }); 18 | } 19 | 20 | async query(filter: EventFilter): Promise<{ events: T[]; maxSequenceNumber: number }> { 21 | const client = await this.pool.connect(); 22 | try { 23 | const filterCondition = this.buildFilterCondition(filter); 24 | 25 | const query = ` 26 | SELECT *, 27 | MAX(sequence_number) OVER () AS max_seq_overall 28 | FROM events 29 | WHERE ${filterCondition.condition} 30 | ORDER BY sequence_number ASC 31 | `; 32 | 33 | const result = await client.query(query, filterCondition.params); 34 | 35 | const events = result.rows.map(row => this.deserializeEvent(row)); 36 | const maxSequenceNumber = result.rows.length > 0 ? 37 | parseInt(result.rows[0].max_seq_overall, 10) : 0; 38 | 39 | return { events, maxSequenceNumber }; 40 | } finally { 41 | client.release(); 42 | } 43 | } 44 | 45 | async append(filter: EventFilter, events: T[], expectedMaxSequence: number): Promise { 46 | if (events.length === 0) return; 47 | 48 | const client = await this.pool.connect(); 49 | try { 50 | 51 | const eventTypes: string[] = []; 52 | const payloads: string[] = []; 53 | const metadata: string[] = []; 54 | 55 | for (const event of events) { 56 | eventTypes.push(event.eventType()); 57 | payloads.push(JSON.stringify(event)); 58 | metadata.push(JSON.stringify({ 59 | version: event.eventVersion?.() || '1.0' 60 | })); 61 | } 62 | 63 | const filterCondition = this.buildFilterCondition(filter); 64 | const cteQuery = this.buildCteInsertQuery(filter, expectedMaxSequence); 65 | 66 | const params = [ 67 | ...filterCondition.params, // Filter parameters (dynamic based on filter) 68 | eventTypes, // Event types to insert 69 | payloads, // Payloads to insert 70 | metadata // Metadata to insert 71 | ]; 72 | 73 | const result = await client.query(cteQuery, params); 74 | 75 | if (result.rowCount === 0) { 76 | throw new Error('Context changed: events were modified between query and append'); 77 | } 78 | 79 | } catch (error) { 80 | throw error; 81 | } finally { 82 | client.release(); 83 | } 84 | } 85 | 86 | private buildFilterCondition(filter: EventFilter): { condition: string; params: unknown[] } { 87 | let condition = 'event_type = ANY($1)'; 88 | const params: unknown[] = [filter.eventTypes]; 89 | 90 | if (filter.payloadPredicates && Object.keys(filter.payloadPredicates).length > 0) { 91 | condition += ' AND payload @> $2'; 92 | params.push(JSON.stringify(filter.payloadPredicates)); 93 | } 94 | 95 | if (filter.payloadPredicateOptions && filter.payloadPredicateOptions.length > 0) { 96 | const orConditions = filter.payloadPredicateOptions.map((_, index) => { 97 | const paramIndex = params.length + 1; 98 | params.push(JSON.stringify(filter.payloadPredicateOptions![index])); 99 | return `payload @> $${paramIndex}`; 100 | }); 101 | condition += ` AND (${orConditions.join(' OR ')})`; 102 | } 103 | 104 | return { condition, params }; 105 | } 106 | 107 | private buildCteInsertQuery(filter: EventFilter, expectedMaxSeq: number): string { 108 | const filterCondition = this.buildFilterCondition(filter); 109 | 110 | const contextParamCount = filterCondition.params.length; 111 | const eventTypesParam = contextParamCount + 1; 112 | const payloadsParam = contextParamCount + 2; 113 | const metadataParam = contextParamCount + 3; 114 | 115 | return ` 116 | WITH context AS ( 117 | SELECT MAX(sequence_number) AS max_seq 118 | FROM events 119 | WHERE ${filterCondition.condition} 120 | ) 121 | INSERT INTO events (event_type, payload, metadata) 122 | SELECT unnest($${eventTypesParam}::text[]), unnest($${payloadsParam}::jsonb[]), unnest($${metadataParam}::jsonb[]) 123 | FROM context 124 | WHERE COALESCE(max_seq, 0) = ${expectedMaxSeq} 125 | `; 126 | } 127 | 128 | private deserializeEvent(row: any): T { 129 | const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload; 130 | return { 131 | ...payload, 132 | event_type: row.event_type, 133 | sequenceNumber: row.sequence_number, 134 | occurredAt: row.occurred_at 135 | } as T; 136 | } 137 | 138 | async close(): Promise { 139 | await this.pool.end(); 140 | } 141 | 142 | async migrate(): Promise { 143 | await this.createTables(); 144 | } 145 | 146 | async createTables(): Promise { 147 | await this.ensureDatabaseExists(); 148 | 149 | const client = await this.pool.connect(); 150 | try { 151 | await client.query(` 152 | CREATE TABLE IF NOT EXISTS events ( 153 | sequence_number BIGSERIAL PRIMARY KEY, 154 | occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 155 | event_type TEXT NOT NULL, 156 | payload JSONB NOT NULL, 157 | metadata JSONB NOT NULL DEFAULT '{}' 158 | ) 159 | `); 160 | 161 | await client.query(` 162 | CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type) 163 | `); 164 | 165 | await client.query(` 166 | CREATE INDEX IF NOT EXISTS idx_events_occurred_at ON events(occurred_at) 167 | `); 168 | 169 | await client.query(` 170 | CREATE INDEX IF NOT EXISTS idx_events_payload_gin ON events USING gin(payload) 171 | `); 172 | } finally { 173 | client.release(); 174 | } 175 | } 176 | 177 | private async ensureDatabaseExists(): Promise { 178 | const adminConnectionString = this.changeDatabaseInConnectionString( 179 | process.env.DATABASE_URL!, 180 | 'postgres' 181 | ); 182 | 183 | const adminPool = new Pool({ connectionString: adminConnectionString }); 184 | const client = await adminPool.connect(); 185 | 186 | try { 187 | await client.query(`CREATE DATABASE ${this.dbName}`); 188 | console.log(`Database created: ${this.dbName}`); 189 | } catch (err: any) { 190 | if (err.code === '42P04') { // already exists 191 | console.log(`Database already exists: ${this.dbName}`); 192 | } else { 193 | throw err; 194 | } 195 | } finally { 196 | client.release(); 197 | await adminPool.end(); 198 | } 199 | } 200 | 201 | private changeDatabaseInConnectionString(connStr: string, newDbName: string): string { 202 | const url = new URL(connStr); 203 | url.pathname = `/${newDbName}`; 204 | return url.toString(); 205 | } 206 | 207 | private getDatabaseNameFromConnectionString(connStr: string): string | null { 208 | try { 209 | const url = new URL(connStr); 210 | const dbName = url.pathname.startsWith('/') ? url.pathname.slice(1) : url.pathname; 211 | return dbName || null; 212 | } catch (err) { 213 | console.error('Invalid connection string:', err); 214 | return null; 215 | } 216 | } 217 | 218 | } -------------------------------------------------------------------------------- /tests/optimistic-locking.test.ts: -------------------------------------------------------------------------------- 1 | import { EventStore, EventFilter } from '../src/eventstore'; 2 | import { HasEventType } from '../src/eventstore/types'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | 7 | class TestEvent implements HasEventType { 8 | constructor( 9 | public readonly id: string, 10 | public readonly data: Record, 11 | public readonly eventTypeName: string = 'TestEvent', 12 | public readonly timestamp: Date = new Date() 13 | ) {} 14 | 15 | eventType(): string { 16 | return this.eventTypeName; 17 | } 18 | 19 | eventVersion(): string { 20 | return '1.0'; 21 | } 22 | } 23 | 24 | describe('Optimistic Locking CTE Condition', () => { 25 | let eventStore: EventStore; 26 | 27 | beforeEach(async () => { 28 | eventStore = new EventStore( 29 | { connectionString: process.env.DATABASE_TEST_URL || 'postgres://postgres:postgres@localhost:5432/eventstore_test' } 30 | ); 31 | await eventStore.migrate(); 32 | }); 33 | 34 | afterEach(async () => { 35 | await eventStore.close(); 36 | }); 37 | 38 | it('should succeed when sequence number matches expected', async () => { 39 | const eventType = `TestEvent_${Date.now()}_1`; 40 | const filter = EventFilter.createFilter([eventType]); 41 | 42 | // First, query to get current state and sequence 43 | const initialResult = await eventStore.query(filter); 44 | expect(initialResult.events).toHaveLength(0); 45 | expect(initialResult.maxSequenceNumber).toBe(0); 46 | 47 | // Append with correct expected sequence should succeed 48 | const event1 = new TestEvent('test-1', { value: 'first' }, eventType); 49 | await expect(eventStore.append(filter, [event1], 0)).resolves.not.toThrow(); 50 | 51 | // Verify the event was inserted 52 | const afterInsert = await eventStore.query(filter); 53 | expect(afterInsert.events).toHaveLength(1); 54 | expect(afterInsert.maxSequenceNumber).toBeGreaterThan(0); 55 | expect(afterInsert.events[0]?.id).toBe('test-1'); 56 | }); 57 | 58 | it('should fail when sequence number does not match (CTE condition)', async () => { 59 | const eventType = `TestEvent_${Date.now()}_2`; 60 | const filter = EventFilter.createFilter([eventType]); 61 | 62 | // Insert an initial event 63 | const event1 = new TestEvent('test-1', { value: 'first' }, eventType); 64 | await eventStore.append(filter, [event1], 0); 65 | 66 | // Get current state 67 | const currentResult = await eventStore.query(filter); 68 | const currentSequence = currentResult.maxSequenceNumber; 69 | expect(currentSequence).toBeGreaterThan(0); 70 | 71 | // Try to append with outdated sequence number (should fail) 72 | const event2 = new TestEvent('test-2', { value: 'second' }, eventType); 73 | await expect( 74 | eventStore.append(filter, [event2], 0) // Using outdated sequence 0 instead of current 75 | ).rejects.toThrow('Context changed: events were modified between query and append'); 76 | 77 | // Verify the second event was NOT inserted 78 | const afterFailedInsert = await eventStore.query(filter); 79 | expect(afterFailedInsert.events).toHaveLength(1); 80 | expect(afterFailedInsert.maxSequenceNumber).toBe(currentSequence); 81 | }); 82 | 83 | it('should handle concurrent modifications correctly', async () => { 84 | const eventType = `TestEvent_${Date.now()}_3`; 85 | const filter = EventFilter.createFilter([eventType]); 86 | 87 | // Simulate concurrent scenario: 88 | // 1. Two processes query at the same time 89 | const [result1, result2] = await Promise.all([ 90 | eventStore.query(filter), 91 | eventStore.query(filter) 92 | ]); 93 | 94 | expect(result1.maxSequenceNumber).toBe(0); 95 | expect(result2.maxSequenceNumber).toBe(0); 96 | 97 | // 2. First process successfully appends 98 | const event1 = new TestEvent('concurrent-1', { process: 'A' }, eventType); 99 | await eventStore.append(filter, [event1], result1.maxSequenceNumber); 100 | 101 | // 3. Second process tries to append with same sequence (should fail) 102 | const event2 = new TestEvent('concurrent-2', { process: 'B' }, eventType); 103 | await expect( 104 | eventStore.append(filter, [event2], result2.maxSequenceNumber) 105 | ).rejects.toThrow('Context changed: events were modified between query and append'); 106 | 107 | // Verify only the first event was inserted 108 | const finalResult = await eventStore.query(filter); 109 | expect(finalResult.events).toHaveLength(1); 110 | expect(finalResult.events[0]?.id).toBe('concurrent-1'); 111 | expect(finalResult.maxSequenceNumber).toBeGreaterThan(0); 112 | }); 113 | 114 | it('should work with payload predicates in CTE condition', async () => { 115 | const eventType = `TestEvent_${Date.now()}_4`; 116 | const accountId = 'account-123'; 117 | const filter = EventFilter.createFilter([eventType]) 118 | .withPayloadPredicates({ accountId }); 119 | 120 | // Insert event for different account (should not affect our context) 121 | const otherFilter = EventFilter.createFilter([eventType]) 122 | .withPayloadPredicates({ accountId: 'other-account' }); 123 | const otherEvent = { 124 | id: 'other', 125 | accountId: 'other-account', // Top level property 126 | value: 'other', 127 | eventType: () => eventType, 128 | eventVersion: () => '1.0' 129 | }; 130 | await eventStore.append(otherFilter, [otherEvent], 0); 131 | 132 | // Query our specific context 133 | const result = await eventStore.query(filter); 134 | expect(result.events).toHaveLength(0); 135 | expect(result.maxSequenceNumber).toBe(0); // Should be 0 for our context 136 | 137 | // Create event with accountId at the top level (not nested in data) 138 | const event = { 139 | id: 'test-1', 140 | accountId, // Top level property for payload predicate matching 141 | value: 'first', 142 | eventType: () => eventType, 143 | eventVersion: () => '1.0' 144 | }; 145 | await expect(eventStore.append(filter, [event], 0)).resolves.not.toThrow(); 146 | 147 | // Verify our event was inserted 148 | const afterInsert = await eventStore.query(filter); 149 | expect(afterInsert.events).toHaveLength(1); 150 | expect(afterInsert.events[0]?.id).toBe('test-1'); 151 | expect(afterInsert.maxSequenceNumber).toBeGreaterThan(0); 152 | }); 153 | 154 | it('should work with multiple payload predicate options (OR conditions)', async () => { 155 | const eventType = `TestEvent_${Date.now()}_5`; 156 | const filter = EventFilter.createFilter([eventType], [ 157 | { accountId: 'account-1' }, 158 | { accountId: 'account-2' } 159 | ]); 160 | 161 | // Insert event for account-1 162 | const event1 = { 163 | id: 'test-1', 164 | accountId: 'account-1', // Top level property 165 | value: 'first', 166 | eventType: () => eventType, 167 | eventVersion: () => '1.0' 168 | }; 169 | await eventStore.append(filter, [event1], 0); 170 | 171 | // Query the OR context 172 | const result = await eventStore.query(filter); 173 | expect(result.events).toHaveLength(1); 174 | const currentSequence = result.maxSequenceNumber; 175 | expect(currentSequence).toBeGreaterThan(0); 176 | 177 | // Insert event for account-2 with correct sequence 178 | const event2 = { 179 | id: 'test-2', 180 | accountId: 'account-2', // Top level property 181 | value: 'second', 182 | eventType: () => eventType, 183 | eventVersion: () => '1.0' 184 | }; 185 | await expect(eventStore.append(filter, [event2], currentSequence)).resolves.not.toThrow(); 186 | 187 | // Try to insert with outdated sequence (should fail) 188 | const event3 = { 189 | id: 'test-3', 190 | accountId: 'account-1', // Top level property 191 | value: 'third', 192 | eventType: () => eventType, 193 | eventVersion: () => '1.0' 194 | }; 195 | await expect( 196 | eventStore.append(filter, [event3], currentSequence) // Outdated, should be currentSequence + 1 197 | ).rejects.toThrow('Context changed: events were modified between query and append'); 198 | }); 199 | }); -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as readline from 'readline'; 4 | import dotenv from 'dotenv'; 5 | import { EventStore } from './eventstore'; 6 | import * as OpenBankAccount from './features/open-bank-account'; 7 | import * as GetAccount from './features/get-account'; 8 | import * as DepositMoney from './features/deposit-money'; 9 | import * as WithdrawMoney from './features/withdraw-money'; 10 | import * as TransferMoney from './features/transfer-money'; 11 | 12 | dotenv.config(); 13 | 14 | const rl = readline.createInterface({ 15 | input: process.stdin, 16 | output: process.stdout 17 | }); 18 | 19 | class BankingCLI { 20 | private eventStore: EventStore; 21 | 22 | constructor() { 23 | this.eventStore = new EventStore(); 24 | } 25 | 26 | async start() { 27 | try { 28 | await this.eventStore.migrate(); 29 | console.log('🏦 Welcome to the Event-Sourced Banking System!\n'); 30 | await this.showMainMenu(); 31 | } catch (error) { 32 | console.error('Failed to initialize:', error); 33 | await this.eventStore.close(); 34 | rl.close(); 35 | process.exit(1); 36 | } 37 | } 38 | 39 | private async showMainMenu() { 40 | console.log('Choose an option:'); 41 | console.log('1. Open Bank Account'); 42 | console.log('2. Deposit Money'); 43 | console.log('3. Withdraw Money'); 44 | console.log('4. Transfer Money'); 45 | console.log('5. View Account Balance'); 46 | console.log('6. Exit'); 47 | console.log(); 48 | 49 | const choice = await this.askQuestion('Enter your choice (1-6): '); 50 | 51 | switch (choice) { 52 | case '1': 53 | await this.handleOpenAccount(); 54 | break; 55 | case '2': 56 | await this.handleDeposit(); 57 | break; 58 | case '3': 59 | await this.handleWithdraw(); 60 | break; 61 | case '4': 62 | await this.handleTransfer(); 63 | break; 64 | case '5': 65 | await this.handleViewBalance(); 66 | break; 67 | case '6': 68 | console.log('Thank you for using the Banking System!'); 69 | await this.eventStore.close(); 70 | rl.close(); 71 | process.exit(0); 72 | default: 73 | console.log('Invalid choice. Please try again.\n'); 74 | await this.showMainMenu(); 75 | } 76 | } 77 | 78 | private async handleOpenAccount() { 79 | console.log('\n📝 Opening Bank Account'); 80 | 81 | const customerName = await this.askQuestion('Customer Name: '); 82 | const accountTypeInput = await this.askQuestion('Account Type (checking/savings, default checking): '); 83 | const accountType = (accountTypeInput.trim() === '' ? 'checking' : accountTypeInput) as 'checking' | 'savings'; 84 | const initialDepositInput = await this.askQuestion('Initial Deposit (optional, default 0): '); 85 | const initialDeposit = initialDepositInput.trim() === '' ? undefined : parseFloat(initialDepositInput); 86 | const currencyInput = await this.askQuestion('Currency (USD/EUR/GBP, default USD): '); 87 | const currency = currencyInput.trim() === '' ? 'USD' : currencyInput; 88 | 89 | const command: OpenBankAccount.OpenBankAccountCommand = { 90 | customerName, 91 | accountType, 92 | currency 93 | }; 94 | 95 | if (initialDeposit !== undefined) { 96 | command.initialDeposit = initialDeposit; 97 | } 98 | 99 | const result = await OpenBankAccount.execute(this.eventStore, command); 100 | 101 | if (result.success) { 102 | console.log('✅ Account opened successfully!'); 103 | console.log(`Account ID: ${result.event.accountId}`); 104 | console.log(`Customer: ${result.event.customerName}`); 105 | console.log(`Type: ${result.event.accountType}`); 106 | console.log(`Initial Balance: ${result.event.initialDeposit} ${result.event.currency}`); 107 | } else { 108 | console.log('❌ Error:', result.error.message); 109 | } 110 | 111 | await this.continueOrExit(); 112 | } 113 | 114 | private async handleDeposit() { 115 | console.log('\n💰 Deposit Money'); 116 | 117 | const accountId = await this.askQuestion('Account ID: '); 118 | const amountInput = await this.askQuestion('Amount: '); 119 | const amount = parseFloat(amountInput); 120 | 121 | if (isNaN(amount) || amount <= 0) { 122 | console.log('❌ Error: Please enter a valid positive amount'); 123 | await this.continueOrExit(); 124 | return; 125 | } 126 | 127 | const depositId = `deposit-${Date.now()}`; 128 | 129 | const result = await DepositMoney.execute(this.eventStore, { 130 | accountId, 131 | amount, 132 | currency: '', // Will use account's currency 133 | depositId 134 | }); 135 | 136 | if (result.success) { 137 | console.log('✅ Money deposited successfully!'); 138 | console.log(`Amount: ${result.event.amount} ${result.event.currency}`); 139 | console.log(`Deposit ID: ${result.event.depositId}`); 140 | } else { 141 | console.log('❌ Error:', result.error.message); 142 | } 143 | 144 | await this.continueOrExit(); 145 | } 146 | 147 | private async handleWithdraw() { 148 | console.log('\n🏧 Withdraw Money'); 149 | 150 | const accountId = await this.askQuestion('Account ID: '); 151 | const amountInput = await this.askQuestion('Amount: '); 152 | const amount = parseFloat(amountInput); 153 | 154 | if (isNaN(amount) || amount <= 0) { 155 | console.log('❌ Error: Please enter a valid positive amount'); 156 | await this.continueOrExit(); 157 | return; 158 | } 159 | 160 | const withdrawalId = `withdrawal-${Date.now()}`; 161 | 162 | const result = await WithdrawMoney.execute(this.eventStore, { 163 | accountId, 164 | amount, 165 | withdrawalId 166 | }); 167 | 168 | if (result.success) { 169 | console.log('✅ Money withdrawn successfully!'); 170 | console.log(`Amount: ${result.event.amount} ${result.event.currency}`); 171 | console.log(`Withdrawal ID: ${result.event.withdrawalId}`); 172 | } else { 173 | console.log('❌ Error:', result.error.message); 174 | } 175 | 176 | await this.continueOrExit(); 177 | } 178 | 179 | private async handleTransfer() { 180 | console.log('\n🔄 Transfer Money'); 181 | 182 | const fromAccountId = await this.askQuestion('From Account ID: '); 183 | const toAccountId = await this.askQuestion('To Account ID: '); 184 | const amountInput = await this.askQuestion('Amount: '); 185 | const amount = parseFloat(amountInput); 186 | 187 | if (isNaN(amount) || amount <= 0) { 188 | console.log('❌ Error: Please enter a valid positive amount'); 189 | await this.continueOrExit(); 190 | return; 191 | } 192 | 193 | const transferId = `transfer-${Date.now()}`; 194 | 195 | const result = await TransferMoney.execute(this.eventStore, { 196 | fromAccountId, 197 | toAccountId, 198 | amount, 199 | transferId 200 | }); 201 | 202 | if (result.success) { 203 | console.log('✅ Money transferred successfully!'); 204 | console.log(`From: ${result.event.fromAccountId}`); 205 | console.log(`To: ${result.event.toAccountId}`); 206 | console.log(`Amount: ${result.event.amount} ${result.event.currency}`); 207 | console.log(`Transfer ID: ${result.event.transferId}`); 208 | } else { 209 | console.log('❌ Error:', result.error.message); 210 | } 211 | 212 | await this.continueOrExit(); 213 | } 214 | 215 | private async handleViewBalance() { 216 | console.log('\n📊 View Account Balance'); 217 | 218 | const accountId = await this.askQuestion('Account ID: '); 219 | 220 | const account = await GetAccount.execute(this.eventStore, { accountId }); 221 | 222 | if (account) { 223 | console.log('✅ Account Information:'); 224 | console.log(`Account ID: ${account.accountId}`); 225 | console.log(`Customer: ${account.customerName}`); 226 | console.log(`Type: ${account.accountType}`); 227 | console.log(`Balance: ${account.balance} ${account.currency}`); 228 | console.log(`Opened: ${new Date(account.openedAt).toISOString()}`); 229 | } else { 230 | console.log('❌ Account not found'); 231 | } 232 | 233 | await this.continueOrExit(); 234 | } 235 | 236 | private async continueOrExit() { 237 | console.log(); 238 | const choice = await this.askQuestion('Continue? (Y/n): '); 239 | 240 | if (choice === '' || choice.toLowerCase() === 'y' || choice.toLowerCase() === 'yes') { 241 | console.log(); 242 | await this.showMainMenu(); 243 | } else { 244 | console.log('Thank you for using the Banking System!'); 245 | await this.eventStore.close(); 246 | rl.close(); 247 | process.exit(0); 248 | } 249 | } 250 | 251 | private askQuestion(question: string): Promise { 252 | return new Promise((resolve) => { 253 | rl.question(question, (answer) => { 254 | resolve(answer.trim()); 255 | }); 256 | }); 257 | } 258 | } 259 | 260 | if (require.main === module) { 261 | const cli = new BankingCLI(); 262 | cli.start().catch(console.error); 263 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aggregateless Event Store with TypeScript and PostgreSQL 2 | 3 | A minimal TypeScript implementation of an aggregateless event sourcing system with PostgreSQL persistence. This approach eliminates DDD aggregates in favor of independent feature slices that rebuild minimal state on-demand from events. In general, we get rid of central, shared states and OO-paradigms. 4 | 5 | This is a practical implementation of the concepts described in [Aggregateless Event Sourcing](https://ricofritzsche.me/p/ec16995f-c69d-4946-83c7-efdf98835585/?member_status=free) and the step-by-step implementation guide [How I Built an Aggregateless Event Store with TypeScript and PostgreSQL](https://ricofritzsche.me/how-i-built-an-aggregateless-event-store-with-typescript-and-postgresql/). 6 | 7 | ## Quick Start 8 | 9 | ```bash 10 | npm install && npm run build 11 | echo "DATABASE_URL=postgres://postgres:postgres@localhost:5432/bank" > .env 12 | npm run cli 13 | ``` 14 | 15 | Start with opening a bank account, then try deposits, withdrawals, and transfers. Press Enter to continue between operations. 16 | 17 | ## Core Philosophy 18 | 19 | The **aggregateless approach** means no large shared object clusters in memory. Instead: 20 | 21 | - **Events as the only shared resource** - A single events table serves all features 22 | - **Independent feature slices** - Each feature queries events specific to its context 23 | - **Pure decision functions** - Business logic separated from I/O operations 24 | - **Optimistic locking via CTE** - Consistency without version numbers or row locks 25 | - **Minimal state reconstruction** - Load only what's needed for the current decision 26 | 27 | ## Key Features 28 | 29 | ### Functional Core Pattern 30 | Business logic is implemented as pure functions that are easy to test and reason about: 31 | 32 | ```typescript 33 | // Pure functions - no side effects 34 | function processOpenAccountCommand( 35 | command: OpenBankAccountCommand, 36 | accountId: string, 37 | existingCustomerNames?: string[] 38 | ): OpenAccountResult { 39 | const commandWithDefaults = { 40 | ...command, 41 | accountType: command.accountType || 'checking', 42 | currency: command.currency || 'USD' 43 | }; 44 | 45 | const validationError = validateOpenAccountCommand(commandWithDefaults); 46 | if (validationError) { 47 | return { success: false, error: validationError }; 48 | } 49 | 50 | // Check for unique customer name 51 | if (existingCustomerNames && existingCustomerNames.includes(commandWithDefaults.customerName.trim())) { 52 | return { 53 | success: false, 54 | error: { type: 'InvalidCustomerName', message: 'Customer name already exists' } 55 | }; 56 | } 57 | 58 | const event: BankAccountOpenedEvent = { 59 | type: 'BankAccountOpened', 60 | accountId, 61 | customerName: commandWithDefaults.customerName, 62 | accountType: commandWithDefaults.accountType, 63 | initialDeposit: commandWithDefaults.initialDeposit || 0, 64 | currency: commandWithDefaults.currency, 65 | openedAt: new Date() 66 | }; 67 | 68 | return { success: true, event }; 69 | } 70 | ``` 71 | 72 | ### Optimistic Locking 73 | Ensures consistency without traditional database locks by validating context hasn't changed: 74 | 75 | ```typescript 76 | // Query with specific context 77 | const filter = EventFilter 78 | .createFilter(['BankAccountOpened']) 79 | .withPayloadPredicate('accountId', accountId); 80 | 81 | const depositStateResult = await getDepositState(eventStore, command.accountId); 82 | const result = processDepositCommand(command, depositStateResult.state.existingDepositIds); 83 | 84 | // Append with same filter and captured sequence - fails if context changed 85 | await store.append(filter, newEvents, depositStateResult.maxSequenceNumber); 86 | ``` 87 | 88 | ### Payload-Based Querying 89 | Efficient event filtering using PostgreSQL JSONB containment operators with OR conditions: 90 | 91 | ```typescript 92 | // Unified query with multiple payload predicates (OR logic) 93 | const filter = EventFilter.createFilter( 94 | ['BankAccountOpened', 'MoneyDeposited', 'MoneyWithdrawn', 'MoneyTransferred'], 95 | [ 96 | { accountId: fromAccountId }, // Account events for source 97 | { accountId: toAccountId }, // Account events for target 98 | { toAccountId: fromAccountId }, // Transfers to source 99 | { fromAccountId: toAccountId }, // Transfers from target 100 | ] 101 | ); 102 | const result = await eventStore.query(filter); 103 | const events = result.events; 104 | 105 | // Generates SQL: WHERE event_type = ANY($1) AND (payload @> $2 OR payload @> $3 OR ...) 106 | ``` 107 | 108 | ## Architecture 109 | 110 | ### Core Interfaces 111 | 112 | ```typescript 113 | // Events must implement this interface 114 | interface HasEventType { 115 | eventType(): string; 116 | eventVersion?(): string; 117 | } 118 | 119 | // Main EventStore interface 120 | interface IEventStore { 121 | query(filter: EventFilter): Promise<{ events: T[]; maxSequenceNumber: number }>; 122 | append(filter: EventFilter, events: T[], expectedMaxSequence: number): Promise; 123 | close(): Promise; 124 | } 125 | ``` 126 | 127 | ### PostgreSQL Schema 128 | 129 | ```sql 130 | CREATE TABLE events ( 131 | sequence_number BIGSERIAL PRIMARY KEY, 132 | occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 133 | event_type TEXT NOT NULL, 134 | payload JSONB NOT NULL, 135 | metadata JSONB NOT NULL DEFAULT '{}' 136 | ); 137 | 138 | -- Optimized indexes for querying 139 | CREATE INDEX idx_events_type ON events(event_type); 140 | CREATE INDEX idx_events_occurred_at ON events(occurred_at); 141 | CREATE INDEX idx_events_payload_gin ON events USING gin(payload); 142 | ``` 143 | 144 | ### Optimistic Locking Implementation 145 | 146 | The append operation uses a CTE (Common Table Expression) to ensure atomicity and prevent race conditions: 147 | 148 | ```sql 149 | WITH context AS ( 150 | SELECT MAX(sequence_number) AS max_seq 151 | FROM events 152 | WHERE event_type = ANY($1) AND payload @> $2 153 | ) 154 | INSERT INTO events (event_type, payload, metadata) 155 | SELECT unnest($4::text[]), unnest($5::jsonb[]), unnest($6::jsonb[]) 156 | FROM context 157 | WHERE COALESCE(max_seq, 0) = $3 158 | ``` 159 | 160 | This ensures that: 161 | - Context validation and event insertion happen atomically 162 | - No events can be inserted if the context has changed 163 | - Multiple events can be inserted efficiently in a single operation 164 | - Race conditions between concurrent operations are prevented 165 | 166 | ## Getting Started 167 | 168 | ### Installation 169 | 170 | ```bash 171 | npm install 172 | ``` 173 | 174 | ### Database Setup 175 | 176 | 1. **Start PostgreSQL** (Docker example): 177 | ```bash 178 | docker run --name eventstore-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres -p 5432:5432 -d postgres:15 179 | ``` 180 | 181 | 2. **Create .env file**: 182 | ```bash 183 | echo "DATABASE_URL=postgres://postgres:postgres@localhost:5432/bank" > .env 184 | ``` 185 | 186 | The system will automatically create the `bank` database if it does not exist. 187 | 188 | ### Build the Project 189 | 190 | ```bash 191 | npm run build 192 | ``` 193 | 194 | ### Basic Usage 195 | 196 | ```typescript 197 | import { EventStore, EventFilter, HasEventType } from './src/eventstore'; 198 | 199 | // 1. Define your events 200 | class BankAccountOpenedEvent implements HasEventType { 201 | constructor( 202 | public readonly accountId: string, 203 | public readonly customerName: string, 204 | public readonly accountType: string, 205 | public readonly initialDeposit: number, 206 | public readonly currency: string, 207 | public readonly openedAt: Date = new Date() 208 | ) {} 209 | 210 | eventType(): string { 211 | return 'BankAccountOpened'; 212 | } 213 | } 214 | 215 | // 2. Create EventStore and migrate 216 | const store = new EventStore(); 217 | await store.migrate(); 218 | 219 | // 3. Store events with context 220 | const filter = EventFilter 221 | .createFilter(['BankAccountOpened']) 222 | .withPayloadPredicate('accountId', accountId); 223 | 224 | const events = [new BankAccountOpenedEvent(accountId, 'John Doe', 'checking', 100, 'USD')]; 225 | await store.append(filter, events, 0); 226 | 227 | // 4. Query events 228 | const result = await store.query(filter); 229 | const storedEvents = result.events; 230 | ``` 231 | 232 | ## Features 233 | 234 | ### Banking Domain Implementation 235 | 236 | The system includes five feature slices following Single Responsibility Principle: 237 | 238 | - **open-bank-account**: Creates new bank accounts with unique customer name validation 239 | - **deposit-money**: Handles money deposits with currency auto-detection 240 | - **withdraw-money**: Processes withdrawals with balance validation 241 | - **transfer-money**: Manages money transfers between accounts 242 | - **get-account**: Retrieves account information (note: for demonstration only - production should use read models) 243 | 244 | Each feature: 245 | - Uses single query pattern for efficient state building 246 | - Follows functional core/imperative shell architecture 247 | - Maintains complete independence from other features 248 | - Rebuilds state from events for decision making 249 | 250 | ### Running the Banking Example 251 | 252 | #### Interactive CLI 253 | ```bash 254 | npm run cli 255 | ``` 256 | 257 | This provides an interactive banking system where you can: 258 | - Open bank accounts with auto-generated UUIDs and unique customer names 259 | - Deposit money to accounts 260 | - Withdraw money from accounts 261 | - Transfer money between accounts 262 | - View account balances 263 | 264 | #### Unique Customer Name Test 265 | ```bash 266 | node test-unique-customer.js 267 | ``` 268 | 269 | This tests the unique customer name validation feature. 270 | 271 | #### End-to-End Test 272 | ```bash 273 | node test-all-operations.js 274 | ``` 275 | 276 | This demonstrates: 277 | - Account creation with UUID generation and unique customer name validation 278 | - Money deposits with automatic currency detection 279 | - Money withdrawals with balance validation 280 | - Money transfers between accounts 281 | - Insufficient funds error handling 282 | - Balance reconstruction from events 283 | - Single query pattern for efficient state building 284 | 285 | ## Usage Patterns 286 | 287 | ### 1. Command Handler Pattern 288 | 289 | ```typescript 290 | export async function execute( 291 | eventStore: IEventStore, 292 | command: DepositMoneyCommand 293 | ): Promise { 294 | // Single query to build complete state 295 | const depositStateResult = await getDepositState(eventStore, command.accountId); 296 | 297 | if (!depositStateResult.state.account) { 298 | return { 299 | success: false, 300 | error: { type: 'InvalidAmount', message: 'Account not found' } 301 | }; 302 | } 303 | 304 | // Use account's currency if not specified 305 | const effectiveCommand = { 306 | ...command, 307 | currency: command.currency || depositStateResult.state.account.currency 308 | }; 309 | 310 | // Pure business logic with complete state 311 | const result = processDepositCommand(effectiveCommand, depositStateResult.state.existingDepositIds); 312 | if (!result.success) { 313 | return result; 314 | } 315 | 316 | // Persist with optimistic locking 317 | try { 318 | const filter = EventFilter.createFilter(['MoneyDeposited']) 319 | .withPayloadPredicate('accountId', command.accountId); 320 | 321 | const event = new MoneyDepositedEvent( 322 | result.event.accountId, 323 | result.event.amount, 324 | result.event.currency, 325 | result.event.depositId, 326 | result.event.timestamp 327 | ); 328 | 329 | await eventStore.append(filter, [event], depositStateResult.maxSequenceNumber); 330 | return result; 331 | } catch (error) { 332 | return { 333 | success: false, 334 | error: { type: 'InvalidAmount', message: 'Failed to save deposit event' } 335 | }; 336 | } 337 | } 338 | ``` 339 | 340 | ### 2. Event Projections 341 | 342 | ```typescript 343 | // Build account state from events with single query 344 | async function getAccountViewState(eventStore: IEventStore, accountId: string): Promise<{ 345 | account: BankAccount | null; 346 | }> { 347 | // Single comprehensive query 348 | const filter = EventFilter.createFilter(['BankAccountOpened', 'MoneyDeposited', 'MoneyWithdrawn', 'MoneyTransferred']); 349 | const result = await eventStore.query(filter); 350 | 351 | // Filter for relevant events in memory 352 | const relevantEvents = result.events.filter(event => { 353 | const eventType = event.event_type || (event.eventType && event.eventType()); 354 | return ( 355 | (eventType === 'BankAccountOpened' && event.accountId === accountId) || 356 | (eventType === 'MoneyDeposited' && event.accountId === accountId) || 357 | (eventType === 'MoneyWithdrawn' && event.accountId === accountId) || 358 | (eventType === 'MoneyTransferred' && (event.fromAccountId === accountId || event.toAccountId === accountId)) 359 | ); 360 | }); 361 | 362 | const openingEvent = relevantEvents.find(e => 363 | (e.event_type || (e.eventType && e.eventType())) === 'BankAccountOpened' 364 | ); 365 | 366 | if (!openingEvent) { 367 | return { account: null }; 368 | } 369 | 370 | // Calculate current balance by folding events 371 | let currentBalance = openingEvent.initialDeposit; 372 | 373 | for (const event of relevantEvents) { 374 | const eventType = event.event_type || (event.eventType && event.eventType()); 375 | 376 | if (eventType === 'MoneyDeposited' && event.currency === openingEvent.currency) { 377 | currentBalance += event.amount; 378 | } else if (eventType === 'MoneyWithdrawn' && event.currency === openingEvent.currency) { 379 | currentBalance -= event.amount; 380 | } else if (eventType === 'MoneyTransferred' && event.currency === openingEvent.currency) { 381 | if (event.fromAccountId === accountId) { 382 | currentBalance -= event.amount; 383 | } else if (event.toAccountId === accountId) { 384 | currentBalance += event.amount; 385 | } 386 | } 387 | } 388 | 389 | return { 390 | account: { 391 | accountId: openingEvent.accountId, 392 | customerName: openingEvent.customerName, 393 | accountType: openingEvent.accountType, 394 | balance: currentBalance, 395 | currency: openingEvent.currency, 396 | openedAt: openingEvent.openedAt 397 | } 398 | }; 399 | } 400 | ``` 401 | 402 | ## Testing Strategy 403 | 404 | ### Unit Tests (Pure Functions) 405 | ```typescript 406 | describe('Deposit Money Logic', () => { 407 | it('should allow deposit to existing account', () => { 408 | const account = { accountId: 'acc-1', balance: 100, currency: 'USD' }; 409 | const command = { accountId: 'acc-1', amount: 50, depositId: 'dep-1' }; 410 | 411 | const result = processDepositCommand(command, account); 412 | 413 | expect(result.success).toBe(true); 414 | expect(result.event.amount).toBe(50); 415 | }); 416 | 417 | it('should reject negative amounts', () => { 418 | const account = { accountId: 'acc-1', balance: 100, currency: 'USD' }; 419 | const command = { accountId: 'acc-1', amount: -50, depositId: 'dep-1' }; 420 | 421 | const result = processDepositCommand(command, account); 422 | 423 | expect(result.success).toBe(false); 424 | expect(result.error.type).toBe('InvalidAmount'); 425 | }); 426 | }); 427 | ``` 428 | 429 | ### Integration Tests 430 | ```typescript 431 | describe('Banking System Integration', () => { 432 | it('should handle complete account lifecycle', async () => { 433 | const store = new EventStore(); 434 | await store.migrate(); 435 | 436 | // Open account 437 | const openResult = await OpenBankAccount.execute(store, { 438 | customerName: 'Alice', 439 | accountType: 'checking', 440 | initialDeposit: 500, 441 | currency: 'EUR' 442 | }); 443 | 444 | const accountId = openResult.event.accountId; 445 | 446 | // Deposit money 447 | await DepositMoney.execute(store, { 448 | accountId, 449 | amount: 200, 450 | depositId: 'deposit-1' 451 | }); 452 | 453 | // Withdraw money 454 | await WithdrawMoney.execute(store, { 455 | accountId, 456 | amount: 150, 457 | withdrawalId: 'withdrawal-1' 458 | }); 459 | 460 | // Check final balance 461 | const account = await GetAccount.execute(store, { accountId }); 462 | expect(account.balance).toBe(550); // 500 + 200 - 150 463 | }); 464 | }); 465 | ``` 466 | 467 | ## Configuration 468 | 469 | ### Environment Variables 470 | 471 | - `DATABASE_URL` - PostgreSQL connection string 472 | - Default: `"postgres://postgres:postgres@localhost:5432/bank"` 473 | 474 | ### TypeScript Configuration 475 | 476 | Requires `exactOptionalPropertyTypes: true` for proper type safety with optional properties. 477 | 478 | ## Performance Considerations 479 | 480 | - **Indexing**: JSONB GIN indexes enable fast payload queries 481 | - **Batching**: Use bulk operations for high-throughput scenarios 482 | - **Partitioning**: Consider table partitioning for very large event stores 483 | - **Connection Pooling**: Uses pg connection pooling by default 484 | 485 | ## Contributing 486 | 487 | 1. Fork the repository 488 | 2. Create a feature branch 489 | 3. Add tests for new functionality 490 | 4. Ensure all tests pass 491 | 5. Submit a pull request 492 | 493 | ## License 494 | 495 | MIT License - see LICENSE file for details --------------------------------------------------------------------------------