├── 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