├── .eslintignore ├── .prettierignore ├── .gitignore ├── .firebaserc ├── .commitlintrc.js ├── example └── src │ ├── domain │ ├── index.ts │ └── cart │ │ ├── index.ts │ │ ├── state.ts │ │ ├── events.ts │ │ └── commands.ts │ ├── views │ ├── index.ts │ ├── reports.ts │ └── carts.ts │ ├── config.ts │ ├── flows │ ├── index.ts │ ├── if-order-placed-then-send-email.ts │ └── every-night-send-report-email.ts │ ├── services │ ├── index.ts │ └── email.ts │ ├── functions.ts │ └── client.ts ├── app └── package.json ├── .prettierrc.js ├── tsconfig.build.json ├── .lintstagedrc.js ├── functions └── package.json ├── src ├── types │ ├── service.ts │ ├── app.ts │ ├── misc.ts │ ├── aggregate.ts │ ├── flow.ts │ ├── event.ts │ ├── command.ts │ └── view.ts ├── utils │ ├── get-fully-qualified-event-name.ts │ ├── generate-id.ts │ └── flatten.ts ├── constants.ts ├── functions │ ├── index.ts │ ├── https │ │ ├── utils │ │ │ └── parse-location-from-headers.ts │ │ ├── middlewares │ │ │ └── auth.ts │ │ ├── commands.test.ts │ │ └── commands.ts │ ├── pubsub.ts │ └── firestore.ts ├── services │ ├── flow.ts │ ├── aggregates.ts │ ├── projections.ts │ └── logger.ts ├── index.ts ├── client.ts ├── stores │ ├── event-store.ts │ └── event-store.test.ts └── app.ts ├── .huskyrc.js ├── jest.config.js ├── firebase.json ├── tsconfig.json ├── tsup.config.js ├── scripts ├── test.js └── build-example.js ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── README.md ├── .eslintrc.js └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | dist 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .cache 4 | .vscode 5 | node_modules 6 | dist 7 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "fir-event-sourcing" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /example/src/domain/index.ts: -------------------------------------------------------------------------------- 1 | import { aggregate as cart } from './cart' 2 | 3 | export const domain = { 4 | cart, 5 | } 6 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/app.js", 3 | "module": "../dist/app.mjs", 4 | "typings": "../dist/app.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | semi: false, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src" 5 | }, 6 | "include": ["./src"] 7 | } 8 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.js': ['eslint', 'prettier --check'], 3 | '*.ts': ['eslint', 'prettier --check', () => 'tsc --noEmit'], 4 | } 5 | -------------------------------------------------------------------------------- /example/src/views/index.ts: -------------------------------------------------------------------------------- 1 | import { carts } from './carts' 2 | import { reports } from './reports' 3 | 4 | export const views = { 5 | carts, 6 | reports, 7 | } 8 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "../dist/functions/index.js", 3 | "module": "../dist/functions/index.mjs", 4 | "typings": "../dist/functions/index.d.ts" 5 | } 6 | -------------------------------------------------------------------------------- /src/types/service.ts: -------------------------------------------------------------------------------- 1 | import { LoggerService } from '../services/logger' 2 | 3 | export type ServiceContext = { 4 | logger: LoggerService 5 | } 6 | 7 | export interface Services {} 8 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS', 4 | 'pre-commit': 'yarn check --integrity && lint-staged', 5 | 'pre-push': 'yarn test', 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /example/src/config.ts: -------------------------------------------------------------------------------- 1 | const projectId = 'fir-event-sourcing' 2 | 3 | export const config = { 4 | firebase: { 5 | functionsUrl: `http://localhost:5001/${projectId}/us-central1`, 6 | projectId, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/get-fully-qualified-event-name.ts: -------------------------------------------------------------------------------- 1 | import { Event } from '../types/event' 2 | 3 | export const getFullyQualifiedEventName = (event: Event): string => { 4 | return `${event.aggregateName}.${event.name}` 5 | } 6 | -------------------------------------------------------------------------------- /example/src/domain/cart/index.ts: -------------------------------------------------------------------------------- 1 | import * as commands from './commands' 2 | import * as events from './events' 3 | import { getInitialState } from './state' 4 | 5 | export const aggregate = { 6 | getInitialState, 7 | commands, 8 | events, 9 | } 10 | -------------------------------------------------------------------------------- /example/src/flows/index.ts: -------------------------------------------------------------------------------- 1 | import { everyNightSendReportEmail } from './every-night-send-report-email' 2 | import { ifOrderPlacedThenSendEmail } from './if-order-placed-then-send-email' 3 | 4 | export const flows = { 5 | everyNightSendReportEmail, 6 | ifOrderPlacedThenSendEmail, 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'], 3 | modulePathIgnorePatterns: ['dist'], 4 | testMatch: ['**/*.test.{js,jsx,ts,tsx}'], 5 | testTimeout: 10000, 6 | transform: { 7 | '\\.(ts|tsx)$': 'ts-jest', 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | const CREATE = Symbol('CREATE') 2 | const UPDATE = Symbol('UPDATE') 3 | const DELETE = Symbol('DELETE') 4 | const WRITE = Symbol('WRITE') 5 | 6 | export const Trigger = { 7 | CREATE, 8 | UPDATE, 9 | DELETE, 10 | WRITE, 11 | } as const 12 | 13 | export type Trigger = typeof Trigger[keyof typeof Trigger] 14 | -------------------------------------------------------------------------------- /example/src/domain/cart/state.ts: -------------------------------------------------------------------------------- 1 | import { GetInitialAggregateState } from '../../../../src' 2 | 3 | declare global { 4 | namespace Domain.Cart { 5 | type State = { 6 | isPlaced: boolean 7 | } 8 | } 9 | } 10 | 11 | export const getInitialState: GetInitialAggregateState = 12 | () => ({ 13 | isPlaced: false, 14 | }) 15 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "functions": { 4 | "port": 5001 5 | }, 6 | "firestore": { 7 | "port": 8080 8 | }, 9 | "ui": { 10 | "enabled": true 11 | } 12 | }, 13 | "functions": { 14 | "ignore": [".cache", "node_modules", "src"], 15 | "runtime": "nodejs12", 16 | "source": "./example/dist" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/src/services/index.ts: -------------------------------------------------------------------------------- 1 | import { ServiceContext, Services } from '../../../src' 2 | 3 | import { createEmailService } from './email' 4 | 5 | declare module '../../../src' { 6 | interface Services { 7 | email: Services.Email.Instance 8 | } 9 | } 10 | 11 | export const services = (ctx: ServiceContext): Services => ({ 12 | email: createEmailService(ctx), 13 | }) 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "importHelpers": true, 6 | "lib": ["dom", "esnext"], 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "noImplicitReturns": true, 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "strict": true 13 | }, 14 | "exclude": ["./dist", "./node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /tsup.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tsup').Options} */ 2 | module.exports = { 3 | dts: { 4 | entry: { 5 | index: './src/index.ts', 6 | app: './src/app.ts', 7 | 'functions/index': './src/functions/index.ts', 8 | }, 9 | }, 10 | entryPoints: ['./src/index.ts', './src/app.ts', './src/functions/index.ts'], 11 | format: ['cjs', 'esm'], 12 | keepNames: true, 13 | splitting: false, 14 | } 15 | -------------------------------------------------------------------------------- /example/src/flows/if-order-placed-then-send-email.ts: -------------------------------------------------------------------------------- 1 | import { FlowDefinition } from '../../../src' 2 | 3 | export const ifOrderPlacedThenSendEmail: FlowDefinition = 4 | { 5 | reactions: { 6 | 'cart.orderPlaced': async (event, ctx) => { 7 | await ctx.email.send({ 8 | to: 'customer@example.com', 9 | body: 'Order placed with success!', 10 | }) 11 | }, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/generate-id.ts: -------------------------------------------------------------------------------- 1 | // Adapted from: https://github.com/invertase/react-native-firebase/blob/6783245e19f81297363fc56a53063c8a053782b8/packages/app/lib/common/id.js 2 | export const generateId = (): string => { 3 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 4 | let autoId = '' 5 | for (let i = 0; i < 20; i++) { 6 | autoId += chars.charAt(Math.floor(Math.random() * chars.length)) 7 | } 8 | return autoId 9 | } 10 | -------------------------------------------------------------------------------- /example/src/services/email.ts: -------------------------------------------------------------------------------- 1 | import { ServiceContext } from '../../../src' 2 | 3 | declare global { 4 | namespace Services.Email { 5 | type Instance = ReturnType 6 | } 7 | } 8 | 9 | export const createEmailService = (ctx: ServiceContext) => { 10 | return { 11 | send: async ({ to, body }: { to: string; body: string }): Promise => { 12 | await new Promise(resolve => setTimeout(resolve, 200)) 13 | console.log(`Sent email to ${to}`) 14 | }, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /example/src/functions.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { createFunctions } from '../../src/functions' 4 | import { domain } from './domain' 5 | import { flows } from './flows' 6 | import { services } from './services' 7 | import { views } from './views' 8 | 9 | const firebaseApp = firebase.initializeApp() 10 | 11 | firebaseApp.firestore().settings({ 12 | ignoreUndefinedProperties: true, 13 | }) 14 | 15 | module.exports = createFunctions(firebaseApp, { 16 | domain, 17 | flows, 18 | views, 19 | services, 20 | }) 21 | -------------------------------------------------------------------------------- /src/types/app.ts: -------------------------------------------------------------------------------- 1 | import { AggregateDefinition } from './aggregate' 2 | import { Event } from './event' 3 | import { FlowDefinition } from './flow' 4 | import { ServiceContext, Services } from './service' 5 | import { ViewDefinition } from './view' 6 | 7 | export type AppDefinition = { 8 | domain: { 9 | [aggregateName: string]: AggregateDefinition 10 | } 11 | 12 | flows: { 13 | [flowName: string]: FlowDefinition 14 | } 15 | 16 | views: { 17 | [viewName: string]: ViewDefinition> 18 | } 19 | 20 | services?: (ctx: ServiceContext) => Services 21 | } 22 | -------------------------------------------------------------------------------- /src/functions/index.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { AppDefinition } from '../types/app' 4 | import { createCommandsEndpoint } from './https/commands' 5 | import { createPubsubFunctions } from './pubsub' 6 | 7 | export const createFunctions = ( 8 | firebaseApp: firebase.app.App, 9 | appDefinition: TAppDefinition, 10 | ) => { 11 | const pubsubFunctions = createPubsubFunctions(firebaseApp, appDefinition) 12 | 13 | return { 14 | commands: createCommandsEndpoint(firebaseApp, appDefinition), 15 | ...pubsubFunctions, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /example/src/client.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app' 2 | 3 | import { createClient } from '../../src' 4 | import { config } from './config' 5 | import type { domain } from './domain' 6 | import type { flows } from './flows' 7 | import type { views } from './views' 8 | 9 | type AppDefinition = { 10 | domain: typeof domain 11 | flows: typeof flows 12 | views: typeof views 13 | } 14 | 15 | export const createEventSourcingClient = () => { 16 | const client = createClient(firebase.app(), { 17 | functionsUrl: config.firebase.functionsUrl, 18 | }) 19 | 20 | return client 21 | } 22 | -------------------------------------------------------------------------------- /src/types/misc.ts: -------------------------------------------------------------------------------- 1 | export type Location = { 2 | city: string | null 3 | region: string | null 4 | country: string | null 5 | coordinates: { 6 | latitude: number 7 | longitude: number 8 | } | null 9 | } 10 | 11 | export type ClientInfo = { 12 | ip: string 13 | ua: string 14 | location: Location | null 15 | } 16 | 17 | export type Promisable = T | Promise 18 | 19 | export type Split< 20 | TString extends string, 21 | TDelimiter extends string, 22 | > = TString extends `${infer THead}${TDelimiter}${infer TTail}` 23 | ? [THead, ...Split] 24 | : [TString] 25 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { execSync } = require('child_process') 3 | 4 | const jestArgs = process.argv.slice(2).join(' ') 5 | 6 | const matchedTestFiles = execSync(`jest --listTests ${jestArgs}`) 7 | .toString() 8 | .split('\n') 9 | .filter(Boolean) 10 | 11 | if (matchedTestFiles.length === 0) { 12 | process.exit(0) 13 | } 14 | 15 | execSync('./scripts/build-example.js', { 16 | stdio: 'inherit', 17 | }) 18 | 19 | const jestCommand = `jest --passWithNoTests --runInBand ${jestArgs}` 20 | 21 | execSync(`NODE_ENV=test yarn firebase emulators:exec "${jestCommand}"`, { 22 | stdio: 'inherit', 23 | }) 24 | -------------------------------------------------------------------------------- /example/src/views/reports.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { ViewDefinition } from '../../../src' 4 | 5 | export const TOTALS_ID = 'totals' 6 | 7 | declare global { 8 | namespace Views.Reports { 9 | type Report = { 10 | id: string 11 | orderCount: number 12 | } 13 | } 14 | } 15 | 16 | export const reports: ViewDefinition< 17 | Views.Reports.Report, 18 | Domain.Cart.OrderPlaced 19 | > = { 20 | collectionName: 'reports', 21 | 22 | projections: { 23 | 'cart.orderPlaced': event => ({ 24 | id: TOTALS_ID, 25 | orderCount: firebase.firestore.FieldValue.increment(1) as any, 26 | }), 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /example/src/flows/every-night-send-report-email.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { FlowDefinition } from '../../../src' 4 | import { TOTALS_ID, reports as reportsView } from '../views/reports' 5 | 6 | export const everyNightSendReportEmail: FlowDefinition = { 7 | cron: { 8 | 'every day 01:00': async ctx => { 9 | const reportSnap = await firebase 10 | .firestore() 11 | .collection(reportsView.collectionName) 12 | .doc(TOTALS_ID) 13 | .get() 14 | const report = reportSnap.data() as Views.Reports.Report 15 | 16 | await ctx.email.send({ 17 | to: 'admin@example.com', 18 | body: `We have ${report.orderCount} orders until now.`, 19 | }) 20 | }, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/flatten.ts: -------------------------------------------------------------------------------- 1 | const isPlainObject = (value: any): boolean => { 2 | if (typeof value !== 'object') { 3 | return false 4 | } 5 | 6 | if (value === null) { 7 | return false 8 | } 9 | 10 | const proto = Object.getPrototypeOf(value) 11 | return proto === Object.prototype || proto === null 12 | } 13 | 14 | export const flatten = ( 15 | object: T, 16 | path?: string, 17 | ): T => { 18 | return Object.keys(object).reduce((acc: T, key: string): T => { 19 | const newPath = [path, key].filter(Boolean).join('.') 20 | return isPlainObject(object[key]) && Object.keys(object[key]).length > 0 21 | ? { ...acc, ...flatten(object[key], newPath) } 22 | : { ...acc, [newPath]: object[key] } 23 | }, {} as T) 24 | } 25 | -------------------------------------------------------------------------------- /src/services/flow.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { Command, CommandWithMetadata } from '../types/command' 4 | import { Event } from '../types/event' 5 | 6 | export type FlowService = ReturnType 7 | 8 | export const createFlowService = ( 9 | dispatch: (command: CommandWithMetadata) => Promise, 10 | causationEvent: Event | null, 11 | ) => { 12 | return { 13 | dispatch: async ( 14 | command: TCommand, 15 | ): Promise => { 16 | await dispatch({ 17 | ...command, 18 | metadata: { 19 | causationId: causationEvent?.id ?? null, 20 | correlationId: causationEvent?.metadata.correlationId ?? null, 21 | userId: 'system', 22 | timestamp: firebase.firestore.Timestamp.now(), 23 | client: null, 24 | }, 25 | }) 26 | }, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scripts/build-example.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs') 4 | const { join } = require('path') 5 | const esbuild = require('esbuild') 6 | const { nodeExternalsPlugin } = require('esbuild-node-externals') 7 | 8 | const main = async () => { 9 | await esbuild.build({ 10 | bundle: true, 11 | entryPoints: ['./example/src/functions.ts'], 12 | logLevel: 'info', 13 | outdir: './example/dist', 14 | platform: 'node', 15 | plugins: [nodeExternalsPlugin()], 16 | sourcemap: true, 17 | target: 'node12', 18 | }) 19 | 20 | const packageJson = { 21 | main: './functions', 22 | dependencies: { 23 | express: '4.17.1', 24 | 'firebase-admin': '9.0.0', 25 | 'firebase-functions': '3.9.0', 26 | }, 27 | } 28 | 29 | fs.writeFileSync( 30 | join(process.cwd(), 'example', 'dist', 'package.json'), 31 | JSON.stringify(packageJson), 32 | ) 33 | } 34 | 35 | main() 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - uses: actions/cache@v2 17 | with: 18 | path: | 19 | ./example/node_modules 20 | ./node_modules 21 | key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} 22 | restore-keys: | 23 | ${{ runner.os }}- 24 | - run: yarn --frozen-lockfile 25 | 26 | - run: yarn lint 27 | 28 | - run: yarn build 29 | 30 | - run: yarn test 31 | env: 32 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 33 | 34 | - run: npx semantic-release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /src/functions/https/utils/parse-location-from-headers.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | 3 | import { Location } from '../../../types/misc' 4 | 5 | export const parseLocationFromHeaders = (req: Request): Location | null => { 6 | const city = req.header('X-Appengine-City') || null 7 | const region = req.header('X-Appengine-Region') || null 8 | const country = req.header('X-Appengine-Country') || null 9 | 10 | let coordinates: { latitude: number; longitude: number } | null = null 11 | const latLongHeader = req.header('X-Appengine-CityLatLong') 12 | if (latLongHeader) { 13 | const [latitude, longitude] = latLongHeader.split(',').map(Number) 14 | coordinates = { latitude, longitude } 15 | } 16 | 17 | if (!city && !region && !country && !coordinates) { 18 | return null 19 | } 20 | 21 | return { 22 | city, 23 | region: region?.toLowerCase() ?? null, 24 | country: country?.toLowerCase() ?? null, 25 | coordinates, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/types/aggregate.ts: -------------------------------------------------------------------------------- 1 | import { Command, CommandDefinition } from './command' 2 | import { Event, EventDefinition } from './event' 3 | 4 | export type AggregateState = { 5 | [key: string]: any 6 | } 7 | 8 | export type AggregateData< 9 | TAggregateState extends AggregateState = AggregateState, 10 | > = { 11 | id: string 12 | revision: number 13 | state: TAggregateState 14 | } 15 | 16 | export type Aggregate = 17 | AggregateData & { 18 | exists: boolean 19 | } 20 | 21 | export type GetInitialAggregateState = 22 | () => TAggregateState 23 | 24 | export type AggregateDefinition = { 25 | getInitialState?: GetInitialAggregateState 26 | 27 | commands: { 28 | [commandName: string]: CommandDefinition, Event> 29 | } 30 | 31 | events: { 32 | [eventName: string]: EventDefinition> 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createClient } from './client' 2 | export { Trigger } from './constants' 3 | export { flatten } from './utils/flatten' 4 | export { generateId } from './utils/generate-id' 5 | 6 | export type { 7 | Aggregate, 8 | AggregateData, 9 | AggregateDefinition, 10 | AggregateState, 11 | GetInitialAggregateState, 12 | } from './types/aggregate' 13 | export type { 14 | Command, 15 | CommandContext, 16 | CommandCreationProps, 17 | CommandData, 18 | CommandDefinition, 19 | CommandMetadata, 20 | CommandPreset, 21 | CommandWithMetadata, 22 | } from './types/command' 23 | export type { 24 | Event, 25 | EventCreationProps, 26 | EventData, 27 | EventDefinition, 28 | EventMetadata, 29 | EventPreset, 30 | } from './types/event' 31 | export type { FlowDefinition } from './types/flow' 32 | export type { ServiceContext, Services } from './types/service' 33 | export type { 34 | ViewDefinition, 35 | ViewProjectionContext, 36 | ViewReactionContext, 37 | } from './types/view' 38 | -------------------------------------------------------------------------------- /src/types/flow.ts: -------------------------------------------------------------------------------- 1 | import { FlowService } from '../services/flow' 2 | import { LoggerService } from '../services/logger' 3 | import { Event, ExtractFullyQualifiedEventName } from './event' 4 | import { Split } from './misc' 5 | import { Services } from './service' 6 | 7 | export type FlowContext = Services & { 8 | flow: FlowService 9 | logger: LoggerService 10 | } 11 | 12 | export type FlowCronHandler = (context: FlowContext) => Promise 13 | 14 | export type FlowReactionHandler = ( 15 | event: TEvent, 16 | context: FlowContext, 17 | ) => Promise 18 | 19 | export type FlowDefinition = { 20 | cron?: { 21 | [jobName: string]: FlowCronHandler 22 | } 23 | 24 | reactions?: { 25 | [eventName in ExtractFullyQualifiedEventName]: FlowReactionHandler< 26 | TEvent extends { 27 | aggregateName: Split[0] 28 | name: Split[1] 29 | } 30 | ? TEvent 31 | : never 32 | > 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /example/src/domain/cart/events.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventCreationProps, 3 | EventDefinition, 4 | EventPreset, 5 | } from '../../../../src' 6 | 7 | type Event = EventPreset<'cart', T> 8 | 9 | declare global { 10 | namespace Domain.Cart { 11 | type Initialized = Event<{ 12 | name: 'initialized' 13 | data: null 14 | }> 15 | 16 | type ItemAdded = Event<{ 17 | name: 'itemAdded' 18 | data: { title: string } 19 | }> 20 | 21 | type ItemRemoved = Event<{ 22 | name: 'itemRemoved' 23 | data: { itemId: string } 24 | }> 25 | 26 | type OrderPlaced = Event<{ 27 | name: 'orderPlaced' 28 | data: null 29 | }> 30 | 31 | type Discarded = Event<{ 32 | name: 'discarded' 33 | data: null 34 | }> 35 | } 36 | } 37 | 38 | export const orderPlaced: EventDefinition< 39 | Domain.Cart.State, 40 | Domain.Cart.OrderPlaced 41 | > = { 42 | handle: (cart, event) => { 43 | return { 44 | isPlaced: true, 45 | } 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Gustavo P. Cardoso 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. 22 | -------------------------------------------------------------------------------- /src/services/aggregates.ts: -------------------------------------------------------------------------------- 1 | import { EventStore } from '../stores/event-store' 2 | import { Aggregate } from '../types/aggregate' 3 | 4 | export type AggregatesService = ReturnType 5 | 6 | export const createAggregatesService = (eventStore: EventStore) => { 7 | return { 8 | exists: async ( 9 | aggregateIdOrIds: string | string[] | null, 10 | ): Promise => { 11 | if (!aggregateIdOrIds) { 12 | return false 13 | } 14 | 15 | const aggregateIds = Array.isArray(aggregateIdOrIds) 16 | ? aggregateIdOrIds 17 | : [aggregateIdOrIds] 18 | 19 | const aggregates = await Promise.all( 20 | aggregateIds.map(id => eventStore.getAggregate(id)), 21 | ) 22 | 23 | return aggregates.every(Boolean) 24 | }, 25 | 26 | get: async (aggregateId: string): Promise => { 27 | return eventStore.getAggregate(aggregateId) 28 | }, 29 | 30 | getAll: async (aggregateIds: string[]): Promise => { 31 | return ( 32 | await Promise.all(aggregateIds.map(id => eventStore.getAggregate(id))) 33 | ).filter((aggregate): aggregate is Aggregate => Boolean(aggregate)) 34 | }, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /example/src/views/carts.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { ViewDefinition, generateId } from '../../../src' 4 | 5 | declare global { 6 | namespace Views.Carts { 7 | type Status = 'open' | 'placed' 8 | 9 | type Item = { 10 | title: string 11 | } 12 | 13 | type Cart = { 14 | id: string 15 | initializedAt: number 16 | placedAt: number | null 17 | status: Status 18 | items: { [id: string]: Item } 19 | } 20 | } 21 | } 22 | 23 | export const carts: ViewDefinition< 24 | Views.Carts.Cart, 25 | | Domain.Cart.Initialized 26 | | Domain.Cart.ItemAdded 27 | | Domain.Cart.ItemRemoved 28 | | Domain.Cart.OrderPlaced 29 | | Domain.Cart.Discarded 30 | > = { 31 | collectionName: 'carts', 32 | 33 | projections: { 34 | 'cart.initialized': event => ({ 35 | id: event.aggregateId, 36 | initializedAt: event.metadata.timestamp.toMillis(), 37 | placedAt: null, 38 | status: 'open', 39 | items: {}, 40 | }), 41 | 42 | 'cart.itemAdded': event => ({ 43 | items: { 44 | [generateId()]: { 45 | title: event.data.title, 46 | }, 47 | }, 48 | }), 49 | 50 | 'cart.itemRemoved': event => ({ 51 | items: { 52 | [event.data.itemId]: firebase.firestore.FieldValue.delete() as any, 53 | }, 54 | }), 55 | 56 | 'cart.orderPlaced': event => ({ 57 | placedAt: event.metadata.timestamp.toMillis(), 58 | status: 'placed', 59 | }), 60 | 61 | 'cart.discarded': event => null, 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /src/services/projections.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { ViewProjectionState } from '../types/view' 4 | 5 | export type ProjectionsService = ReturnType 6 | 7 | export const createProjectionsService = (firebaseApp: firebase.app.App) => { 8 | const db = firebaseApp.firestore() 9 | 10 | const get = async ( 11 | collectionName: string, 12 | projectionId: string, 13 | ): Promise => { 14 | const doc = await db 15 | .collection(collectionName) 16 | .doc(projectionId) 17 | .get() 18 | .then(snap => snap.data() ?? null) 19 | 20 | return doc 21 | } 22 | 23 | const getAll = async ( 24 | collectionName: string, 25 | projectionIds: string[], 26 | ): Promise => { 27 | const docs = ( 28 | await Promise.all(projectionIds.map(id => get(collectionName, id))) 29 | ).filter((doc): doc is ViewProjectionState => Boolean(doc)) 30 | 31 | return docs 32 | } 33 | 34 | const query = async ( 35 | collectionName: string, 36 | query?: ( 37 | collection: firebase.firestore.CollectionReference, 38 | ) => firebase.firestore.Query, 39 | ): Promise => { 40 | const collection = db.collection(collectionName) 41 | 42 | const snap = await (query ? query(collection).get() : collection.get()) 43 | const docs = snap.docs.map(snap => snap.data()) 44 | 45 | return docs 46 | } 47 | 48 | return { 49 | get, 50 | getAll, 51 | query, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app' 2 | import 'firebase/auth' 3 | 4 | import { AppDefinition } from './types/app' 5 | 6 | export type Client = { 7 | dispatch: < 8 | TAggregateName extends keyof TAppDefinition['domain'], 9 | TCommandName extends keyof TAppDefinition['domain'][TAggregateName]['commands'], 10 | >( 11 | aggregateName: TAggregateName, 12 | commandName: TCommandName, 13 | aggregateId: string, 14 | data: Parameters[1]['data'], // prettier-ignore 15 | ) => Promise<{ eventIds: string[] }> 16 | } 17 | 18 | export const createClient = ( 19 | firebaseApp: firebase.app.App, 20 | options: { 21 | functionsUrl: string 22 | }, 23 | ): Client => { 24 | return { 25 | dispatch: async (aggregateName, commandName, aggregateId, data) => { 26 | const idToken = await firebaseApp.auth().currentUser?.getIdToken() 27 | 28 | const res = await fetch(`${options.functionsUrl}/commands`, { 29 | method: 'POST', 30 | headers: { 31 | 'Content-Type': 'application/json', 32 | ...(idToken ? { Authorization: `Bearer ${idToken}` } : {}), 33 | }, 34 | body: JSON.stringify({ 35 | aggregateName, 36 | aggregateId, 37 | name: commandName, 38 | data, 39 | }), 40 | }) 41 | 42 | if (!res.ok) { 43 | throw new Error('Command rejected') 44 | } 45 | 46 | return res.json() 47 | }, 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/types/event.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { Aggregate, AggregateState } from './aggregate' 4 | import { ClientInfo } from './misc' 5 | 6 | export type EventData = { 7 | [key: string]: any 8 | } | null 9 | 10 | export type EventMetadata = { 11 | causationId: string 12 | correlationId: string 13 | userId: string 14 | timestamp: firebase.firestore.Timestamp 15 | revision: number 16 | client: ClientInfo | null 17 | } 18 | 19 | export type Event< 20 | TProps extends { 21 | aggregateName: string 22 | name: string 23 | data: EventData 24 | } = { 25 | aggregateName: string 26 | name: string 27 | data: EventData 28 | }, 29 | > = { 30 | aggregateName: TProps['aggregateName'] 31 | aggregateId: string 32 | id: string 33 | name: TProps['name'] 34 | data: TProps['data'] 35 | metadata: EventMetadata 36 | } 37 | 38 | export type EventCreationProps = 39 | TEvent extends any ? Pick : never 40 | 41 | export type EventPreset< 42 | TAggregateName extends string, 43 | TEventCreationProps extends EventCreationProps, 44 | > = Event<{ 45 | aggregateName: TAggregateName 46 | name: TEventCreationProps['name'] 47 | data: TEventCreationProps['data'] 48 | }> 49 | 50 | export type EventHandler = ( 51 | aggregate: Pick, 52 | event: TEvent, 53 | ) => Partial 54 | 55 | export type EventDefinition< 56 | TAggregateState extends AggregateState, 57 | TEvent extends Event, 58 | > = { 59 | handle: EventHandler, TEvent> 60 | } 61 | 62 | export type ExtractFullyQualifiedEventName = 63 | TEvent extends any ? `${TEvent['aggregateName']}.${TEvent['name']}` : never 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase Event Sourcing 2 | 3 | ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/gustavopch/firebase-event-sourcing-alt/CI?style=flat-square) 4 | 5 | > Event Sourcing + CQRS + DDD for Firebase. 6 | 7 | ## Basic concepts 8 | 9 | **Event Sourcing**: Think of a bank account. You can't simply store the current balance. Instead, you must store the records (or the **events**) that have led up to the current balance (or the **state**). The events (`deposited 100 dollars`, `withdrawn 50 dollars`, etc.) are the source of truth. The state (the current balance) is just derived from the events. 10 | 11 | **CQRS**: It means Command Query Responsibility Segregation. All those words... it must be a pretty hard concept, doesn't it? Nah, it just means that the part of your system that's responsible for writing data will be separated from the part that's in charge of reading data. So when you write data, you'll be writing events, but when you need to read data, you'll read projections (the data derived from the events). For example, whenever you call the `deposit({ to: 'john', amount: 100 })` command, a `deposited 100 dollars to John's account` event will be recorded. In the background, that will trigger a function (a projection handler) that will update the `balance` of John's account in the `accounts` collection. Did you see it? You wrote to the `events` collection, but you'll read from the `accounts` collection. 12 | 13 | **DDD**: It means Domain-Driven Design. It's a hard way to say you'll mostly name things in your code exactly how other non-tech people name them. Don't worry, you'll see that in action. 14 | 15 | ## Installation 16 | 17 | ```sh 18 | npm i firebase-event-sourcing 19 | ``` 20 | 21 | ```sh 22 | yarn add firebase-event-sourcing 23 | ``` 24 | 25 | ## Usage 26 | 27 | WIP. 28 | 29 | ## License 30 | 31 | Released under the [MIT License](./LICENSE.md). 32 | -------------------------------------------------------------------------------- /src/functions/https/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express' 2 | import firebase from 'firebase-admin' 3 | import * as functions from 'firebase-functions' 4 | 5 | declare global { 6 | // eslint-disable-next-line @typescript-eslint/no-namespace 7 | namespace Express { 8 | interface Request { 9 | userId: string 10 | } 11 | } 12 | } 13 | 14 | export const auth = (firebaseApp: firebase.app.App): RequestHandler => { 15 | return async (req, res, next) => { 16 | // Testing? 17 | if (process.env.NODE_ENV === 'test') { 18 | req.userId = 'test' 19 | next() 20 | return 21 | } 22 | 23 | const authorization = req.header('Authorization') 24 | if (!authorization) { 25 | console.log("Missing the 'Authorization' header") 26 | res.status(403).send('Unauthorized') 27 | return 28 | } 29 | 30 | if (authorization.startsWith('ApiKey ')) { 31 | const { apiKey } = functions.config() 32 | if (!apiKey) { 33 | console.warn("Missing the 'apiKey' environment configuration") 34 | res.status(403).send('Unauthorized') 35 | return 36 | } 37 | 38 | const [, incomingApiKey] = authorization.split('ApiKey ') 39 | if (incomingApiKey !== apiKey) { 40 | console.log(`Wrong API key: ${incomingApiKey}`) 41 | res.status(403).send('Unauthorized') 42 | return 43 | } 44 | 45 | req.userId = 'system' 46 | next() 47 | return 48 | } 49 | 50 | if (authorization.startsWith('Bearer ')) { 51 | try { 52 | const [, incomingIdToken] = authorization.split('Bearer ') 53 | const decodedIdToken = await firebaseApp 54 | .auth() 55 | .verifyIdToken(incomingIdToken) 56 | req.userId = decodedIdToken.uid 57 | next() 58 | return 59 | } catch (error) { 60 | console.log(`Couldn't verify the Firebase ID token: ${error}`) 61 | res.status(403).send('Unauthorized') 62 | return 63 | } 64 | } 65 | 66 | console.log(`Unexpected 'Authorization' header: ${authorization}`) 67 | res.status(403).send('Unauthorized') 68 | return 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/services/logger.ts: -------------------------------------------------------------------------------- 1 | import { Logging } from '@google-cloud/logging' 2 | import { Request } from 'express' 3 | 4 | import { generateId } from '../utils/generate-id' 5 | 6 | export type LogSeverity = 'debug' | 'info' | 'warn' | 'error' 7 | 8 | export type LoggerService = ReturnType 9 | 10 | export const createLoggerService = (req: Request | null) => { 11 | const logging = new Logging() 12 | const log = logging.log('global') 13 | 14 | const writeLogEntry = ( 15 | severity: LogSeverity, 16 | scope: string, 17 | message: string, 18 | body?: TBody, 19 | options?: { 20 | timestamp?: Date 21 | labels?: { [key: string]: string | number | boolean } 22 | }, 23 | ) => { 24 | const timestamp = options?.timestamp ?? new Date() 25 | 26 | void log.write( 27 | // Docs: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry 28 | log.entry( 29 | { 30 | timestamp, 31 | httpRequest: { 32 | remoteIp: req?.ip, 33 | userAgent: req?.header('User-Agent'), 34 | }, 35 | labels: { 36 | ...options?.labels, 37 | scope, 38 | reqId: generateId(), 39 | userId: req?.userId ?? 'unknown', 40 | }, 41 | resource: { 42 | type: 'global', 43 | }, 44 | severity: { 45 | debug: 'DEBUG', 46 | info: 'INFO', 47 | warn: 'WARNING', 48 | error: 'ERROR', 49 | }[severity], 50 | }, 51 | { 52 | message, 53 | data: body, 54 | }, 55 | ), 56 | ) 57 | } 58 | 59 | return { 60 | debug: writeLogEntry.bind(null, 'debug'), 61 | info: writeLogEntry.bind(null, 'info'), 62 | warn: writeLogEntry.bind(null, 'warn'), 63 | error: writeLogEntry.bind(null, 'error'), 64 | 65 | withScope: (scope: string) => ({ 66 | debug: writeLogEntry.bind(null, 'debug', scope), 67 | info: writeLogEntry.bind(null, 'info', scope), 68 | warn: writeLogEntry.bind(null, 'warn', scope), 69 | error: writeLogEntry.bind(null, 'error', scope), 70 | }), 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/types/command.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { AggregatesService } from '../services/aggregates' 4 | import { LoggerService } from '../services/logger' 5 | import { Aggregate, AggregateState } from './aggregate' 6 | import { Event, EventCreationProps } from './event' 7 | import { ClientInfo, Promisable } from './misc' 8 | import { Services } from './service' 9 | 10 | export type CommandData = { 11 | [key: string]: any 12 | } | null 13 | 14 | export type CommandMetadata = { 15 | causationId: string | null 16 | correlationId: string | null 17 | userId: string 18 | timestamp: firebase.firestore.Timestamp 19 | client: ClientInfo | null 20 | } 21 | 22 | export type Command< 23 | TProps extends { 24 | aggregateName: string 25 | name: string 26 | data: CommandData 27 | } = { 28 | aggregateName: string 29 | name: string 30 | data: CommandData 31 | }, 32 | > = { 33 | aggregateName: TProps['aggregateName'] 34 | aggregateId: string 35 | name: TProps['name'] 36 | data: TProps['data'] 37 | } 38 | 39 | export type CommandWithMetadata = Command & { 40 | metadata: CommandMetadata 41 | } 42 | 43 | export type CommandCreationProps = 44 | TCommand extends any ? Pick : never 45 | 46 | export type CommandPreset< 47 | TAggregateName extends string, 48 | TEventCreationProps extends EventCreationProps, 49 | > = Command<{ 50 | aggregateName: TAggregateName 51 | name: TEventCreationProps['name'] 52 | data: TEventCreationProps['data'] 53 | }> 54 | 55 | export type CommandContext = Services & { 56 | aggregates: AggregatesService 57 | logger: LoggerService 58 | } 59 | 60 | export type CommandHandler< 61 | TAggregate extends Aggregate, 62 | TCommand extends Command, 63 | TEvent extends Event, 64 | > = ( 65 | aggregate: TAggregate, 66 | command: TCommand & { metadata: CommandMetadata }, 67 | context: CommandContext, 68 | ) => Promisable | Array>> 69 | 70 | export type CommandDefinition< 71 | TAggregateState extends AggregateState, 72 | TCommand extends Command, 73 | TEvent extends Event, 74 | > = { 75 | isAuthorized?: (command: TCommand) => Promisable 76 | handle: CommandHandler, TCommand, TEvent> 77 | } 78 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path') 2 | 3 | const baseRules = { 4 | quotes: ['error', 'single', { avoidEscape: true }], 5 | 'sort-imports': ['error', { ignoreDeclarationSort: true }], 6 | } 7 | 8 | module.exports = { 9 | extends: [ 10 | 'react-app', 11 | 'plugin:import/errors', 12 | 'plugin:import/warnings', 13 | 'prettier', 14 | ], 15 | rules: { 16 | ...baseRules, 17 | }, 18 | overrides: [ 19 | { 20 | files: ['**/*.ts?(x)'], 21 | extends: [ 22 | 'react-app', 23 | 'plugin:@typescript-eslint/recommended', 24 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 25 | 'plugin:import/errors', 26 | 'plugin:import/warnings', 27 | 'plugin:import/typescript', 28 | 'prettier', 29 | 'prettier/@typescript-eslint', 30 | ], 31 | parserOptions: { 32 | project: [join(__dirname, 'tsconfig.json')], 33 | }, 34 | rules: { 35 | ...baseRules, 36 | 37 | 'no-return-await': 'off', 38 | 39 | // https://npm.im/eslint-plugin-import 40 | 'import/order': ['error', { alphabetize: { order: 'asc' } }], 41 | 42 | // https://npm.im/@typescript-eslint/eslint-plugin 43 | '@typescript-eslint/ban-ts-comment': [ 44 | 'error', 45 | { 46 | 'ts-expect-error': false, 47 | 'ts-ignore': 'allow-with-description', 48 | 'ts-nocheck': true, 49 | 'ts-check': true, 50 | }, 51 | ], 52 | '@typescript-eslint/explicit-function-return-type': 'off', 53 | '@typescript-eslint/explicit-module-boundary-types': 'off', 54 | '@typescript-eslint/no-empty-interface': 'off', 55 | '@typescript-eslint/no-explicit-any': 'off', 56 | '@typescript-eslint/no-namespace': 'off', 57 | '@typescript-eslint/no-non-null-assertion': 'off', 58 | '@typescript-eslint/no-unsafe-assignment': 'off', 59 | '@typescript-eslint/no-unsafe-call': 'off', 60 | '@typescript-eslint/no-unsafe-member-access': 'off', 61 | '@typescript-eslint/no-unsafe-return': 'off', 62 | '@typescript-eslint/restrict-template-expressions': 'off', 63 | '@typescript-eslint/return-await': 'error', 64 | '@typescript-eslint/unbound-method': 'off', 65 | }, 66 | }, 67 | ], 68 | } 69 | -------------------------------------------------------------------------------- /example/src/domain/cart/commands.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandCreationProps, 3 | CommandDefinition, 4 | CommandPreset, 5 | } from '../../../../src' 6 | 7 | type Command = CommandPreset<'cart', T> 8 | 9 | declare global { 10 | namespace Domain.Cart { 11 | type Initialize = Command<{ 12 | name: 'initialize' 13 | data: null 14 | }> 15 | 16 | type AddItem = Command<{ 17 | name: 'addItem' 18 | data: { title: string } 19 | }> 20 | 21 | type RemoveItem = Command<{ 22 | name: 'removeItem' 23 | data: { itemId: string } 24 | }> 25 | 26 | type PlaceOrder = Command<{ 27 | name: 'placeOrder' 28 | data: null 29 | }> 30 | 31 | type Discard = Command<{ 32 | name: 'discard' 33 | data: null 34 | }> 35 | } 36 | } 37 | 38 | export const initialize: CommandDefinition< 39 | Domain.Cart.State, 40 | Domain.Cart.Initialize, 41 | Domain.Cart.Initialized 42 | > = { 43 | handle: (cart, command) => { 44 | return { 45 | name: 'initialized', 46 | data: null, 47 | } 48 | }, 49 | } 50 | 51 | export const addItem: CommandDefinition< 52 | Domain.Cart.State, 53 | Domain.Cart.AddItem, 54 | Domain.Cart.ItemAdded 55 | > = { 56 | handle: (cart, command) => { 57 | return { 58 | name: 'itemAdded', 59 | data: { 60 | title: command.data.title, 61 | }, 62 | } 63 | }, 64 | } 65 | 66 | export const removeItem: CommandDefinition< 67 | Domain.Cart.State, 68 | Domain.Cart.RemoveItem, 69 | Domain.Cart.ItemRemoved 70 | > = { 71 | handle: (cart, command) => { 72 | return { 73 | name: 'itemRemoved', 74 | data: { 75 | itemId: command.data.itemId, 76 | }, 77 | } 78 | }, 79 | } 80 | 81 | export const placeOrder: CommandDefinition< 82 | Domain.Cart.State, 83 | Domain.Cart.PlaceOrder, 84 | Domain.Cart.OrderPlaced 85 | > = { 86 | handle: (cart, command) => { 87 | return { 88 | name: 'orderPlaced', 89 | data: null, 90 | } 91 | }, 92 | } 93 | 94 | export const discard: CommandDefinition< 95 | Domain.Cart.State, 96 | Domain.Cart.Discard, 97 | Domain.Cart.Discarded 98 | > = { 99 | handle: (cart, command) => { 100 | return { 101 | name: 'discarded', 102 | data: null, 103 | } 104 | }, 105 | } 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firebase-event-sourcing", 3 | "version": "0.0.0-development", 4 | "description": "Event Sourcing + CQRS + DDD for Firebase", 5 | "keywords": [ 6 | "firebase", 7 | "firestore", 8 | "es", 9 | "event sourcing", 10 | "cqrs", 11 | "ddd" 12 | ], 13 | "license": "MIT", 14 | "author": "Gustavo P. Cardoso ", 15 | "files": [ 16 | "dist" 17 | ], 18 | "main": "dist/index.js", 19 | "module": "dist/index.mjs", 20 | "typings": "dist/index.d.ts", 21 | "repository": "github:gustavopch/firebase-event-sourcing", 22 | "scripts": { 23 | "build": "tsup", 24 | "lint:eslint": "eslint '**/*.{js,jsx,ts,tsx}'", 25 | "lint:prettier": "prettier --check '**/*.{js,jsx,ts,tsx,json,md}'", 26 | "lint": "yarn lint:eslint && yarn lint:prettier", 27 | "type-check": "tsc --noEmit", 28 | "test": "./scripts/test.js" 29 | }, 30 | "dependencies": { 31 | "@google-cloud/logging": "^9.0.0", 32 | "ajv": "^6.12.5", 33 | "cors": "^2.8.5", 34 | "express": "^4.17.1" 35 | }, 36 | "devDependencies": { 37 | "@commitlint/cli": "12.1.4", 38 | "@commitlint/config-conventional": "12.1.4", 39 | "@firebase/rules-unit-testing": "1.3.10", 40 | "@typescript-eslint/eslint-plugin": "4.28.3", 41 | "@typescript-eslint/parser": "4.28.3", 42 | "@types/cors": "2.8.12", 43 | "@types/jest": "25.2.3", 44 | "@types/node-fetch": "2.5.11", 45 | "babel-eslint": "10.1.0", 46 | "esbuild": "0.12.16", 47 | "esbuild-node-externals": "1.3.0", 48 | "eslint": "6.8.0", 49 | "eslint-config-prettier": "6.11.0", 50 | "eslint-config-react-app": "5.2.1", 51 | "eslint-plugin-flowtype": "3.13.0", 52 | "eslint-plugin-import": "2.22.0", 53 | "eslint-plugin-jsx-a11y": "6.3.1", 54 | "eslint-plugin-prettier": "3.1.4", 55 | "eslint-plugin-react": "7.20.3", 56 | "eslint-plugin-react-hooks": "2.5.1", 57 | "firebase": "8.7.1", 58 | "firebase-admin": "9.10.0", 59 | "firebase-functions": "3.14.1", 60 | "firebase-tools": "9.16.0", 61 | "husky": "4.3.8", 62 | "jest": "25.5.4", 63 | "lint-staged": "11.0.1", 64 | "node-fetch": "2.6.1", 65 | "prettier": "2.3.2", 66 | "ts-jest": "25.5.1", 67 | "tsup": "4.12.5", 68 | "typescript": "4.3.5" 69 | }, 70 | "peerDependencies": { 71 | "firebase": ">=7.0.0", 72 | "firebase-admin": "^9.0.0", 73 | "firebase-functions": "^3.0.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/functions/pubsub.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | import * as functions from 'firebase-functions' 3 | 4 | import { createApp } from '../app' 5 | import { createAggregatesService } from '../services/aggregates' 6 | import { createLoggerService } from '../services/logger' 7 | import { createProjectionsService } from '../services/projections' 8 | import { createEventStore } from '../stores/event-store' 9 | import { AppDefinition } from '../types/app' 10 | import { Services } from '../types/service' 11 | 12 | type PubsubFunctions = { 13 | [functionName: string]: functions.CloudFunction 14 | } 15 | 16 | export const createPubsubFunctions = ( 17 | firebaseApp: firebase.app.App, 18 | appDefinition: AppDefinition, 19 | ): PubsubFunctions => { 20 | const eventStore = createEventStore(firebaseApp) 21 | const aggregatesService = createAggregatesService(eventStore) 22 | const loggerService = createLoggerService(null) 23 | const projectionsService = createProjectionsService(firebaseApp) 24 | const userlandServices = (appDefinition.services?.({ 25 | logger: loggerService, 26 | }) ?? {}) as Services 27 | 28 | const app = createApp(firebaseApp, appDefinition, eventStore, { 29 | aggregates: aggregatesService, 30 | logger: loggerService, 31 | projections: projectionsService, 32 | userland: userlandServices, 33 | }) 34 | 35 | const pubsubFunctions: PubsubFunctions = {} 36 | 37 | for (const [flowName, flow] of Object.entries(appDefinition.flows)) { 38 | const [firstEntry, ...ignoredEntries] = Object.entries(flow.cron ?? {}) 39 | 40 | if (!firstEntry) { 41 | // No cron jobs defined in this flow 42 | continue 43 | } 44 | 45 | const [schedule, handler] = firstEntry 46 | 47 | if (ignoredEntries.length > 0) { 48 | console.error( 49 | 'A single cron job is allowed per flow because the generated Cloud ' + 50 | 'Function will be named according to the name of the flow and we ' + 51 | 'must not have two Cloud Functions with the same name. Ignored: ' + 52 | ignoredEntries.map(([schedule]) => `'${schedule}'`).join(', '), 53 | ) 54 | } 55 | 56 | pubsubFunctions[flowName] = functions.pubsub 57 | .schedule(schedule) 58 | .onRun(async ctx => { 59 | const flowService = app.getFlowService({ 60 | causationEvent: null, 61 | }) 62 | 63 | await handler({ 64 | flow: flowService, 65 | logger: loggerService, 66 | ...userlandServices, 67 | }) 68 | }) 69 | } 70 | 71 | return pubsubFunctions 72 | } 73 | -------------------------------------------------------------------------------- /src/types/view.ts: -------------------------------------------------------------------------------- 1 | import { Trigger } from '../constants' 2 | import { LoggerService } from '../services/logger' 3 | import { ProjectionsService } from '../services/projections' 4 | import { Event, ExtractFullyQualifiedEventName } from './event' 5 | import { Promisable, Split } from './misc' 6 | import { Services } from './service' 7 | 8 | export type ViewProjectionState = { 9 | [key: string]: any 10 | } 11 | 12 | export type ViewProjectionContext = Services & { 13 | logger: LoggerService 14 | projections: ProjectionsService 15 | } 16 | 17 | export type ViewProjectionHandler< 18 | TViewProjectionState extends ViewProjectionState, 19 | TEvent extends Event, 20 | > = ( 21 | event: TEvent, 22 | context: ViewProjectionContext, 23 | ) => Promisable< 24 | | Partial 25 | | null 26 | | Array<{ 27 | id: string 28 | state: Partial | null 29 | }> 30 | > 31 | 32 | export type ViewReactionContext = Services & { 33 | logger: LoggerService 34 | projections: ProjectionsService 35 | } 36 | 37 | export type ViewReactionHandler = ( 38 | ...params: [...params: TParams, context: ViewReactionContext] 39 | ) => Promise 40 | 41 | export type ViewDefinition< 42 | TViewProjectionState extends ViewProjectionState, 43 | TEvent extends Event, 44 | > = { 45 | collectionName: string 46 | 47 | projections: { 48 | [eventName in ExtractFullyQualifiedEventName]: ViewProjectionHandler< 49 | TViewProjectionState, 50 | TEvent extends { 51 | aggregateName: Split[0] 52 | name: Split[1] 53 | } 54 | ? TEvent 55 | : never 56 | > 57 | } 58 | 59 | reactions?: { 60 | [Trigger.CREATE]?: ViewReactionHandler<[state: TViewProjectionState]> 61 | 62 | [Trigger.UPDATE]?: ViewReactionHandler< 63 | [ 64 | change: { 65 | before: TViewProjectionState 66 | after: TViewProjectionState 67 | }, 68 | ] 69 | > 70 | 71 | [Trigger.DELETE]?: ViewReactionHandler<[state: TViewProjectionState]> 72 | 73 | [Trigger.WRITE]?: ViewReactionHandler< 74 | [ 75 | change: 76 | | { 77 | // Creating 78 | before: null 79 | after: TViewProjectionState 80 | } 81 | | { 82 | // Updating 83 | before: TViewProjectionState 84 | after: TViewProjectionState 85 | } 86 | | { 87 | // Deleting 88 | before: TViewProjectionState 89 | after: null 90 | }, 91 | ] 92 | > 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/functions/firestore.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | import * as functions from 'firebase-functions' 3 | 4 | import { Trigger } from '../constants' 5 | import { createLoggerService } from '../services/logger' 6 | import { createProjectionsService } from '../services/projections' 7 | import { AppDefinition } from '../types/app' 8 | import { Services } from '../types/service' 9 | 10 | type FirestoreFunctions = { 11 | [functionName: string]: functions.CloudFunction 12 | } 13 | 14 | export const createFirestoreFunctions = ( 15 | firebaseApp: firebase.app.App, 16 | appDefinition: AppDefinition, 17 | ): FirestoreFunctions => { 18 | const loggerService = createLoggerService(null) 19 | const projectionsService = createProjectionsService(firebaseApp) 20 | const userlandServices = (appDefinition.services?.({ 21 | logger: loggerService, 22 | }) ?? {}) as Services 23 | 24 | const firestoreFunctions: FirestoreFunctions = {} 25 | 26 | for (const [viewName, view] of Object.entries(appDefinition.views)) { 27 | const viewNameInKebabCase = viewName 28 | .replace(/([A-Z])/g, '-$1') 29 | .toLowerCase() 30 | 31 | const createHandler = view.reactions?.[Trigger.CREATE] 32 | const updateHandler = view.reactions?.[Trigger.UPDATE] 33 | const deleteHandler = view.reactions?.[Trigger.DELETE] 34 | const writeHandler = view.reactions?.[Trigger.WRITE] 35 | 36 | if (createHandler) { 37 | firestoreFunctions[`${viewNameInKebabCase}-create`] = functions.firestore 38 | .document(viewName) 39 | .onCreate(async snap => { 40 | await createHandler(snap.data(), { 41 | logger: loggerService, 42 | projections: projectionsService, 43 | ...userlandServices, 44 | }) 45 | }) 46 | } 47 | 48 | if (updateHandler) { 49 | firestoreFunctions[`${viewNameInKebabCase}-update`] = functions.firestore 50 | .document(viewName) 51 | .onUpdate(async change => { 52 | await updateHandler( 53 | { 54 | before: change.before.data(), 55 | after: change.after.data(), 56 | }, 57 | { 58 | logger: loggerService, 59 | projections: projectionsService, 60 | ...userlandServices, 61 | }, 62 | ) 63 | }) 64 | } 65 | 66 | if (deleteHandler) { 67 | firestoreFunctions[`${viewNameInKebabCase}-delete`] = functions.firestore 68 | .document(viewName) 69 | .onDelete(async snap => { 70 | await deleteHandler(snap.data(), { 71 | logger: loggerService, 72 | projections: projectionsService, 73 | ...userlandServices, 74 | }) 75 | }) 76 | } 77 | 78 | if (writeHandler) { 79 | firestoreFunctions[`${viewNameInKebabCase}-write`] = functions.firestore 80 | .document(viewName) 81 | .onWrite(async change => { 82 | await writeHandler( 83 | { 84 | before: change.before.data() ?? null, 85 | after: change.after.data() ?? null, 86 | }, 87 | { 88 | logger: loggerService, 89 | projections: projectionsService, 90 | ...userlandServices, 91 | }, 92 | ) 93 | }) 94 | } 95 | } 96 | 97 | return firestoreFunctions 98 | } 99 | -------------------------------------------------------------------------------- /src/functions/https/commands.test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/rules-unit-testing' 2 | import firebase from 'firebase-admin' 3 | import fetch from 'node-fetch' 4 | 5 | import { config } from '../../../example/src/config' 6 | import { carts as cartsView } from '../../../example/src/views/carts' 7 | import { createEventStore } from '../../stores/event-store' 8 | import { Event } from '../../types/event' 9 | 10 | const firebaseApp = firebase.initializeApp({ 11 | projectId: config.firebase.projectId, 12 | }) 13 | 14 | firebaseApp.firestore().settings({ 15 | ignoreUndefinedProperties: true, 16 | }) 17 | 18 | const eventStore = createEventStore(firebaseApp) 19 | 20 | afterEach(async () => { 21 | await testing.clearFirestoreData({ projectId: config.firebase.projectId }) 22 | }) 23 | 24 | afterAll(async () => { 25 | await Promise.all(testing.apps().map(app => app.delete())) 26 | }) 27 | 28 | const projectName = process.env.GCLOUD_PROJECT! 29 | const endpoint = `http://localhost:5001/${projectName}/us-central1/commands` 30 | 31 | describe('/commands endpoint', () => { 32 | it.each([ 33 | ['aggregate', 'xxxx', 'initialize'], 34 | ['command', 'cart', 'xxxxxxxxxx'], 35 | ])( 36 | 'gracefully fails when %s is not found', 37 | async (_, aggregateName, commandName) => { 38 | const res = await fetch(endpoint, { 39 | method: 'POST', 40 | headers: { 41 | 'Content-Type': 'application/json', 42 | }, 43 | body: JSON.stringify({ 44 | aggregateName, 45 | aggregateId: '123', 46 | name: commandName, 47 | data: null, 48 | }), 49 | }) 50 | 51 | expect(res.status).toBe(422) 52 | }, 53 | ) 54 | 55 | it('returns an error if the command is invalid', async () => { 56 | const res = await fetch(endpoint, { 57 | method: 'POST', 58 | headers: { 59 | 'Content-Type': 'application/json', 60 | }, 61 | body: JSON.stringify({ 62 | aggregateName: 'cart', 63 | // 'aggregateId' left out to make the command invalid 64 | name: 'initialize', 65 | data: null, 66 | }), 67 | }) 68 | 69 | expect(res.status).toBe(422) 70 | }) 71 | 72 | it('returns the ID of the new event', async () => { 73 | const res = await fetch(endpoint, { 74 | method: 'POST', 75 | headers: { 76 | 'Content-Type': 'application/json', 77 | }, 78 | body: JSON.stringify({ 79 | aggregateName: 'cart', 80 | aggregateId: '123', 81 | name: 'initialize', 82 | data: null, 83 | }), 84 | }) 85 | 86 | expect(res.status).toBe(201) 87 | expect(await res.json()).toEqual({ 88 | eventIds: [expect.any(String)], 89 | }) 90 | }) 91 | 92 | it('saves the new event into Firestore', async () => { 93 | await fetch(endpoint, { 94 | method: 'POST', 95 | headers: { 96 | 'Content-Type': 'application/json', 97 | 'X-Forwarded-For': '127.0.0.1', 98 | }, 99 | body: JSON.stringify({ 100 | aggregateName: 'cart', 101 | aggregateId: '123', 102 | name: 'initialize', 103 | data: null, 104 | }), 105 | }) 106 | 107 | const events: Event[] = [] 108 | await eventStore.getReplayForAggregate('123', 0, event => { 109 | events.push(event) 110 | }) 111 | 112 | const eventId = events[0].id 113 | 114 | expect(events).toEqual([ 115 | { 116 | aggregateName: 'cart', 117 | aggregateId: '123', 118 | name: 'initialized', 119 | id: eventId, 120 | data: null, 121 | metadata: { 122 | causationId: eventId, 123 | correlationId: eventId, 124 | userId: 'test', 125 | timestamp: expect.any(firebase.firestore.Timestamp), 126 | revision: 1, 127 | client: { 128 | ip: '127.0.0.1', 129 | ua: expect.any(String), 130 | location: null, 131 | }, 132 | }, 133 | }, 134 | ]) 135 | }) 136 | 137 | it('updates the projections', async () => { 138 | await fetch(endpoint, { 139 | method: 'POST', 140 | headers: { 141 | 'Content-Type': 'application/json', 142 | }, 143 | body: JSON.stringify({ 144 | aggregateName: 'cart', 145 | aggregateId: '123', 146 | name: 'initialize', 147 | data: null, 148 | }), 149 | }) 150 | 151 | let cart 152 | 153 | cart = await firebase 154 | .firestore() 155 | .collection(cartsView.collectionName) 156 | .doc('123') 157 | .get() 158 | .then(snap => snap.data()) 159 | 160 | expect(cart).toEqual({ 161 | id: '123', 162 | initializedAt: expect.any(Number), 163 | placedAt: null, 164 | status: 'open', 165 | items: {}, 166 | }) 167 | 168 | await fetch(endpoint, { 169 | method: 'POST', 170 | headers: { 171 | 'Content-Type': 'application/json', 172 | }, 173 | body: JSON.stringify({ 174 | aggregateName: 'cart', 175 | aggregateId: '123', 176 | name: 'discard', 177 | data: null, 178 | }), 179 | }) 180 | 181 | cart = await firebase 182 | .firestore() 183 | .collection(cartsView.collectionName) 184 | .doc('123') 185 | .get() 186 | .then(snap => snap.data()) 187 | 188 | expect(cart).toBeUndefined() 189 | }) 190 | }) 191 | -------------------------------------------------------------------------------- /src/functions/https/commands.ts: -------------------------------------------------------------------------------- 1 | import Ajv from 'ajv' 2 | import cors from 'cors' 3 | import express from 'express' 4 | import firebase from 'firebase-admin' 5 | import * as functions from 'firebase-functions' 6 | 7 | import { createApp } from '../../app' 8 | import { createAggregatesService } from '../../services/aggregates' 9 | import { createLoggerService } from '../../services/logger' 10 | import { createProjectionsService } from '../../services/projections' 11 | import { createEventStore } from '../../stores/event-store' 12 | import { AppDefinition } from '../../types/app' 13 | import { CommandWithMetadata } from '../../types/command' 14 | import { Services } from '../../types/service' 15 | import { auth } from './middlewares/auth' 16 | import { parseLocationFromHeaders } from './utils/parse-location-from-headers' 17 | 18 | const aggregateNameSchema = { 19 | type: 'string', 20 | minLength: 1, 21 | } 22 | 23 | const aggregateIdSchema = { 24 | type: 'string', 25 | minLength: 1, 26 | } 27 | 28 | const nameSchema = { 29 | type: 'string', 30 | minLength: 1, 31 | } 32 | 33 | const dataSchema = { 34 | type: ['object', 'null'], 35 | properties: {}, 36 | } 37 | 38 | const causationIdSchema = { 39 | type: 'string', 40 | minLength: 1, 41 | } 42 | 43 | const correlationIdSchema = { 44 | type: 'string', 45 | minLength: 1, 46 | } 47 | 48 | const metadataSchema = { 49 | type: 'object', 50 | properties: { 51 | causationId: causationIdSchema, 52 | correlationId: correlationIdSchema, 53 | }, 54 | required: ['causationId', 'correlationId'], 55 | additionalProperties: false, 56 | } 57 | 58 | const commandSchema = { 59 | type: 'object', 60 | properties: { 61 | aggregateName: aggregateNameSchema, 62 | aggregateId: aggregateIdSchema, 63 | name: nameSchema, 64 | data: dataSchema, 65 | }, 66 | required: ['aggregateName', 'aggregateId', 'name', 'data'], 67 | additionalProperties: false, 68 | } 69 | 70 | const commandWithMetadataSchema = { 71 | type: 'object', 72 | properties: { 73 | aggregateName: aggregateNameSchema, 74 | aggregateId: aggregateIdSchema, 75 | name: nameSchema, 76 | data: dataSchema, 77 | metadata: metadataSchema, 78 | }, 79 | required: ['aggregateName', 'aggregateId', 'name', 'data'], 80 | additionalProperties: false, 81 | } 82 | 83 | const ajv = new Ajv() 84 | const validateCommand = ajv.compile(commandSchema) 85 | const validateCommandWithMetadata = ajv.compile(commandWithMetadataSchema) 86 | 87 | export const createCommandsEndpoint = ( 88 | firebaseApp: firebase.app.App, 89 | appDefinition: AppDefinition, 90 | ): functions.HttpsFunction => { 91 | const server = express() 92 | server.set('trust proxy', true) 93 | server.use(cors({ origin: true })) 94 | server.use(auth(firebaseApp)) 95 | 96 | server.post('/', async (req, res) => { 97 | const eventStore = createEventStore(firebaseApp) 98 | const aggregatesService = createAggregatesService(eventStore) 99 | const loggerService = createLoggerService(req) 100 | const projectionsService = createProjectionsService(firebaseApp) 101 | const userlandServices = (appDefinition.services?.({ 102 | logger: loggerService, 103 | }) ?? {}) as Services 104 | 105 | const app = createApp(firebaseApp, appDefinition, eventStore, { 106 | aggregates: aggregatesService, 107 | logger: loggerService, 108 | projections: projectionsService, 109 | userland: userlandServices, 110 | }) 111 | 112 | try { 113 | const isCommandValid = 114 | req.userId === 'system' 115 | ? await validateCommandWithMetadata(req.body) 116 | : await validateCommand(req.body) 117 | 118 | if (!isCommandValid) { 119 | console.error('Invalid command:', validateCommand.errors) 120 | res.status(422).send('Invalid command') 121 | return 122 | } 123 | 124 | const userAgent = req.header('User-Agent') 125 | 126 | if (!userAgent) { 127 | console.error("Missing 'User-Agent' header", { headers: req.headers }) 128 | res.status(400).send("Missing 'User-Agent' header") 129 | return 130 | } 131 | 132 | const command: CommandWithMetadata = { 133 | aggregateName: req.body.aggregateName, 134 | aggregateId: req.body.aggregateId, 135 | name: req.body.name, 136 | data: req.body.data, 137 | metadata: { 138 | causationId: req.body.metadata?.causationId || null, 139 | correlationId: req.body.metadata?.correlationId || null, 140 | userId: req.userId, 141 | timestamp: firebase.firestore.Timestamp.now(), 142 | client: { 143 | ip: req.ip, 144 | ua: userAgent, 145 | location: parseLocationFromHeaders(req), 146 | }, 147 | }, 148 | } 149 | 150 | const { eventIds } = await app.dispatch( 151 | command.aggregateName, 152 | command.name, 153 | command.aggregateId, 154 | command.data, 155 | command.metadata, 156 | ) 157 | 158 | res.status(201).send({ eventIds }) 159 | } catch (error) { 160 | if (error.name === 'AggregateNotFound') { 161 | console.log(error.message) 162 | res.status(422).send(error.message) 163 | return 164 | } 165 | 166 | if (error.name === 'CommandHandlerNotFound') { 167 | console.log(error.message) 168 | res.status(422).send(error.message) 169 | return 170 | } 171 | 172 | if (error.name === 'Unauthorized') { 173 | console.log(error.message) 174 | res.status(403).send(error.message) 175 | return 176 | } 177 | 178 | console.error('Error while handling command:', error) 179 | res.status(500).send() 180 | } 181 | }) 182 | 183 | return functions.https.onRequest(server) 184 | } 185 | -------------------------------------------------------------------------------- /src/stores/event-store.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { Aggregate, AggregateData, AggregateState } from '../types/aggregate' 4 | import { Event } from '../types/event' 5 | import { ClientInfo, Promisable } from '../types/misc' 6 | import { generateId } from '../utils/generate-id' 7 | 8 | export const AGGREGATES = 'aggregates' 9 | export const EVENTS = 'events' 10 | 11 | const queryInBatches = async ( 12 | query: firebase.firestore.Query, 13 | onNext: OnEvent, 14 | ) => { 15 | let lastDocSnap: firebase.firestore.DocumentSnapshot | undefined = undefined 16 | 17 | while (true) { 18 | query = query.limit(1000).limit(10000) 19 | 20 | if (lastDocSnap) { 21 | query = query.startAfter(lastDocSnap) 22 | } 23 | 24 | const batch: firebase.firestore.QuerySnapshot = await query.get() 25 | 26 | lastDocSnap = batch.docs[batch.size - 1] 27 | if (batch.empty) { 28 | return 29 | } 30 | 31 | for (const docSnap of batch.docs) { 32 | const event = docSnap.data() as Event 33 | await onNext(event) 34 | } 35 | } 36 | } 37 | 38 | export type OnEvent = (event: Event) => Promisable 39 | 40 | export type EventStore = ReturnType 41 | 42 | export const createEventStore = (firebaseApp: firebase.app.App) => { 43 | const db = firebaseApp.firestore() 44 | const aggregatesCollection = db.collection(AGGREGATES) 45 | const eventsCollection = db.collection(EVENTS) 46 | 47 | return { 48 | getEvent: async ( 49 | eventId: string | null | undefined, 50 | ): Promise => { 51 | if (!eventId) { 52 | return null 53 | } 54 | 55 | const docSnap = await eventsCollection.doc(eventId).get() 56 | return (docSnap.data() ?? null) as Event | null 57 | }, 58 | 59 | getEventsByCausationId: async (causationId: string): Promise => { 60 | const query = eventsCollection.where('metadata.causationId', '==', causationId) // prettier-ignore 61 | 62 | const querySnap = await query.get() 63 | return querySnap.docs.map(docSnap => docSnap.data() as Event) 64 | }, 65 | 66 | getEventsByCorrelationId: async ( 67 | correlationId: string, 68 | ): Promise => { 69 | const query = eventsCollection.where('metadata.correlationId', '==', correlationId) // prettier-ignore 70 | 71 | const querySnap = await query.get() 72 | return querySnap.docs.map(docSnap => docSnap.data() as Event) 73 | }, 74 | 75 | getEventsByUserId: async ( 76 | userId: string, 77 | onNext: OnEvent, 78 | ): Promise => { 79 | const query = eventsCollection.where('metadata.userId', '==', userId) 80 | 81 | await queryInBatches(query, onNext) 82 | }, 83 | 84 | getReplay: async ( 85 | fromTimestamp: firebase.firestore.Timestamp | Date | string | number, 86 | onNext: OnEvent, 87 | ): Promise => { 88 | const query = eventsCollection.where( 89 | 'metadata.timestamp', 90 | '>=', 91 | fromTimestamp instanceof firebase.firestore.Timestamp 92 | ? fromTimestamp 93 | : new Date(fromTimestamp), 94 | ) 95 | 96 | await queryInBatches(query, onNext) 97 | }, 98 | 99 | getReplayForAggregate: async ( 100 | aggregateId: string, 101 | fromRevision: number, 102 | onNext: OnEvent, 103 | ): Promise => { 104 | const query = eventsCollection 105 | .where('aggregateId', '==', aggregateId) 106 | .where('metadata.revision', '>=', fromRevision) 107 | 108 | await queryInBatches(query, onNext) 109 | }, 110 | 111 | saveEvent: async < 112 | TEvent extends Event, 113 | TAggregateState extends AggregateState, 114 | >( 115 | { 116 | aggregateName, 117 | aggregateId, 118 | name, 119 | data, 120 | causationId, 121 | correlationId, 122 | userId, 123 | client, 124 | }: { 125 | aggregateName: TEvent['aggregateName'] 126 | aggregateId: TEvent['aggregateId'] 127 | name: TEvent['name'] 128 | data: TEvent['data'] 129 | causationId: string | null 130 | correlationId: string | null 131 | userId: string 132 | client: ClientInfo | null 133 | }, 134 | initialState: TAggregateState, 135 | getNewState: (state: TAggregateState, event: Event) => TAggregateState, 136 | ): Promise => { 137 | const eventId = generateId() 138 | 139 | const aggregateRef = aggregatesCollection.doc(aggregateId) 140 | const eventRef = eventsCollection.doc(eventId) 141 | 142 | await db.runTransaction(async transaction => { 143 | const oldAggregate = await transaction 144 | .get(aggregateRef) 145 | .then(docSnap => { 146 | const existingAggregate = docSnap.data() as 147 | | AggregateData 148 | | undefined 149 | 150 | if (existingAggregate) { 151 | return existingAggregate 152 | } 153 | 154 | return { 155 | id: aggregateId, 156 | revision: 0, 157 | state: initialState, 158 | } 159 | }) 160 | 161 | const newRevision = oldAggregate.revision + 1 162 | 163 | const event: Event = { 164 | aggregateName, 165 | aggregateId, 166 | id: eventId, 167 | name, 168 | data, 169 | metadata: { 170 | causationId: causationId ?? eventId, 171 | correlationId: correlationId ?? eventId, 172 | userId, 173 | timestamp: firebase.firestore.Timestamp.now(), 174 | revision: newRevision, 175 | client, 176 | }, 177 | } 178 | 179 | transaction.set(eventRef, event) 180 | 181 | const newAggregate: AggregateData = { 182 | id: aggregateId, 183 | revision: newRevision, 184 | state: getNewState(oldAggregate.state as any, event), 185 | } 186 | 187 | transaction.set(aggregateRef, newAggregate) 188 | }) 189 | 190 | return eventId 191 | }, 192 | 193 | importEvents: async (events: Event[]): Promise => { 194 | for (const event of events) { 195 | await eventsCollection.doc(event.id).set(event) 196 | } 197 | }, 198 | 199 | getAggregate: async ( 200 | aggregateId: string | null | undefined, 201 | ): Promise => { 202 | if (!aggregateId) { 203 | return null 204 | } 205 | 206 | const aggregate = await aggregatesCollection 207 | .doc(aggregateId) 208 | .get() 209 | .then(snap => snap.data() as AggregateData | undefined) 210 | 211 | return aggregate ? { ...aggregate, exists: true } : null 212 | }, 213 | 214 | saveAggregate: async (aggregate: AggregateData): Promise => { 215 | await aggregatesCollection.doc(aggregate.id).set(aggregate) 216 | }, 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/stores/event-store.test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/rules-unit-testing' 2 | import firebase from 'firebase-admin' 3 | 4 | import { config } from '../../example/src/config' 5 | import { AggregateData } from '../types/aggregate' 6 | import { Event } from '../types/event' 7 | import { AGGREGATES, EVENTS, createEventStore } from './event-store' 8 | 9 | const firebaseApp = firebase.initializeApp({ 10 | projectId: config.firebase.projectId, 11 | }) 12 | 13 | firebaseApp.firestore().settings({ 14 | ignoreUndefinedProperties: true, 15 | }) 16 | 17 | const eventStore = createEventStore(firebaseApp) 18 | 19 | const testData: { 20 | aggregates: { [id: string]: AggregateData } 21 | events: { [id: string]: Event } 22 | } = { 23 | aggregates: { 24 | E: { 25 | id: 'E', 26 | revision: 3, 27 | state: {}, 28 | }, 29 | }, 30 | events: { 31 | '1': { 32 | aggregateName: 'cart', 33 | aggregateId: 'A', 34 | name: 'initialized', 35 | id: '1', 36 | data: { title: 'Whatever' }, 37 | metadata: { 38 | causationId: '1', 39 | correlationId: '1', 40 | userId: 'john', 41 | timestamp: firebase.firestore.Timestamp.fromDate(new Date('2020-07-01')), // prettier-ignore 42 | revision: 1, 43 | client: null, 44 | }, 45 | }, 46 | '2': { 47 | aggregateName: 'cart', 48 | aggregateId: 'B', 49 | name: 'initialized', 50 | id: '2', 51 | data: { title: 'Whatever' }, 52 | metadata: { 53 | causationId: '2', 54 | correlationId: '2', 55 | userId: 'system', 56 | timestamp: firebase.firestore.Timestamp.fromDate(new Date('2020-07-02')), // prettier-ignore 57 | revision: 1, 58 | client: null, 59 | }, 60 | }, 61 | '3': { 62 | aggregateName: 'cart', 63 | aggregateId: 'C', 64 | name: 'initialized', 65 | id: '3', 66 | data: { title: 'Whatever' }, 67 | metadata: { 68 | causationId: '3', 69 | correlationId: '3', 70 | userId: 'john', 71 | timestamp: firebase.firestore.Timestamp.fromDate(new Date('2020-07-03')), // prettier-ignore 72 | revision: 1, 73 | client: null, 74 | }, 75 | }, 76 | '4': { 77 | aggregateName: 'cart', 78 | aggregateId: 'D', 79 | name: 'initialized', 80 | id: '4', 81 | data: { title: 'Whatever' }, 82 | metadata: { 83 | causationId: '4', 84 | correlationId: '4', 85 | userId: 'system', 86 | timestamp: firebase.firestore.Timestamp.fromDate(new Date('2020-07-04')), // prettier-ignore 87 | revision: 1, 88 | client: null, 89 | }, 90 | }, 91 | '5': { 92 | aggregateName: 'cart', 93 | aggregateId: 'E', 94 | name: 'initialized', 95 | id: '5', 96 | data: { title: 'Whatever' }, 97 | metadata: { 98 | causationId: '5', 99 | correlationId: '5', 100 | userId: 'system', 101 | timestamp: firebase.firestore.Timestamp.fromDate(new Date('2020-07-05')), // prettier-ignore 102 | revision: 1, 103 | client: null, 104 | }, 105 | }, 106 | '5.1': { 107 | aggregateName: 'cart', 108 | aggregateId: 'E', 109 | name: 'itemAdded', 110 | id: '5.1', 111 | data: { title: 'Whatever' }, 112 | metadata: { 113 | causationId: '5', 114 | correlationId: '5', 115 | userId: 'system', 116 | timestamp: firebase.firestore.Timestamp.fromDate(new Date('2020-07-05')), // prettier-ignore 117 | revision: 2, 118 | client: null, 119 | }, 120 | }, 121 | '5.2': { 122 | aggregateName: 'cart', 123 | aggregateId: 'E', 124 | name: 'itemAdded', 125 | id: '5.2', 126 | data: { title: 'Whatever' }, 127 | metadata: { 128 | causationId: '5.1', 129 | correlationId: '5', 130 | userId: 'system', 131 | timestamp: firebase.firestore.Timestamp.fromDate(new Date('2020-07-05')), // prettier-ignore 132 | revision: 3, 133 | client: null, 134 | }, 135 | }, 136 | }, 137 | } 138 | 139 | beforeAll(async () => { 140 | for (const event of Object.values(testData.events)) { 141 | await firebaseApp.firestore().collection(EVENTS).doc(event.id).set(event) 142 | } 143 | 144 | for (const aggregate of Object.values(testData.aggregates)) { 145 | await firebaseApp 146 | .firestore() 147 | .collection(AGGREGATES) 148 | .doc(aggregate.id) 149 | .set(aggregate) 150 | } 151 | }) 152 | 153 | afterAll(async () => { 154 | await testing.clearFirestoreData({ projectId: config.firebase.projectId }) 155 | await firebaseApp.delete() 156 | }) 157 | 158 | describe('Event Store', () => { 159 | test('getEvent', async () => { 160 | expect(await eventStore.getEvent('1')).toEqual(testData.events['1']) 161 | }) 162 | 163 | test('getEventsByCausationId', async () => { 164 | expect(await eventStore.getEventsByCausationId('1')).toEqual([ 165 | testData.events['1'], 166 | ]) 167 | 168 | expect(await eventStore.getEventsByCausationId('5.1')).toEqual([ 169 | testData.events['5.2'], 170 | ]) 171 | }) 172 | 173 | test('getEventsByCorrelationId', async () => { 174 | expect(await eventStore.getEventsByCorrelationId('1')).toEqual([ 175 | testData.events['1'], 176 | ]) 177 | 178 | expect(await eventStore.getEventsByCorrelationId('5')).toEqual([ 179 | testData.events['5'], 180 | testData.events['5.1'], 181 | testData.events['5.2'], 182 | ]) 183 | }) 184 | 185 | test('getEventsByUserId', async () => { 186 | const events: Event[] = [] 187 | await eventStore.getEventsByUserId('john', event => { 188 | events.push(event) 189 | }) 190 | 191 | expect(events).toEqual([testData.events['1'], testData.events['3']]) 192 | }) 193 | 194 | test('getReplay', async () => { 195 | const events: Event[] = [] 196 | await eventStore.getReplay('2020-07-03T00:00:00Z', event => { 197 | events.push(event) 198 | }) 199 | 200 | expect(events).toEqual([ 201 | testData.events['3'], 202 | testData.events['4'], 203 | testData.events['5'], 204 | testData.events['5.1'], 205 | testData.events['5.2'], 206 | ]) 207 | }) 208 | 209 | test('getReplayForAggregate', async () => { 210 | const events: Event[] = [] 211 | await eventStore.getReplayForAggregate('E', 2, event => { 212 | events.push(event) 213 | }) 214 | 215 | expect(events).toEqual([testData.events['5.1'], testData.events['5.2']]) 216 | }) 217 | 218 | test('saveEvent', async () => { 219 | const id = await eventStore.saveEvent< 220 | Domain.Cart.Initialized, 221 | Domain.Cart.State 222 | >( 223 | { 224 | aggregateName: 'cart', 225 | aggregateId: 'x', 226 | name: 'initialized', 227 | data: null, 228 | causationId: null, 229 | correlationId: null, 230 | userId: 'system', 231 | client: null, 232 | }, 233 | { 234 | isPlaced: false, 235 | }, 236 | (state, event) => ({ 237 | isPlaced: false, 238 | }), 239 | ) 240 | 241 | expect(await eventStore.getEvent(id)).toEqual({ 242 | aggregateName: 'cart', 243 | aggregateId: 'x', 244 | id, 245 | name: 'initialized', 246 | data: null, 247 | metadata: { 248 | causationId: id, 249 | correlationId: id, 250 | userId: 'system', 251 | timestamp: expect.any(firebase.firestore.Timestamp), 252 | revision: 1, 253 | client: null, 254 | }, 255 | }) 256 | }) 257 | 258 | test('importEvents', async () => { 259 | const events = Object.values(testData.events) 260 | await eventStore.importEvents(events) 261 | 262 | for (const event of events) { 263 | expect(await eventStore.getEvent(event.id)).toEqual(event) 264 | } 265 | }) 266 | 267 | test('getAggregate', async () => { 268 | const aggregate = await eventStore.getAggregate(testData.aggregates['E'].id) 269 | 270 | expect(aggregate).toEqual({ 271 | ...testData.aggregates['E'], 272 | exists: true, 273 | }) 274 | }) 275 | 276 | test('saveAggregate', async () => { 277 | await eventStore.saveAggregate({ 278 | id: 'x', 279 | revision: 7, 280 | state: { 281 | foo: 'bar', 282 | }, 283 | }) 284 | 285 | expect(await eventStore.getAggregate('x')).toEqual({ 286 | id: 'x', 287 | revision: 7, 288 | state: { 289 | foo: 'bar', 290 | }, 291 | exists: true, 292 | }) 293 | }) 294 | }) 295 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase-admin' 2 | 3 | import { AggregatesService } from './services/aggregates' 4 | import { FlowService, createFlowService } from './services/flow' 5 | import { LoggerService } from './services/logger' 6 | import { ProjectionsService } from './services/projections' 7 | import { EventStore } from './stores/event-store' 8 | import { AppDefinition } from './types/app' 9 | import { CommandMetadata } from './types/command' 10 | import { Event } from './types/event' 11 | import { FlowReactionHandler } from './types/flow' 12 | import { Services } from './types/service' 13 | import { ViewProjectionHandler } from './types/view' 14 | import { getFullyQualifiedEventName } from './utils/get-fully-qualified-event-name' 15 | 16 | export type App = { 17 | dispatch: < 18 | TAggregateName extends keyof TAppDefinition['domain'] & string, 19 | TCommandName extends keyof TAppDefinition['domain'][TAggregateName]['commands'] & string, // prettier-ignore 20 | >( 21 | aggregateName: TAggregateName, 22 | commandName: TCommandName, 23 | aggregateId: string, 24 | data: Parameters[1]['data'], // prettier-ignore 25 | metadata: CommandMetadata, 26 | ) => Promise<{ eventIds: string[] }> 27 | replayEvents: () => Promise 28 | getFlowService: (params: { causationEvent: Event | null }) => FlowService 29 | } 30 | 31 | export const createApp = ( 32 | firebaseApp: firebase.app.App, 33 | appDefinition: TAppDefinition, 34 | eventStore: EventStore, 35 | services: { 36 | aggregates: AggregatesService 37 | logger: LoggerService 38 | projections: ProjectionsService 39 | userland: Services 40 | }, 41 | ): App => { 42 | const db = firebaseApp.firestore() 43 | 44 | const runProjections = async (event: Event) => { 45 | const fullyQualifiedEventName = getFullyQualifiedEventName(event) 46 | 47 | for (const [viewName, view] of Object.entries(appDefinition.views)) { 48 | const projectionEntries: Array< 49 | [string, ViewProjectionHandler] 50 | > = Object.entries(view.projections) 51 | 52 | for (const [handlerKey, handler] of projectionEntries) { 53 | if (handlerKey === fullyQualifiedEventName) { 54 | const stateOrStatesWithTheirIds = handler(event, { 55 | logger: services.logger, 56 | projections: services.projections, 57 | ...services.userland, 58 | }) 59 | 60 | const statesWithTheirIds = Array.isArray(stateOrStatesWithTheirIds) 61 | ? stateOrStatesWithTheirIds 62 | : [{ id: event.aggregateId, state: stateOrStatesWithTheirIds }] 63 | 64 | await Promise.allSettled( 65 | statesWithTheirIds.map(async ({ id, state }) => { 66 | const ref = db.collection(view.collectionName).doc(id) 67 | 68 | try { 69 | if (state) { 70 | await ref.set(state, { merge: true }) 71 | } else { 72 | await ref.delete() 73 | } 74 | 75 | console.log(`Ran '${viewName}' projection with event '${fullyQualifiedEventName}:${event.id}'`) // prettier-ignore 76 | } catch (error) { 77 | console.error(`Failed to run '${viewName}' projection with event '${fullyQualifiedEventName}:${event.id}':`, error) // prettier-ignore 78 | } 79 | }), 80 | ) 81 | } 82 | } 83 | } 84 | } 85 | 86 | const runReactions = async (event: Event) => { 87 | const fullyQualifiedEventName = getFullyQualifiedEventName(event) 88 | 89 | const promises: Array> = [] 90 | 91 | for (const [flowName, flow] of Object.entries(appDefinition.flows)) { 92 | const reactionEntries: Array<[string, FlowReactionHandler]> = 93 | Object.entries(flow.reactions ?? {}) 94 | 95 | for (const [handlerKey, handler] of reactionEntries) { 96 | if (handlerKey === fullyQualifiedEventName) { 97 | const flowService = getFlowService({ 98 | causationEvent: event, 99 | }) 100 | 101 | const promise = handler(event, { 102 | flow: flowService, 103 | logger: services.logger, 104 | ...services.userland, 105 | }) 106 | .then(() => { 107 | console.log(`Ran '${flowName}' reaction with event '${fullyQualifiedEventName}:${event.id}'`) // prettier-ignore 108 | }) 109 | .catch(error => { 110 | console.error(`Failed to run '${flowName}' reaction with event '${fullyQualifiedEventName}:${event.id}':`, error) // prettier-ignore 111 | }) 112 | 113 | promises.push(promise) 114 | } 115 | } 116 | } 117 | 118 | await Promise.all(promises) 119 | } 120 | 121 | const dispatch: App['dispatch'] = async ( 122 | aggregateName, 123 | commandName, 124 | aggregateId, 125 | data, 126 | metadata, 127 | ) => { 128 | const aggregateDefinition = appDefinition.domain?.[aggregateName] 129 | if (!aggregateDefinition) { 130 | const error = new Error() 131 | error.name = 'AggregateNotFound' 132 | error.message = `Aggregate '${aggregateName}' not found` 133 | throw error 134 | } 135 | 136 | const commandDefinition = aggregateDefinition.commands[commandName] 137 | if (!commandDefinition) { 138 | const error = new Error() 139 | error.name = 'CommandHandlerNotFound' 140 | error.message = `Command handler for '${aggregateName}.${commandName}' not found` 141 | throw error 142 | } 143 | 144 | const command = { 145 | aggregateName, 146 | aggregateId, 147 | name: commandName, 148 | data, 149 | metadata, 150 | } 151 | 152 | const isAuthorized = (await commandDefinition.isAuthorized?.(command)) ?? true // prettier-ignore 153 | if (!isAuthorized) { 154 | const error = new Error() 155 | error.name = 'Unauthorized' 156 | error.message = 'Unauthorized' 157 | throw error 158 | } 159 | 160 | const aggregate = await eventStore.getAggregate(aggregateId) 161 | 162 | const eventOrEventsProps = await commandDefinition.handle( 163 | aggregate 164 | ? { 165 | id: aggregateId, 166 | revision: aggregate.revision, 167 | exists: true, 168 | state: aggregate.state, 169 | } 170 | : { 171 | id: aggregateId, 172 | revision: 0, 173 | exists: false, 174 | state: {}, 175 | }, 176 | command, 177 | { 178 | aggregates: services.aggregates, 179 | logger: services.logger, 180 | ...services.userland, 181 | }, 182 | ) 183 | 184 | const eventsProps = Array.isArray(eventOrEventsProps) 185 | ? eventOrEventsProps 186 | : [eventOrEventsProps] 187 | 188 | const initialState = aggregateDefinition.getInitialState?.() ?? {} 189 | const eventIds: string[] = [] 190 | for (const { name: eventName, data: eventData } of eventsProps) { 191 | const eventId = await eventStore.saveEvent( 192 | { 193 | aggregateName, 194 | aggregateId, 195 | name: eventName, 196 | data: eventData, 197 | causationId: metadata.causationId, 198 | correlationId: metadata.correlationId, 199 | userId: metadata.userId, 200 | client: metadata.client, 201 | }, 202 | initialState, 203 | (state, event) => { 204 | const eventDefinition = aggregateDefinition.events?.[event.name] 205 | 206 | const newState = eventDefinition?.handle?.( 207 | aggregate 208 | ? { id: aggregateId, state: aggregate.state } 209 | : { id: aggregateId, state: {} }, 210 | event, 211 | ) 212 | 213 | return newState ?? {} 214 | }, 215 | ) 216 | 217 | eventIds.push(eventId) 218 | const event = (await eventStore.getEvent(eventId))! 219 | console.log('Saved event:', event) 220 | 221 | await runProjections(event) 222 | 223 | await runReactions(event) 224 | } 225 | 226 | return { eventIds } 227 | } 228 | 229 | const replayEvents: App['replayEvents'] = async () => { 230 | await eventStore.getReplay(0, async event => { 231 | await runProjections(event) 232 | }) 233 | } 234 | 235 | const getFlowService: App['getFlowService'] = ({ 236 | causationEvent, 237 | }) => { 238 | return createFlowService( 239 | command => 240 | dispatch( 241 | command.aggregateName, 242 | command.name, 243 | command.aggregateId, 244 | command.data, 245 | command.metadata, 246 | ), 247 | causationEvent, 248 | ) 249 | } 250 | 251 | return { 252 | dispatch, 253 | replayEvents, 254 | getFlowService, 255 | } 256 | } 257 | --------------------------------------------------------------------------------