├── packages ├── framework │ ├── index.ts │ ├── src │ │ ├── empty.test.ts │ │ ├── event.ts │ │ ├── index.ts │ │ ├── infrastructure │ │ │ ├── mediator │ │ │ │ ├── index.ts │ │ │ │ ├── request.ts │ │ │ │ ├── publish.ts │ │ │ │ └── registry.ts │ │ │ ├── index.ts │ │ │ ├── pubsub.ts │ │ │ ├── errors.ts │ │ │ ├── executePostCommitHandlers.ts │ │ │ ├── context.base.ts │ │ │ ├── decorators │ │ │ │ └── index.ts │ │ │ ├── RouteBuilder.ts │ │ │ ├── domainEventHandler.ts │ │ │ ├── createDependencyNamespace.ts │ │ │ └── SimpleContainer.ts │ │ ├── utils │ │ │ ├── index.ts │ │ │ ├── generateUuid.ts │ │ │ ├── assert.ts │ │ │ ├── validation.ts │ │ │ ├── misc.ts │ │ │ └── logger.ts │ │ ├── entity.ts │ │ └── errors.ts │ ├── tsconfig.json │ ├── package.json │ └── README.md ├── hosting.koa │ ├── index.ts │ ├── src │ │ ├── index.ts │ │ ├── RouteBuilder.ts │ │ ├── middleware.ts │ │ └── generateKoaHandler.ts │ ├── tsconfig.json │ └── package.json ├── neverthrow-extensions │ ├── index.ts │ ├── src │ │ ├── index.ts │ │ ├── promise-pipe.ts │ │ └── neverthrow-extensions.ts │ ├── tsconfig.json │ └── package.json ├── types │ ├── index.d.ts │ ├── package.json │ ├── joi-to-json-schema.d.ts │ ├── global.d.ts │ └── promise-pipe-types.d.ts └── io.diskdb │ ├── src │ ├── index.ts │ ├── utils.ts │ ├── ReadContext.ts │ └── RecordContext.ts │ ├── tsconfig.json │ ├── index.ts │ └── package.json ├── .gitignore ├── samples └── basic │ ├── src │ ├── config.ts │ ├── TrainTrip │ │ ├── eventhandlers │ │ │ ├── integration.events.ts │ │ │ └── index.ts │ │ ├── infrastructure │ │ │ ├── TrainTripReadContext.disk.ts │ │ │ ├── trainTripPublisher.inMemory.ts │ │ │ ├── api.ts │ │ │ └── TrainTripContext.disk.ts │ │ ├── usecases │ │ │ ├── lockTrainTrip.ts │ │ │ ├── deleteTrainTrip.ts │ │ │ ├── registerCloud.ts │ │ │ ├── getTrainTrip.ts │ │ │ ├── others.ts │ │ │ ├── types.ts │ │ │ ├── changeTrainTrip.ts │ │ │ └── createTrainTrip.ts │ │ ├── FutureDate.ts │ │ ├── TravelClassDefinition.ts │ │ ├── PaxDefinition.ts │ │ ├── Trip.ts │ │ ├── TrainTrip.ts │ │ └── integration.test.ts │ ├── root.router.ts │ ├── resolveIntegrationEvent.ts │ ├── start-server.ts │ ├── TrainTrip.router.ts │ └── root.ts │ ├── nodemon.json │ ├── tsconfig.json │ ├── jest.config.js │ ├── package.json │ └── router-schema.json ├── .prettierrc.js ├── package.json ├── jest.config.js ├── .vscode └── settings.json ├── wallaby.js ├── tsconfig.json ├── .eslintrc.js └── README.md /packages/framework/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./src" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | data/ 3 | coverage/ 4 | *.log -------------------------------------------------------------------------------- /packages/hosting.koa/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./src" 2 | -------------------------------------------------------------------------------- /packages/neverthrow-extensions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./src" 2 | -------------------------------------------------------------------------------- /packages/neverthrow-extensions/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./neverthrow-extensions" 2 | import "./promise-pipe" 3 | -------------------------------------------------------------------------------- /samples/basic/src/config.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_AUTH = "test:test" 2 | 3 | const { PORT = "3535" } = process.env 4 | 5 | export { PORT } 6 | -------------------------------------------------------------------------------- /packages/framework/src/empty.test.ts: -------------------------------------------------------------------------------- 1 | describe("I should write some tests", () => { 2 | it("is true", () => { 3 | expect(true).toBeTruthy() 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /packages/hosting.koa/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./middleware" 2 | export * from "./RouteBuilder" 3 | export { default as KoaRouteBuilder } from "./RouteBuilder" 4 | -------------------------------------------------------------------------------- /packages/types/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, // true 3 | trailingComma: 'all', 4 | singleQuote: false, // true 5 | printWidth: 120, 6 | tabWidth: 2, // 4 7 | }; 8 | -------------------------------------------------------------------------------- /packages/io.diskdb/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as DiskRecordContext } from "./RecordContext" 2 | export { default as ReadContext } from "./ReadContext" 3 | export * from "./utils" 4 | -------------------------------------------------------------------------------- /packages/framework/src/event.ts: -------------------------------------------------------------------------------- 1 | import { generateShortUuid } from "./utils" 2 | 3 | export default abstract class Event { 4 | readonly id = generateShortUuid() 5 | readonly createdAt = new Date() 6 | } 7 | -------------------------------------------------------------------------------- /packages/framework/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./errors" 2 | export { default as Entity } from "./entity" 3 | export { default as Event } from "./event" 4 | export * from "./utils" 5 | export * from "./infrastructure" 6 | -------------------------------------------------------------------------------- /packages/framework/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "*": [ 7 | "types/*" 8 | ], 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /packages/hosting.koa/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "*": [ 7 | "types/*" 8 | ] 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /packages/io.diskdb/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "*": [ 7 | "types/*" 8 | ] 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /packages/neverthrow-extensions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "*": [ 7 | "types/*" 8 | ] 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /samples/basic/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | ".", 4 | "src/**/*", 5 | "../../packages/**/*" 6 | ], 7 | "ignore": [ 8 | "data/**/*", 9 | "router-schema.json" 10 | ], 11 | "ext": "ts, js, json" 12 | } -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/mediator/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./publish" 2 | export { default as publish } from "./publish" 3 | export * from "./request" 4 | export { default as request } from "./request" 5 | export * from "./registry" 6 | -------------------------------------------------------------------------------- /packages/io.diskdb/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./src" 2 | 3 | import { parse as parseOriginal, stringify } from "flatted" 4 | 5 | const parse: (input: string) => T = parseOriginal 6 | 7 | export { 8 | parse, 9 | stringify, 10 | } 11 | -------------------------------------------------------------------------------- /packages/framework/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./logger" 2 | export * from "./misc" 3 | export * from "./validation" 4 | export { default as generateUuid } from "./generateUuid" 5 | export * from "./generateUuid" 6 | export { default as assert } from "./assert" 7 | -------------------------------------------------------------------------------- /samples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "*": [ 7 | "./types/*" 8 | ], 9 | "@/*": [ 10 | "./src/*" 11 | ] 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": { 4 | "packages": [ 5 | "packages/*", 6 | "samples/*" 7 | ] 8 | }, 9 | "scripts": { 10 | "start": "cd samples/basic && yarn start", 11 | "testsuite": "cd samples/basic && yarn testsuite && cd ../../packages/framework && yarn testsuite" 12 | } 13 | } -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/eventhandlers/integration.events.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "@fp-app/framework" 2 | 3 | // tslint:disable:max-classes-per-file 4 | 5 | export class CustomerRequestedChanges extends Event { 6 | constructor(readonly trainTripId: string, readonly itineraryId: string) { 7 | super() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/io.diskdb/src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import { promisify } from "util" 3 | 4 | const readFile = promisify(fs.readFile) 5 | const writeFile = promisify(fs.writeFile) 6 | const exists = promisify(fs.exists) 7 | const mkdir = promisify(fs.mkdir) 8 | const deleteFile = promisify(fs.unlink) 9 | 10 | export { readFile, writeFile, exists, mkdir, deleteFile } 11 | -------------------------------------------------------------------------------- /packages/framework/src/utils/generateUuid.ts: -------------------------------------------------------------------------------- 1 | import short from "short-uuid" 2 | import { v4 } from "uuid" 3 | 4 | const generateUuid = () => v4() 5 | export default generateUuid 6 | 7 | const translator = short() 8 | export const generateShortUuid = translator.generate 9 | export const convertToShortUuid = translator.fromUUID 10 | export const convertToUuid = translator.toUUID 11 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/infrastructure/TrainTripReadContext.disk.ts: -------------------------------------------------------------------------------- 1 | import { generateKeyFromC } from "@fp-app/framework" 2 | import { ReadContext } from "@fp-app/io.diskdb" 3 | import { TrainTripView } from "../usecases/getTrainTrip" 4 | 5 | export default class TrainTripReadContext extends ReadContext { 6 | constructor() { 7 | super("trainTrip") 8 | } 9 | } 10 | 11 | export const trainTripReadContextKey = generateKeyFromC(TrainTripReadContext) 12 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@types/fp-app-global", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "author": "Patrick Roza ", 6 | "license": "MIT", 7 | "types": "index", 8 | "dependencies": {}, 9 | "devDependencies": { 10 | "@types/node": "12" 11 | }, 12 | "scripts": { 13 | "test": "jest", 14 | "test:compile": "tsc --noEmit", 15 | "test:lint": "eslint \"src/**/*.ts\"", 16 | "testsuite": "yarn test:compile && yarn test:lint && yarn test" 17 | } 18 | } -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/mediator/request.ts: -------------------------------------------------------------------------------- 1 | import { NamedHandlerWithDependencies, NamedRequestHandler, requestType } from "./registry" 2 | 3 | const request = (get: getRequestType): requestType => (requestHandler, input) => { 4 | const handler = get(requestHandler) 5 | return handler(input) 6 | } 7 | 8 | export default request 9 | 10 | type getRequestType = ( 11 | key: NamedHandlerWithDependencies, 12 | ) => NamedRequestHandler 13 | -------------------------------------------------------------------------------- /samples/basic/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: false, 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest", 5 | }, 6 | moduleNameMapper: { 7 | "^@/(.*)$": "/src/$1", 8 | }, 9 | watchPathIgnorePatterns: ["data/*", "router-schema.json"], 10 | globals: { 11 | "ts-jest": { 12 | diagnostics: false, 13 | }, 14 | }, 15 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 16 | testURL: "http://localhost:8110", 17 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 18 | } 19 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createDependencyNamespace } from "./createDependencyNamespace" 2 | export * from "./context.base" 3 | export { default as ContextBase } from "./context.base" 4 | export * from "./errors" 5 | export { default as executePostCommitHandlers } from "./executePostCommitHandlers" 6 | export * from "./SimpleContainer" 7 | export * from "./RouteBuilder" 8 | export { default as RouteBuilder } from "./RouteBuilder" 9 | export * from "./mediator" 10 | export { default as DomainEventHandler } from "./domainEventHandler" 11 | export * from "./pubsub" 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "collectCoverage": false, 3 | "transform": { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | "moduleNameMapper": { 7 | "^@/(.*)$": "/samples/basic/src/$1" 8 | }, 9 | globals: { 10 | 'ts-jest': { 11 | diagnostics: false 12 | } 13 | }, 14 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 15 | "testURL": "http://localhost:8110", 16 | "moduleFileExtensions": [ 17 | "ts", 18 | "tsx", 19 | "js", 20 | "jsx", 21 | "json", 22 | "node" 23 | ], 24 | "testPathIgnorePatterns": ["/node_modules/"] 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib", 3 | "eslint.autoFixOnSave": true, 4 | "eslint.validate": [ 5 | "javascript", 6 | "javascriptreact", 7 | { 8 | "language": "typescript", 9 | "autoFix": true 10 | }, 11 | { 12 | "language": "typescriptreact", 13 | "autoFix": true 14 | } 15 | ], 16 | "editor.formatOnSave": true, 17 | "[javascript]": { 18 | "editor.formatOnSave": false, 19 | }, 20 | "[javascriptreact]": { 21 | "editor.formatOnSave": false, 22 | }, 23 | "[typescript]": { 24 | "editor.formatOnSave": false, 25 | }, 26 | "[typescriptreact]": { 27 | "editor.formatOnSave": false, 28 | } 29 | } -------------------------------------------------------------------------------- /samples/basic/src/root.router.ts: -------------------------------------------------------------------------------- 1 | import { requestType, writeRouterSchema } from "@fp-app/framework" 2 | import { createRouterFromMap, KoaRouteBuilder } from "@fp-app/hosting.koa" 3 | import { DEFAULT_AUTH } from "./config" 4 | import createTrainTripRouter from "./TrainTrip.router" 5 | 6 | const createRootRouter = (request: requestType) => { 7 | const routerMap = new Map() 8 | routerMap.set("/train-trip", createTrainTripRouter()) 9 | routerMap.set("/train-trip-auth", createTrainTripRouter().enableBasicAuth(DEFAULT_AUTH)) 10 | writeRouterSchema(routerMap) 11 | 12 | const rootRouter = createRouterFromMap(routerMap, request) 13 | return rootRouter 14 | } 15 | 16 | export default createRootRouter 17 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function (w) { 2 | return { 3 | files: [ 4 | '**/*.ts', 5 | { pattern: '**/*.d.ts', ignore: true }, 6 | { pattern: '**/*.test.ts', ignore: true }, 7 | { pattern: 'node_modules', ignore: true } 8 | ], 9 | 10 | tests: [ 11 | '**/*.test.ts', 12 | { pattern: 'node_modules', ignore: true } 13 | ], 14 | // for node.js tests you need to set env property as well 15 | // https://wallabyjs.com/docs/integration/node.html 16 | env: { 17 | type: 'node', 18 | runner: 'node' 19 | }, 20 | 21 | testFramework: 'jest', 22 | compilers: { 23 | '**/*.ts?(x)': w.compilers.typeScript({ isolatedModules: true }) 24 | } 25 | }; 26 | }; -------------------------------------------------------------------------------- /packages/types/joi-to-json-schema.d.ts: -------------------------------------------------------------------------------- 1 | declare module "joi-to-json-schema" { 2 | function index(joi: any, ...args: any[]): any; 3 | namespace index { 4 | namespace TYPES { 5 | function alternatives(schema: any, joi: any, transformer: any): any; 6 | function any(schema: any): any; 7 | function array(schema: any, joi: any, transformer: any): any; 8 | function binary(schema: any, joi: any): any; 9 | function boolean(schema: any): any; 10 | function date(schema: any, joi: any): any; 11 | function number(schema: any, joi: any): any; 12 | function object(schema: any, joi: any, transformer: any): any; 13 | function string(schema: any, joi: any): any; 14 | } 15 | } 16 | export = index 17 | } 18 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/usecases/lockTrainTrip.ts: -------------------------------------------------------------------------------- 1 | import { createCommandWithDeps, DbError } from "@fp-app/framework" 2 | import { flatMap, map, pipe } from "@fp-app/neverthrow-extensions" 3 | import { DbContextKey, defaultDependencies } from "./types" 4 | 5 | const createCommand = createCommandWithDeps({ db: DbContextKey, ...defaultDependencies }) 6 | 7 | const lockTrainTrip = createCommand("lockTrainTrip", ({ db }) => 8 | pipe( 9 | map(({ trainTripId }) => trainTripId), 10 | flatMap(db.trainTrips.load), 11 | map(trainTrip => trainTrip.lock()), 12 | ), 13 | ) 14 | 15 | export default lockTrainTrip 16 | export interface Input { 17 | trainTripId: string 18 | } 19 | type LockTrainTripError = DbError 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "baseUrl": ".", 5 | "experimentalDecorators": true, 6 | "downlevelIteration": true, 7 | "declaration": true, 8 | "incremental": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "emitDecoratorMetadata": true, 12 | "lib": [ 13 | "es2018" 14 | ], 15 | "module": "commonjs", 16 | "outDir": "dist/ts", 17 | "paths": { 18 | "*": [ 19 | "types/*" 20 | ], 21 | }, 22 | "esModuleInterop": true, 23 | "removeComments": true, 24 | "sourceMap": true, 25 | "strict": true, 26 | "target": "es2018" 27 | }, 28 | "exclude": [ 29 | "dist", 30 | "node_modules" 31 | ] 32 | } -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/FutureDate.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "@fp-app/framework" 2 | import { err, ok, Result } from "@fp-app/neverthrow-extensions" 3 | 4 | // Can use for input, but for storage we should just store as date. 5 | // because it is temporal; what is today valid may be invalid tomorrow etc. 6 | export default class FutureDate { 7 | static create(dateStr: string): Result { 8 | const date = new Date(dateStr) 9 | if (!isInFuture(date)) { 10 | return err(new ValidationError(`${date.toDateString()} is not in future`)) 11 | } 12 | return ok(new FutureDate(date)) 13 | } 14 | private constructor(readonly value: Date) {} 15 | } 16 | 17 | const isInFuture = (date: Date) => date > new Date() 18 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/TravelClassDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from "@fp-app/framework" 2 | import { err, ok, Result } from "@fp-app/neverthrow-extensions" 3 | 4 | export default class TravelClassDefinition { 5 | static create(travelClassName: string): Result { 6 | if (!validtravelClasses.some(x => x === travelClassName)) { 7 | return err(new ValidationError(`${travelClassName} is not a valid travel class name`)) 8 | } 9 | return ok(new TravelClassDefinition(travelClassName)) 10 | } 11 | 12 | private constructor(readonly value: string) {} 13 | } 14 | 15 | const validtravelClasses = ["second", "first", "business"] 16 | 17 | export type TravelClassName = "first" | "second" | "business" 18 | -------------------------------------------------------------------------------- /packages/framework/src/entity.ts: -------------------------------------------------------------------------------- 1 | import Event from "./event" 2 | import { Writeable } from "./utils" 3 | 4 | export default abstract class Entity { 5 | private events: Event[] = [] 6 | 7 | constructor(readonly id: string) { 8 | // workaround so that we can make props look readonly on the outside, but allow to change on the inside. 9 | // doesn't work if assigned as property :/ 10 | Object.defineProperty(this, "w", { value: this }) 11 | } 12 | protected get w() { 13 | return this as Writeable 14 | } 15 | 16 | readonly intGetAndClearEvents = () => { 17 | const events = this.events 18 | this.events = [] 19 | return events 20 | } 21 | 22 | protected registerDomainEvent(evt: Event) { 23 | this.events.push(evt) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/pubsub.ts: -------------------------------------------------------------------------------- 1 | import Event from "../event" 2 | import { publishType, resolveEventType } from "./mediator" 3 | 4 | const processReceivedEvent = ({ 5 | publish, 6 | resolveEvent, 7 | }: { 8 | resolveEvent: resolveEventType 9 | publish: publishType 10 | }) => async (body: string) => { 11 | const { type, payload } = JSON.parse(body) as EventDTO 12 | const event = resolveEvent({ type, payload }) 13 | if (!event) { 14 | return 15 | } 16 | await publish(event) 17 | } 18 | 19 | export interface EventDTO { 20 | type: string 21 | payload: any 22 | } 23 | 24 | const createEventDTO = (evt: Event): EventDTO => ({ 25 | payload: evt, 26 | type: evt.constructor.name, 27 | }) 28 | 29 | export { createEventDTO, processReceivedEvent } 30 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/usecases/deleteTrainTrip.ts: -------------------------------------------------------------------------------- 1 | import { createCommandWithDeps, DbError } from "@fp-app/framework" 2 | import { flatMap, map, pipe, tee } from "@fp-app/neverthrow-extensions" 3 | import { DbContextKey, defaultDependencies } from "./types" 4 | 5 | const createCommand = createCommandWithDeps({ db: DbContextKey, ...defaultDependencies }) 6 | 7 | const deleteTrainTrip = createCommand("deleteTrainTrip", ({ db }) => 8 | pipe( 9 | map(({ trainTripId }) => trainTripId), 10 | flatMap(db.trainTrips.load), 11 | // TODO: this should normally be on a different object. 12 | map(tee(x => x.delete())), 13 | map(db.trainTrips.remove), 14 | ), 15 | ) 16 | 17 | export default deleteTrainTrip 18 | export interface Input { 19 | trainTripId: string 20 | } 21 | type DeleteTrainTripError = DbError 22 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/usecases/registerCloud.ts: -------------------------------------------------------------------------------- 1 | import { createCommandWithDeps, DbError } from "@fp-app/framework" 2 | import { flatMap, map, pipe, toTup } from "@fp-app/neverthrow-extensions" 3 | import { DbContextKey, defaultDependencies, sendCloudSyncKey } from "./types" 4 | 5 | const createCommand = createCommandWithDeps({ 6 | db: DbContextKey, 7 | sendCloudSync: sendCloudSyncKey, 8 | ...defaultDependencies, 9 | }) 10 | 11 | const registerCloud = createCommand("registerCloud", ({ db, sendCloudSync }) => 12 | pipe( 13 | map(({ trainTripId }) => trainTripId), 14 | flatMap(db.trainTrips.load), 15 | flatMap(toTup(sendCloudSync)), 16 | map(([opportunityId, trainTrip]) => trainTrip.assignOpportunity(opportunityId)), 17 | ), 18 | ) 19 | 20 | export default registerCloud 21 | export interface Input { 22 | trainTripId: string 23 | } 24 | -------------------------------------------------------------------------------- /packages/neverthrow-extensions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp-app/neverthrow-extensions", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "author": "Patrick Roza ", 6 | "license": "MIT", 7 | "dependencies": { 8 | "neverthrow": "https://github.com/patroza/neverthrow.git#909a1de01bb3760f8e9433648ad783e93e5f289b" 9 | }, 10 | "devDependencies": { 11 | "@typescript-eslint/eslint-plugin": "^1.10.2", 12 | "@typescript-eslint/parser": "^1.10.2", 13 | "eslint": "^5.16.0", 14 | "eslint-config-prettier": "^5.0.0", 15 | "eslint-plugin-prettier": "^3.1.0", 16 | "prettier": "^1.18.2" 17 | }, 18 | "scripts": { 19 | "prettier": "prettier --write \"src/**/*.ts\"", 20 | "test": "jest", 21 | "test:compile": "tsc --noEmit", 22 | "test:lint": "eslint \"src/**/*.ts\"", 23 | "testsuite": "yarn test:compile && yarn test:lint && yarn test" 24 | } 25 | } -------------------------------------------------------------------------------- /samples/basic/src/resolveIntegrationEvent.ts: -------------------------------------------------------------------------------- 1 | import { EventDTO, logger, resolveEventType } from "@fp-app/framework" 2 | import { CustomerRequestedChanges } from "./TrainTrip/eventhandlers/integration.events" 3 | 4 | const resolveEvent = (): resolveEventType => (evt: IntegrationEvents) => { 5 | logger.log("Received integration event", evt.type, evt.payload) 6 | 7 | switch (evt.type) { 8 | case "CustomerRequestedChanges": 9 | return new CustomerRequestedChanges(evt.payload.trainTripId, evt.payload.itineraryId) 10 | default: { 11 | logger.warn("Received event, but have no handler: ", evt) 12 | return undefined 13 | } 14 | } 15 | } 16 | 17 | export interface CustomerRequestedChangesDTO extends EventDTO { 18 | type: "CustomerRequestedChanges" 19 | payload: { trainTripId: string; itineraryId: string } 20 | } 21 | type IntegrationEvents = CustomerRequestedChangesDTO 22 | 23 | export default resolveEvent 24 | -------------------------------------------------------------------------------- /packages/types/global.d.ts: -------------------------------------------------------------------------------- 1 | type Diff = T extends U ? never : T 2 | interface FunctionDefinitions { [key: string]: (...args: any[]) => any } 3 | 4 | type ReturnTypes = { 5 | [P in keyof T]: ReturnType 6 | } 7 | 8 | type FilterFlags = { 9 | [Key in keyof Base]: 10 | Base[Key] extends Condition ? Key : never 11 | } 12 | type AllowedNames = 13 | FilterFlags[keyof Base] 14 | 15 | type SubType = 16 | Pick> 17 | 18 | 19 | 20 | // tslint:disable-next-line:ban-types 21 | // type NonFunction = T extends ((...args: any[]) => any) | Function ? never : T 22 | // type SubType = Pick 25 | 26 | // tslint:disable-next-line:ban-types 27 | // type NotMethods = SubType, Function | Date | string | number | undefined | null> 28 | -------------------------------------------------------------------------------- /packages/framework/src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | import invariant from "invariant" 2 | 3 | interface Assert { 4 | (testValue: boolean, format: string, ...extra: any[]): void 5 | isNotNull: (object: any) => void 6 | } 7 | 8 | /** 9 | * Throws invariant error with format as text when testValue is Falsey 10 | * @param {*} testValue 11 | * @param {String} format 12 | * @param {...extra} extra 13 | */ 14 | const assert = ((testValue: boolean, format: string, ...extra) => invariant(testValue, format, ...extra)) as Assert 15 | 16 | /** 17 | * Asserts that any of the specified properties are not null 18 | * @param {Object} properties 19 | */ 20 | const propertiesAreNotNull = (properties: { [key: string]: any }) => { 21 | for (const prop of Object.keys(properties)) { 22 | isNotNull(properties[prop], prop) 23 | } 24 | } 25 | 26 | /** 27 | * Asserts that value is not null 28 | * @param {*} value 29 | * @param {string} name 30 | */ 31 | const isNotNull = (value: any, name: string) => invariant(value != null, `${name} must not be null`) 32 | 33 | assert.isNotNull = propertiesAreNotNull 34 | 35 | export default assert 36 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/errors.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-classes-per-file 2 | 3 | import { ErrorBase } from "../errors" 4 | 5 | export type DbError = RecordNotFound | ConnectionError | OptimisticLockError | CouldNotAquireDbLockError 6 | export type ApiError = RecordNotFound | ConnectionError 7 | 8 | export class ConnectionError extends ErrorBase { 9 | readonly name = "ConnectionError" 10 | constructor(readonly error: Error) { 11 | super("A connection error ocurred") 12 | } 13 | } 14 | 15 | export class RecordNotFound extends ErrorBase { 16 | readonly name = "RecordNotFound" 17 | constructor(readonly type: string, readonly id: string) { 18 | super(`The ${type} with ${id} was not found`) 19 | } 20 | } 21 | 22 | export class CouldNotAquireDbLockError extends Error { 23 | readonly name = "CouldNotAquireDbLockError" 24 | constructor(readonly type: string, readonly id: string, readonly error: Error) { 25 | super(`Couldn't lock db record ${type}: ${id}`) 26 | } 27 | } 28 | 29 | export class OptimisticLockError extends Error { 30 | readonly name = "OptimisticLockError" 31 | constructor(readonly type: string, readonly id: string) { 32 | super(`Existing ${type} ${id} record changed`) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/executePostCommitHandlers.ts: -------------------------------------------------------------------------------- 1 | import { benchLog, getLogger } from "../utils" 2 | import { NamedHandlerWithDependencies, requestInNewScopeType } from "./mediator" 3 | 4 | const logger = getLogger("executePostCommitHandlers") 5 | 6 | const executePostCommitHandlers = ({ executeIntegrationEvent }: { executeIntegrationEvent: requestInNewScopeType }) => ( 7 | eventsMap: eventsMapType, 8 | ) => 9 | process.nextTick(async () => { 10 | try { 11 | await tryProcessEvents(executeIntegrationEvent, eventsMap) 12 | } catch (err) { 13 | logger.error("Unexpected error during applying IntegrationEvents", err) 14 | } 15 | }) 16 | 17 | async function tryProcessEvents(executeIntegrationEvent: requestInNewScopeType, eventsMap: eventsMapType) { 18 | for (const [evt, hndlrs] of eventsMap.entries()) { 19 | for (const pch of hndlrs) { 20 | const r = await benchLog(() => executeIntegrationEvent(pch, evt), "postCommitHandler") 21 | if (r.isErr()) { 22 | logger.warn(`Error during applying IntegrationEvents`, r) 23 | } 24 | } 25 | } 26 | } 27 | 28 | type eventsMapType = Map[]> 29 | 30 | export default executePostCommitHandlers 31 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/PaxDefinition.ts: -------------------------------------------------------------------------------- 1 | import { createValidator, Joi, predicate, typedKeysOf, ValidationError } from "@fp-app/framework" 2 | import { flatMap, map, Result } from "@fp-app/neverthrow-extensions" 3 | 4 | export default class PaxDefinition { 5 | static create(pax: Pax): Result { 6 | return validate(pax).pipe( 7 | flatMap(predicate(p => typedKeysOf(p).some(k => p[k] > 0), "pax requires at least 1 person")), 8 | flatMap( 9 | predicate(p => typedKeysOf(p).reduce((prev, cur) => (prev += p[cur]), 0) <= 6, "pax must be 6 or less people"), 10 | ), 11 | map(validatedPax => new PaxDefinition(validatedPax)), 12 | ) 13 | } 14 | 15 | private constructor(readonly value: Pax) {} 16 | } 17 | 18 | const paxEntrySchema = Joi.number() 19 | .integer() 20 | .min(0) 21 | .max(6) 22 | .required() 23 | export const paxSchema = Joi.object({ 24 | adults: paxEntrySchema, 25 | babies: paxEntrySchema, 26 | children: paxEntrySchema, 27 | infants: paxEntrySchema, 28 | teenagers: paxEntrySchema, 29 | }).required() 30 | const validate = createValidator(paxSchema) 31 | 32 | export interface Pax { 33 | adults: number 34 | babies: number 35 | children: number 36 | infants: number 37 | teenagers: number 38 | } 39 | -------------------------------------------------------------------------------- /packages/framework/src/errors.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-classes-per-file 2 | 3 | const combineValidationErrors = (errors: E[]) => new CombinedValidationError(errors) 4 | 5 | const toFieldError = (fieldName: string) => (err: ValidationError) => new FieldValidationError(fieldName, err) 6 | 7 | export { combineValidationErrors, toFieldError } 8 | 9 | export abstract class ErrorBase { 10 | constructor(readonly message: string) {} 11 | 12 | toString() { 13 | return `${this.constructor.name}\n${this.message}` 14 | } 15 | } 16 | 17 | export class ValidationError extends ErrorBase { 18 | readonly name = "ValidationError" 19 | } 20 | 21 | export class InvalidStateError extends ErrorBase { 22 | readonly name = "InvalidStateError" 23 | } 24 | 25 | export class ForbiddenError extends ErrorBase { 26 | readonly name = "ForbiddenError" 27 | } 28 | 29 | export class FieldValidationError extends ValidationError { 30 | constructor(readonly fieldName: string, readonly error: ValidationError | ErrorBase) { 31 | super(error.message) 32 | } 33 | 34 | toString() { 35 | return `${this.fieldName}: ${this.message}` 36 | } 37 | } 38 | 39 | export class CombinedValidationError extends ValidationError { 40 | constructor(readonly errors: ValidationError[]) { 41 | super(errors.join("\n")) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/context.base.ts: -------------------------------------------------------------------------------- 1 | import { PipeFunction, PipeFunctionN, Result } from "@fp-app/neverthrow-extensions" 2 | import Event from "../event" 3 | import { Disposable } from "../utils" 4 | import DomainEventHandler from "./domainEventHandler" 5 | import { DbError } from "./errors" 6 | import { autoinject } from "./SimpleContainer" 7 | 8 | // tslint:disable-next-line:max-classes-per-file 9 | @autoinject 10 | export default abstract class ContextBase implements Disposable { 11 | private disposed = false 12 | 13 | constructor(private readonly eventHandler: DomainEventHandler) {} 14 | 15 | readonly save = (): Promise> => { 16 | if (this.disposed) { 17 | throw new Error("The context is already disposed") 18 | } 19 | return this.eventHandler.commitAndPostEvents(() => this.getAndClearEvents(), () => this.saveImpl()) 20 | } 21 | 22 | dispose() { 23 | this.disposed = true 24 | } 25 | 26 | protected abstract getAndClearEvents(): Event[] 27 | 28 | protected abstract async saveImpl(): Promise> 29 | } 30 | 31 | export interface UnitOfWork extends Disposable { 32 | save: PipeFunctionN 33 | } 34 | 35 | export interface RecordContext { 36 | add: (record: T) => void 37 | remove: (record: T) => void 38 | load: PipeFunction 39 | } 40 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin 5 | 6 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 7 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 8 | ], 9 | parserOptions: { 10 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 11 | sourceType: 'module', // Allows for the use of imports 12 | ecmaFeatures: { 13 | jsx: true, // Allows for the parsing of JSX 14 | }, 15 | }, 16 | rules: { 17 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 18 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 19 | "@typescript-eslint/no-non-null-assertion": "off", 20 | "@typescript-eslint/no-use-before-define": "off", 21 | "@typescript-eslint/no-parameter-properties": "off", 22 | "@typescript-eslint/explicit-function-return-type": "off", 23 | "@typescript-eslint/explicit-member-accessibility": "no-public" 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/mediator/publish.ts: -------------------------------------------------------------------------------- 1 | import { err, PipeFunction, PipeFunctionN, Result, success } from "@fp-app/neverthrow-extensions" 2 | 3 | import Event from "../../event" 4 | import { getLogger } from "../../utils" 5 | 6 | const logger = getLogger("publish") 7 | 8 | const publish = ( 9 | getMany: (evt: TInput) => PipeFunction[], 10 | ): publishType => async (evt: TInput) => { 11 | const hndl = getMany(evt) 12 | logger.log( 13 | `Publishing Domain event: ${evt.constructor.name} (${hndl ? hndl.length : 0} handlers)`, 14 | JSON.stringify(evt), 15 | ) 16 | 17 | if (!hndl) { 18 | return success() 19 | } 20 | 21 | for (const evtHandler of hndl) { 22 | logger.log(`Handling ${evtHandler.name}`) 23 | const r = await evtHandler(evt) 24 | if (r.isErr()) { 25 | return err(r.error) 26 | } 27 | } 28 | 29 | logger.log(`Published event: ${evt.constructor.name}`) 30 | return success() 31 | } 32 | 33 | export default publish 34 | 35 | // tslint:disable-next-line:max-line-length 36 | export type publishType = (evt: TInput) => Promise> 37 | 38 | export type DomainEventReturnType = void | IntegrationEventReturnType 39 | export interface IntegrationEventReturnType { 40 | consistency?: "eventual" | "strict" 41 | handler: PipeFunctionN 42 | } 43 | -------------------------------------------------------------------------------- /packages/io.diskdb/src/ReadContext.ts: -------------------------------------------------------------------------------- 1 | import { RecordNotFound } from "@fp-app/framework" 2 | import { err, ok, Result } from "@fp-app/neverthrow-extensions" 3 | import { getFilename } from "./RecordContext" 4 | import { deleteFile, exists, readFile, writeFile } from "./utils" 5 | 6 | const deleteReadContextEntry = async (type: string, id: string) => { 7 | // Somehow this return an empty object, so we void it here. 8 | await deleteFile(getFilename(type, id)) 9 | } 10 | 11 | const createOrUpdateReadContextEntry = (type: string, id: string, value: T) => 12 | writeFile(getFilename(type, id), JSON.stringify(value)) 13 | 14 | const readReadContextEntry = async (type: string, id: string) => { 15 | const json = await readFile(getFilename(type, id), { encoding: "utf-8" }) 16 | return JSON.parse(json) as T 17 | } 18 | 19 | export default class ReadContext { 20 | constructor(readonly type: string) { 21 | this.type = `read-${type}` 22 | } 23 | 24 | readonly create = (id: string, value: T) => createOrUpdateReadContextEntry(this.type, id, value) 25 | readonly delete = (id: string) => deleteReadContextEntry(this.type, id) 26 | readonly read = async (id: string): Promise> => { 27 | const filePath = getFilename(this.type, id) 28 | if (!(await exists(filePath))) { 29 | return err(new RecordNotFound(this.type, id)) 30 | } 31 | const r = await readReadContextEntry(this.type, id) 32 | return ok(r) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /samples/basic/src/start-server.ts: -------------------------------------------------------------------------------- 1 | import { logger, setLogger } from "@fp-app/framework" 2 | import { 3 | handleAuthenticationFailedMiddleware, 4 | logRequestTime, 5 | saveStartTime, 6 | setupNamespace, 7 | } from "@fp-app/hosting.koa" 8 | import Koa from "koa" 9 | import bodyParser from "koa-bodyparser" 10 | import { PORT } from "./config" 11 | import createRoot from "./root" 12 | import createRootRouter from "./root.router" 13 | 14 | const startServer = async () => { 15 | const { addToLoggingContext, bindLogger, initialize, setupRequestContext, request } = createRoot() 16 | 17 | await initialize() 18 | 19 | const rootRouter = createRootRouter(request) 20 | 21 | setLogger({ 22 | addToLoggingContext, 23 | // tslint:disable-next-line:no-console 24 | debug: bindLogger(console.debug), 25 | // tslint:disable-next-line:no-console 26 | error: bindLogger(console.error), 27 | // tslint:disable-next-line:no-console 28 | log: bindLogger(console.log), 29 | // tslint:disable-next-line:no-console 30 | warn: bindLogger(console.warn), 31 | }) 32 | 33 | const app = new Koa() 34 | .use(saveStartTime) 35 | .use(setupNamespace({ setupRequestContext })) 36 | .use(logRequestTime) 37 | .use(bodyParser()) 38 | .use(handleAuthenticationFailedMiddleware) 39 | .use(rootRouter.allowedMethods()) 40 | .use(rootRouter.routes()) 41 | 42 | return app.listen(PORT, () => logger.log("server listening on 3535")) 43 | } 44 | 45 | startServer().catch(logger.error) 46 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/usecases/getTrainTrip.ts: -------------------------------------------------------------------------------- 1 | // TODO: we have to find out a way on how we can prevent serializing domain objects to the outside world by accident 2 | // One way could that it's somehow whitelisted (ie no behavior allowed) 3 | // or that a serializer must be presented at all times, if no serializer, then automatically void.. 4 | // the alternative is making sure there are return types defined in Typescript, and e.g validated with Tests. 5 | // to make sure accidental `any` casts are catched. 6 | 7 | import { createQueryWithDeps, DbError } from "@fp-app/framework" 8 | import { flatMap, map, pipe } from "@fp-app/neverthrow-extensions" 9 | import { trainTripReadContextKey } from "../infrastructure/TrainTripReadContext.disk" 10 | import { Pax } from "../PaxDefinition" 11 | import { TravelClassName } from "../TravelClassDefinition" 12 | import { defaultDependencies } from "./types" 13 | 14 | const createQuery = createQueryWithDeps({ readCtx: trainTripReadContextKey, ...defaultDependencies }) 15 | 16 | const getTrainTrip = createQuery("getTrainTrip", ({ readCtx }) => 17 | pipe( 18 | map(({ trainTripId }) => trainTripId), 19 | flatMap(readCtx.read), 20 | ), 21 | ) 22 | 23 | export default getTrainTrip 24 | export interface Input { 25 | trainTripId: string 26 | } 27 | 28 | export interface TrainTripView { 29 | id: string 30 | createdAt: Date 31 | 32 | allowUserModification: boolean 33 | 34 | pax: Pax 35 | travelClass: TravelClassName 36 | travelClasses: { templateId: string; name: TravelClassName }[] 37 | startDate: Date 38 | } 39 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/Trip.ts: -------------------------------------------------------------------------------- 1 | import { assert, InvalidStateError } from "@fp-app/framework" 2 | import { err, ok, Result } from "@fp-app/neverthrow-extensions" 3 | import { TemplateId } from "./TrainTrip" 4 | import { TravelClassName } from "./TravelClassDefinition" 5 | 6 | export default class Trip { 7 | static create(serviceLevels: TravelClass[]): Result { 8 | if (!serviceLevels.length) { 9 | return err(new InvalidStateError("A trip requires at least 1 service level")) 10 | } 11 | return ok(new Trip(serviceLevels)) 12 | } 13 | 14 | constructor(readonly travelClasses: TravelClass[]) { 15 | assert(Boolean(travelClasses.length), "A trip must have at least 1 travel class") 16 | } 17 | } 18 | 19 | // tslint:disable-next-line:max-classes-per-file 20 | export class TripWithSelectedTravelClass { 21 | static create(trip: Trip, travelClassName: TravelClassName): Result { 22 | const selectedTravelClass = trip.travelClasses.find(x => x.name === travelClassName) 23 | if (!selectedTravelClass) { 24 | return err(new InvalidStateError("The service level is not available")) 25 | } 26 | return ok(new TripWithSelectedTravelClass(trip.travelClasses, selectedTravelClass)) 27 | } 28 | private constructor(readonly travelClasses: TravelClass[], readonly currentTravelClass: TravelClass) {} 29 | } 30 | // tslint:disable-next-line:max-classes-per-file 31 | export class TravelClass { 32 | readonly createdAt = new Date() 33 | 34 | constructor(public templateId: TemplateId, public name: TravelClassName) {} 35 | } 36 | -------------------------------------------------------------------------------- /packages/framework/src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import { err, ok, Result } from "@fp-app/neverthrow-extensions" 2 | import Joi from "@hapi/joi" 3 | import { CombinedValidationError, FieldValidationError, ValidationError } from "../errors" 4 | export { Joi } 5 | import convert from "joi-to-json-schema" 6 | 7 | const createValidator = (schema: any): ValidatorType => { 8 | const validator = (object: TIn): Result => { 9 | const r = Joi.validate(object, schema, { abortEarly: false }) 10 | if (r.error) { 11 | return err(new CombinedValidationError(r.error.details.map(x => new FieldValidationError(x.path.join("."), x)))) 12 | } 13 | return ok(r.value) 14 | } 15 | validator.jsonSchema = convert(schema) 16 | return validator 17 | } 18 | 19 | export type ValidatorType = ((object: TIn) => Result) & { jsonSchema: string } 20 | 21 | const predicate = (pred: (inp: T) => boolean, errMsg: string) => ( 22 | inp: T, 23 | ): Result => { 24 | if (pred(inp)) { 25 | return ok(inp) 26 | } 27 | return err(new ValidationError(errMsg)) 28 | } 29 | 30 | const valueEquals = ({ value }: { value: T }, otherValue: T, extracter?: (v: T) => any) => 31 | extracter ? extracter(value) === extracter(otherValue) : value === otherValue 32 | const valueEquals2 = ({ value }: { value: T }, { value: otherValue }: { value: T }, extracter?: (v: T) => any) => 33 | extracter ? extracter(value) === extracter(otherValue) : value === otherValue 34 | 35 | export { createValidator, predicate, valueEquals, valueEquals2 } 36 | -------------------------------------------------------------------------------- /packages/framework/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp-app/framework", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "author": "Patrick Roza ", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@fp-app/neverthrow-extensions": "0.1.0", 9 | "@hapi/joi": "^15.0.3", 10 | "chalk": "^2.4.2", 11 | "cls-hooked": "^4.2.2", 12 | "date-fns": "^1.30.1", 13 | "invariant": "^2.2.4", 14 | "joi-to-json-schema": "^5.0.0", 15 | "lodash": "^4.17.11", 16 | "reflect-metadata": "^0.1.13", 17 | "short-uuid": "^3.1.1", 18 | "tsconfig-paths": "^3.8.0", 19 | "typescript": "^3.5.2", 20 | "uuid": "^3.3.2" 21 | }, 22 | "devDependencies": { 23 | "@types/cls-hooked": "^4.3.0", 24 | "@types/fp-app-global": "0.1.0", 25 | "@types/hapi__joi": "^15.0.1", 26 | "@types/invariant": "^2.2.29", 27 | "@types/jest": "^24.0.13", 28 | "@types/joi": "^14.3.3", 29 | "@types/lodash": "^4.14.129", 30 | "@types/node": "12", 31 | "@types/uuid": "^3.4.4", 32 | "@typescript-eslint/eslint-plugin": "^1.10.2", 33 | "@typescript-eslint/parser": "^1.10.2", 34 | "eslint": "^5.16.0", 35 | "eslint-config-prettier": "^5.0.0", 36 | "eslint-plugin-prettier": "^3.1.0", 37 | "jest": "^24.8.0", 38 | "nodemon": "^1.19.0", 39 | "prettier": "^1.18.2", 40 | "ts-jest": "^24.0.2", 41 | "ts-node": "^8.1.0", 42 | "tslint": "^5.16.0" 43 | }, 44 | "scripts": { 45 | "prettier": "prettier --write \"src/**/*.ts\"", 46 | "test": "jest", 47 | "test:compile": "tsc --noEmit", 48 | "test:lint": "eslint \"src/**/*.ts\"", 49 | "testsuite": "yarn test:compile && yarn test:lint && yarn test" 50 | } 51 | } -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/decorators/index.ts: -------------------------------------------------------------------------------- 1 | import { flatMap, flatTee, liftType, mapErr, Result } from "@fp-app/neverthrow-extensions" 2 | import { benchLog, logger, using } from "../../utils" 3 | import { DbError } from "../errors" 4 | import { configureDependencies, NamedRequestHandler, UOWKey } from "../mediator" 5 | import { requestTypeSymbol } from "../SimpleContainer" 6 | 7 | const loggingDecorator = (): RequestDecorator => request => (key, input) => { 8 | const prefix = `${key.name} ${key[requestTypeSymbol]}` 9 | return benchLog( 10 | () => 11 | using(logger.addToLoggingContext({ request: prefix }), async () => { 12 | logger.log(`${prefix} input`, input) 13 | const result = await request(key, input) 14 | logger.log(`${prefix} result`, result) 15 | return result 16 | }), 17 | prefix, 18 | ) 19 | } 20 | 21 | const uowDecorator = configureDependencies( 22 | { unitOfWork: UOWKey }, 23 | "uowDecorator", 24 | ({ unitOfWork }): RequestDecorator => request => (key, input) => { 25 | if (key[requestTypeSymbol] !== "COMMAND" && key[requestTypeSymbol] !== "INTEGRATIONEVENT") { 26 | return request(key, input) 27 | } 28 | 29 | return request(key, input).pipe( 30 | mapErr(liftType()), 31 | flatMap(flatTee(unitOfWork.save)), 32 | ) 33 | }, 34 | ) 35 | 36 | export { loggingDecorator, uowDecorator } 37 | 38 | type RequestDecorator = ( 39 | request: (key: NamedRequestHandler, input: TInput) => Promise>, 40 | ) => (key: NamedRequestHandler, input: TInput) => Promise> 41 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/usecases/others.ts: -------------------------------------------------------------------------------- 1 | //// Separate endpoint sample; unused. 2 | 3 | import { 4 | createCommandWithDeps, 5 | ForbiddenError, 6 | InvalidStateError, 7 | RecordNotFound, 8 | ValidationError, 9 | } from "@fp-app/framework" 10 | import { flatMap, pipe, toFlatTup, toTup } from "@fp-app/neverthrow-extensions" 11 | import FutureDate from "../FutureDate" 12 | import TravelClassDefinition, { TravelClassName } from "../TravelClassDefinition" 13 | import { DbContextKey, defaultDependencies } from "./types" 14 | 15 | const createCommand = createCommandWithDeps({ db: DbContextKey, ...defaultDependencies }) 16 | 17 | export const changeStartDate = createCommand( 18 | "changeStartDate", 19 | ({ db }) => 20 | pipe( 21 | flatMap(toTup(({ startDate }) => FutureDate.create(startDate))), 22 | flatMap(toFlatTup(([, i]) => db.trainTrips.load(i.trainTripId))), 23 | flatMap(([trainTrip, sd]) => trainTrip.changeStartDate(sd)), 24 | ), 25 | ) 26 | 27 | export interface ChangeStartDateInput { 28 | trainTripId: string 29 | startDate: string 30 | } 31 | type ChangeStartDateError = ValidationError | ForbiddenError | RecordNotFound 32 | 33 | export const changeTravelClass = createCommand( 34 | "changeTravelClass", 35 | ({ db }) => 36 | pipe( 37 | flatMap(toTup(({ travelClass }) => TravelClassDefinition.create(travelClass))), 38 | flatMap(toFlatTup(([, i]) => db.trainTrips.load(i.trainTripId))), 39 | flatMap(([trainTrip, sl]) => trainTrip.changeTravelClass(sl)), 40 | ), 41 | ) 42 | 43 | export interface ChangeTravelClassInput { 44 | trainTripId: string 45 | travelClass: TravelClassName 46 | } 47 | type ChangeTravelClassError = ForbiddenError | InvalidStateError | ValidationError | RecordNotFound 48 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip.router.ts: -------------------------------------------------------------------------------- 1 | import { createValidator, Joi } from "@fp-app/framework" 2 | import { KoaRouteBuilder } from "@fp-app/hosting.koa" 3 | import { paxSchema } from "./TrainTrip/PaxDefinition" 4 | import changeTrainTrip from "./TrainTrip/usecases/changeTrainTrip" 5 | import createTrainTrip from "./TrainTrip/usecases/createTrainTrip" 6 | import deleteTrainTrip from "./TrainTrip/usecases/deleteTrainTrip" 7 | import getTrainTrip from "./TrainTrip/usecases/getTrainTrip" 8 | import lockTrainTrip from "./TrainTrip/usecases/lockTrainTrip" 9 | 10 | const createTrainTripRouter = () => 11 | new KoaRouteBuilder() 12 | .post("/", createTrainTrip, { 13 | validator: createValidator( 14 | Joi.object({ 15 | pax: paxSchema.required(), 16 | startDate: Joi.date().required(), 17 | templateId: Joi.string().required(), 18 | }).required(), 19 | ), 20 | }) 21 | .get("/:trainTripId", getTrainTrip, { 22 | validator: createValidator(routeWithTrainTripId), 23 | }) 24 | .patch("/:trainTripId", changeTrainTrip, { 25 | validator: createValidator( 26 | Joi.object({ 27 | pax: paxSchema, 28 | startDate: Joi.date(), 29 | trainTripId: trainTripIdValidator, 30 | travelClass: Joi.string(), 31 | }) 32 | .or("pax", "travelClass", "startDate") 33 | .required(), 34 | ), 35 | }) 36 | .delete("/:trainTripId", deleteTrainTrip, { 37 | validator: createValidator(routeWithTrainTripId), 38 | }) 39 | .post("/:trainTripId/lock", lockTrainTrip, { 40 | validator: createValidator(routeWithTrainTripId), 41 | }) 42 | 43 | const trainTripIdValidator = Joi.string() 44 | .guid() 45 | .required() 46 | const routeWithTrainTripId = Joi.object({ 47 | trainTripId: trainTripIdValidator, 48 | }).required() 49 | 50 | export default createTrainTripRouter 51 | -------------------------------------------------------------------------------- /packages/framework/README.md: -------------------------------------------------------------------------------- 1 | # @fp-app/framework 2 | 3 | ## Dependency Injection 4 | 5 | ### Class vs Function sample 6 | 7 | ``` 8 | interface ISomething { 9 | handle(x: X): void 10 | } 11 | 12 | export class Something implements ISomething { 13 | constructor(private readonly somethingElse: SomethingElse) {} 14 | 15 | public handle(x: X) { 16 | // ...impl 17 | this.somethingElse(...) 18 | } 19 | } 20 | ``` 21 | 22 | ``` 23 | type SomethingType = (x: X) => void 24 | 25 | const Something = ({somethingElse}: {somethingElse: SomethingElse}): SomethingType => 26 | x => { // type of X is inferred by SomethingType 27 | // ...impl 28 | somethingElse(...) 29 | } 30 | 31 | export default Something // we export separately because then the function will be assigned .name "Something" 32 | ``` 33 | 34 | For singular functions, implementing the additional `public handle(x: X) {` and on the interface is what we would save. 35 | 36 | 37 | #### With dependency configuration 38 | 39 | ``` 40 | interface ISomething { 41 | handle(x: X): void 42 | } 43 | 44 | @autoinject 45 | export class Something implements ISomething { 46 | constructor(private readonly somethingElse: SomethingElse) {} 47 | 48 | public handle(x: X) { 49 | // ...impl 50 | this.somethingElse(...) 51 | } 52 | } 53 | ``` 54 | 55 | ``` 56 | type SomethingType = (x: X) => void 57 | 58 | const Something = configureDependencies("Something", {somethingElse: SomethingElse}, 59 | ({somethingElse}: {somethingElse: SomethingElse}): SomethingType => 60 | x => { // type of X is inferred by SomethingType 61 | // ...impl 62 | somethingElse(...) 63 | } 64 | ) 65 | export default Something // we export separately because then the function will be assigned .name "Something" 66 | ``` 67 | 68 | here it gets a bit more hairy, because we loose the "Something" name assignment because we create an anonymous function. 69 | (alternative would be to pass it a named `function Something()` instead) 70 | -------------------------------------------------------------------------------- /packages/hosting.koa/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp-app/hosting.koa", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "author": "Patrick Roza ", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@hapi/joi": "^15.0.3", 9 | "chalk": "^2.4.2", 10 | "cls-hooked": "^4.2.2", 11 | "invariant": "^2.2.4", 12 | "joi-to-json-schema": "^5.0.0", 13 | "koa": "^2.7.0", 14 | "koa-basic-auth": "^4.0.0", 15 | "koa-bodyparser": "^4.2.1", 16 | "koa-router": "^7.4.0", 17 | "lodash": "^4.17.11", 18 | "on-finished": "^2.3.0", 19 | "proper-lockfile": "^4.1.1", 20 | "short-uuid": "^3.1.1", 21 | "tsconfig-paths": "^3.8.0", 22 | "typescript": "^3.5.2", 23 | "uuid": "^3.3.2" 24 | }, 25 | "devDependencies": { 26 | "@types/cls-hooked": "^4.3.0", 27 | "@types/hapi__joi": "^15.0.1", 28 | "@types/invariant": "^2.2.29", 29 | "@types/jest": "^24.0.13", 30 | "@types/joi": "^14.3.3", 31 | "@types/koa": "^2.0.48", 32 | "@types/koa-basic-auth": "^2.0.3", 33 | "@types/koa-bodyparser": "^4.2.2", 34 | "@types/koa-router": "^7.0.40", 35 | "@types/lodash": "^4.14.129", 36 | "@types/node": "12", 37 | "@types/on-finished": "^2.3.1", 38 | "@types/proper-lockfile": "^4.1.0", 39 | "@types/uuid": "^3.4.4", 40 | "@typescript-eslint/eslint-plugin": "^1.10.2", 41 | "@typescript-eslint/parser": "^1.10.2", 42 | "eslint": "^5.16.0", 43 | "eslint-config-prettier": "^5.0.0", 44 | "eslint-plugin-prettier": "^3.1.0", 45 | "jest": "^24.8.0", 46 | "nodemon": "^1.19.0", 47 | "prettier": "^1.18.2", 48 | "ts-jest": "^24.0.2", 49 | "ts-node": "^8.1.0", 50 | "tslint": "^5.16.0" 51 | }, 52 | "scripts": { 53 | "prettier": "prettier --write \"src/**/*.ts\"", 54 | "test": "jest", 55 | "test:compile": "tsc --noEmit", 56 | "test:lint": "eslint \"src/**/*.ts\"", 57 | "testsuite": "yarn test:compile && yarn test:lint && yarn test" 58 | } 59 | } -------------------------------------------------------------------------------- /packages/io.diskdb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp-app/io.diskdb", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "author": "Patrick Roza ", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@hapi/joi": "^15.0.3", 9 | "chalk": "^2.4.2", 10 | "cls-hooked": "^4.2.2", 11 | "flatted": "^2.0.0", 12 | "invariant": "^2.2.4", 13 | "joi-to-json-schema": "^5.0.0", 14 | "koa": "^2.7.0", 15 | "koa-basic-auth": "^4.0.0", 16 | "koa-bodyparser": "^4.2.1", 17 | "koa-router": "^7.4.0", 18 | "lodash": "^4.17.11", 19 | "on-finished": "^2.3.0", 20 | "proper-lockfile": "^4.1.1", 21 | "short-uuid": "^3.1.1", 22 | "tsconfig-paths": "^3.8.0", 23 | "typescript": "^3.5.2", 24 | "uuid": "^3.3.2" 25 | }, 26 | "devDependencies": { 27 | "@types/cls-hooked": "^4.3.0", 28 | "@types/hapi__joi": "^15.0.1", 29 | "@types/invariant": "^2.2.29", 30 | "@types/jest": "^24.0.13", 31 | "@types/joi": "^14.3.3", 32 | "@types/koa": "^2.0.48", 33 | "@types/koa-basic-auth": "^2.0.3", 34 | "@types/koa-bodyparser": "^4.2.2", 35 | "@types/koa-router": "^7.0.40", 36 | "@types/lodash": "^4.14.129", 37 | "@types/node": "12", 38 | "@types/on-finished": "^2.3.1", 39 | "@types/proper-lockfile": "^4.1.0", 40 | "@types/uuid": "^3.4.4", 41 | "@typescript-eslint/eslint-plugin": "^1.10.2", 42 | "@typescript-eslint/parser": "^1.10.2", 43 | "eslint": "^5.16.0", 44 | "eslint-config-prettier": "^5.0.0", 45 | "eslint-plugin-prettier": "^3.1.0", 46 | "jest": "^24.8.0", 47 | "nodemon": "^1.19.0", 48 | "prettier": "^1.18.2", 49 | "ts-jest": "^24.0.2", 50 | "ts-node": "^8.1.0", 51 | "tslint": "^5.16.0" 52 | }, 53 | "scripts": { 54 | "prettier": "prettier --write \"src/**/*.ts\"", 55 | "test": "jest", 56 | "test:compile": "tsc --noEmit", 57 | "test:lint": "eslint \"src/**/*.ts\"", 58 | "testsuite": "yarn test:compile && yarn test:lint && yarn test" 59 | } 60 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Functional Programming Application Framework 2 | 3 | ## Inspiration 4 | 5 | - Railway Oriented Programming. Result (either) (neverthrow) 6 | - Clean Architecture, ports and adapters 7 | - Rich Domain Model 8 | - Persistence Ignorant Domain 9 | - Command/Query Handlers 10 | - Domain Events (and Integration Events) 11 | - Screaming Architecture 12 | - Domain Driven Design 13 | - Dependency Injection (IoC), but lightweight, just enough to manage some lifecycles (singleton and scoped) 14 | 15 | ## Usage 16 | 17 | ### Run Sample 18 | 19 | - `yarn` 20 | - `yarn start` 21 | 22 | ### Run compile/lint/test 23 | 24 | - `yarn testsuite` 25 | 26 | Access over `http://localhost:3535/train-trip` 27 | see `TrainTrip.router.ts` for paths and methods, and `router-schema.json` for a complete picture. 28 | 29 | 30 | ## Thoughts 31 | 32 | - Authentication (for whole router [DONE], for just a route, opt-in or opt-out) 33 | - Based on command/query metadata or is it infrastructure concern? 34 | - BasicAuth 35 | - future: OAuth based 36 | - Authorization 37 | - Keep up with Typescript improvements for Generic inference etc. 38 | 39 | ## After stabilization 40 | 41 | - Enhance container to prevent dependency capturing (ie a singleton that 'captures' a transient or scoped dependency) 42 | - Remove "opinionation" 43 | - Make it easy to use any validation framework 44 | - Look into performance signatures and identify areas that need improvement 45 | 46 | ### Additional usecase samples 47 | 48 | - Soft delete 49 | 50 | ## Resources 51 | 52 | - https://fsharpforfunandprofit.com/ddd/ 53 | - https://fsharpforfunandprofit.com/rop/ 54 | - https://github.com/gcanti/fp-ts 55 | - https://dev.to/_gdelgado/type-safe-error-handling-in-typescript-1p4n 56 | - https://khalilstemmler.com/articles/enterprise-typescript-nodejs/handling-errors-result-class/ 57 | - & more: https://khalilstemmler.com/articles 58 | - SimpleInjector 59 | - MediatR Request/Event handlers and Pipelines. 60 | - https://github.com/tc39/proposal-pipeline-operator/wiki 61 | -------------------------------------------------------------------------------- /samples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fp-app/sample", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "author": "Patrick Roza ", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@fp-app/framework": "0.1.0", 9 | "@fp-app/hosting.koa": "0.1.0", 10 | "@fp-app/io.diskdb": "0.1.0", 11 | "@fp-app/neverthrow-extensions": "0.1.0", 12 | "@hapi/joi": "^15.0.3", 13 | "chalk": "^2.4.2", 14 | "date-fns": "^1.30.1", 15 | "flatted": "^2.0.0", 16 | "invariant": "^2.2.4", 17 | "lodash": "^4.17.11", 18 | "on-finished": "^2.3.0", 19 | "proper-lockfile": "^4.1.1", 20 | "short-uuid": "^3.1.1", 21 | "tsconfig-paths": "^3.8.0", 22 | "typescript": "^3.5.2", 23 | "uuid": "^3.3.2" 24 | }, 25 | "devDependencies": { 26 | "@types/fp-app-global": "0.1.0", 27 | "@types/hapi__joi": "^15.0.1", 28 | "@types/invariant": "^2.2.29", 29 | "@types/jest": "^24.0.13", 30 | "@types/joi": "^14.3.3", 31 | "@types/lodash": "^4.14.129", 32 | "@types/node": "12", 33 | "@types/on-finished": "^2.3.1", 34 | "@types/proper-lockfile": "^4.1.0", 35 | "@types/uuid": "^3.4.4", 36 | "@typescript-eslint/eslint-plugin": "^1.10.2", 37 | "@typescript-eslint/parser": "^1.10.2", 38 | "eslint": "^5.16.0", 39 | "eslint-config-prettier": "^5.0.0", 40 | "eslint-plugin-prettier": "^3.1.0", 41 | "jest": "^24.8.0", 42 | "nodemon": "^1.19.0", 43 | "prettier": "^1.18.2", 44 | "ts-jest": "^24.0.2", 45 | "ts-node": "^8.1.0", 46 | "tslint": "^5.16.0" 47 | }, 48 | "scripts": { 49 | "start": "nodemon --exec ts-node --transpile-only -r tsconfig-paths/register --files src/start-server.ts", 50 | "start:prod": "ts-node --transpile-only -r tsconfig-paths/register --files src/start-server.ts", 51 | "prettier": "prettier --write \"src/**/*.ts\"", 52 | "test": "jest", 53 | "test:compile": "tsc --noEmit", 54 | "test:lint": "eslint \"src/**/*.ts\"", 55 | "testsuite": "yarn test:compile && yarn test:lint && yarn test" 56 | } 57 | } -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/usecases/types.ts: -------------------------------------------------------------------------------- 1 | import TrainTrip, { Price } from "@/TrainTrip/TrainTrip" 2 | import { 3 | ApiError, 4 | ConnectionError, 5 | generateKey, 6 | generateKeyFromFn, 7 | RecordContext, 8 | RequestContextBase, 9 | UnitOfWork, 10 | } from "@fp-app/framework" 11 | import { PipeFunction, Result } from "@fp-app/neverthrow-extensions" 12 | import { TrainTripPublisher } from "../eventhandlers" 13 | import { getTrip, sendCloudSyncFake, Template, TravelPlan } from "../infrastructure/api" 14 | import PaxDefinition from "../PaxDefinition" 15 | 16 | export const getTripKey = generateKeyFromFn(getTrip) 17 | export const sendCloudSyncKey = generateKey>("sendCloudSync") 18 | export type getTravelPlanType = PipeFunction 19 | export type getTemplateType = PipeFunction 20 | export type getPricingType = ( 21 | templateId: string, 22 | pax: PaxDefinition, 23 | startDate: Date, 24 | ) => Promise> 25 | export type createTravelPlanType = ( 26 | templateId: string, 27 | info: { pax: PaxDefinition; startDate: Date }, 28 | ) => Promise> 29 | 30 | // tslint:disable-next-line:no-empty-interface 31 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 32 | export interface ReadonlyContext {} 33 | 34 | export interface ReadonlyTrainTripContext extends ReadonlyContext { 35 | trainTrips: RecordContext 36 | } 37 | 38 | export interface TrainTripContext extends ReadonlyTrainTripContext, UnitOfWork {} 39 | 40 | // tslint:disable-next-line:no-empty-interface 41 | export type RequestContext = RequestContextBase & { [key: string]: any } 42 | 43 | export const RequestContextKey = generateKey("request-context") 44 | export const DbContextKey = generateKey("db-context") 45 | export const TrainTripPublisherKey = generateKey("trainTripPublisher") 46 | 47 | export const defaultDependencies = { context: RequestContextKey } 48 | -------------------------------------------------------------------------------- /packages/framework/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import assert from "./assert" 2 | 3 | export type Constructor = new (...args: any[]) => T 4 | 5 | const asWritable = (obj: T) => obj as Writeable 6 | export type Writeable = { -readonly [P in keyof T]-?: T[P] } 7 | 8 | const isTruthyFilter = (item: T | null | undefined | void): item is T => Boolean(item) 9 | 10 | const setFunctionName = (fnc: any, name: string) => Object.defineProperty(fnc, "name", { value: name }) 11 | 12 | export const typedKeysOf = (obj: T) => Object.keys(obj) as (keyof T)[] 13 | 14 | export interface Disposable { 15 | dispose(): void 16 | } 17 | 18 | const using = async (disposable: Disposable, fnc: () => Promise | T) => { 19 | assert(!disposable || !!disposable.dispose, "The provided disposable must implement a `dispose` function") 20 | try { 21 | return await fnc() 22 | } finally { 23 | disposable.dispose() 24 | } 25 | } 26 | 27 | const removeElement = (array: T[], element: T) => { 28 | const index = array.indexOf(element) 29 | if (index !== -1) { 30 | array.splice(index, 1) 31 | } 32 | } 33 | 34 | const noop = () => void 0 35 | 36 | // Defers object creation until the instance is accessed 37 | const createLazy = (creatorFunction: () => T) => { 38 | let instance: T 39 | return { 40 | get value() { 41 | return instance || (instance = creatorFunction()) 42 | }, 43 | } 44 | } 45 | 46 | // TODO: don't allow to put more properties in as default, than T 47 | function immutableObj() { 48 | return >(def?: TDefaults) => { 49 | const frozenDefault = Object.freeze({ ...def }) 50 | return (current: Omit & Partial, updates?: Partial) => { 51 | const newObj = Object.freeze({ ...frozenDefault, ...current, ...updates }) 52 | // TODO: consider 53 | // type UpdateableT = T & { update: (current: T, updates?: Partial) => UpdateableT } 54 | return newObj as T 55 | } 56 | } 57 | } 58 | 59 | export { asWritable, createLazy, isTruthyFilter, immutableObj, noop, removeElement, setFunctionName, using } 60 | -------------------------------------------------------------------------------- /packages/hosting.koa/src/RouteBuilder.ts: -------------------------------------------------------------------------------- 1 | import { HALConfig, requestType, RouteBuilder, typedKeysOf } from "@fp-app/framework" 2 | import Koa from "koa" 3 | import KoaRouter from "koa-router" 4 | import generateKoaHandler from "./generateKoaHandler" 5 | import { authMiddleware } from "./middleware" 6 | 7 | export default class KoaRouteBuilder extends RouteBuilder { 8 | build(request: requestType) { 9 | const router = new KoaRouter() 10 | if (this.basicAuthEnabled) { 11 | if (!this.userPass) { 12 | throw new Error("cannot enable auth without loginPass") 13 | } 14 | router.use(authMiddleware(this.userPass)()) 15 | } 16 | 17 | this.setup.forEach(({ method, path, requestHandler, validator, errorHandler, responseTransform }) => { 18 | router.register( 19 | path, 20 | [method], 21 | generateKoaHandler(request, requestHandler, validator, errorHandler, responseTransform), 22 | ) 23 | }) 24 | 25 | return router 26 | } 27 | } 28 | 29 | export function createRouterFromMap(routerMap: Map>, request: requestType) { 30 | return [...routerMap.entries()].reduce((prev, cur) => { 31 | const koaRouter = cur[1].build(request) 32 | return prev.use(cur[0], koaRouter.allowedMethods(), koaRouter.routes()) 33 | }, new KoaRouter()) 34 | } 35 | 36 | export const extendWithHalLinks = (config: HALConfig) => (output: TOutput, ctx: Koa.Context) => ({ 37 | ...output, 38 | _links: generateHalLinks(ctx, config, output), 39 | }) 40 | 41 | // TODO: Perhaps a transformer would be more flexible. 42 | export const generateHalLinks = (ctx: Koa.Context, halConfig: HALConfig, data: any) => { 43 | const halLinks = typedKeysOf(halConfig).reduce( 44 | (prev, cur) => { 45 | let href = halConfig[cur] 46 | if (href.startsWith(".")) { 47 | href = href.replace(".", ctx.URL.pathname) 48 | } 49 | Object.keys(data).forEach(x => (href = href.replace(`:${x}`, data[x]))) 50 | prev[cur] = { href } 51 | return prev 52 | }, 53 | {} as any, 54 | ) 55 | return halLinks 56 | } 57 | -------------------------------------------------------------------------------- /packages/framework/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk" 2 | import { Disposable, noop, typedKeysOf } from "./misc" 3 | 4 | type logLevels = Pick 5 | interface AddLogging { 6 | addToLoggingContext: (item: { [key: string]: any }) => Disposable 7 | } 8 | type logger = logLevels & AddLogging 9 | 10 | const logLevels: logLevels = { 11 | ...console, 12 | } 13 | export const logger: logger = { 14 | ...logLevels, 15 | addToLoggingContext: () => ({ dispose: noop }), 16 | } 17 | const setLogger = (l: logLevels & Partial) => Object.assign(logger, l) 18 | 19 | // TODO: add support for log context open/close (via using?), tracked via async namespace? 20 | const loggers = new Map() 21 | const getLogger = (name: string) => { 22 | if (loggers.has(name)) { 23 | return loggers.get(name) 24 | } 25 | 26 | // const levels = ["info", "log", "debug", "error", "warn"] as const 27 | const l = typedKeysOf(logLevels).reduce( 28 | (prev, current) => { 29 | prev[current] = (...args: any[]) => logger[current](chalk.yellow(`[${name}]`), ...args) 30 | return prev 31 | }, 32 | // eslint-disable-next-line @typescript-eslint/no-object-literal-type-assertion 33 | {} as typeof logger, 34 | ) 35 | l.addToLoggingContext = logger.addToLoggingContext 36 | loggers.set(name, l) 37 | return logger 38 | } 39 | 40 | async function bench( 41 | wrappedFunction: () => Promise, 42 | log: (title: string, elapsed: number) => void, 43 | title?: string, 44 | ) { 45 | const start = process.hrtime() 46 | try { 47 | return await wrappedFunction() 48 | } finally { 49 | log(title || "", calculateElapsed(start)) 50 | } 51 | } 52 | 53 | function calculateElapsed(start: [number, number]) { 54 | const elapsed = process.hrtime(start) 55 | return elapsed[0] * 1000 + elapsed[1] / 1000000 56 | } 57 | 58 | const benchLog = (wrappedFunction: () => Promise, title?: string) => 59 | bench(wrappedFunction, (t, elapsed) => logger.log(chalk.bgWhite.black(`${elapsed}ms`), t), title) 60 | 61 | export { bench, benchLog, calculateElapsed, getLogger, setLogger } 62 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/infrastructure/trainTripPublisher.inMemory.ts: -------------------------------------------------------------------------------- 1 | import { TrainTripPublisher } from "@/TrainTrip/eventhandlers" 2 | import { TrainTripId } from "@/TrainTrip/TrainTrip" 3 | import { getLogger, paramInject, requestInNewScopeKey, requestInNewScopeType } from "@fp-app/framework" 4 | import registerCloud from "../usecases/registerCloud" 5 | 6 | export default class TrainTripPublisherInMemory implements TrainTripPublisher { 7 | private readonly map = new Map() 8 | // TODO: easy way how to inject a configured logger 9 | // ie the key is 'configuredLogger', and it will be configured based on the 10 | // function/class. 11 | private readonly logger = getLogger(this.constructor.name) 12 | 13 | constructor(@paramInject(requestInNewScopeKey) private readonly request: requestInNewScopeType) {} 14 | 15 | registerIfPending = async (trainTripId: TrainTripId) => { 16 | if (!this.trainTripIsPending(trainTripId)) { 17 | return 18 | } 19 | return await this.register(trainTripId) 20 | } 21 | 22 | register = async (trainTripId: TrainTripId) => { 23 | const current = this.map.get(trainTripId) 24 | if (current) { 25 | clearTimeout(current) 26 | } 27 | this.map.set(trainTripId, setTimeout(() => this.tryPublishTrainTrip(trainTripId), CLOUD_PUBLISH_DELAY)) 28 | } 29 | 30 | private tryPublishTrainTrip = async (trainTripId: string) => { 31 | try { 32 | this.logger.log(`Publishing TrainTrip to Cloud: ${trainTripId}`) 33 | // Talk to the Cloud Service to sync with Cloud 34 | const result = await this.request(registerCloud, { trainTripId }) 35 | if (result.isErr()) { 36 | // TODO: really handle error 37 | this.logger.error(result.error) 38 | } 39 | } catch (err) { 40 | // TODO: really handle error 41 | this.logger.error(err) 42 | } finally { 43 | this.map.delete(trainTripId) 44 | } 45 | } 46 | 47 | private trainTripIsPending(trainTripID: TrainTripId) { 48 | return this.map.has(trainTripID) 49 | } 50 | } 51 | 52 | export interface IntegrationEventCommands { 53 | registerCloud: typeof registerCloud 54 | } 55 | 56 | const CLOUD_PUBLISH_DELAY = 10 * 1000 57 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/usecases/changeTrainTrip.ts: -------------------------------------------------------------------------------- 1 | import { StateProposition as ValidatedStateProposition } from "@/TrainTrip/TrainTrip" 2 | import { 3 | combineValidationErrors, 4 | createCommandWithDeps, 5 | DbError, 6 | ForbiddenError, 7 | InvalidStateError, 8 | toFieldError, 9 | ValidationError, 10 | } from "@fp-app/framework" 11 | import { 12 | flatMap, 13 | map, 14 | mapErr, 15 | ok, 16 | pipe, 17 | PipeFunction, 18 | resultTuple, 19 | toFlatTup, 20 | toTup, 21 | valueOrUndefined, 22 | } from "@fp-app/neverthrow-extensions" 23 | import FutureDate from "../FutureDate" 24 | import PaxDefinition, { Pax } from "../PaxDefinition" 25 | import TravelClassDefinition from "../TravelClassDefinition" 26 | import { DbContextKey, defaultDependencies } from "./types" 27 | 28 | const createCommand = createCommandWithDeps({ db: DbContextKey, ...defaultDependencies }) 29 | 30 | const changeTrainTrip = createCommand("changeTrainTrip", ({ db }) => 31 | pipe( 32 | flatMap(toTup(validateStateProposition)), 33 | flatMap(toFlatTup(([, i]) => db.trainTrips.load(i.trainTripId))), 34 | flatMap(([trainTrip, proposal]) => trainTrip.proposeChanges(proposal)), 35 | ), 36 | ) 37 | 38 | export default changeTrainTrip 39 | 40 | export interface Input extends StateProposition { 41 | trainTripId: string 42 | } 43 | 44 | export interface StateProposition { 45 | pax?: Pax 46 | startDate?: string 47 | travelClass?: string 48 | } 49 | 50 | const validateStateProposition: PipeFunction = pipe( 51 | flatMap(({ travelClass, pax, startDate, ...rest }) => 52 | resultTuple( 53 | valueOrUndefined(travelClass, TravelClassDefinition.create).pipe(mapErr(toFieldError("travelClass"))), 54 | valueOrUndefined(startDate, FutureDate.create).pipe(mapErr(toFieldError("startDate"))), 55 | valueOrUndefined(pax, PaxDefinition.create).pipe(mapErr(toFieldError("pax"))), 56 | ok(rest), 57 | ).pipe(mapErr(combineValidationErrors)), 58 | ), 59 | map(([travelClass, startDate, pax, rest]) => ({ 60 | ...rest, 61 | pax, 62 | startDate, 63 | travelClass, 64 | })), 65 | ) 66 | 67 | type ChangeTrainTripError = ForbiddenError | InvalidStateError | ValidationError | DbError 68 | -------------------------------------------------------------------------------- /packages/hosting.koa/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { calculateElapsed, logger, RequestContextBase } from "@fp-app/framework" 2 | import chalk from "chalk" 3 | import { EventEmitter } from "events" 4 | import Koa from "koa" 5 | import auth from "koa-basic-auth" 6 | import onFinished from "on-finished" 7 | 8 | export const saveStartTime: Koa.Middleware = (ctx, next) => { 9 | ctx["start-time"] = process.hrtime() 10 | return next() 11 | } 12 | 13 | export const setupNamespace = ({ 14 | setupRequestContext, 15 | }: { 16 | setupRequestContext: ( 17 | cb: (context: RequestContextBase, bindEmitter: (emitter: EventEmitter) => void) => Promise, 18 | ) => Promise 19 | }): Koa.Middleware => (ctx, next) => 20 | setupRequestContext((context, bindEmitter) => { 21 | bindEmitter(ctx.req) 22 | bindEmitter(ctx.res) 23 | 24 | const correllationId = ctx.get("X-Request-ID") || context.id 25 | ctx.set("X-Request-Id", correllationId) 26 | Object.assign(context, { correllationId }) 27 | 28 | return next() 29 | }) 30 | 31 | export const logRequestTime: Koa.Middleware = async (ctx, next) => { 32 | const reqPath = `${ctx.method} ${ctx.path}` 33 | const reqHeaders = ctx.headers 34 | logger.log(`${chalk.bold(reqPath)} Start request`, { headers: JSON.parse(JSON.stringify(reqHeaders)) }) 35 | 36 | onFinished(ctx.res, () => { 37 | const elapsed = calculateElapsed(ctx["start-time"]) 38 | const elapsedFormatted = `${elapsed}ms` 39 | logger.debug(`${chalk.bgWhite.black(elapsedFormatted)} ${chalk.bold(reqPath)} Closed HTTP request`) 40 | }) 41 | 42 | await next() 43 | 44 | const headers = ctx.response.headers 45 | const status = ctx.status 46 | const elapsed2 = calculateElapsed(ctx["start-time"]) 47 | const elapsedFormatted2 = `${elapsed2}ms` 48 | logger.log( 49 | `${chalk.bgWhite.black(elapsedFormatted2)} ${chalk.bold(reqPath)} Finished HTTP processing`, 50 | JSON.parse(JSON.stringify({ status, headers })), 51 | ) 52 | } 53 | 54 | export const authMiddleware = (defaultNamePass: string) => (namePass: string = defaultNamePass) => { 55 | const [name, pass] = namePass.split(":") 56 | return auth({ name, pass }) 57 | } 58 | 59 | export const handleAuthenticationFailedMiddleware: Koa.Middleware = async (ctx, next) => { 60 | try { 61 | await next() 62 | } catch (err) { 63 | if (401 === err.status) { 64 | ctx.status = 401 65 | ctx.set("WWW-Authenticate", "Basic") 66 | ctx.body = { messsage: "Unauthorized" } 67 | } else { 68 | throw err 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/types/promise-pipe-types.d.ts: -------------------------------------------------------------------------------- 1 | //type OperatorFunction = (input: T) => Promise | A 2 | 3 | interface UnaryFunction { 4 | (source: T): R; 5 | } 6 | interface OperatorFunction extends UnaryFunction | R> { 7 | } 8 | 9 | interface Promise { 10 | // this variation blocks the error pipeline from switching accidentally 11 | // pipe(fn: (input: T) => TOut): Promise 12 | pipe(op1: OperatorFunction): Promise; 13 | pipe(op1: OperatorFunction, op2: OperatorFunction): Promise; 14 | pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Promise; 15 | pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction, op4: OperatorFunction): Promise; 16 | pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction, op4: OperatorFunction, op5: OperatorFunction): Promise; 17 | pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction, op4: OperatorFunction, op5: OperatorFunction, op6: OperatorFunction): Promise; 18 | pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction, op4: OperatorFunction, op5: OperatorFunction, op6: OperatorFunction, op7: OperatorFunction): Promise; 19 | pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction, op4: OperatorFunction, op5: OperatorFunction, op6: OperatorFunction, op7: OperatorFunction, op8: OperatorFunction): Promise; 20 | pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction, op4: OperatorFunction, op5: OperatorFunction, op6: OperatorFunction, op7: OperatorFunction, op8: OperatorFunction, op9: OperatorFunction): Promise; 21 | pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction, op4: OperatorFunction, op5: OperatorFunction, op6: OperatorFunction, op7: OperatorFunction, op8: OperatorFunction, op9: OperatorFunction, ...operations: OperatorFunction[]): Promise<{}>; 22 | 23 | // this variation allows error pipeline mismatch, it matches the original .then() 24 | // pipe(onfulfilled?: ((value: T) => TResult1 | PromiseLike)): Promise 25 | } 26 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/RouteBuilder.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import { ErrorBase } from "../errors" 3 | import { ValidatorType } from "../utils/validation" 4 | import { DbError } from "./errors" 5 | import { NamedHandlerWithDependencies, requestType } from "./mediator" 6 | 7 | export default abstract class RouteBuilder { 8 | private static register = (method: METHODS, obj: RouteBuilder) => < 9 | TDependencies, 10 | TInput, 11 | TOutput, 12 | TError, 13 | TValidationError 14 | >( 15 | path: string, 16 | requestHandler: NamedHandlerWithDependencies, 17 | configuration: { 18 | errorHandler?: ErrorHandlerType 19 | responseTransform?: ResponseTransform 20 | validator: ValidatorType 21 | }, 22 | ) => { 23 | obj.setup.push({ method, path, requestHandler, ...configuration }) 24 | return obj 25 | } 26 | 27 | readonly post = RouteBuilder.register("POST", this) 28 | readonly get = RouteBuilder.register("GET", this) 29 | readonly delete = RouteBuilder.register("DELETE", this) 30 | readonly patch = RouteBuilder.register("PATCH", this) 31 | 32 | protected userPass?: string 33 | protected setup: RegisteredRoute[] = [] 34 | protected basicAuthEnabled: boolean = false 35 | 36 | abstract build(request: requestType): any 37 | 38 | getJsonSchema() { 39 | return this.setup.map(({ method, path, validator }) => [method, path, validator.jsonSchema] as const) 40 | } 41 | 42 | enableBasicAuth(userPass: string) { 43 | this.basicAuthEnabled = true 44 | this.userPass = userPass 45 | return this 46 | } 47 | } 48 | 49 | export interface HALConfig { 50 | [key: string]: string 51 | } 52 | 53 | export type ResponseTransform = (output: TOutput, ctx: TContext) => any 54 | 55 | export function writeRouterSchema(routerMap: Map>) { 56 | const schema = [...routerMap.entries()].reduce( 57 | (prev, [path, r]) => { 58 | prev[path] = r 59 | .getJsonSchema() 60 | .map(([method, p, s2]) => ({ method, subPath: p, fullPath: `${path}${p}`, schema: s2 })) 61 | return prev 62 | }, 63 | {} as any, 64 | ) 65 | fs.writeFileSync("./router-schema.json", JSON.stringify(schema, undefined, 2)) 66 | } 67 | 68 | export type ErrorHandlerType = ( 69 | ctx: TContext, 70 | ) => (err: TError) => TErr | TError | void 71 | 72 | export const defaultErrorPassthrough = () => (err: any) => err 73 | 74 | interface RegisteredRoute { 75 | method: METHODS 76 | path: string 77 | requestHandler: NamedHandlerWithDependencies 78 | validator: ValidatorType 79 | errorHandler?: ErrorHandlerType 80 | responseTransform?: ResponseTransform 81 | } 82 | 83 | type METHODS = "POST" | "GET" | "DELETE" | "PATCH" 84 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/usecases/createTrainTrip.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApiError, 3 | CombinedValidationError, 4 | combineValidationErrors, 5 | createCommandWithDeps, 6 | DbError, 7 | InvalidStateError, 8 | toFieldError, 9 | ValidationError, 10 | } from "@fp-app/framework" 11 | import { 12 | err, 13 | flatMap, 14 | map, 15 | mapErr, 16 | ok, 17 | pipe, 18 | PipeFunction, 19 | Result, 20 | resultTuple, 21 | tee, 22 | toTup, 23 | } from "@fp-app/neverthrow-extensions" 24 | import FutureDate from "../FutureDate" 25 | import PaxDefinition, { Pax } from "../PaxDefinition" 26 | import TrainTrip, { CreateTrainTripInfo } from "../TrainTrip" 27 | import { DbContextKey, defaultDependencies, getTripKey } from "./types" 28 | 29 | const createCommand = createCommandWithDeps({ db: DbContextKey, getTrip: getTripKey, ...defaultDependencies }) 30 | 31 | const createTrainTrip = createCommand("createTrainTrip", ({ db, getTrip }) => 32 | pipe( 33 | flatMap(validateCreateTrainTripInfo), 34 | flatMap(toTup(({ templateId }) => getTrip(templateId))), 35 | map(([trip, proposal]) => TrainTrip.create(proposal, trip)), 36 | map(tee(db.trainTrips.add)), 37 | map(trainTrip => trainTrip.id), 38 | ), 39 | ) 40 | 41 | export default createTrainTrip 42 | export interface Input { 43 | templateId: string 44 | pax: Pax 45 | startDate: string 46 | } 47 | 48 | const validateCreateTrainTripInfo: PipeFunction = pipe( 49 | flatMap(({ pax, startDate, templateId }) => 50 | resultTuple( 51 | PaxDefinition.create(pax).pipe(mapErr(toFieldError("pax"))), 52 | FutureDate.create(startDate).pipe(mapErr(toFieldError("startDate"))), 53 | validateString(templateId).pipe(mapErr(toFieldError("templateId"))), 54 | ).pipe(mapErr(combineValidationErrors)), 55 | ), 56 | 57 | // Alt 1 58 | // flatMap(input => 59 | // resultTuple3( 60 | // input, 61 | // ({ pax }) => PaxDefinition.create(pax).pipe(mapErr(toFieldError('pax'))), 62 | // ({ startDate }) => FutureDate.create(startDate).pipe(mapErr(toFieldError('startDate'))), 63 | // ({ templateId }) => validateString(templateId).pipe(mapErr(toFieldError('templateId'))), 64 | // ).mapErr(combineValidationErrors), 65 | // ), 66 | 67 | // Alt 2 68 | // Why doesn't this work? 69 | // flatMap(resultTuple2( 70 | // ({pax}) => PaxDefinition.create(pax).pipe(mapErr(toFieldError('pax'))), 71 | // ({startDate}) => FutureDate.create(startDate).pipe(mapErr(toFieldError('startDate'))), 72 | // ({templateId}) => validateString(templateId).pipe(mapErr(toFieldError('templateId'))), 73 | // )), 74 | // mapErr(combineValidationErrors), 75 | 76 | map(([pax, startDate, templateId]) => ({ 77 | pax, 78 | startDate, 79 | templateId, 80 | })), 81 | ) 82 | 83 | // TODO 84 | const validateString = (str: string): Result => 85 | str ? ok(str as T) : err(new ValidationError("not a valid str")) 86 | 87 | type CreateError = CombinedValidationError | InvalidStateError | ValidationError | ApiError | DbError 88 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/domainEventHandler.ts: -------------------------------------------------------------------------------- 1 | import { err, map, Result, success, tee } from "@fp-app/neverthrow-extensions" 2 | import Event from "../event" 3 | import { EventHandlerWithDependencies } from "./mediator" 4 | import { publishType } from "./mediator/publish" 5 | import { generateKey } from "./SimpleContainer" 6 | 7 | // tslint:disable-next-line:max-classes-per-file 8 | export default class DomainEventHandler { 9 | private events: Event[] = [] 10 | private processedEvents: Event[] = [] 11 | 12 | constructor( 13 | private readonly publish: publishType, 14 | private readonly getIntegrationHandlers: ( 15 | evt: Event, 16 | ) => EventHandlerWithDependencies[] | undefined, 17 | private readonly executeIntegrationEvents: typeof executePostCommitHandlersKey, 18 | ) {} 19 | 20 | // Note: Eventhandlers in this case have unbound errors.. 21 | async commitAndPostEvents( 22 | getAndClearEvents: () => Event[], 23 | commit: () => Promise>, 24 | ): Promise> { 25 | // 1. pre-commit: post domain events 26 | // 2. commit! 27 | // 3. post-commit: post integration events 28 | 29 | this.processedEvents = [] 30 | const updateEvents = () => (this.events = this.events.concat(getAndClearEvents())) 31 | updateEvents() 32 | let processedEvents: Event[] = [] 33 | // loop until we have all events captured, event events of events. 34 | // lets hope we don't get stuck in stackoverflow ;-) 35 | while (this.events.length) { 36 | const events = this.events 37 | this.events = [] 38 | processedEvents = processedEvents.concat(events) 39 | const r = await this.publishEvents(events) 40 | if (r.isErr()) { 41 | this.events = processedEvents 42 | return err(r.error) 43 | } 44 | updateEvents() 45 | } 46 | this.processedEvents = processedEvents 47 | return await commit().pipe(tee(map(this.publishIntegrationEvents))) 48 | } 49 | 50 | private readonly publishEvents = async (events: Event[]): Promise> => { 51 | for (const evt of events) { 52 | const r = await this.publish(evt) 53 | if (r.isErr()) { 54 | return err(r.error) 55 | } 56 | } 57 | return success() 58 | } 59 | 60 | private readonly publishIntegrationEvents = () => { 61 | this.events = [] 62 | const integrationEventsMap = new Map[]>() 63 | for (const evt of this.processedEvents) { 64 | const integrationEventHandlers = this.getIntegrationHandlers(evt) 65 | if (!integrationEventHandlers || !integrationEventHandlers.length) { 66 | continue 67 | } 68 | integrationEventsMap.set(evt, integrationEventHandlers) 69 | } 70 | if (integrationEventsMap.size) { 71 | this.executeIntegrationEvents(integrationEventsMap) 72 | } 73 | this.processedEvents = [] 74 | } 75 | } 76 | 77 | export const executePostCommitHandlersKey = generateKey< 78 | (eventMap: Map[]>) => void 79 | >("executePostCommitHandlers") 80 | -------------------------------------------------------------------------------- /samples/basic/src/root.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createDependencyNamespace, 3 | factoryOf, 4 | Key, 5 | logger, 6 | resolveEventKey, 7 | UnitOfWork, 8 | UOWKey, 9 | } from "@fp-app/framework" 10 | import { exists, mkdir } from "@fp-app/io.diskdb" 11 | import chalk from "chalk" 12 | import resolveEvent from "./resolveIntegrationEvent" 13 | import "./TrainTrip/eventhandlers" // To be ble to auto register them :/ 14 | import { getPricingFake, getTemplateFake, getTrip, sendCloudSyncFake } from "./TrainTrip/infrastructure/api" 15 | import DiskDBContext from "./TrainTrip/infrastructure/TrainTripContext.disk" 16 | import TrainTripPublisherInMemory from "./TrainTrip/infrastructure/trainTripPublisher.inMemory" 17 | import TrainTripReadContext, { trainTripReadContextKey } from "./TrainTrip/infrastructure/TrainTripReadContext.disk" 18 | import { 19 | DbContextKey, 20 | getTripKey, 21 | RequestContextKey, 22 | sendCloudSyncKey, 23 | TrainTripPublisherKey, 24 | } from "./TrainTrip/usecases/types" 25 | 26 | const createRoot = () => { 27 | const { 28 | addToLoggingContext, 29 | bindLogger, 30 | container, 31 | setupRequestContext, 32 | 33 | publishInNewContext, 34 | request, 35 | } = createDependencyNamespace(namespace, RequestContextKey) 36 | 37 | container.registerScopedC2(DbContextKey, DiskDBContext) 38 | container.registerPassthrough(UOWKey, (DbContextKey as any) as Key) 39 | 40 | container.registerSingletonC2(TrainTripPublisherKey, TrainTripPublisherInMemory) 41 | container.registerSingletonC2(trainTripReadContextKey, TrainTripReadContext) 42 | container.registerSingletonF(sendCloudSyncKey, factoryOf(sendCloudSyncFake, f => f({ cloudUrl: "" }))) 43 | container.registerSingletonF(getTripKey, () => { 44 | const { getTrip: getTripF } = createInventoryClient({ templateApiUrl: "http://localhost:8110" }) 45 | return getTripF 46 | }) 47 | 48 | container.registerSingletonF(resolveEventKey, () => resolveEvent()) 49 | 50 | // Prevent stack-overflow; as logger depends on requestcontext 51 | // tslint:disable-next-line:no-console 52 | const consoleOrLogger = (key: any) => (key !== RequestContextKey ? logger : console) 53 | container.registerInitializerF("global", (i, key) => 54 | consoleOrLogger(key).debug(chalk.magenta(`Created function of ${key.name} (${i.name})`)), 55 | ) 56 | container.registerInitializerC("global", (i, key) => 57 | consoleOrLogger(key).debug(chalk.magenta(`Created instance of ${key.name} (${i.constructor.name})`)), 58 | ) 59 | container.registerInitializerO("global", (i, key) => 60 | consoleOrLogger(key).debug(chalk.magenta(`Created object of ${key.name} (${i.constructor.name})`)), 61 | ) 62 | 63 | return { 64 | addToLoggingContext, 65 | bindLogger, 66 | initialize, 67 | setupRequestContext, 68 | 69 | publishInNewContext, 70 | request, 71 | } 72 | } 73 | 74 | const initialize = async () => { 75 | if (!(await exists("./data"))) { 76 | await mkdir("./data") 77 | } 78 | } 79 | 80 | const namespace = "train-trip-service" 81 | 82 | export default createRoot 83 | 84 | const createInventoryClient = ({ templateApiUrl }: { templateApiUrl: string }) => { 85 | const getTemplate = getTemplateFake({ templateApiUrl }) 86 | return { 87 | getPricing: getPricingFake({ getTemplate, pricingApiUrl: templateApiUrl }), 88 | getTemplate, 89 | getTrip: getTrip({ getTemplate }), 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/neverthrow-extensions/src/promise-pipe.ts: -------------------------------------------------------------------------------- 1 | type UnaryFunction = (source: T) => R 2 | 3 | /* tslint:disable:max-line-length */ 4 | export function pipe(): UnaryFunction 5 | export function pipe(fn1: UnaryFunction): UnaryFunction 6 | export function pipe(fn1: UnaryFunction, fn2: UnaryFunction): UnaryFunction 7 | export function pipe( 8 | fn1: UnaryFunction, 9 | fn2: UnaryFunction, 10 | fn3: UnaryFunction, 11 | ): UnaryFunction 12 | export function pipe( 13 | fn1: UnaryFunction, 14 | fn2: UnaryFunction, 15 | fn3: UnaryFunction, 16 | fn4: UnaryFunction, 17 | ): UnaryFunction 18 | export function pipe( 19 | fn1: UnaryFunction, 20 | fn2: UnaryFunction, 21 | fn3: UnaryFunction, 22 | fn4: UnaryFunction, 23 | fn5: UnaryFunction, 24 | ): UnaryFunction 25 | export function pipe( 26 | fn1: UnaryFunction, 27 | fn2: UnaryFunction, 28 | fn3: UnaryFunction, 29 | fn4: UnaryFunction, 30 | fn5: UnaryFunction, 31 | fn6: UnaryFunction, 32 | ): UnaryFunction 33 | export function pipe( 34 | fn1: UnaryFunction, 35 | fn2: UnaryFunction, 36 | fn3: UnaryFunction, 37 | fn4: UnaryFunction, 38 | fn5: UnaryFunction, 39 | fn6: UnaryFunction, 40 | fn7: UnaryFunction, 41 | ): UnaryFunction 42 | export function pipe( 43 | fn1: UnaryFunction, 44 | fn2: UnaryFunction, 45 | fn3: UnaryFunction, 46 | fn4: UnaryFunction, 47 | fn5: UnaryFunction, 48 | fn6: UnaryFunction, 49 | fn7: UnaryFunction, 50 | fn8: UnaryFunction, 51 | ): UnaryFunction 52 | export function pipe( 53 | fn1: UnaryFunction, 54 | fn2: UnaryFunction, 55 | fn3: UnaryFunction, 56 | fn4: UnaryFunction, 57 | fn5: UnaryFunction, 58 | fn6: UnaryFunction, 59 | fn7: UnaryFunction, 60 | fn8: UnaryFunction, 61 | fn9: UnaryFunction, 62 | ): UnaryFunction 63 | export function pipe( 64 | fn1: UnaryFunction, 65 | fn2: UnaryFunction, 66 | fn3: UnaryFunction, 67 | fn4: UnaryFunction, 68 | fn5: UnaryFunction, 69 | fn6: UnaryFunction, 70 | fn7: UnaryFunction, 71 | fn8: UnaryFunction, 72 | fn9: UnaryFunction, 73 | ...fns: UnaryFunction[] 74 | ): UnaryFunction 75 | /* tslint:enable:max-line-length */ 76 | 77 | export function pipe(...fns: UnaryFunction[]): UnaryFunction { 78 | return pipeFromArray(fns, (prev, fn) => prev.then(fn)) 79 | } 80 | 81 | export function pipe2(...fns: UnaryFunction[]): UnaryFunction { 82 | return pipeFromArray(fns, (prev, fn) => fn(prev)) 83 | } 84 | 85 | // tslint:disable-next-line:no-empty 86 | const noop = () => {} 87 | 88 | /** @internal */ 89 | export function pipeFromArray( 90 | fns: UnaryFunction[], 91 | transform: (prev: any, fn: any) => any, 92 | ): UnaryFunction { 93 | if (!fns) { 94 | return noop as UnaryFunction 95 | } 96 | 97 | // if (fns.length === 1) { 98 | // return (input) => transform(input, fns[0]); 99 | // } 100 | 101 | return function piped(input: T): R { 102 | return fns.reduce((prev: any, fn: UnaryFunction) => transform(prev, fn), input as any) 103 | } 104 | } 105 | 106 | Promise.prototype.pipe = function piper(this: Promise, ...fns: any[]) { 107 | const pipeAny: any = pipe 108 | return pipeAny(...fns)((this as any) as Promise) 109 | } as any 110 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/eventhandlers/index.ts: -------------------------------------------------------------------------------- 1 | import { TrainTripCreated, TrainTripId, TrainTripStateChanged, UserInputReceived } from "@/TrainTrip/TrainTrip" 2 | import { DbContextKey, defaultDependencies, getTripKey, TrainTripPublisherKey } from "@/TrainTrip/usecases/types" 3 | import { 4 | createDomainEventHandlerWithDeps, 5 | createIntegrationEventHandlerWithDeps, 6 | curryRequest, 7 | DbError, 8 | requestKey, 9 | } from "@fp-app/framework" 10 | import { flatMap, map, pipe, toTup } from "@fp-app/neverthrow-extensions" 11 | import lockTrainTrip from "../usecases/lockTrainTrip" 12 | import { CustomerRequestedChanges } from "./integration.events" 13 | 14 | // Domain Events should primarily be used to be turned into Integration Event (Post-Commit, call other service) 15 | // There may be other small reasons to use it, like to talk to an external system Pre-Commit. 16 | // Otherwise they just add additional layers of indirection and take behavior away often more suited for the Aggregrates/root. 17 | // Informing other bounded contexts, generally should not occur within the same transaction, and should thus be handled 18 | // by turning into Integration Events. 19 | 20 | // Ideas: Store Integration Events into the database within the same Commit, and process them in an outside service. 21 | // So that we may ensure the events will be processed. 22 | // Other options can be to have a compensating action running regularly that checks and fixes things. A sort of eventual consistency. 23 | 24 | // There are some pitfalls: when turning into an integration event callback 25 | // one should generally not access dependencies that were passed into the domain event :/ 26 | // because of possible scope mismatch issues (ie a db context should be closed after a request has finished processing). 27 | // Below implementations violate this principal, at the time of writing ;-) 28 | // (trainTripPublisher is passed in as part of the domain event handler, and used by the integration event handler) 29 | 30 | const createIntegrationEventHandler = createIntegrationEventHandlerWithDeps({ 31 | trainTripPublisher: TrainTripPublisherKey, 32 | ...defaultDependencies, 33 | }) 34 | 35 | createIntegrationEventHandler( 36 | /* on */ TrainTripCreated, 37 | "ScheduleCloudSync", 38 | ({ trainTripPublisher }) => pipe(map(({ trainTripId }) => trainTripPublisher.register(trainTripId))), 39 | ) 40 | 41 | createIntegrationEventHandler( 42 | /* on */ TrainTripStateChanged, 43 | "EitherDebounceOrScheduleCloudSync", 44 | ({ trainTripPublisher }) => pipe(map(({ trainTripId }) => trainTripPublisher.register(trainTripId))), 45 | ) 46 | 47 | const createDomainEventHandler = createDomainEventHandlerWithDeps({ db: DbContextKey, getTrip: getTripKey }) 48 | 49 | createDomainEventHandler( 50 | /* on */ TrainTripStateChanged, 51 | "RefreshTripInfo", 52 | ({ db, getTrip }) => 53 | pipe( 54 | flatMap(({ trainTripId }) => db.trainTrips.load(trainTripId)), 55 | flatMap(toTup(trainTrip => getTrip(trainTrip.currentTravelClassConfiguration.travelClass.templateId))), 56 | map(([trip, trainTrip]) => trainTrip.updateTrip(trip)), 57 | ), 58 | ) 59 | 60 | createIntegrationEventHandler( 61 | /* on */ UserInputReceived, 62 | "DebouncePendingCloudSync", 63 | ({ trainTripPublisher }) => pipe(map(({ trainTripId }) => trainTripPublisher.registerIfPending(trainTripId))), 64 | ) 65 | 66 | // const createIntegrationCommandEventHandler = createIntegrationEventHandlerWithDeps({ db: DbContextKey, ...defaultDependencies }) 67 | const createIntegrationCommandEventHandler = createIntegrationEventHandlerWithDeps({ 68 | request: requestKey, 69 | ...defaultDependencies, 70 | }) 71 | 72 | createIntegrationCommandEventHandler( 73 | /* on */ CustomerRequestedChanges, 74 | "LockTrainTrip", 75 | curryRequest(lockTrainTrip), 76 | ) 77 | 78 | export interface TrainTripPublisher { 79 | registerIfPending(trainTripId: TrainTripId): Promise 80 | register(trainTripId: TrainTripId): Promise 81 | } 82 | -------------------------------------------------------------------------------- /packages/hosting.koa/src/generateKoaHandler.ts: -------------------------------------------------------------------------------- 1 | import Koa from "koa" 2 | 3 | import { 4 | CombinedValidationError, 5 | ConnectionError, 6 | CouldNotAquireDbLockError, 7 | DbError, 8 | defaultErrorPassthrough, 9 | ErrorBase, 10 | ErrorHandlerType, 11 | FieldValidationError, 12 | ForbiddenError, 13 | InvalidStateError, 14 | logger, 15 | NamedHandlerWithDependencies, 16 | OptimisticLockError, 17 | RecordNotFound, 18 | requestType, 19 | ValidationError, 20 | } from "@fp-app/framework" 21 | import { flatMap, Result, startWithVal } from "@fp-app/neverthrow-extensions" 22 | 23 | export default function generateKoaHandler( 24 | request: requestType, 25 | handler: NamedHandlerWithDependencies, 26 | validate: (i: I) => Result, 27 | handleErrorOrPassthrough: ErrorHandlerType = defaultErrorPassthrough, 28 | responseTransform?: (input: T, ctx: Koa.Context) => TOutput, 29 | ) { 30 | return async (ctx: Koa.Context) => { 31 | try { 32 | const input = { ...ctx.request.body, ...ctx.request.query, ...ctx.params } // query, headers etc 33 | 34 | // DbError, because request handler is enhanced with it (decorator) 35 | // E2 because the validator enhances it. 36 | const result = await startWithVal(input)().pipe( 37 | flatMap(validate), 38 | flatMap(validatedInput => request(handler, validatedInput)), 39 | ) 40 | result.match( 41 | output => { 42 | if (responseTransform) { 43 | ctx.body = responseTransform(output, ctx) 44 | } else { 45 | ctx.body = output 46 | } 47 | if (ctx.method === "POST" && output) { 48 | ctx.status = 201 49 | } 50 | }, 51 | err => (handleErrorOrPassthrough(ctx)(err) ? handleDefaultError(ctx)(err) : undefined), 52 | ) 53 | } catch (err) { 54 | logger.error(err) 55 | ctx.status = 500 56 | } 57 | } 58 | } 59 | 60 | const handleDefaultError = (ctx: Koa.Context) => (err: ErrorBase) => { 61 | const { message } = err 62 | 63 | // TODO: Exhaustive condition error so that we remain aware of possible errors 64 | // but needs to be then Typed somehow 65 | // const err2 = new ValidationError("some message") as Err 66 | // switch (err2.name) { 67 | // case "FieldValidationError": 68 | // case "CombinedValidationError": 69 | // case "ValidationError": break 70 | // case "ConnectionError": break 71 | // case "RecordNotFound": break 72 | // // tslint:disable-next-line 73 | // default: { const exhaustiveCheck: never = err2; return exhaustiveCheck } 74 | // } 75 | 76 | if (err instanceof RecordNotFound) { 77 | ctx.body = { message } 78 | ctx.status = 404 79 | } else if (err instanceof CombinedValidationError) { 80 | const { errors } = err 81 | ctx.body = { 82 | fields: combineErrors(errors), 83 | message, 84 | } 85 | ctx.status = 400 86 | } else if (err instanceof FieldValidationError) { 87 | ctx.body = { 88 | fields: { 89 | [err.fieldName]: err.error instanceof CombinedValidationError ? combineErrors(err.error.errors) : err.message, 90 | }, 91 | message, 92 | } 93 | ctx.status = 400 94 | } else if (err instanceof ValidationError) { 95 | ctx.body = { message } 96 | ctx.status = 400 97 | } else if (err instanceof InvalidStateError) { 98 | ctx.body = { message } 99 | ctx.status = 422 100 | } else if (err instanceof ForbiddenError) { 101 | ctx.body = { message } 102 | ctx.status = 403 103 | } else if (err instanceof OptimisticLockError) { 104 | ctx.status = 409 105 | } else if (err instanceof CouldNotAquireDbLockError) { 106 | ctx.status = 503 107 | } else if (err instanceof ConnectionError) { 108 | ctx.status = 504 109 | } else { 110 | // Unknown error 111 | ctx.status = 500 112 | } 113 | } 114 | 115 | const combineErrors = (ers: any[]) => 116 | ers.reduce((prev: any, cur) => { 117 | if (cur instanceof FieldValidationError) { 118 | if (cur.error instanceof CombinedValidationError) { 119 | prev[cur.fieldName] = combineErrors(cur.error.errors) 120 | } else { 121 | prev[cur.fieldName] = cur.message 122 | } 123 | } 124 | return prev 125 | }, {}) 126 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/infrastructure/api.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-interface */ 2 | /* eslint-disable @typescript-eslint/no-object-literal-type-assertion */ 3 | import TrainTrip, { Price } from "@/TrainTrip/TrainTrip" 4 | import { createTravelPlanType, getTemplateType, getTravelPlanType } from "@/TrainTrip/usecases/types" 5 | import { ApiError, ConnectionError, InvalidStateError, RecordNotFound, typedKeysOf } from "@fp-app/framework" 6 | import { 7 | err, 8 | flatMap, 9 | liftType, 10 | map, 11 | mapErr, 12 | ok, 13 | PipeFunction, 14 | sequenceAsync, 15 | startWithVal, 16 | } from "@fp-app/neverthrow-extensions" 17 | import { v4 } from "uuid" 18 | import { Pax } from "../PaxDefinition" 19 | import { TravelClassName } from "../TravelClassDefinition" 20 | import Trip, { TravelClass, TripWithSelectedTravelClass } from "../Trip" 21 | 22 | const getTrip = ({ 23 | getTemplate, 24 | }: { 25 | getTemplate: getTemplateType 26 | }): PipeFunction => templateId => 27 | getTemplate(templateId).pipe( 28 | mapErr(liftType()), 29 | flatMap(toTrip(getTemplate)), 30 | ) 31 | 32 | const toTrip = (getTemplate: getTemplateType) => (tpl: Template) => { 33 | const currentTravelClass = tplToTravelClass(tpl) 34 | return sequenceAsync( 35 | [startWithVal(currentTravelClass)()].concat( 36 | typedKeysOf(tpl.travelClasses) 37 | .filter(x => x !== currentTravelClass.name) 38 | .map(slKey => tpl.travelClasses[slKey]!) 39 | .map(sl => getTemplate(sl.id).pipe(map(tplToTravelClass))), 40 | ), 41 | ).pipe( 42 | mapErr(liftType()), 43 | map(travelClasses => new Trip(travelClasses)), 44 | flatMap(trip => TripWithSelectedTravelClass.create(trip, currentTravelClass.name)), 45 | ) 46 | } 47 | 48 | const tplToTravelClass = (tpl: Template) => new TravelClass(tpl.id, getTplLevelName(tpl)) 49 | 50 | const getTplLevelName = (tpl: Template) => 51 | typedKeysOf(tpl.travelClasses).find(x => tpl.travelClasses[x]!.id === tpl.id) as TravelClassName 52 | 53 | // Typescript support for partial application is not really great, so we try currying instead for now 54 | // https://stackoverflow.com/questions/50400120/using-typescript-for-partial-application 55 | const getTemplateFake = ({ }: { templateApiUrl: string }): getTemplateType => async templateId => { 56 | const tpl = mockedTemplates()[templateId] as Template | undefined 57 | if (!tpl) { 58 | return err(new RecordNotFound("Template", templateId)) 59 | } 60 | return ok(tpl) 61 | } 62 | 63 | const mockedTemplates: () => { [key: string]: Template } = () => ({ 64 | "template-id1": { 65 | id: "template-id1", 66 | travelClasses: { second: { id: "template-id1" }, first: { id: "template-id2" } }, 67 | } as Template, 68 | "template-id2": { 69 | id: "template-id2", 70 | travelClasses: { second: { id: "template-id1" }, first: { id: "template-id2" } }, 71 | } as Template, 72 | }) 73 | 74 | const getPricingFake = ({ getTemplate }: { pricingApiUrl: string; getTemplate: getTemplateType }) => ( 75 | templateId: string, 76 | ) => getTemplate(templateId).pipe(map(getFakePriceFromTemplate)) 77 | 78 | const getFakePriceFromTemplate = (_: any) => ({ price: { amount: 100, currency: "EUR" } }) 79 | 80 | const createTravelPlanFake = ({ }: { travelPlanApiUrl: string }): createTravelPlanType => async () => ok(v4()) 81 | 82 | const sendCloudSyncFake = ({ }: { cloudUrl: string }): PipeFunction => async () => 83 | ok(v4()) 84 | 85 | const getTravelPlanFake = ({ }: { travelPlanApiUrl: string }): getTravelPlanType => async travelPlanId => 86 | ok({ id: travelPlanId } as TravelPlan) 87 | 88 | export { createTravelPlanFake, getPricingFake, getTemplateFake, getTrip, sendCloudSyncFake, getTravelPlanFake } 89 | 90 | export interface Conversation { 91 | id: string 92 | 93 | startDate: string 94 | pax: Pax 95 | } 96 | 97 | export interface Template { 98 | id: string 99 | 100 | price: Price 101 | stops: TemplateStop[] 102 | 103 | cityCodes: string[] 104 | 105 | travelClasses: { 106 | business?: { id: string } 107 | first?: { id: string } 108 | second?: { id: string } 109 | } 110 | } 111 | 112 | export interface TravelPlan { 113 | id: string 114 | 115 | price: Price 116 | stops: TravelPlanStop[] 117 | startDate: Date 118 | } 119 | 120 | // tslint:disable-next-line:no-empty-interface 121 | interface Stop {} 122 | // tslint:disable-next-line:no-empty-interface 123 | interface TravelPlanStop extends Stop {} 124 | // tslint:disable-next-line:no-empty-interface 125 | interface TemplateStop extends Stop {} 126 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/infrastructure/TrainTripContext.disk.ts: -------------------------------------------------------------------------------- 1 | import TrainTrip, { TravelClassConfiguration } from "@/TrainTrip/TrainTrip" 2 | import { TrainTripContext } from "@/TrainTrip/usecases/types" 3 | import { autoinject, ContextBase, DbError, DomainEventHandler, Event, RecordContext } from "@fp-app/framework" 4 | import { DiskRecordContext } from "@fp-app/io.diskdb" 5 | import { ok, Result } from "@fp-app/neverthrow-extensions" 6 | import { parse, stringify } from "flatted" 7 | import PaxDefinition, { Pax } from "../PaxDefinition" 8 | import { TravelClassName } from "../TravelClassDefinition" 9 | import { TravelClass } from "../Trip" 10 | import { TrainTripView } from "../usecases/getTrainTrip" 11 | import TrainTripReadContext from "./TrainTripReadContext.disk" 12 | 13 | // Since we assume that saving a valid object, means restoring a valid object 14 | // we can assume data correctness and can skip normal validation and constructing. 15 | // until proven otherwise. 16 | // tslint:disable-next-line:max-classes-per-file 17 | @autoinject 18 | export default class DiskDBContext extends ContextBase implements TrainTripContext { 19 | get trainTrips() { 20 | return this.trainTripsi as RecordContext 21 | } 22 | 23 | private readonly trainTripsi = new DiskRecordContext( 24 | "trainTrip", 25 | serializeTrainTrip, 26 | deserializeDbTrainTrip, 27 | ) 28 | constructor( 29 | private readonly readContext: TrainTripReadContext, 30 | eventHandler: DomainEventHandler, 31 | // test sample 32 | // @paramInject(sendCloudSyncKey) sendCloudSync: typeof sendCloudSyncKey, 33 | ) { 34 | super(eventHandler) 35 | } 36 | 37 | protected getAndClearEvents(): Event[] { 38 | return this.trainTripsi.intGetAndClearEvents() 39 | } 40 | protected saveImpl(): Promise> { 41 | return this.trainTripsi.intSave( 42 | async i => ok(await this.readContext.create(i.id, TrainTripToView(i))), 43 | async i => ok(await this.readContext.delete(i.id)), 44 | ) 45 | } 46 | } 47 | 48 | const TrainTripToView = ({ 49 | isLocked, 50 | createdAt, 51 | id, 52 | pax, 53 | currentTravelClassConfiguration, 54 | startDate, 55 | travelClassConfiguration, 56 | }: TrainTrip): TrainTripView => { 57 | return { 58 | id, 59 | 60 | allowUserModification: !isLocked, 61 | createdAt, 62 | 63 | pax: pax.value, 64 | startDate, 65 | travelClass: currentTravelClassConfiguration.travelClass.name, 66 | travelClasses: travelClassConfiguration.map(({ travelClass: { templateId, name } }) => ({ templateId, name })), 67 | } 68 | } 69 | 70 | const serializeTrainTrip = ({ events, ...rest }: any) => stringify(rest) 71 | 72 | function deserializeDbTrainTrip(serializedTrainTrip: string) { 73 | const { 74 | id, 75 | createdAt, 76 | currentTravelClassConfiguration, 77 | lockedAt, 78 | startDate, 79 | pax: paxInput, 80 | travelClassConfiguration, 81 | ...rest 82 | } = parse(serializedTrainTrip) as TrainTripDTO 83 | // what do we do? we restore all properties that are just property bags 84 | // and we recreate proper object graph for properties that have behaviors 85 | // TODO: use type information or configuration, probably a library ;-) 86 | 87 | const travelClassConfigurations = travelClassConfiguration.map(mapTravelClassConfigurationDTO) 88 | const trainTrip = new TrainTrip( 89 | id, 90 | new (PaxDefinition as any)(paxInput.value), 91 | new Date(startDate), 92 | travelClassConfigurations, 93 | travelClassConfigurations.find(x => x.travelClass.name === currentTravelClassConfiguration.travelClass.name)!, 94 | { 95 | ...rest, 96 | createdAt: new Date(createdAt), 97 | lockedAt: lockedAt ? new Date(lockedAt) : undefined, 98 | }, 99 | ) 100 | 101 | return trainTrip 102 | } 103 | 104 | const mapTravelClassConfigurationDTO = ({ travelClass, ...slRest }: { travelClass: TravelClassDTO }) => { 105 | const slc = new TravelClassConfiguration(mapTravelClassDTO(travelClass)) 106 | Object.assign(slc, slRest) 107 | return slc 108 | } 109 | 110 | const mapTravelClassDTO = ({ createdAt, templateId, name }: TravelClassDTO): TravelClass => { 111 | const sl = new TravelClass(templateId, name) 112 | Object.assign(sl, { createdAt: new Date(createdAt) }) 113 | return sl 114 | } 115 | 116 | interface TrainTripDTO { 117 | createdAt: string 118 | currentTravelClassConfiguration: TravelClassConfigurationDTO 119 | id: string 120 | trip: TripDTO 121 | startDate: string 122 | lockedAt?: string 123 | pax: { 124 | value: Pax 125 | } 126 | travelClassConfiguration: TravelClassConfigurationDTO[] 127 | } 128 | interface TravelClassConfigurationDTO { 129 | travelClass: TravelClassDTO 130 | } 131 | interface TripDTO { 132 | travelClasses: TravelClassDTO[] 133 | } 134 | interface TravelClassDTO { 135 | createdAt: string 136 | name: TravelClassName 137 | templateId: string 138 | } 139 | -------------------------------------------------------------------------------- /packages/io.diskdb/src/RecordContext.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectionError, 3 | CouldNotAquireDbLockError, 4 | DbError, 5 | Event, 6 | OptimisticLockError, 7 | RecordContext, 8 | RecordNotFound, 9 | } from "@fp-app/framework" 10 | import { 11 | err, 12 | flatMap, 13 | liftType, 14 | map, 15 | mapErr, 16 | ok, 17 | PipeFunctionN, 18 | Result, 19 | startWithVal, 20 | success, 21 | } from "@fp-app/neverthrow-extensions" 22 | import { lock } from "proper-lockfile" 23 | import { deleteFile, exists, readFile, writeFile } from "./utils" 24 | 25 | // tslint:disable-next-line:max-classes-per-file 26 | export default class DiskRecordContext implements RecordContext { 27 | private cache = new Map>() 28 | private removals: T[] = [] 29 | 30 | constructor( 31 | private readonly type: string, 32 | private readonly serializer: (record: T) => string, 33 | private readonly deserializer: (serialized: string) => T, 34 | ) {} 35 | 36 | readonly add = (record: T) => { 37 | this.cache.set(record.id, { version: 0, data: record }) 38 | } 39 | 40 | readonly remove = (record: T) => { 41 | this.removals.push(record) 42 | } 43 | 44 | readonly load = async (id: string): Promise> => { 45 | const cachedRecord = this.cache.get(id) 46 | if (cachedRecord) { 47 | return ok(cachedRecord.data) 48 | } 49 | return await tryReadFromDb(this.type, id).pipe( 50 | map(serializedStr => JSON.parse(serializedStr) as SerializedDBRecord), 51 | map(({ data, version }) => ({ data: this.deserializer(data), version })), 52 | map(({ version, data }) => { 53 | this.cache.set(id, { version, data }) 54 | return data 55 | }), 56 | ) 57 | } 58 | 59 | // Internal 60 | readonly intGetAndClearEvents = () => { 61 | const items = [...this.cache.values()].map(x => x.data).concat(this.removals) 62 | return items.reduce((prev, cur) => prev.concat(cur.intGetAndClearEvents()), [] as Event[]) 63 | } 64 | 65 | readonly intSave = ( 66 | forEachSave?: (item: T) => Promise>, 67 | forEachDelete?: (item: T) => Promise>, 68 | ): Promise> => 69 | this.handleDeletions(forEachDelete).pipe(flatMap(() => this.handleInsertionsAndUpdates(forEachSave))) 70 | 71 | private readonly handleDeletions = async ( 72 | forEachDelete?: (item: T) => Promise>, 73 | ): Promise> => { 74 | for (const e of this.removals) { 75 | const r = await this.deleteRecord(e) 76 | if (r.isErr()) { 77 | return r 78 | } 79 | if (forEachDelete) { 80 | const rEs = await forEachDelete(e) 81 | if (rEs.isErr()) { 82 | return rEs 83 | } 84 | } 85 | this.cache.delete(e.id) 86 | } 87 | return success() 88 | } 89 | 90 | private readonly handleInsertionsAndUpdates = async ( 91 | forEachSave?: (item: T) => Promise>, 92 | ): Promise> => { 93 | for (const e of this.cache.entries()) { 94 | const r = await this.saveRecord(e[1].data) 95 | if (r.isErr()) { 96 | return r 97 | } 98 | if (forEachSave) { 99 | const rEs = await forEachSave(e[1].data) 100 | if (rEs.isErr()) { 101 | return rEs 102 | } 103 | } 104 | } 105 | return success() 106 | } 107 | 108 | private readonly saveRecord = async (record: T): Promise> => { 109 | const cachedRecord = this.cache.get(record.id)! 110 | 111 | if (!cachedRecord.version) { 112 | await this.actualSave(record, cachedRecord.version) 113 | return success() 114 | } 115 | 116 | return await lockRecordOnDisk(this.type, record.id, () => 117 | tryReadFromDb(this.type, record.id).pipe( 118 | flatMap( 119 | async (storedSerialized): Promise> => { 120 | const { version } = JSON.parse(storedSerialized) as SerializedDBRecord 121 | if (version !== cachedRecord.version) { 122 | return err(new OptimisticLockError(this.type, record.id)) 123 | } 124 | await this.actualSave(record, version) 125 | return success() 126 | }, 127 | ), 128 | ), 129 | ) 130 | } 131 | 132 | private readonly deleteRecord = (record: T): Promise> => 133 | lockRecordOnDisk(this.type, record.id, () => 134 | startWithVal(void 0)().pipe(map(() => deleteFile(getFilename(this.type, record.id)))), 135 | ) 136 | 137 | private readonly actualSave = async (record: T, version: number) => { 138 | const data = this.serializer(record) 139 | 140 | const serialized = JSON.stringify({ version: version + 1, data }) 141 | await writeFile(getFilename(this.type, record.id), serialized, { encoding: "utf-8" }) 142 | this.cache.set(record.id, { version, data: record }) 143 | } 144 | } 145 | 146 | interface DBRecord { 147 | id: string 148 | intGetAndClearEvents: () => Event[] 149 | } 150 | interface SerializedDBRecord { 151 | version: number 152 | data: string 153 | } 154 | interface CachedRecord { 155 | version: number 156 | data: T 157 | } 158 | 159 | const lockRecordOnDisk = (type: string, id: string, cb: PipeFunctionN) => 160 | tryLock(type, id).pipe( 161 | mapErr(liftType()), 162 | flatMap(async release => { 163 | try { 164 | return await cb() 165 | } finally { 166 | await release() 167 | } 168 | }), 169 | ) 170 | 171 | const tryLock = async (type: string, id: string): Promise Promise, CouldNotAquireDbLockError>> => { 172 | try { 173 | return ok(await lock(getFilename(type, id))) 174 | } catch (er) { 175 | return err(new CouldNotAquireDbLockError(type, id, er)) 176 | } 177 | } 178 | 179 | const tryReadFromDb = async (type: string, id: string): Promise> => { 180 | try { 181 | const filePath = getFilename(type, id) 182 | if (!(await exists(filePath))) { 183 | return err(new RecordNotFound(type, id)) 184 | } 185 | return ok(await readFile(filePath, { encoding: "utf-8" })) 186 | } catch (err) { 187 | return err(new ConnectionError(err)) 188 | } 189 | } 190 | 191 | export const getFilename = (type: string, id: string) => `./data/${type}-${id}.json` 192 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/createDependencyNamespace.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk" 2 | import { createNamespace, getNamespace } from "cls-hooked" 3 | import format from "date-fns/format" 4 | import { EventEmitter } from "events" 5 | import Event from "../event" 6 | import { Constructor, generateShortUuid, getLogger, removeElement, using } from "../utils" 7 | import { loggingDecorator, uowDecorator } from "./decorators" 8 | import DomainEventHandler, { executePostCommitHandlersKey } from "./domainEventHandler" 9 | import executePostCommitHandlers from "./executePostCommitHandlers" 10 | import { 11 | getRegisteredRequestAndEventHandlers, 12 | publish, 13 | request, 14 | RequestContextBase, 15 | requestInNewScopeKey, 16 | requestInNewScopeType, 17 | requestKey, 18 | requestType, 19 | resolveEventKey, 20 | } from "./mediator" 21 | import { processReceivedEvent } from "./pubsub" 22 | import SimpleContainer, { DependencyScope, factoryOf, Key } from "./SimpleContainer" 23 | 24 | const logger = getLogger("registry") 25 | 26 | export default function createDependencyNamespace(namespace: string, requestScopeKey: Key) { 27 | const ns = createNamespace(namespace) 28 | const getDependencyScope = (): DependencyScope => getNamespace(namespace).get(dependencyScopeKey) 29 | const setDependencyScope = (scope: DependencyScope) => getNamespace(namespace).set(dependencyScopeKey, scope) 30 | const hasDependencyScope = () => getDependencyScope() != null 31 | 32 | interface LoggingScope { 33 | items: {}[] 34 | } 35 | 36 | const container = new SimpleContainer(getDependencyScope, setDependencyScope) 37 | 38 | const getLoggingScope = (): LoggingScope => getNamespace(namespace).get(loggingScopeKey) 39 | 40 | const addToLoggingContext = (item: { [key: string]: any }) => { 41 | getLoggingScope().items.push(item) 42 | return { 43 | dispose: () => removeElement(getLoggingScope().items, item), 44 | } 45 | } 46 | 47 | const bindLogger = (fnc: (...args2: any[]) => void) => (...args: any[]) => { 48 | const context = hasDependencyScope() && container.getO(requestScopeKey) 49 | const datetime = new Date() 50 | const timestamp = format(datetime, "YYYY-MM-DD HH:mm:ss") 51 | const scope = getLoggingScope() 52 | const items = scope && scope.items.reduce((prev, cur) => ({ ...prev, ...cur }), {} as any) 53 | const id = context 54 | ? context.correllationId === context.id 55 | ? context.id 56 | : `${context.id} (${context.correllationId})` 57 | : "root context" 58 | return fnc( 59 | `${chalk.green(timestamp)} ${chalk.blue(`[${id}]`)}`, 60 | ...args.concat(items && Object.keys(items).length ? [items] : []), 61 | ) 62 | } 63 | 64 | const setupChildContext = (cb: () => Promise) => 65 | ns.runPromise(() => { 66 | const currentContext = container.getO(requestScopeKey) 67 | const { correllationId, id } = currentContext 68 | return using(container.createScope(), () => { 69 | const context = container.getO(requestScopeKey) 70 | Object.assign(context, { correllationId: correllationId || id }) 71 | logger.debug(chalk.magenta("Created child context")) 72 | return cb() 73 | }) 74 | }) 75 | 76 | const setupRequestContext = ( 77 | cb: (context: RequestContextBase, bindEmitter: (typeof ns)["bindEmitter"]) => Promise, 78 | ) => 79 | ns.runPromise(() => 80 | using(container.createScope(), () => { 81 | getNamespace(namespace).set(loggingScopeKey, { items: [] }) 82 | logger.debug(chalk.magenta("Created request context")) 83 | return cb(container.getO(requestScopeKey), (emitter: EventEmitter) => ns.bindEmitter(emitter)) 84 | }), 85 | ) 86 | 87 | const publishDomainEventHandler = publish(evt => 88 | (domainHandlerMap.get(evt.constructor) || []).map(x => container.getF(x as any)), 89 | ) 90 | const getIntegrationEventHandlers = (evt: Event) => integrationHandlerMap.get(evt.constructor) 91 | const publishIntegrationEventHandler = publish(evt => 92 | (integrationHandlerMap.get(evt.constructor) || []).map(x => container.getF(x)), 93 | ) 94 | container.registerScopedC( 95 | DomainEventHandler, 96 | () => 97 | new DomainEventHandler( 98 | publishDomainEventHandler, 99 | getIntegrationEventHandlers, 100 | container.getF(executePostCommitHandlersKey), 101 | ), 102 | ) 103 | container.registerScopedO(requestScopeKey, () => { 104 | const id = generateShortUuid() 105 | return { id, correllationId: id } 106 | }) 107 | getRegisteredRequestAndEventHandlers().forEach(h => container.registerScopedConcrete(h)) 108 | 109 | container.registerScopedConcrete(uowDecorator) 110 | container.registerSingletonConcrete(loggingDecorator) 111 | container.registerDecorator(requestKey, uowDecorator, loggingDecorator) 112 | 113 | container.registerSingletonF( 114 | executePostCommitHandlersKey, 115 | factoryOf(executePostCommitHandlers, i => i({ executeIntegrationEvent: container.getF(requestInNewScopeKey) })), 116 | ) 117 | 118 | const publishInNewContext = (evt: string, requestId: string) => 119 | setupRequestContext(context => { 120 | const correllationId = requestId || context.id 121 | Object.assign(context, { correllationId }) 122 | 123 | return processReceivedEvent({ 124 | publish: publishIntegrationEventHandler, 125 | resolveEvent: container.getF(resolveEventKey), 126 | })(evt) 127 | }) 128 | 129 | const requestInNewContext: requestInNewScopeType = (key: any, evt: any) => 130 | setupChildContext(() => container.getF(requestKey)(key, evt)) 131 | container.registerSingletonF(requestKey, factoryOf(request, i => i(key => container.getConcrete(key)))) 132 | container.registerInstanceF(requestInNewScopeKey, requestInNewContext) 133 | 134 | // In a perfect world, the decorators also enhance the type here 135 | // however they also apply different behavior depending on the request. 136 | // ie the uowDecorator, if a command, will call save on the uow and thus should 137 | // extend the error with | DbError... 138 | const request2: requestType = (key, input) => container.getF(requestKey)(key, input) 139 | 140 | return { 141 | addToLoggingContext, 142 | bindLogger, 143 | container, 144 | setupRequestContext, 145 | 146 | publishInNewContext, 147 | request: request2, 148 | } 149 | } 150 | 151 | const dependencyScopeKey = "dependencyScope" 152 | const loggingScopeKey = "loggingScope" 153 | 154 | const registerDomainEventHandler = (event: Constructor, handler: any) => { 155 | logger.debug(chalk.magenta(`Registered Domain event handler for ${event.name}`)) 156 | const current = domainHandlerMap.get(event) || [] 157 | current.push(handler) 158 | domainHandlerMap.set(event, current) 159 | } 160 | 161 | const registerIntegrationEventHandler = (event: Constructor, handler: any) => { 162 | logger.debug(chalk.magenta(`Registered Integration event handler for ${event.name}`)) 163 | const current = integrationHandlerMap.get(event) || [] 164 | current.push(handler) 165 | integrationHandlerMap.set(event, current) 166 | } 167 | 168 | // tslint:disable-next-line:ban-types 169 | const domainHandlerMap = new Map() // Array 170 | const integrationHandlerMap = new Map() // Array 171 | 172 | export { registerDomainEventHandler, registerIntegrationEventHandler } 173 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/TrainTrip.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-classes-per-file 2 | 3 | import { 4 | Entity, 5 | ForbiddenError, 6 | generateUuid, 7 | InvalidStateError, 8 | ValidationError, 9 | valueEquals, 10 | } from "@fp-app/framework" 11 | import Event from "@fp-app/framework/src/event" 12 | import { 13 | anyTrue, 14 | applyIfNotUndefined, 15 | err, 16 | flatMap, 17 | liftType, 18 | map, 19 | mapErr, 20 | mapStatic, 21 | ok, 22 | Result, 23 | success, 24 | valueOrUndefined, 25 | } from "@fp-app/neverthrow-extensions" 26 | import isEqual from "lodash/fp/isEqual" 27 | import FutureDate from "./FutureDate" 28 | import PaxDefinition from "./PaxDefinition" 29 | import TravelClassDefinition from "./TravelClassDefinition" 30 | import Trip, { TravelClass, TripWithSelectedTravelClass } from "./Trip" 31 | 32 | export default class TrainTrip extends Entity { 33 | /** the primary way to create a new TrainTrip */ 34 | static create({ startDate, pax }: { startDate: FutureDate; pax: PaxDefinition }, trip: TripWithSelectedTravelClass) { 35 | const travelClassConfiguration = trip.travelClasses.map(x => new TravelClassConfiguration(x)) 36 | const currentTravelClassConfiguration = travelClassConfiguration.find( 37 | x => x.travelClass.name === trip.currentTravelClass.name, 38 | )! 39 | 40 | // TODO: not trip. 41 | const t = new TrainTrip( 42 | generateUuid(), 43 | pax, 44 | startDate.value, 45 | travelClassConfiguration, 46 | currentTravelClassConfiguration, 47 | ) 48 | t.registerDomainEvent(new TrainTripCreated(t.id)) 49 | 50 | return t 51 | } 52 | 53 | readonly createdAt = new Date() 54 | readonly opportunityId?: string 55 | readonly lockedAt?: Date 56 | get isLocked() { 57 | return Boolean(this.lockedAt) 58 | } 59 | 60 | /** use TrainTrip.create() instead */ 61 | constructor( 62 | id: string, 63 | readonly pax: PaxDefinition, 64 | readonly startDate: Date, 65 | readonly travelClassConfiguration: TravelClassConfiguration[] = [], 66 | readonly currentTravelClassConfiguration: TravelClassConfiguration, 67 | rest?: Partial< 68 | Omit< 69 | { -readonly [key in keyof TrainTrip]: TrainTrip[key] }, 70 | "id" | "pax" | "startDate" | "travelClassConfiguration" | "currentTravelClassConfiguration" | "trip" 71 | > 72 | >, 73 | // rest?: Partial<{ -readonly [key in keyof TrainTrip]: TrainTrip[key] }>, 74 | ) { 75 | super(id) 76 | Object.assign(this, rest) 77 | } 78 | 79 | proposeChanges(state: StateProposition) { 80 | return this.confirmUserChangeAllowed().pipe( 81 | mapStatic(state), 82 | mapErr(liftType()), 83 | flatMap(this.applyDefinedChanges), 84 | map(this.createChangeEvents), 85 | ) 86 | } 87 | 88 | lock() { 89 | this.w.lockedAt = new Date() 90 | 91 | this.registerDomainEvent(new TrainTripStateChanged(this.id)) 92 | } 93 | 94 | assignOpportunity(opportunityId: string) { 95 | this.w.opportunityId = opportunityId 96 | } 97 | 98 | readonly updateTrip = (trip: Trip) => { 99 | // This will clear all configurations upon trip update 100 | // TODO: Investigate a resolution mechanism to update existing configurations, depends on business case ;-) 101 | this.w.travelClassConfiguration = trip.travelClasses.map(x => new TravelClassConfiguration(x)) 102 | const currentTravelClassConfiguration = this.travelClassConfiguration.find( 103 | x => this.currentTravelClassConfiguration.travelClass.name === x.travelClass.name, 104 | ) 105 | this.w.currentTravelClassConfiguration = currentTravelClassConfiguration || this.travelClassConfiguration[0]! 106 | } 107 | 108 | // TODO: This seems like cheating, we're missing another Aggregate Root.. 109 | readonly delete = () => { 110 | this.registerDomainEvent(new TrainTripDeleted(this.id)) 111 | } 112 | 113 | //////////// 114 | //// Separate sample; not used other than testing 115 | async changeStartDate(startDate: FutureDate) { 116 | return this.confirmUserChangeAllowed().pipe( 117 | mapStatic(startDate), 118 | map(this.intChangeStartDate), 119 | map(this.createChangeEvents), 120 | ) 121 | } 122 | 123 | async changePax(pax: PaxDefinition) { 124 | return this.confirmUserChangeAllowed().pipe( 125 | mapStatic(pax), 126 | map(this.intChangePax), 127 | map(this.createChangeEvents), 128 | ) 129 | } 130 | 131 | async changeTravelClass(travelClass: TravelClassDefinition) { 132 | return this.confirmUserChangeAllowed().pipe( 133 | mapStatic(travelClass), 134 | mapErr(liftType()), 135 | flatMap(this.intChangeTravelClass), 136 | map(this.createChangeEvents), 137 | ) 138 | } 139 | //// End Separate sample; not used other than testing 140 | //////////// 141 | 142 | private readonly applyDefinedChanges = ({ startDate, pax, travelClass }: StateProposition) => 143 | anyTrue( 144 | map(() => applyIfNotUndefined(startDate, this.intChangeStartDate)), 145 | map(() => applyIfNotUndefined(pax, this.intChangePax)), 146 | flatMap(() => valueOrUndefined(travelClass, this.intChangeTravelClass)), 147 | ) 148 | 149 | private readonly intChangeStartDate = (startDate: FutureDate) => { 150 | if (valueEquals(startDate, this.startDate, v => v.toISOString())) { 151 | return false 152 | } 153 | 154 | this.w.startDate = startDate.value 155 | // TODO: other business logic 156 | 157 | return true 158 | } 159 | 160 | private readonly intChangePax = (pax: PaxDefinition) => { 161 | if (isEqual(this.pax, pax)) { 162 | return false 163 | } 164 | 165 | this.w.pax = pax 166 | // TODO: other business logic 167 | 168 | return true 169 | } 170 | 171 | private readonly intChangeTravelClass = (travelClass: TravelClassDefinition): Result => { 172 | const slc = this.travelClassConfiguration.find(x => x.travelClass.name === travelClass.value) 173 | if (!slc) { 174 | return err(new InvalidStateError(`${travelClass.value} not available currently`)) 175 | } 176 | if (this.currentTravelClassConfiguration === slc) { 177 | return ok(false) 178 | } 179 | this.w.currentTravelClassConfiguration = slc 180 | return ok(true) 181 | } 182 | 183 | private confirmUserChangeAllowed(): Result { 184 | if (this.isLocked) { 185 | return err(new ForbiddenError(`No longer allowed to change TrainTrip ${this.id}`)) 186 | } 187 | return success() 188 | } 189 | 190 | private readonly createChangeEvents = (changed: boolean) => { 191 | this.registerDomainEvent(new UserInputReceived(this.id)) 192 | if (changed) { 193 | this.registerDomainEvent(new TrainTripStateChanged(this.id)) 194 | } 195 | } 196 | } 197 | 198 | export class TravelClassConfiguration { 199 | readonly priceLastUpdated?: Date 200 | readonly price!: Price 201 | 202 | constructor(readonly travelClass: TravelClass) {} 203 | } 204 | 205 | /* 206 | These event names look rather technical (like CRUD) and not very domain driven 207 | 208 | */ 209 | 210 | export class TrainTripCreated extends Event { 211 | constructor(readonly trainTripId: TrainTripId) { 212 | super() 213 | } 214 | } 215 | 216 | export class UserInputReceived extends Event { 217 | constructor(readonly trainTripId: TrainTripId) { 218 | super() 219 | } 220 | } 221 | 222 | export class TrainTripStateChanged extends Event { 223 | constructor(readonly trainTripId: TrainTripId) { 224 | super() 225 | } 226 | } 227 | 228 | export class TrainTripDeleted extends Event { 229 | constructor(readonly trainTripId: TrainTripId) { 230 | super() 231 | } 232 | } 233 | 234 | export interface StateProposition { 235 | pax?: PaxDefinition 236 | startDate?: FutureDate 237 | travelClass?: TravelClassDefinition 238 | } 239 | 240 | export interface CreateTrainTripInfo { 241 | pax: PaxDefinition 242 | startDate: FutureDate 243 | templateId: string 244 | } 245 | 246 | export type ID = string 247 | export type TrainTripId = ID 248 | export type TemplateId = ID 249 | 250 | export interface Price { 251 | amount: number 252 | currency: string 253 | } 254 | -------------------------------------------------------------------------------- /samples/basic/src/TrainTrip/integration.test.ts: -------------------------------------------------------------------------------- 1 | jest.mock("@fp-app/framework/src/infrastructure/executePostCommitHandlers") 2 | 3 | import { CustomerRequestedChangesDTO } from "@/resolveIntegrationEvent" 4 | import { 5 | CombinedValidationError, 6 | executePostCommitHandlers, 7 | generateShortUuid, 8 | InvalidStateError, 9 | logger, 10 | noop, 11 | RecordNotFound, 12 | setLogger, 13 | } from "@fp-app/framework" 14 | import { Err, Ok } from "@fp-app/neverthrow-extensions" 15 | import createRoot from "../root" 16 | import changeTrainTrip, { StateProposition } from "./usecases/changeTrainTrip" 17 | import createTrainTrip from "./usecases/createTrainTrip" 18 | import deleteTrainTrip from "./usecases/deleteTrainTrip" 19 | import getTrainTrip from "./usecases/getTrainTrip" 20 | import lockTrainTrip from "./usecases/lockTrainTrip" 21 | import registerCloud from "./usecases/registerCloud" 22 | 23 | let trainTripId: string 24 | let executePostCommitHandlersMock: jest.Mock> 25 | 26 | let root: ReturnType 27 | 28 | // cls helpers 29 | const createRootAndBind = (cb: () => Promise) => { 30 | root = createRoot() 31 | return root.setupRequestContext(cb) 32 | } 33 | 34 | // Silence logger 35 | setLogger({ 36 | debug: noop, 37 | error: noop, 38 | log: noop, 39 | warn: noop, 40 | }) 41 | 42 | beforeEach(() => 43 | createRootAndBind(async () => { 44 | await root.initialize() 45 | executePostCommitHandlersMock = jest.fn() 46 | const aAny = executePostCommitHandlers as any 47 | aAny.mockReturnValue(executePostCommitHandlersMock) 48 | executePostCommitHandlersMock.mockClear() 49 | const templateId = "template-id1" 50 | 51 | const result = await root.request(createTrainTrip, { 52 | pax: { adults: 2, children: 0, babies: 0, infants: 0, teenagers: 0 }, 53 | startDate: "2020-01-01", 54 | templateId, 55 | }) 56 | 57 | trainTripId = result._unsafeUnwrap() 58 | expect(executePostCommitHandlersMock).toBeCalledTimes(1) 59 | executePostCommitHandlersMock.mockClear() 60 | }), 61 | ) 62 | 63 | describe("usecases", () => { 64 | describe("get", () => { 65 | it("works", () => 66 | createRootAndBind(async () => { 67 | const result = await root.request(getTrainTrip, { trainTripId }) 68 | 69 | expect(result).toBeInstanceOf(Ok) 70 | // We don't want to leak accidentally domain objects 71 | expect(result._unsafeUnwrap()).toEqual({ 72 | allowUserModification: true, 73 | createdAt: expect.any(String), 74 | id: expect.any(String), 75 | pax: { adults: 2, babies: 0, children: 0, infants: 0, teenagers: 0 }, 76 | startDate: expect.any(String), 77 | travelClass: "second", 78 | travelClasses: [ 79 | { templateId: "template-id1", name: "second" }, 80 | { templateId: "template-id2", name: "first" }, 81 | ], 82 | }) 83 | 84 | logger.log(result._unsafeUnwrap()) 85 | expect(executePostCommitHandlersMock).toBeCalledTimes(0) 86 | })) 87 | }) 88 | 89 | describe("propose new state", () => { 90 | it("changes state accordingly", () => 91 | createRootAndBind(async () => { 92 | const state: StateProposition = { 93 | pax: { adults: 2, babies: 2, children: 1, infants: 1, teenagers: 0 }, 94 | startDate: "2030-01-01T00:00:00.000Z", 95 | travelClass: "first", 96 | } 97 | 98 | const result = await root.request(changeTrainTrip, { trainTripId, ...state }) 99 | const newTrainTripResult = await root.request(getTrainTrip, { trainTripId }) 100 | 101 | expect(result).toBeInstanceOf(Ok) 102 | expect(newTrainTripResult).toBeInstanceOf(Ok) 103 | // We don't want to leak accidentally domain objects 104 | expect(result._unsafeUnwrap()).toBe(void 0) 105 | const r = newTrainTripResult._unsafeUnwrap() 106 | expect(r.travelClass).toBe(state.travelClass) 107 | expect(r.startDate).toEqual(state.startDate!) 108 | expect(r.pax).toEqual(state.pax) 109 | expect(executePostCommitHandlersMock).toBeCalledTimes(1) 110 | logger.log(r) 111 | })) 112 | 113 | it("errors on non existent travel class", () => 114 | createRootAndBind(async () => { 115 | const state: StateProposition = { travelClass: "business" } 116 | 117 | const r = await root.request(changeTrainTrip, { trainTripId, ...state }) 118 | 119 | expect(r.isErr()).toBe(true) 120 | const error = r._unsafeUnwrapErr() 121 | expect(error).toBeInstanceOf(InvalidStateError) 122 | expect(error.message).toBe("business not available currently") 123 | expect(executePostCommitHandlersMock).toBeCalledTimes(0) 124 | })) 125 | 126 | it("errors on multiple invalid", () => 127 | createRootAndBind(async () => { 128 | const state: StateProposition = { travelClass: "bogus", pax: { children: 0 } as any, startDate: "2000-01-01" } 129 | 130 | const r = await root.request(changeTrainTrip, { trainTripId, ...state }) 131 | 132 | expect(r.isErr()).toBe(true) 133 | const error = r._unsafeUnwrapErr() 134 | expect(error).toBeInstanceOf(CombinedValidationError) 135 | const cve = error as CombinedValidationError 136 | expect(cve.errors.length).toBe(3) 137 | expect(executePostCommitHandlersMock).toBeCalledTimes(0) 138 | })) 139 | }) 140 | 141 | describe("able to lock the TrainTrip", () => { 142 | it("changes state accordingly", () => 143 | createRootAndBind(async () => { 144 | const currentTrainTripResult = await root.request(getTrainTrip, { trainTripId }) 145 | 146 | const result = await root.request(lockTrainTrip, { trainTripId }) 147 | 148 | const newTrainTripResult = await root.request(getTrainTrip, { trainTripId }) 149 | expect(result).toBeInstanceOf(Ok) 150 | // We don't want to leak accidentally domain objects 151 | expect(result._unsafeUnwrap()).toBe(void 0) 152 | expect(currentTrainTripResult).toBeInstanceOf(Ok) 153 | expect(currentTrainTripResult._unsafeUnwrap().allowUserModification).toBe(true) 154 | expect(newTrainTripResult._unsafeUnwrap().allowUserModification).toBe(false) 155 | expect(executePostCommitHandlersMock).toBeCalledTimes(1) 156 | })) 157 | }) 158 | 159 | describe("able to delete the TrainTrip", () => { 160 | it("deletes accordingly", () => 161 | createRootAndBind(async () => { 162 | const currentTrainTripResult = await root.request(getTrainTrip, { trainTripId }) 163 | 164 | const result = await root.request(deleteTrainTrip, { trainTripId }) 165 | 166 | const newTrainTripResult = await root.request(getTrainTrip, { trainTripId }) 167 | expect(result).toBeInstanceOf(Ok) 168 | // We don't want to leak accidentally domain objects 169 | expect(result._unsafeUnwrap()).toBe(void 0) 170 | expect(currentTrainTripResult).toBeInstanceOf(Ok) 171 | expect(currentTrainTripResult._unsafeUnwrap().allowUserModification).toBe(true) 172 | expect(newTrainTripResult).toBeInstanceOf(Err) 173 | expect(newTrainTripResult._unsafeUnwrapErr()).toBeInstanceOf(RecordNotFound) 174 | expect(executePostCommitHandlersMock).toBeCalledTimes(0) 175 | })) 176 | }) 177 | 178 | describe("register Cloud", () => { 179 | it("works", () => 180 | createRootAndBind(async () => { 181 | const result = await root.request(registerCloud, { trainTripId }) 182 | 183 | expect(result).toBeInstanceOf(Ok) 184 | expect(result._unsafeUnwrap()).toBe(void 0) 185 | expect(executePostCommitHandlersMock).toBeCalledTimes(0) 186 | })) 187 | }) 188 | }) 189 | 190 | describe("integration events", () => { 191 | describe("CustomerRequestedChanges", () => { 192 | it("locks the TrainTrip", () => 193 | createRootAndBind(async () => { 194 | const p: CustomerRequestedChangesDTO = { 195 | payload: { trainTripId, itineraryId: "some-itinerary-id" }, 196 | type: "CustomerRequestedChanges", 197 | } 198 | await root.publishInNewContext(JSON.stringify(p), generateShortUuid()) 199 | 200 | const newTrainTripResult = await root.request(getTrainTrip, { trainTripId }) 201 | expect(newTrainTripResult._unsafeUnwrap().allowUserModification).toBe(false) 202 | })) 203 | }) 204 | }) 205 | -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/mediator/registry.ts: -------------------------------------------------------------------------------- 1 | import { PipeFunction, Result } from "@fp-app/neverthrow-extensions" 2 | import chalk from "chalk" 3 | import Event from "../../event" 4 | import { Constructor, getLogger, setFunctionName, typedKeysOf } from "../../utils" 5 | import assert from "../../utils/assert" 6 | import { UnitOfWork } from "../context.base" 7 | import { registerDomainEventHandler, registerIntegrationEventHandler } from "../createDependencyNamespace" 8 | import { 9 | generateKey, 10 | InjectedDependencies, 11 | injectSymbol, 12 | requestTypeSymbol, 13 | WithDependencies, 14 | WithDependenciesConfig, 15 | } from "../SimpleContainer" 16 | 17 | const logger = getLogger("registry") 18 | 19 | export interface RequestContextBase { 20 | id: string 21 | correllationId: string 22 | } 23 | 24 | export type EventHandlerWithDependencies = HandlerWithDependencies< 25 | TDependencies, 26 | TInput, 27 | TOutput, 28 | TError 29 | > 30 | export type UsecaseWithDependencies = HandlerWithDependencies< 31 | TDependencies, 32 | TInput, 33 | TOutput, 34 | TError 35 | > 36 | 37 | export const configureDependencies = ( 38 | deps: TDependencies, 39 | name: string, 40 | f: WithDependencies, 41 | ): WithDependenciesConfig => { 42 | const keys = typedKeysOf(deps) 43 | if (keys.length && keys.some(key => !deps[key])) { 44 | throw new Error(`Has empty dependencies`) 45 | } 46 | setFunctionName(f, name) 47 | const anyF: any = f 48 | anyF[injectSymbol] = deps 49 | return anyF 50 | } 51 | 52 | export const UOWKey = generateKey("unit-of-work") 53 | 54 | export type resolveEventType = (evt: { type: any; payload: any }) => Event | undefined 55 | export const resolveEventKey = generateKey("resolveEvent") 56 | 57 | type HandlerWithDependencies = WithDependencies< 58 | TDependencies, 59 | PipeFunction 60 | > 61 | 62 | // tslint:disable-next-line:max-line-length 63 | export type NamedHandlerWithDependencies = WithDependencies< 64 | TDependencies, 65 | NamedRequestHandler 66 | > & 67 | HandlerInfo 68 | 69 | interface HandlerTypeInfo { 70 | [requestTypeSymbol]: HandlerType 71 | } 72 | type HandlerInfo = InjectedDependencies & HandlerTypeInfo 73 | type HandlerType = "COMMAND" | "QUERY" | "DOMAINEVENT" | "INTEGRATIONEVENT" 74 | 75 | // tslint:disable-next-line:max-line-length 76 | // type HandlerTuple = readonly [ 77 | // NamedHandlerWithDependencies, 78 | // TDependencies, 79 | // { name: string, type: HandlerType } 80 | // ] 81 | 82 | const registerUsecaseHandler = (deps: TDependencies) => (name: string, type: HandlerType) => < 83 | TInput, 84 | TOutput, 85 | TError 86 | >( 87 | handler: UsecaseWithDependencies, 88 | ) => { 89 | assert(!typedKeysOf(deps).some(x => !deps[x]), "Dependencies must not be null") 90 | 91 | const newHandler = handler as NamedHandlerWithDependencies 92 | newHandler[requestTypeSymbol] = type 93 | newHandler[injectSymbol] = deps 94 | setFunctionName(handler, name) 95 | 96 | // const r = [newHandler, deps, { name, type }] as const 97 | // dependencyMap.set(handler, r) 98 | requestAndEventHandlers.push(newHandler) 99 | return newHandler 100 | } 101 | 102 | // tslint:disable-next-line:max-line-length 103 | const createCommandWithDeps = (deps: TDependencies) => ( 104 | name: string, 105 | handler: UsecaseWithDependencies, 106 | ) => { 107 | handler = wrapHandler(handler) 108 | const setupWithDeps = registerUsecaseHandler(deps) 109 | const newHandler = setupWithDeps(name + "Command", "COMMAND")(handler) 110 | logger.debug(chalk.magenta(`Created Command handler ${name}`)) 111 | return newHandler 112 | } 113 | 114 | // tslint:disable-next-line:max-line-length 115 | const createQueryWithDeps = (deps: TDependencies) => ( 116 | name: string, 117 | handler: UsecaseWithDependencies, 118 | ) => { 119 | handler = wrapHandler(handler) 120 | const setupWithDeps = registerUsecaseHandler(deps) 121 | const newHandler = setupWithDeps(name + "Query", "QUERY")(handler) 122 | logger.debug(chalk.magenta(`Created Query handler ${name}`)) 123 | return newHandler 124 | } 125 | 126 | // tslint:disable-next-line:max-line-length 127 | const createDomainEventHandlerWithDeps = (deps: TDependencies) => ( 128 | event: Constructor, 129 | name: string, 130 | handler: UsecaseWithDependencies, 131 | ) => { 132 | handler = wrapHandler(handler) 133 | const setupWithDeps = registerUsecaseHandler(deps) 134 | const newHandler = setupWithDeps(`on${event.name}${name}`, "DOMAINEVENT")(handler) 135 | registerDomainEventHandler(event, handler) 136 | return newHandler 137 | } 138 | 139 | // tslint:disable-next-line:max-line-length 140 | const createIntegrationEventHandlerWithDeps = (deps: TDependencies) => ( 141 | event: Constructor, 142 | name: string, 143 | handler: UsecaseWithDependencies, 144 | ) => { 145 | handler = wrapHandler(handler) 146 | const setupWithDeps = registerUsecaseHandler(deps) 147 | const newHandler = setupWithDeps(`on${event.name}${name}`, "INTEGRATIONEVENT")(handler) 148 | registerIntegrationEventHandler(event, handler) 149 | return newHandler 150 | } 151 | 152 | const wrapHandler = (handler: any) => (...args: any[]) => handler(...args) 153 | 154 | const requestAndEventHandlers: NamedHandlerWithDependencies[] = [] 155 | 156 | const getRegisteredRequestAndEventHandlers = () => [...requestAndEventHandlers] 157 | 158 | const curryRequest = ( 159 | req: NamedHandlerWithDependencies, 160 | ) => ({ request }: { request: requestType }) => (input: TInput) => request(req, input) 161 | 162 | export { 163 | getRegisteredRequestAndEventHandlers, 164 | createCommandWithDeps, 165 | createDomainEventHandlerWithDeps, 166 | createIntegrationEventHandlerWithDeps, 167 | createQueryWithDeps, 168 | curryRequest, 169 | } 170 | 171 | export type requestType = ( 172 | requestHandler: NamedHandlerWithDependencies, 173 | input: TInput, 174 | ) => Promise> 175 | 176 | export type requestInNewScopeType = ( 177 | requestHandler: NamedHandlerWithDependencies, 178 | input: TInput, 179 | ) => Promise> 180 | 181 | export type NamedRequestHandler = PipeFunction & HandlerInfo 182 | 183 | export const requestKey = generateKey("request") 184 | export const requestInNewScopeKey = generateKey("requestInNewScope") 185 | 186 | // const dependencyMap = new Map, HandlerTuple>() 187 | 188 | // Allow requesting a class directly, instead of requiring a key 189 | // However one should depend on abstract base classes (don't satisfy the contraint) 190 | // or interfaces / function signatures (requires key) 191 | // export const asDep = any)>(t: T) => t as any as InstanceType 192 | 193 | // const generateConfiguredHandler = ( 194 | // // Have to specify name as we don't use classes to retrieve the name from 195 | // name: string, 196 | // createHandler: () => PipeFunction, 197 | // decoratorFactories: Array<() => RequestHandlerDecorator>, 198 | // isCommand = false, 199 | // ): NamedRequestHandler => { 200 | // const anyHandler: any = (input: TInput) => { 201 | // const execFunc = createHandler() 202 | // return execFunc(input) 203 | // } 204 | // const handler: NamedRequestHandler = anyHandler 205 | // return handler 206 | // } 207 | 208 | // tslint:disable-next-line:max-line-length 209 | // type RequestHandlerDecorator = Decorator, PipeFunction> 210 | // type RequestDecorator = ( 211 | // handler: NamedRequestHandler) => (input: TInput) => Promise> 212 | 213 | // type Decorator = (inp: T) => T2 214 | -------------------------------------------------------------------------------- /samples/basic/router-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "/train-trip": [ 3 | { 4 | "method": "POST", 5 | "subPath": "/", 6 | "fullPath": "/train-trip/", 7 | "schema": { 8 | "type": "object", 9 | "properties": { 10 | "pax": { 11 | "type": "object", 12 | "properties": { 13 | "adults": { 14 | "type": "integer", 15 | "minimum": 0, 16 | "maximum": 6 17 | }, 18 | "babies": { 19 | "type": "integer", 20 | "minimum": 0, 21 | "maximum": 6 22 | }, 23 | "children": { 24 | "type": "integer", 25 | "minimum": 0, 26 | "maximum": 6 27 | }, 28 | "infants": { 29 | "type": "integer", 30 | "minimum": 0, 31 | "maximum": 6 32 | }, 33 | "teenagers": { 34 | "type": "integer", 35 | "minimum": 0, 36 | "maximum": 6 37 | } 38 | }, 39 | "additionalProperties": false, 40 | "patterns": [], 41 | "required": [ 42 | "adults", 43 | "babies", 44 | "children", 45 | "infants", 46 | "teenagers" 47 | ] 48 | }, 49 | "startDate": { 50 | "type": "string", 51 | "format": "date-time" 52 | }, 53 | "templateId": { 54 | "type": "string" 55 | } 56 | }, 57 | "additionalProperties": false, 58 | "patterns": [], 59 | "required": [ 60 | "pax", 61 | "startDate", 62 | "templateId" 63 | ] 64 | } 65 | }, 66 | { 67 | "method": "GET", 68 | "subPath": "/:trainTripId", 69 | "fullPath": "/train-trip/:trainTripId", 70 | "schema": { 71 | "type": "object", 72 | "properties": { 73 | "trainTripId": { 74 | "type": "string" 75 | } 76 | }, 77 | "additionalProperties": false, 78 | "patterns": [], 79 | "required": [ 80 | "trainTripId" 81 | ] 82 | } 83 | }, 84 | { 85 | "method": "PATCH", 86 | "subPath": "/:trainTripId", 87 | "fullPath": "/train-trip/:trainTripId", 88 | "schema": { 89 | "type": "object", 90 | "properties": { 91 | "pax": { 92 | "type": "object", 93 | "properties": { 94 | "adults": { 95 | "type": "integer", 96 | "minimum": 0, 97 | "maximum": 6 98 | }, 99 | "babies": { 100 | "type": "integer", 101 | "minimum": 0, 102 | "maximum": 6 103 | }, 104 | "children": { 105 | "type": "integer", 106 | "minimum": 0, 107 | "maximum": 6 108 | }, 109 | "infants": { 110 | "type": "integer", 111 | "minimum": 0, 112 | "maximum": 6 113 | }, 114 | "teenagers": { 115 | "type": "integer", 116 | "minimum": 0, 117 | "maximum": 6 118 | } 119 | }, 120 | "additionalProperties": false, 121 | "patterns": [], 122 | "required": [ 123 | "adults", 124 | "babies", 125 | "children", 126 | "infants", 127 | "teenagers" 128 | ] 129 | }, 130 | "startDate": { 131 | "type": "string", 132 | "format": "date-time" 133 | }, 134 | "trainTripId": { 135 | "type": "string" 136 | }, 137 | "travelClass": { 138 | "type": "string" 139 | } 140 | }, 141 | "additionalProperties": false, 142 | "patterns": [], 143 | "required": [ 144 | "pax", 145 | "trainTripId" 146 | ] 147 | } 148 | }, 149 | { 150 | "method": "DELETE", 151 | "subPath": "/:trainTripId", 152 | "fullPath": "/train-trip/:trainTripId", 153 | "schema": { 154 | "type": "object", 155 | "properties": { 156 | "trainTripId": { 157 | "type": "string" 158 | } 159 | }, 160 | "additionalProperties": false, 161 | "patterns": [], 162 | "required": [ 163 | "trainTripId" 164 | ] 165 | } 166 | }, 167 | { 168 | "method": "POST", 169 | "subPath": "/:trainTripId/lock", 170 | "fullPath": "/train-trip/:trainTripId/lock", 171 | "schema": { 172 | "type": "object", 173 | "properties": { 174 | "trainTripId": { 175 | "type": "string" 176 | } 177 | }, 178 | "additionalProperties": false, 179 | "patterns": [], 180 | "required": [ 181 | "trainTripId" 182 | ] 183 | } 184 | } 185 | ], 186 | "/train-trip-auth": [ 187 | { 188 | "method": "POST", 189 | "subPath": "/", 190 | "fullPath": "/train-trip-auth/", 191 | "schema": { 192 | "type": "object", 193 | "properties": { 194 | "pax": { 195 | "type": "object", 196 | "properties": { 197 | "adults": { 198 | "type": "integer", 199 | "minimum": 0, 200 | "maximum": 6 201 | }, 202 | "babies": { 203 | "type": "integer", 204 | "minimum": 0, 205 | "maximum": 6 206 | }, 207 | "children": { 208 | "type": "integer", 209 | "minimum": 0, 210 | "maximum": 6 211 | }, 212 | "infants": { 213 | "type": "integer", 214 | "minimum": 0, 215 | "maximum": 6 216 | }, 217 | "teenagers": { 218 | "type": "integer", 219 | "minimum": 0, 220 | "maximum": 6 221 | } 222 | }, 223 | "additionalProperties": false, 224 | "patterns": [], 225 | "required": [ 226 | "adults", 227 | "babies", 228 | "children", 229 | "infants", 230 | "teenagers" 231 | ] 232 | }, 233 | "startDate": { 234 | "type": "string", 235 | "format": "date-time" 236 | }, 237 | "templateId": { 238 | "type": "string" 239 | } 240 | }, 241 | "additionalProperties": false, 242 | "patterns": [], 243 | "required": [ 244 | "pax", 245 | "startDate", 246 | "templateId" 247 | ] 248 | } 249 | }, 250 | { 251 | "method": "GET", 252 | "subPath": "/:trainTripId", 253 | "fullPath": "/train-trip-auth/:trainTripId", 254 | "schema": { 255 | "type": "object", 256 | "properties": { 257 | "trainTripId": { 258 | "type": "string" 259 | } 260 | }, 261 | "additionalProperties": false, 262 | "patterns": [], 263 | "required": [ 264 | "trainTripId" 265 | ] 266 | } 267 | }, 268 | { 269 | "method": "PATCH", 270 | "subPath": "/:trainTripId", 271 | "fullPath": "/train-trip-auth/:trainTripId", 272 | "schema": { 273 | "type": "object", 274 | "properties": { 275 | "pax": { 276 | "type": "object", 277 | "properties": { 278 | "adults": { 279 | "type": "integer", 280 | "minimum": 0, 281 | "maximum": 6 282 | }, 283 | "babies": { 284 | "type": "integer", 285 | "minimum": 0, 286 | "maximum": 6 287 | }, 288 | "children": { 289 | "type": "integer", 290 | "minimum": 0, 291 | "maximum": 6 292 | }, 293 | "infants": { 294 | "type": "integer", 295 | "minimum": 0, 296 | "maximum": 6 297 | }, 298 | "teenagers": { 299 | "type": "integer", 300 | "minimum": 0, 301 | "maximum": 6 302 | } 303 | }, 304 | "additionalProperties": false, 305 | "patterns": [], 306 | "required": [ 307 | "adults", 308 | "babies", 309 | "children", 310 | "infants", 311 | "teenagers" 312 | ] 313 | }, 314 | "startDate": { 315 | "type": "string", 316 | "format": "date-time" 317 | }, 318 | "trainTripId": { 319 | "type": "string" 320 | }, 321 | "travelClass": { 322 | "type": "string" 323 | } 324 | }, 325 | "additionalProperties": false, 326 | "patterns": [], 327 | "required": [ 328 | "pax", 329 | "trainTripId" 330 | ] 331 | } 332 | }, 333 | { 334 | "method": "DELETE", 335 | "subPath": "/:trainTripId", 336 | "fullPath": "/train-trip-auth/:trainTripId", 337 | "schema": { 338 | "type": "object", 339 | "properties": { 340 | "trainTripId": { 341 | "type": "string" 342 | } 343 | }, 344 | "additionalProperties": false, 345 | "patterns": [], 346 | "required": [ 347 | "trainTripId" 348 | ] 349 | } 350 | }, 351 | { 352 | "method": "POST", 353 | "subPath": "/:trainTripId/lock", 354 | "fullPath": "/train-trip-auth/:trainTripId/lock", 355 | "schema": { 356 | "type": "object", 357 | "properties": { 358 | "trainTripId": { 359 | "type": "string" 360 | } 361 | }, 362 | "additionalProperties": false, 363 | "patterns": [], 364 | "required": [ 365 | "trainTripId" 366 | ] 367 | } 368 | } 369 | ] 370 | } -------------------------------------------------------------------------------- /packages/framework/src/infrastructure/SimpleContainer.ts: -------------------------------------------------------------------------------- 1 | // TODO: There's obviously a lot of possibility to improve the API, and Implementation here ;-) 2 | 3 | import "reflect-metadata" 4 | import { Constructor, Disposable, setFunctionName } from "../utils" 5 | import assert from "../utils/assert" 6 | 7 | export default class SimpleContainer { 8 | private factories = new Map() 9 | private singletonScope = new DependencyScope() 10 | private decorators = new Map() 11 | private initializersF = new Map() 12 | private initializersC = new Map() 13 | private initializersO = new Map() 14 | constructor( 15 | private tryGetDependencyScope: () => DependencyScope, 16 | private setDependencyScope: (scope: DependencyScope) => void, 17 | ) {} 18 | 19 | getC(key: Constructor): T { 20 | const instance = this.tryCreateInstance(key) 21 | if (!instance) { 22 | throw new Error(`could not resolve ${key}`) 23 | } 24 | return instance 25 | } 26 | 27 | // tslint:disable-next-line:ban-types 28 | getF(key: T) { 29 | const f = this.tryCreateInstance(key) 30 | if (!f) { 31 | throw new Error(`could not resolve ${key}`) 32 | } 33 | return f 34 | } 35 | 36 | getConcrete(key: (deps: TDependencies) => T) { 37 | const f = this.tryCreateInstance(key) 38 | if (!f) { 39 | throw new Error(`could not resolve ${key}`) 40 | } 41 | return f 42 | } 43 | 44 | getO(key: Key) { 45 | const f = this.tryCreateInstance(key) 46 | if (!f) { 47 | throw new Error(`could not resolve ${key}`) 48 | } 49 | return f 50 | } 51 | 52 | createScope() { 53 | const scope = new DependencyScope() 54 | this.setDependencyScope(scope) 55 | return { 56 | dispose: () => scope.dispose(), 57 | } 58 | } 59 | 60 | registerTransientC(key: Constructor, factory: () => T) { 61 | this.registerFactoryC(key, factory) 62 | } 63 | 64 | registerScopedC(key: Constructor, factory: () => T) { 65 | this.registerFactoryC(key, factory, this.getDependencyScope) 66 | } 67 | 68 | registerSingletonC(key: Constructor, factory: () => T) { 69 | this.registerFactoryC(key, factory, this.getSingletonScope) 70 | } 71 | 72 | registerInstanceC(key: Constructor, instance: T) { 73 | this.registerFactoryC(key, () => instance, this.getSingletonScope) 74 | } 75 | 76 | registerTransientF any>(key: T, factory: () => T) { 77 | this.registerFactoryF(key, factory) 78 | } 79 | 80 | registerPassthrough(key: T, key2: T) { 81 | this.factories.set(key, this.factories.get(key2)) 82 | } 83 | 84 | registerScopedO(key: Key, factory?: () => T) { 85 | const fact = factory || (() => this.createNewInstance(key as any)) 86 | this.registerFactoryO(key, fact, this.getDependencyScope) 87 | } 88 | 89 | registerScopedF any>(key: T, factory: () => T) { 90 | this.registerFactoryF(key, factory, this.getDependencyScope) 91 | } 92 | 93 | registerScopedF2 any>( 94 | key: Key, 95 | impl: WithDependenciesConfig, 96 | ) { 97 | const factory = () => this.createFunctionInstance(impl) 98 | setFunctionName(factory, impl.name || `f(${key.name}`) 99 | this.registerFactoryF(key, factory, this.getDependencyScope) 100 | } 101 | 102 | registerSingletonF2 any>( 103 | key: Key, 104 | impl: WithDependenciesConfig, 105 | ) { 106 | const factory = () => this.createFunctionInstance(impl) 107 | setFunctionName(factory, impl.name || `f(${key.name}`) 108 | this.registerSingletonF(key, factory) 109 | } 110 | 111 | registerSingletonConcrete( 112 | key: WithDependenciesConfig | (() => T), 113 | factory?: () => T, 114 | ) { 115 | if (!factory) { 116 | factory = () => this.createFunctionInstance(key) 117 | setFunctionName(factory, key.name) 118 | } 119 | 120 | // TODO 121 | this.registerSingletonF(key, factory as any) 122 | } 123 | 124 | registerScopedConcrete( 125 | key: WithDependenciesConfig | (() => T), 126 | factory?: () => T, 127 | ) { 128 | if (!factory) { 129 | factory = () => this.createFunctionInstance(key) 130 | setFunctionName(factory, key.name) 131 | } 132 | 133 | this.registerScopedF(key, factory as any) 134 | } 135 | 136 | registerDecorator any>(forKey: T, ...decorators: any[]) { 137 | decorators.forEach(x => assert(x !== null, "decorator must not be null")) 138 | const current = this.decorators.get(forKey) || [] 139 | current.push(...decorators) 140 | this.decorators.set(forKey, current) 141 | } 142 | 143 | registerSingletonF any>(key: T, factory: () => T) { 144 | this.registerFactoryF(key, factory, this.getSingletonScope) 145 | } 146 | 147 | registerSingletonO(key: Key, factory?: () => T) { 148 | const fact = factory || (() => this.createNewInstance(key as any)) 149 | // this.factories.set(key, () => this.singletonScope.getOrCreate(key, fact)) 150 | this.registerFactoryO(key, fact, this.getSingletonScope) 151 | } 152 | 153 | registerSingletonC2(key: Key, impl: Constructor) { 154 | const factory = () => this.createNewInstance(impl) 155 | this.registerSingletonC(key as any, factory) 156 | 157 | // Also register the concrete implementation 158 | this.factories.set(impl, this.factories.get(key)) 159 | } 160 | 161 | registerScopedC2(key: Key, impl: Constructor) { 162 | const factory = () => this.createNewInstance(impl) 163 | this.registerScopedC(key as any, factory) 164 | // Also register the concrete implementation 165 | this.factories.set(impl, this.factories.get(key)) 166 | } 167 | 168 | // registerSingletonO2(key: Key, impl: WithDependenciesConfig) { 169 | // const factory = () => this.createFunctionInstance(impl) 170 | // this.registerSingletonO(key, factory) 171 | // } 172 | 173 | registerInstanceF any>(key: T, instance: T) { 174 | this.registerFactoryF(key, () => instance) 175 | } 176 | 177 | registerInitializerF any>( 178 | key: Key | "global", 179 | ...initializers: ((f: T, key: Key) => void)[] 180 | ) { 181 | this.registerInitializer(this.initializersF, key, initializers) 182 | } 183 | 184 | registerInitializerC( 185 | key: Constructor | "global", 186 | ...initializers: ((instance: T, key: Constructor) => void)[] 187 | ) { 188 | this.registerInitializer(this.initializersC, key, initializers) 189 | } 190 | 191 | registerInitializerO(key: Key | "global", ...initializers: ((instance: T, key: Key) => void)[]) { 192 | this.registerInitializer(this.initializersO, key, initializers) 193 | } 194 | 195 | private registerInitializer(initializersMap: any, key: any, initializers: any[]) { 196 | const current = initializersMap.get(key) || [] 197 | initializersMap.set(key, current.concat(initializers)) 198 | } 199 | 200 | private createNewInstance(constructor: Constructor) { 201 | const keys = getDependencyKeys(constructor) 202 | let instance 203 | if (keys) { 204 | instance = new constructor(...keys.map(x => this.getO(x))) 205 | } else { 206 | instance = new constructor() 207 | } 208 | 209 | return instance 210 | } 211 | 212 | private readonly getDependencyScope = () => { 213 | const scope = this.tryGetDependencyScope() 214 | if (!scope) { 215 | throw new Error("There is no scope available, did you forget to .createScope()?") 216 | } 217 | return scope 218 | } 219 | 220 | private readonly getSingletonScope = () => this.singletonScope 221 | 222 | private fixName = (key: any, factory: any) => () => { 223 | const instance = factory() 224 | if (!instance.name) { 225 | setFunctionName(instance, factory.name || key.name) 226 | } 227 | return instance 228 | } 229 | 230 | private registerFactoryC(key: any, factory: () => T, getScope?: () => DependencyScope) { 231 | this.registerFactory(key, factory, this.initializersC, this.resolveDecoratorsC, getScope) 232 | } 233 | private registerFactoryF(key: any, factory: () => T, getScope?: () => DependencyScope) { 234 | this.registerFactory(key, this.fixName(key, factory), this.initializersF, this.resolveDecoratorsF, getScope) 235 | } 236 | private registerFactoryO(key: any, factory: () => T, getScope?: () => DependencyScope) { 237 | this.registerFactory(key, factory, this.initializersO, () => factory, getScope) 238 | } 239 | private registerFactory( 240 | key: any, 241 | factory: () => T, 242 | initializerMap: Map, 243 | resolveDecorators: (key: any, factory: any) => any, 244 | getScope?: () => DependencyScope, 245 | ) { 246 | factory = this.hookInitializers(initializerMap, key, resolveDecorators(key, factory)) 247 | if (!getScope) { 248 | this.factories.set(key, factory) 249 | return 250 | } 251 | this.factories.set(key, () => getScope().getOrCreate(key, factory)) 252 | } 253 | 254 | private hookInitializers = (initializerMap: any, key: any, factory: any) => () => { 255 | const instance = factory() 256 | this.runInitializers(key, instance, initializerMap) 257 | return instance 258 | } 259 | 260 | private runInitializers(key: any, instance: any, initializersMap: Map) { 261 | const globalInitializers = initializersMap.get("global") 262 | if (globalInitializers) { 263 | for (const i of globalInitializers) { 264 | i(instance, key) 265 | } 266 | } 267 | const initializers = initializersMap.get(key) 268 | if (!initializers || !initializers.length) { 269 | return 270 | } 271 | for (const i of initializers) { 272 | i(instance, key) 273 | } 274 | } 275 | 276 | private readonly createFunctionInstance = ( 277 | h: WithDependenciesConfig | (() => T), 278 | ) => { 279 | const deps = getDependencyObjectKeys(h) 280 | const resolved = h(this.resolveDependencies(deps)) 281 | // setFunctionName(resolved, h.name) 282 | return resolved 283 | } 284 | 285 | private readonly resolveDependencies = (deps: TDependencies) => 286 | Object.keys(deps).reduce( 287 | (prev, cur) => { 288 | const dAny = deps as any 289 | const key = dAny[cur] 290 | const pAny = prev as any 291 | pAny[cur] = this.getF(key) 292 | return prev 293 | }, 294 | // eslint-disable-next-line @typescript-eslint/no-object-literal-type-assertion 295 | {} as TDependencies, 296 | ) 297 | 298 | private tryCreateInstance = (key: any) => { 299 | const factory = this.factories.get(key) 300 | const instance = factory() as T 301 | // if (!(instance as any).name) { setFunctionName(instance, key.name) } 302 | return instance 303 | } 304 | 305 | // TODO 306 | private readonly resolveDecoratorsC = (_: Constructor, factory: () => T) => { 307 | return factory 308 | } 309 | 310 | private readonly resolveDecoratorsF = any>(key: T, factory: () => T) => () => { 311 | const decorators = this.decorators.get(key) || [] 312 | 313 | if (!decorators.length) { 314 | return factory() 315 | } 316 | let handler = factory() 317 | const name = handler.name 318 | decorators.forEach((decorator: (inp: T) => T) => { 319 | // Be sure not to use `handler` as it can be rebound :-) 320 | const currentHandler = handler 321 | const anyDecoratedHandler: any = (...args: any[]) => { 322 | const decorate = this.getF(decorator) 323 | const decoratedHandler = decorate(currentHandler) 324 | return decoratedHandler(...args) 325 | } 326 | handler = anyDecoratedHandler 327 | }) 328 | setFunctionName(handler, `$<${name}>`) 329 | return handler 330 | } 331 | 332 | // public registerTransient(key: string, factory: () => T) { 333 | // this.factories.set(key, factory) 334 | // } 335 | 336 | // public registerScoped(key: string, factory: () => T) { 337 | // this.factories.set(key, () => tryOrNull(() => this.getDependencyScope(), s => s.getOrCreate(key, factory))) 338 | // } 339 | 340 | // public registerSingleton(key: string, factory: () => T) { 341 | // this.factories.set(key, () => this.singletonScope.getOrCreate(key, factory)) 342 | // } 343 | 344 | // public registerInstance(key: string, instance: T) { 345 | // this.factories.set(key, () => this.singletonScope.getOrCreate(key, () => instance)) 346 | // } 347 | 348 | // public get(key: string) { 349 | // const instance = this.tryGet(key) 350 | // if (!instance) { throw new Error(`could not resolve ${key}`) } 351 | // return instance 352 | // } 353 | 354 | // public tryGet(key: string) { 355 | // const factory = this.factories.get(key) 356 | // console.log('factory', key, factory) 357 | // const instance = factory() as T 358 | // return instance 359 | // } 360 | } 361 | 362 | // tslint:disable-next-line:max-classes-per-file 363 | export class DependencyScope implements Disposable { 364 | instances: Map = new Map() 365 | 366 | getOrCreate(key: any, instanceCreator: () => T) { 367 | if (this.instances.has(key)) { 368 | return this.instances.get(key) 369 | } 370 | const instance = instanceCreator() 371 | this.instances.set(key, instance) 372 | return instance 373 | } 374 | 375 | dispose() { 376 | for (const d of this.instances.values()) { 377 | if (d.dispose) { 378 | d.dispose() 379 | } 380 | } 381 | } 382 | } 383 | 384 | export const injectSymbol = Symbol("$$inject") 385 | export const requestTypeSymbol = Symbol("$$type") 386 | 387 | export function generateKey(name: string): Key { 388 | const f = () => { 389 | throw new Error(`${name} not implemented function`) 390 | } 391 | if (name) { 392 | setFunctionName(f, name) 393 | } 394 | return f as any 395 | } 396 | 397 | export type Key = T & { name: string } 398 | 399 | /** 400 | * Registers the specified dependencyConstructors as the dependencies for the targeted class. 401 | * 402 | * Configuration will be inherited. Consecutive calls override the previous. 403 | * @param {Array} dependencyConstructors 404 | */ 405 | export const inject = (...dependencyConstructors: any[]): ClassDecorator => { 406 | dependencyConstructors.forEach(dependencyConstructor => assert.isNotNull({ dependencyConstructor })) 407 | // NOTE: Must have a {..} scope here or the Decorators exhibit weird behaviors.. 408 | return (target: any) => { 409 | target[injectSymbol] = dependencyConstructors 410 | } 411 | } 412 | 413 | export const paramInject = (dependencyConstructor: any): ParameterDecorator => { 414 | assert.isNotNull({ dependencyConstructor }) 415 | return (target: any, _: string | symbol, parameterIndex: number) => { 416 | if (!target[injectSymbol]) { 417 | target[injectSymbol] = [] 418 | } 419 | target[injectSymbol][parameterIndex] = dependencyConstructor 420 | } 421 | } 422 | 423 | export const autoinject = (target: any) => { 424 | const metadata = Reflect.getMetadata("design:paramtypes", target) as any[] 425 | metadata.forEach(dependencyConstructor => assert.isNotNull({ dependencyConstructor })) 426 | 427 | // merge existing (ie placed by paraminject) 428 | if (Object.getOwnPropertySymbols(target).includes(injectSymbol)) { 429 | const existing = target[injectSymbol] 430 | const newInject = [...metadata] 431 | let i = 0 432 | for (const dep of existing) { 433 | if (dep) { 434 | newInject[i] = dep 435 | } 436 | i++ 437 | } 438 | target[injectSymbol] = newInject 439 | } else { 440 | target[injectSymbol] = metadata 441 | } 442 | } 443 | 444 | const getDependencyKeys = (constructor: any) => (constructor[injectSymbol] as any[]) || [] 445 | const getDependencyObjectKeys = (constructor: any): TDependencies => constructor[injectSymbol] || {} 446 | 447 | const generateKeyFromFn = (fun: (...args: any[]) => T) => generateKey(fun.name) 448 | const generateKeyFromC = (C: Constructor) => generateKey(C.name) 449 | 450 | // Ability to keep the factory function name so we can restore it for logging 451 | // tslint:disable-next-line:ban-types 452 | export const factoryOf = any>(func: T, factory: (i: T) => ReturnType) => { 453 | const newFactory = () => factory(func) 454 | setFunctionName(newFactory, func.name) 455 | return newFactory 456 | } 457 | 458 | export type WithDependencies = (deps: TDependencies) => T 459 | export interface InjectedDependencies { 460 | [injectSymbol]: TDependencies 461 | } 462 | export type WithDependenciesConfig = ((deps: TDependencies) => T) & 463 | InjectedDependencies 464 | 465 | export { generateKeyFromC, generateKeyFromFn } 466 | -------------------------------------------------------------------------------- /packages/neverthrow-extensions/src/neverthrow-extensions.ts: -------------------------------------------------------------------------------- 1 | // Looks a lot like rxjs 2 | // also similar to _ (underscore)'s .chain (or newer flow(a(), b(), c(), or compose() (reverse of flow)) etc. for partial app 3 | // see https://medium.com/making-internets/why-using-chain-is-a-mistake-9bc1f80d51ba 4 | // Also should look into fp-ts 5 | // Investigate what we can achieve with Generators, if we can get something along the lines of F# result { } (like async { }) 6 | 7 | // TODO: we have to fix the mismatch between Promise.pipe and Result.pipe :/ 8 | 9 | // tslint:disable:max-line-length 10 | 11 | import { flatten, zip } from "lodash" 12 | import { err, Err, ok, Ok, Result } from "neverthrow" 13 | export * from "neverthrow" 14 | 15 | // useful tools for .pipe( continuations 16 | export const mapStatic = (value: TNew) => map(toValue(value)) 17 | export const toValue = (value: TNew) => () => value 18 | export const toVoid = toValue(void 0) 19 | // export const endResult = mapStatic(void 0) 20 | 21 | // TODO: Have to double check these as it may fail in the error case, 22 | // as it wont return a Promise then :/ 23 | export function flatMap( 24 | map: PipeFunction, 25 | ): (result: Result) => Promise> 26 | export function flatMap( 27 | map: (ina: T) => Result, 28 | ): (result: Result) => Result 29 | // export function flatMap(map: (ina: T) => Result): (result: Result) => Promise>; 30 | // export function flatMap(map: (ina: T) => Result): (result: Promise>) => Promise>; 31 | export function flatMap(mapF: any) { 32 | return (result: any) => { 33 | // if (Promise.resolve(result) === result) { 34 | // return result.then((r: any) => { 35 | // if (r.isOk()) { 36 | // return map(r.value) 37 | // } else { 38 | // // Not a Promise :/ 39 | // return err(r.error) 40 | // } 41 | // }) 42 | // } 43 | if (result.isOk()) { 44 | return mapF(result.value) 45 | } else { 46 | // Not a Promise :/ 47 | return err(result.error) 48 | } 49 | } 50 | } 51 | 52 | export function map(map: (ina: T) => Promise): (result: Result) => Promise> 53 | export function map(map: (ina: T) => TNew): (result: Result) => Result 54 | // export function map(map: (ina: T) => TNew): (result: Promise>) => Promise> 55 | // export function map(map: (ina: T) => TNew): (result: Promise>) => Result 56 | export function map(mapF: any) { 57 | return (result: any) => { 58 | // if (Promise.resolve(result) === result) { 59 | // return result.then((r: any) => { 60 | // if (r.isOk()) { 61 | // const r2 = mapF(r.value) 62 | // return ok(r2) 63 | // } else { 64 | // // Not a promise :/ 65 | // return err(r.error) 66 | // } 67 | // }) 68 | // } 69 | if (result.isOk()) { 70 | const r = mapF(result.value) 71 | if (Promise.resolve(r) === r) { 72 | return r.then(ok) 73 | } 74 | return ok(r) 75 | } else { 76 | // Not a promise :/ 77 | return err(result.error) 78 | } 79 | } 80 | } 81 | 82 | export function biMap( 83 | map: (ina: T) => Promise, 84 | mapErr: (ina: E) => Promise, 85 | ): (result: Result) => Promise> 86 | export function biMap( 87 | map: (ina: T) => TNew, 88 | mapErr: (ina: E) => ENew, 89 | ): (result: Result) => Result 90 | export function biMap(mapF: any, mapErrF: any) { 91 | return (result: Result) => { 92 | if (result.isOk()) { 93 | const r = mapF(result.value) 94 | if (Promise.resolve(r) === r) { 95 | return r.then(ok) 96 | } 97 | return ok(r) 98 | } else { 99 | const r = mapErrF(result.error) 100 | if (Promise.resolve(r) === r) { 101 | return r.then(err) 102 | } 103 | return err(r) 104 | } 105 | } 106 | } 107 | 108 | // TODO: Should come with flatMap already wrapped aroun it 109 | export function flatTee(f: PipeFunction): PipeFunction 110 | export function flatTee(f: PipeFunction2): (input: T) => Result 111 | export function flatTee(f: any) { 112 | return (input: any) => { 113 | const r = f(input) 114 | if (Promise.resolve(r) === r) { 115 | return r.then((x: any) => intTee(x, input)) 116 | } else { 117 | return intTee(r, input) 118 | } 119 | } 120 | } 121 | 122 | // TODO: Should come with map already wrapped aroun it 123 | export function tee(f: (x: T2) => Promise): (input: T) => Promise 124 | export function tee(f: (x: T2) => TDontCare): (input: T) => T 125 | export function tee(f: any) { 126 | return (input: any) => { 127 | const r = f(input) 128 | if (Promise.resolve(r) === r) { 129 | return r.then(() => input) 130 | } else { 131 | return input 132 | } 133 | } 134 | } 135 | const intTee = (r: any, input: any) => (r.isOk() ? ok(input) : err(r.error)) 136 | 137 | // Easily pass input -> (input -> output) -> [input, output] 138 | export function toTup( 139 | f: (x: TInput2) => Promise>, 140 | ): (input: TInput) => Promise> 141 | export function toTup( 142 | f: (x: TInput2) => Result, 143 | ): (input: TInput) => Result 144 | export function toTup(f: any) { 145 | return (input: any) => { 146 | const r = f(input) 147 | if (Promise.resolve(r) === r) { 148 | return r.then((x: any) => intToTup(x, input)) 149 | } else { 150 | return intToTup(r, input) 151 | } 152 | } 153 | } 154 | const intToTup = (r: any, input: any) => (r.isOk() ? ok([r.value, input]) : err(r.error)) 155 | 156 | // export function ifErrorflatMap(defaultVal: (e: E) => Promise>): (result: Result) => Promise>; 157 | export function ifErrorflatMap( 158 | defaultVal: (e: E) => Result, 159 | ): (result: Result) => Result 160 | export function ifErrorflatMap(defaultVal: any) { 161 | return (result: Result) => { 162 | if (result.isOk()) { 163 | return result 164 | } else { 165 | return defaultVal(result.error) 166 | } 167 | } 168 | } 169 | 170 | // export function ifError(defaultVal: (e: E) => Promise): (result: Result) => Promise>; 171 | export function ifError(defaultVal: (e: E) => TNew): (result: Result) => Result 172 | export function ifError(defaultVal: any) { 173 | return (result: any) => { 174 | if (result.isOk()) { 175 | return result 176 | } 177 | return ok(defaultVal(result.error)) 178 | } 179 | } 180 | 181 | export const mapErr = (mapErrr: (ina: E) => ENew) => (result: Result) => result.mapErr(mapErrr) 182 | export const toTuple = (value: T2) => (v1: T) => [value, v1] as const 183 | 184 | export const joinError = (result: Result) => result.mapErr(x => x.join("\n")) 185 | 186 | export function resultTuple(r1: Result, r2: Result): Result 187 | export function resultTuple( 188 | r1: Result, 189 | r2: Result, 190 | r3: Result, 191 | ): Result 192 | export function resultTuple( 193 | r1: Result, 194 | r2: Result, 195 | r3: Result, 196 | r4: Result, 197 | ): Result 198 | export function resultTuple( 199 | r1: Result, 200 | r2: Result, 201 | r3: Result, 202 | r4: Result, 203 | r5: Result, 204 | ): Result 205 | export function resultTuple(...results: Result[]) { 206 | const errors = results.filter(isErr).map(x => x.error) 207 | if (errors.length) { 208 | return err(errors) 209 | } 210 | const successes = (results as Ok[]).map(x => x.value) as readonly any[] 211 | return ok(successes) 212 | } 213 | 214 | export const sequence = (results: Result[]): Result => { 215 | return resultAll(results).pipe(mapErr(flattenErrors)) 216 | } 217 | 218 | export const resultAll = (results: Result[]): Result => { 219 | const errors = results.filter(isErr).map(x => x.error) 220 | if (errors.length) { 221 | return err(errors) 222 | } 223 | const successes = results.filter(isOk).map(x => x.value) 224 | return ok(successes) 225 | } 226 | 227 | export const isErr = (x: Result): x is Err => x.isErr() 228 | export const isOk = (x: Result): x is Ok => x.isOk() 229 | 230 | export const sequenceAsync = async (results: Promise>[]) => { 231 | return sequence(await Promise.all(results)) 232 | } 233 | 234 | export const resultAllAsync = async (results: Promise>[]) => { 235 | return resultAll(await Promise.all(results)) 236 | } 237 | 238 | export const flattenErrors = (errors: E[]) => errors[0] 239 | 240 | export const valueOrUndefined = ( 241 | input: TInput | undefined, 242 | resultCreator: (input: TInput) => Result, 243 | ): Result => { 244 | if (input === undefined) { 245 | return ok(undefined) 246 | } 247 | return resultCreator(input) 248 | } 249 | 250 | export const asyncValueOrUndefined = async ( 251 | input: TInput | undefined, 252 | resultCreator: PipeFunction, 253 | ): Promise> => { 254 | if (input === undefined) { 255 | return ok(undefined) 256 | } 257 | return await resultCreator(input) 258 | } 259 | 260 | export const createResult = ( 261 | input: TInput | undefined, 262 | resultCreator: (input: TInput) => TOutput, 263 | ): Result => { 264 | if (input === undefined) { 265 | return ok(undefined) 266 | } 267 | return ok(resultCreator(input)) 268 | } 269 | 270 | export const applyIfNotUndefined = ( 271 | input: T | undefined, 272 | f: (input: T) => TOutput, 273 | ): TOutput | undefined => { 274 | if (input === undefined) { 275 | return undefined 276 | } 277 | return f(input) 278 | } 279 | 280 | export const asyncCreateResult = async ( 281 | input: TInput | undefined, 282 | resultCreator: (input: TInput) => Promise, 283 | ): Promise> => { 284 | if (input === undefined) { 285 | return ok(undefined) 286 | } 287 | return ok(await resultCreator(input)) 288 | } 289 | 290 | export const conditional = ( 291 | input: TInput | undefined, 292 | resultCreator: PipeFunction2, 293 | ): Result => { 294 | if (input === undefined) { 295 | return ok(undefined) 296 | } 297 | return resultCreator(input) 298 | } 299 | 300 | export const liftType = () => (e: TInput) => e as T 301 | 302 | // Experiment 303 | 304 | // Very nasty, need to find a cleaner approach 305 | // TODO: This actually breaks error type enforcement 306 | export const anyTrue = (...mappers: any[]): Result => { 307 | let hasChanged = false 308 | 309 | const mapHasChanged = map(a => (a ? (hasChanged = true) : null)) as any 310 | const items = mappers.map(_ => mapHasChanged) 311 | const execution = flatten(zip(mappers, items)) 312 | 313 | const an = ok(false) as any 314 | return an.pipe( 315 | ...execution, 316 | map(() => hasChanged), 317 | ) 318 | } 319 | 320 | // TODO: what if you could replace 321 | // (event) => kickAsync(event).pipe( 322 | // with: 323 | // pipe( 324 | 325 | // it would have to generate (event) => kickAsync(event).pipe( 326 | // but also it would mean to add: map(event => event.id) to get just the id. 327 | const startWithValInt = () => (value: T) => ok(value) as Result 328 | 329 | // export const startWithVal = () => (value: T) => Promise.resolve(startWithValInt()(value)) 330 | // reversed curry: 331 | export const startWithVal = (value: T) => () => Promise.resolve(startWithValInt()(value)) 332 | // export const startWithVal2 = startWithVal() 333 | export const startWithVal2 = (value: T) => startWithVal(value)() 334 | 335 | type ResultOrPromiseResult = Result | Promise> 336 | type ResultFunction = (r: Result) => ResultOrPromiseResult 337 | 338 | export type PipeFunction = (input: TInput) => Promise> 339 | export type PipeFunctionN = () => Promise> 340 | export type PipeFunction2 = (input: TInput) => Result 341 | export type PipeFunction2N = () => Result 342 | 343 | // tslint:disable:max-line-length 344 | export function pipe(op1: ResultFunction): (input: T) => Promise> 345 | export function pipe( 346 | op1: ResultFunction, 347 | op2: ResultFunction, 348 | ): (input: T) => Promise> 349 | export function pipe( 350 | op1: ResultFunction, 351 | op2: ResultFunction, 352 | op3: ResultFunction, 353 | ): (input: T) => Promise> 354 | export function pipe( 355 | op1: ResultFunction, 356 | op2: ResultFunction, 357 | op3: ResultFunction, 358 | op4: ResultFunction, 359 | ): (input: T) => Promise> 360 | export function pipe( 361 | op1: ResultFunction, 362 | op2: ResultFunction, 363 | op3: ResultFunction, 364 | op4: ResultFunction, 365 | op5: ResultFunction, 366 | ): (input: T) => Promise> 367 | export function pipe( 368 | op1: ResultFunction, 369 | op2: ResultFunction, 370 | op3: ResultFunction, 371 | op4: ResultFunction, 372 | op5: ResultFunction, 373 | op6: ResultFunction, 374 | ): (input: T) => Promise> 375 | export function pipe(...pipes: any[]) { 376 | return (input: any) => { 377 | const a: any = startWithVal2(input) 378 | return a.pipe(...pipes) 379 | } 380 | } 381 | 382 | // the problem with this is that it cannot match the second (or nth) return type :/ 383 | export function pipe2(): ( 384 | op1: (r: Result) => Result, 385 | ) => (input: T) => Promise> 386 | export function pipe2(): ( 387 | op1: (r: Result) => Result, 388 | op2: (r: Result) => Result, 389 | ) => (input: T) => Promise> 390 | export function pipe2(...pipes: any[]) { 391 | // additional scope because thats what the interface says 392 | return () => { 393 | return (input: any) => { 394 | const a: any = startWithVal2(input) 395 | return a.pipe(...pipes) 396 | } 397 | } 398 | } 399 | 400 | // helper for addressing some issues with syntax highlighting in editor when using multiple generics 401 | export type AnyResult = Result 402 | 403 | // We create tuples in reverse, under the assumption that the further away we are 404 | // from previous statements, the less important their output becomes.. 405 | // Alternatively we can always create two variations :) 406 | // tslint:disable:max-line-length 407 | export function toFlatTup( 408 | f: (x: TInput2) => Promise>, 409 | ): (input: readonly [TInput, TInputB]) => Promise> 410 | export function toFlatTup( 411 | f: (x: TInput2) => Result, 412 | ): (input: readonly [TInput, TInputB]) => Result 413 | export function toFlatTup(f: any) { 414 | return (input: any) => { 415 | const r = f(input) 416 | if (Promise.resolve(r) === r) { 417 | return r.then((x: any) => intToFlatTup(x, input)) 418 | } else { 419 | return intToFlatTup(r, input) 420 | } 421 | } 422 | } 423 | const intToFlatTup = (r: any, input: any) => (r.isOk() ? ok([r.value, input[0], input[1]] as const) : err(r.error)) 424 | 425 | export function toMagicTup(input: readonly [[T1, T2], T3]): readonly [T1, T2, T3] 426 | export function toMagicTup([tup1, el]: any) { 427 | return tup1.concat([el]) 428 | } 429 | 430 | export function apply(a: A, f: (a: A) => B): B { 431 | return f(a) 432 | } 433 | 434 | ////// 435 | // Stabl at simplify working with resultTuple 436 | // tslint:disable:max-line-length 437 | // Doesn't work 438 | export function resultTuple2( 439 | r1: (input: TInput) => Result, 440 | r2: (input: TInput) => Result, 441 | ): (input: TInput) => Result 442 | export function resultTuple2( 443 | r1: (input: TInput) => Result, 444 | r2: (input: TInput) => Result, 445 | r3: (input: TInput) => Result, 446 | ): (input: TInput) => Result 447 | export function resultTuple2( 448 | r1: (input: TInput) => Result, 449 | r2: (input: TInput) => Result, 450 | r3: (input: TInput) => Result, 451 | r4: (input: TInput) => Result, 452 | ): (input: TInput) => Result 453 | export function resultTuple2( 454 | r1: (input: TInput) => Result, 455 | r2: (input: TInput) => Result, 456 | r3: (input: TInput) => Result, 457 | r4: (input: TInput) => Result, 458 | r5: (input: TInput) => Result, 459 | ): (input: TInput) => Result 460 | export function resultTuple2(...resultFNs: ((input: any) => Result)[]) { 461 | return (input: any) => { 462 | const results = resultFNs.map(x => x(input)) 463 | const errors = results.filter(isErr).map(x => x.error) 464 | if (errors.length) { 465 | return err(errors) 466 | } 467 | const successes = (results as Ok[]).map(x => x.value) as readonly any[] 468 | return ok(successes) 469 | } 470 | } 471 | 472 | // not so cool? 473 | export function resultTuple3( 474 | input: TInput, 475 | r1: (input: TInput) => Result, 476 | r2: (input: TInput) => Result, 477 | ): Result 478 | export function resultTuple3( 479 | input: TInput, 480 | r1: (input: TInput) => Result, 481 | r2: (input: TInput) => Result, 482 | r3: (input: TInput) => Result, 483 | ): Result 484 | export function resultTuple3( 485 | input: TInput, 486 | r1: (input: TInput) => Result, 487 | r2: (input: TInput) => Result, 488 | r3: (input: TInput) => Result, 489 | r4: (input: TInput) => Result, 490 | ): Result 491 | export function resultTuple3( 492 | input: TInput, 493 | r1: (input: TInput) => Result, 494 | r2: (input: TInput) => Result, 495 | r3: (input: TInput) => Result, 496 | r4: (input: TInput) => Result, 497 | r5: (input: TInput) => Result, 498 | ): Result 499 | export function resultTuple3(input: any, ...resultFNs: ((input: any) => Result)[]) { 500 | const results = resultFNs.map(x => x(input)) 501 | const errors = results.filter(isErr).map(x => x.error) 502 | if (errors.length) { 503 | return err(errors) 504 | } 505 | const successes = (results as Ok[]).map(x => x.value) as readonly any[] 506 | return ok(successes) 507 | } 508 | 509 | export const success = () => ok(void 0) 510 | --------------------------------------------------------------------------------