├── articles ├── .placeholder ├── generators │ ├── 01.ts │ ├── 00.ts │ ├── 04.ts │ ├── 06.ts │ ├── 03.ts │ ├── 02.ts │ └── 05.ts ├── usage │ └── 01.ts ├── node │ ├── 03.ts │ ├── 01.ts │ └── 02.ts ├── effect │ ├── 01.ts │ ├── 02.ts │ ├── 09.ts │ ├── 12.ts │ ├── 06.ts │ ├── 10.ts │ ├── 07.ts │ ├── 11.ts │ ├── 08.ts │ ├── 03.ts │ ├── 04.ts │ └── 05.ts ├── has │ └── 01.ts ├── managed │ ├── 02.ts │ ├── 01.ts │ └── 03.ts └── layer │ ├── 01.ts │ ├── 02.ts │ └── 03.ts ├── migrations ├── .placeholder └── main │ ├── 1602335365496_users.ts │ └── 1602764760081_credentials.ts ├── .gitignore ├── scripts └── jest-setup.ts ├── src ├── api │ ├── index.ts │ ├── registration.ts │ └── authentication.ts ├── db │ ├── index.ts │ ├── database.ts │ ├── config.ts │ ├── client.ts │ ├── api.ts │ ├── pool.ts │ └── migration.ts ├── index.ts ├── model │ ├── collectors.ts │ ├── api.ts │ ├── validation.ts │ ├── common.ts │ ├── credential.ts │ └── user.ts ├── http │ ├── exceptions │ │ └── index.ts │ ├── index.ts │ ├── middlewares │ │ └── index.ts │ ├── api │ │ └── index.ts │ ├── server │ │ └── index.ts │ └── router │ │ └── index.ts ├── tenants │ ├── index.ts │ └── tool.ts ├── dev │ ├── db.ts │ └── containers.ts ├── persistence │ ├── transactions.ts │ ├── user.ts │ └── credential.ts ├── program │ └── index.ts └── crypto │ └── index.ts ├── .prettierrc.js ├── test ├── utils │ ├── crypto.ts │ └── assertions.ts ├── domain.test.ts ├── crypto.test.ts └── integration.test.ts ├── environments ├── dev.yaml └── integration.yaml ├── README.md ├── jest.config.js ├── tsconfig.json ├── .vscode ├── launch.json ├── settings.default.json ├── operators.code-snippets ├── settings.json └── common-imports.code-snippets ├── .github └── workflows │ └── integration.yml ├── package.json └── .eslintrc.js /articles/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/.placeholder: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ -------------------------------------------------------------------------------- /scripts/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata" 2 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./registration" 2 | export * from "./authentication" 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'none', 4 | singleQuote: false, 5 | printWidth: 88, 6 | tabWidth: 2, 7 | endOfLine: 'auto' 8 | }; 9 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api" 2 | export * from "./client" 3 | export * from "./config" 4 | export * from "./database" 5 | export * from "./migration" 6 | export * from "./pool" 7 | -------------------------------------------------------------------------------- /src/db/database.ts: -------------------------------------------------------------------------------- 1 | import type { Tenants } from "../tenants" 2 | import { makeTenants } from "../tenants" 3 | 4 | export const databases = makeTenants("main", "read") 5 | 6 | export type Databases = Tenants 7 | -------------------------------------------------------------------------------- /test/utils/crypto.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | 3 | import { PBKDF2Config } from "../../src/crypto" 4 | 5 | export const quickPBKDF2 = T.replaceService(PBKDF2Config, (_) => ({ 6 | ..._, 7 | iterations: 1 8 | })) 9 | -------------------------------------------------------------------------------- /environments/dev.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | main: 5 | image: postgres:9.6.19-alpine 6 | ports: 7 | - 5432 8 | environment: 9 | - POSTGRES_DB=demo 10 | - POSTGRES_USER=demouser 11 | - POSTGRES_PASSWORD=demopass 12 | -------------------------------------------------------------------------------- /environments/integration.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | main: 5 | image: postgres:9.6.19-alpine 6 | ports: 7 | - 5432 8 | environment: 9 | - POSTGRES_DB=demo 10 | - POSTGRES_USER=demouser 11 | - POSTGRES_PASSWORD=demopass 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { main } from "./program" 2 | 3 | // run the live program 4 | const cancel = main() 5 | 6 | // cancel execution on sigterm 7 | process.on("SIGTERM", () => { 8 | cancel() 9 | }) 10 | 11 | // cancel execution on ctrl+c 12 | process.on("SIGINT", () => { 13 | cancel() 14 | }) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Playground Project 2 | 3 | Install: `yarn` 4 | Run: `yarn start` 5 | 6 | ## Editor Setup 7 | 8 | Use vscode with eslint plugin. 9 | 10 | ## Tests 11 | 12 | You need to have docker installed, if you have docker and access to the docker socket from your user you can run: 13 | 14 | `yarn test` 15 | 16 | It will bootstrap a `PostgreSQL` via `testcontainers` and will perform some `migrations` and `queries` 17 | -------------------------------------------------------------------------------- /articles/generators/01.ts: -------------------------------------------------------------------------------- 1 | function* constant(a: A): Generator { 2 | return yield a 3 | } 4 | 5 | function* countTo(n: number) { 6 | let i = 0 7 | while (i < n) { 8 | const a = yield* constant(i++) 9 | 10 | console.log(a) 11 | } 12 | } 13 | 14 | const iterator = countTo(10) 15 | 16 | let current = iterator.next() 17 | 18 | while (!current.done) { 19 | current = iterator.next(current.value) 20 | } 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | rootDir: "./", 6 | clearMocks: true, 7 | collectCoverage: false, 8 | coverageDirectory: "coverage", 9 | collectCoverageFrom: ["./src/**/*.ts"], 10 | setupFiles: ["./scripts/jest-setup.ts"], 11 | modulePathIgnorePatterns: ["/lib"], 12 | verbose: true, 13 | moduleNameMapper: {} 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "downlevelIteration": true, 4 | "lib": ["ES2019"], 5 | "target": "ES5", 6 | "strict": true, 7 | "outDir": "lib", 8 | "noErrorTruncation": true, 9 | "noEmit": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": [ 13 | "src/**/*.ts", 14 | "test/**/*.ts", 15 | "scripts/**/*.ts", 16 | "migrations/**/*.ts", 17 | "articles/**/*.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /articles/generators/00.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | 4 | const program = pipe( 5 | T.do, 6 | T.bind("a", () => T.succeed(1)), 7 | T.bind("b", () => T.succeed(2)), 8 | T.bind("c", ({ a, b }) => T.succeed(a + b)), 9 | T.map(({ c }) => c) 10 | ) 11 | 12 | pipe( 13 | program, 14 | T.chain((c) => 15 | T.effectTotal(() => { 16 | console.log(c) 17 | }) 18 | ), 19 | T.runMain 20 | ) 21 | -------------------------------------------------------------------------------- /articles/usage/01.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as F from "@effect-ts/core/Effect/Fiber" 5 | 6 | const program = T.succeed(0) 7 | ["|>"](T.map((n) => n + 1)) 8 | ["|>"](T.delay(1000)) 9 | ["|>"](T.fork) 10 | ["|>"](T.chain(F.join)) 11 | ["|>"]( 12 | T.chain((n) => 13 | T.effectTotal(() => { 14 | console.log(n) 15 | }) 16 | ) 17 | ) 18 | 19 | program["|>"](T.runMain) 20 | -------------------------------------------------------------------------------- /articles/node/03.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as MO from "@effect-ts/morphic" 3 | import * as MOD from "@effect-ts/morphic/Decoder" 4 | 5 | const MyCommand = MO.make((F) => 6 | F.interface({ 7 | type: F.stringLiteral("MyCommand"), 8 | data: F.nullable(F.string()) 9 | }) 10 | ) 11 | const effect = (command: unknown) => 12 | T.gen(function* (_) { 13 | const input = yield* _(MOD.decoder(MyCommand).decode(command)) 14 | return input 15 | }) 16 | -------------------------------------------------------------------------------- /articles/effect/01.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | 4 | interface Input { 5 | x: number 6 | y: number 7 | } 8 | 9 | const division = pipe( 10 | T.environment(), 11 | T.chain(({ x, y }) => (y === 0 ? T.fail("division by zero") : T.succeed(x / y))), 12 | T.chain((result) => 13 | T.effectTotal(() => { 14 | console.log(`Final result: ${result}`) 15 | }) 16 | ) 17 | ) 18 | 19 | pipe(division, T.provideAll({ x: 1, y: 1 }), T.runMain) 20 | -------------------------------------------------------------------------------- /src/model/collectors.ts: -------------------------------------------------------------------------------- 1 | import * as O from "@effect-ts/core/Classic/Option" 2 | import type { DecodingError } from "@effect-ts/morphic/Decoder/common" 3 | 4 | import { credentialErrorIds } from "./credential" 5 | import { userErrorIds } from "./user" 6 | 7 | export const allErrorIds = { 8 | ...userErrorIds, 9 | ...credentialErrorIds 10 | } 11 | 12 | export const allErrors = (_: DecodingError) => 13 | _.id != null && _.id in allErrorIds && _.message != null && _.message.length > 0 14 | ? O.some(_.message) 15 | : O.none 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "vscode-jest-tests", 8 | "program": "${workspaceFolder}/node_modules/.bin/jest", 9 | "args": ["--runInBand"], 10 | "console": "integratedTerminal", 11 | "internalConsoleOptions": "neverOpen", 12 | "disableOptimisticBPs": true, 13 | "windows": { 14 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/http/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | import * as Morph from "@effect-ts/morphic" 2 | import * as Guard from "@effect-ts/morphic/Guard" 3 | 4 | export interface HTTPRouteException extends Morph.AType {} 5 | 6 | const HTTPRouteException_ = Morph.make((F) => 7 | F.interface({ 8 | _tag: F.stringLiteral("HTTPRouteException"), 9 | message: F.string(), 10 | status: F.number() 11 | }) 12 | ) 13 | 14 | export const HTTPRouteException = Morph.opaque_()( 15 | HTTPRouteException_ 16 | ) 17 | 18 | export const isHTTPRouteException = Guard.guard(HTTPRouteException).is 19 | -------------------------------------------------------------------------------- /src/http/index.ts: -------------------------------------------------------------------------------- 1 | export { jsonBody, jsonResponse, readBody, readJsonBody } from "./api" 2 | export { HTTPRouteException, isHTTPRouteException } from "./exceptions" 3 | export { drain } from "./middlewares" 4 | export { 5 | addMiddleware, 6 | addRoute, 7 | addRouteM, 8 | create, 9 | isRouterDraining, 10 | matchRegex, 11 | Method, 12 | Routes 13 | } from "./router" 14 | export { 15 | accessQueueM, 16 | accessReqM, 17 | accessResM, 18 | accessServerConfigM, 19 | accessServerM, 20 | HTTPServerConfig, 21 | LiveHTTP, 22 | Request, 23 | RequestQueue, 24 | Server, 25 | serverConfig 26 | } from "./server" 27 | -------------------------------------------------------------------------------- /src/model/api.ts: -------------------------------------------------------------------------------- 1 | import type { AType, EType } from "@effect-ts/morphic" 2 | import { make, opaque } from "@effect-ts/morphic" 3 | import { encoder } from "@effect-ts/morphic/Encoder" 4 | 5 | import { PasswordField } from "./credential" 6 | import { CreateUser } from "./user" 7 | 8 | const Register_ = make((F) => F.intersection(CreateUser(F), PasswordField(F))()) 9 | 10 | export interface Register extends AType {} 11 | export interface RegisterRaw extends EType {} 12 | 13 | export const Register = opaque()(Register_) 14 | 15 | export const encodeRegister = encoder(Register).encode 16 | -------------------------------------------------------------------------------- /test/utils/assertions.ts: -------------------------------------------------------------------------------- 1 | import type * as Ex from "@effect-ts/core/Effect/Exit" 2 | import { AssertionError } from "assert" 3 | 4 | export function assertSuccess( 5 | exit: Ex.Exit 6 | ): asserts exit is Ex.Success { 7 | if (exit._tag === "Failure") { 8 | throw new AssertionError({ 9 | actual: exit, 10 | message: "Exit is a Failure" 11 | }) 12 | } 13 | } 14 | 15 | export function assertFailure( 16 | exit: Ex.Exit 17 | ): asserts exit is Ex.Failure { 18 | if (exit._tag === "Success") { 19 | throw new AssertionError({ 20 | actual: exit, 21 | message: "Exit is a Success" 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Use Node.js 12.x 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - name: npm install, build, and test 19 | run: | 20 | npm install -g yarn 21 | yarn 22 | docker pull postgres:9.6.19-alpine 23 | docker pull testcontainers/ryuk:0.3.0 24 | yarn type-check 25 | yarn test 26 | env: 27 | CI: "true" 28 | -------------------------------------------------------------------------------- /src/db/config.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { tag } from "@effect-ts/core/Has" 3 | import type * as PG from "pg" 4 | 5 | import { deriveTenants } from "../tenants" 6 | import type { Databases } from "./database" 7 | import { databases } from "./database" 8 | 9 | export const configs = deriveTenants(databases) 10 | 11 | export interface PgConfig { 12 | _tag: K 13 | config: PG.ClientConfig 14 | } 15 | 16 | export const PgConfig = (_: K) => 17 | tag>().setKey(configs[_]) 18 | 19 | export function withConfig(_: K) { 20 | return T.deriveAccess(PgConfig(_))(["config"]).config 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.default.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.formatOnSave": true, 4 | "eslint.format.enable": true, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 7 | }, 8 | "[javascriptreact]": { 9 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 10 | }, 11 | "[typescript]": { 12 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 13 | }, 14 | "[typescriptreact]": { 15 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 16 | }, 17 | "prettier.disableLanguages": [ 18 | "javascript", 19 | "javascriptreact", 20 | "typescript", 21 | "typescriptreact" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /articles/generators/04.ts: -------------------------------------------------------------------------------- 1 | import * as E from "@effect-ts/core/Classic/Either" 2 | import * as O from "@effect-ts/core/Classic/Option" 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as S from "@effect-ts/core/Effect/Stream" 5 | import { pipe } from "@effect-ts/core/Function" 6 | 7 | const result = S.gen(function* (_) { 8 | const a = yield* _(O.some(0)) 9 | const b = yield* _(E.right(1)) 10 | const c = yield* _(T.succeed(2)) 11 | const d = yield* _(S.fromArray([a, b, c])) 12 | 13 | return d 14 | }) 15 | 16 | pipe( 17 | result, 18 | S.runCollect, 19 | T.chain((res) => 20 | T.effectTotal(() => { 21 | console.log(res) 22 | }) 23 | ), 24 | T.runMain 25 | ) 26 | -------------------------------------------------------------------------------- /src/api/registration.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as T from "@effect-ts/core/Effect" 4 | 5 | import { Db } from "../db" 6 | import { addRoute, jsonBody, jsonResponse, matchRegex } from "../http" 7 | import { Register } from "../model/api" 8 | import { User } from "../model/user" 9 | import { register } from "../persistence/transactions" 10 | 11 | export const addRegistration = addRoute(matchRegex(/^\/register$/, ["POST"]))(() => 12 | T.gen(function* (_) { 13 | const { withPoolClient } = yield* _(Db("main")) 14 | 15 | const body = yield* _(jsonBody(Register)) 16 | const user = yield* _(register(body)["|>"](T.orDie)["|>"](withPoolClient)) 17 | 18 | return yield* _(jsonResponse(User)(user)) 19 | }) 20 | ) 21 | -------------------------------------------------------------------------------- /articles/generators/06.ts: -------------------------------------------------------------------------------- 1 | import * as A from "@effect-ts/core/Classic/Array" 2 | import * as O from "@effect-ts/core/Classic/Option" 3 | import * as DSL from "@effect-ts/core/Prelude/DSL" 4 | import { isOption } from "@effect-ts/core/Utils" 5 | 6 | const adapter: { 7 | (_: O.Option): DSL.GenHKT, A> 8 | (_: A.Array): DSL.GenHKT, A> 9 | } = (_: any) => { 10 | if (isOption(_)) { 11 | return new DSL.GenHKT(_._tag === "None" ? [] : [_.value]) 12 | } 13 | return new DSL.GenHKT(_) 14 | } 15 | 16 | const gen = DSL.genWithHistoryF(A.Monad, { adapter }) 17 | 18 | const res = gen(function* (_) { 19 | const a = yield* _(O.some(0)) 20 | const x = yield* _(A.range(a, 10)) 21 | 22 | return yield* _(A.range(x, x + 10)) 23 | }) 24 | 25 | console.log(res) 26 | -------------------------------------------------------------------------------- /src/tenants/index.ts: -------------------------------------------------------------------------------- 1 | import type { UnionToIntersection } from "@effect-ts/core/Utils" 2 | 3 | import type { Compute } from "./tool" 4 | 5 | export function makeTenants( 6 | ..._: T 7 | ): Compute< 8 | UnionToIntersection< 9 | { 10 | [k in keyof T]: T[k] extends string 11 | ? { 12 | [h in T[k]]: symbol 13 | } 14 | : never 15 | }[number] 16 | > 17 | > { 18 | const o = {} 19 | for (const k of _) { 20 | o[k] = Symbol() 21 | } 22 | return o 23 | } 24 | 25 | export function deriveTenants(_: T): T { 26 | const o = {} 27 | for (const k of Object.keys(_)) { 28 | o[k] = Symbol() 29 | } 30 | return o 31 | } 32 | 33 | export type Tenants = keyof T 34 | -------------------------------------------------------------------------------- /articles/effect/02.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | 4 | interface InputA { 5 | x: number 6 | } 7 | interface InputB { 8 | y: number 9 | } 10 | interface ErrorA { 11 | _tag: "ErrorA" 12 | value: string 13 | } 14 | interface ErrorB { 15 | _tag: "ErrorB" 16 | value: string 17 | } 18 | 19 | // T.Effect 20 | const program = pipe( 21 | T.access((_: InputA) => _.x), 22 | T.chain((n) => 23 | n === 0 ? T.fail({ _tag: "ErrorA", value: "n === 0" }) : T.succeed(n) 24 | ), 25 | T.chain((n) => T.access((_: InputB) => _.y + n)), 26 | T.chain((n) => 27 | n === 10 ? T.fail({ _tag: "ErrorB", value: "n === 10" }) : T.succeed(n) 28 | ), 29 | T.chain((n) => 30 | T.effectTotal(() => { 31 | console.log(n) 32 | }) 33 | ) 34 | ) 35 | 36 | pipe(program, T.provideAll({ x: 1, y: 1 }), T.runMain) 37 | -------------------------------------------------------------------------------- /articles/generators/03.ts: -------------------------------------------------------------------------------- 1 | import * as E from "@effect-ts/core/Classic/Either" 2 | import * as O from "@effect-ts/core/Classic/Option" 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as M from "@effect-ts/core/Effect/Managed" 5 | import { pipe } from "@effect-ts/core/Function" 6 | 7 | const result = T.gen(function* (_) { 8 | const a = yield* _(O.some(1)) 9 | const b = yield* _(O.some(2)) 10 | const c = yield* _(E.right(3)) 11 | const d = yield* _(T.access((_: { n: number }) => _.n)) 12 | const e = yield* _( 13 | M.makeExit_( 14 | T.effectTotal(() => { 15 | console.log("open") 16 | 17 | return 5 18 | }), 19 | () => 20 | T.effectTotal(() => { 21 | console.log("release") 22 | }) 23 | ) 24 | ) 25 | 26 | yield* _( 27 | T.effectTotal(() => { 28 | console.log(a + b + c + d + e) 29 | }) 30 | ) 31 | }) 32 | 33 | pipe(result, T.provideAll({ n: 4 }), T.runMain) 34 | -------------------------------------------------------------------------------- /articles/effect/09.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | import { tag } from "@effect-ts/core/Has" 4 | 5 | // define a module 6 | export interface ConsoleModule { 7 | log: (message: string) => T.UIO 8 | } 9 | 10 | // define a tag for the service 11 | export const ConsoleModule = tag() 12 | 13 | // access the module from environment 14 | export const { log } = T.deriveLifted(ConsoleModule)(["log"], [], []) 15 | 16 | // T.Effect, never, void> 17 | export const program = pipe( 18 | log("hello"), 19 | T.andThen(log("world")), 20 | T.andThen(log("hello")), 21 | T.andThen(log("eatrh")) 22 | ) 23 | 24 | // Run the program providing a concrete implementation 25 | pipe( 26 | program, 27 | T.provideService(ConsoleModule)({ 28 | log: (message) => 29 | T.effectTotal(() => { 30 | console.log(message) 31 | }) 32 | }), 33 | T.runMain 34 | ) 35 | -------------------------------------------------------------------------------- /src/dev/db.ts: -------------------------------------------------------------------------------- 1 | import * as L from "@effect-ts/core/Effect/Layer" 2 | 3 | import type { Databases } from "../db" 4 | import { PgConfig } from "../db" 5 | import type { Environments } from "./containers" 6 | import { TestContainers } from "./containers" 7 | 8 | function makeConfig(db: H) { 9 | return ({ env }: TestContainers): PgConfig => { 10 | const container = env.getContainer(`${db}_1`) 11 | 12 | const port = container.getMappedPort(5432) 13 | const host = container.getContainerIpAddress() 14 | 15 | return { 16 | _tag: db, 17 | config: { 18 | port, 19 | host, 20 | user: "demouser", 21 | database: "demo", 22 | password: "demopass" 23 | } 24 | } 25 | } 26 | } 27 | 28 | export const PgConfigTest = (db: H) => ( 29 | _name: K 30 | ) => L.fromConstructor(PgConfig(db))(makeConfig(db))(TestContainers(_name)) 31 | -------------------------------------------------------------------------------- /articles/effect/12.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pretty } from "@effect-ts/core/Effect/Cause" 3 | import * as F from "@effect-ts/core/Effect/Fiber" 4 | 5 | const cancellableEffect = T.effectAsyncInterrupt((cb) => { 6 | const timer = setTimeout(() => { 7 | cb(T.succeed(1)) 8 | }, 2000) 9 | 10 | return T.effectTotal(() => { 11 | clearTimeout(timer) 12 | }) 13 | }) 14 | 15 | const main = T.gen(function* (_) { 16 | const fiber = yield* _(T.fork(cancellableEffect)) 17 | 18 | yield* _(T.sleep(200)) 19 | 20 | const exit = yield* _(F.interrupt(fiber)) 21 | 22 | yield* _( 23 | T.effectTotal(() => { 24 | switch (exit._tag) { 25 | case "Failure": { 26 | console.log(pretty(exit.cause)) 27 | break 28 | } 29 | case "Success": { 30 | console.log(`completed with: ${exit.value}`) 31 | } 32 | } 33 | }) 34 | ) 35 | }) 36 | 37 | T.runMain(main) 38 | -------------------------------------------------------------------------------- /src/http/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | 4 | import type { HTTPRouteException } from "../exceptions" 5 | import { isHTTPRouteException } from "../exceptions" 6 | import * as HTTP from "../router" 7 | 8 | export function addHTTPRouteExceptionHandler(routes: HTTP.Routes) { 9 | return pipe( 10 | routes, 11 | HTTP.addMiddleware((cont) => (request, next) => 12 | T.catchAll_(cont(request, next), (e) => 13 | T.suspend(() => { 14 | if (isHTTPRouteException(e)) { 15 | request.res.statusCode = e.status 16 | request.res.end(e.message) 17 | return T.unit 18 | } else { 19 | return T.fail(>e) 20 | } 21 | }) 22 | ) 23 | ) 24 | ) 25 | } 26 | 27 | export function drain(_: HTTP.Routes) { 28 | return HTTP.drain(addHTTPRouteExceptionHandler(_)) 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/operators.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Pipe Operator |": { 3 | "prefix": "|", 4 | "body": ["[\"|>\"]()"], 5 | "description": "Pipe Operator" 6 | }, 7 | "Flow Operator": { 8 | "prefix": ">", 9 | "body": ["[\">>\"]()"], 10 | "description": "Flow Operator" 11 | }, 12 | "Operators Import": { 13 | "prefix": "op", 14 | "body": ["import \"@effect-ts/core/Operators\""], 15 | "description": "Operators Import" 16 | }, 17 | "Gen Function": { 18 | "prefix": "gen", 19 | "body": ["function* (_) {}"], 20 | "description": "Generator FUnction with _ input" 21 | }, 22 | "Gen Function $": { 23 | "prefix": "gen$", 24 | "body": ["function* ($) {}"], 25 | "description": "Generator FUnction with _ input" 26 | }, 27 | "Gen Yield *": { 28 | "prefix": "!", 29 | "body": ["yield* _()"], 30 | "description": "Yield generator calling _()" 31 | }, 32 | "Gen Yield $": { 33 | "prefix": "$", 34 | "body": ["yield* $()"], 35 | "description": "Yield generator calling $()" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /articles/effect/06.ts: -------------------------------------------------------------------------------- 1 | import * as A from "@effect-ts/core/Classic/Array" 2 | import * as T from "@effect-ts/core/Effect" 3 | import * as Rand from "@effect-ts/core/Effect/Random" 4 | import { pipe } from "@effect-ts/core/Function" 5 | 6 | export class ProcessError { 7 | readonly _tag = "ProcessError" 8 | constructor(readonly message: string) {} 9 | } 10 | 11 | // this effect sleeps for a random period between 100ms and 1000ms and randomly suceed or fail 12 | export const randomFailure = (n: number) => 13 | pipe( 14 | Rand.nextIntBetween(100, 1000), 15 | T.chain((delay) => 16 | pipe( 17 | Rand.nextBoolean, 18 | T.chain((b) => (b ? T.unit : T.fail(new ProcessError(`failed at: ${n}`)))), 19 | T.delay(delay) 20 | ) 21 | ) 22 | ) 23 | 24 | // build up a n-tuple of computations 25 | export const program = T.tuplePar( 26 | randomFailure(0), 27 | randomFailure(1), 28 | randomFailure(2), 29 | randomFailure(3), 30 | randomFailure(4), 31 | randomFailure(5) 32 | ) 33 | 34 | // runs the program 35 | pipe(program, T.runMain) 36 | -------------------------------------------------------------------------------- /articles/has/01.ts: -------------------------------------------------------------------------------- 1 | import { tag } from "@effect-ts/core/Has" 2 | 3 | /** 4 | * Any interface or type alias 5 | */ 6 | interface Anything { 7 | a: string 8 | } 9 | 10 | /** 11 | * Tag 12 | */ 13 | const Anything = tag() 14 | 15 | /** 16 | * (r: Has) => Anything 17 | */ 18 | const readFromEnv = Anything.read 19 | 20 | /** 21 | * (_: Anything) => Has 22 | */ 23 | const createEnv = Anything.of 24 | 25 | const hasAnything = createEnv({ a: "foo" }) 26 | 27 | /** 28 | * Has is fake, in reality we have: 29 | * 30 | * { [Symbol()]: { a: 'foo' } } 31 | */ 32 | console.log(hasAnything) 33 | 34 | /** 35 | * The [Symbol()] is: 36 | */ 37 | console.log((hasAnything as any)[Anything.key]) 38 | 39 | /** 40 | * The same as: 41 | */ 42 | console.log(readFromEnv(hasAnything)) 43 | 44 | /** 45 | * In order to take ownership of the symbol used we can do: 46 | */ 47 | const mySymbol = Symbol() 48 | 49 | const Anything_ = tag().setKey(mySymbol) 50 | 51 | console.log((Anything_.of({ a: "bar" }) as any)[mySymbol]) 52 | -------------------------------------------------------------------------------- /migrations/main/1602335365496_users.ts: -------------------------------------------------------------------------------- 1 | import type * as P from "node-pg-migrate" 2 | 3 | export const shorthands = undefined 4 | 5 | export function up(pgm: P.MigrationBuilder) { 6 | pgm.createTable("users", { 7 | id: { 8 | type: "SERIAL", 9 | notNull: true, 10 | primaryKey: true 11 | }, 12 | email: { type: "TEXT", notNull: true }, 13 | createdAt: { 14 | type: "timestamp", 15 | notNull: true, 16 | default: pgm.func("current_timestamp") 17 | }, 18 | updatedAt: { 19 | type: "timestamp", 20 | notNull: true, 21 | default: pgm.func("current_timestamp") 22 | } 23 | }) 24 | 25 | pgm.createTrigger( 26 | "users", 27 | "users_modified", 28 | { 29 | when: "BEFORE", 30 | level: "ROW", 31 | operation: "UPDATE", 32 | language: "plpgsql", 33 | replace: true 34 | }, 35 | `BEGIN 36 | NEW."updatedAt" = NOW(); 37 | RETURN NEW; 38 | END` 39 | ) 40 | } 41 | 42 | export function down(pgm: P.MigrationBuilder) { 43 | pgm.dropTrigger("users", "users_modified") 44 | pgm.dropTable("users") 45 | } 46 | -------------------------------------------------------------------------------- /articles/effect/10.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | import { tag } from "@effect-ts/core/Has" 4 | 5 | // define a module 6 | export interface ConsoleModule { 7 | prefix: T.UIO 8 | end: string 9 | log: (message: string) => T.UIO 10 | } 11 | 12 | // define a tag for the service 13 | export const ConsoleModule = tag() 14 | 15 | // access the module from environment 16 | export const { end, log, prefix } = T.deriveLifted(ConsoleModule)( 17 | ["log"], 18 | ["prefix"], 19 | ["end"] 20 | ) 21 | 22 | // T.Effect, never, void> 23 | export const program = pipe( 24 | prefix, 25 | T.chain(log), 26 | T.andThen(log("world")), 27 | T.andThen(log("hello")), 28 | T.andThen(pipe(end, T.chain(log))) 29 | ) 30 | 31 | // Run the program providing a concrete implementation 32 | pipe( 33 | program, 34 | T.provideService(ConsoleModule)({ 35 | log: (message) => 36 | T.effectTotal(() => { 37 | console.log(message) 38 | }), 39 | prefix: T.succeed("hello"), 40 | end: "earth" 41 | }), 42 | T.runMain 43 | ) 44 | -------------------------------------------------------------------------------- /articles/effect/07.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as Rand from "@effect-ts/core/Effect/Random" 3 | import { pipe } from "@effect-ts/core/Function" 4 | 5 | export class ProcessError { 6 | readonly _tag = "ProcessError" 7 | constructor(readonly message: string) {} 8 | } 9 | 10 | // this effect sleeps for a random period between 100ms and 1000ms and randomly suceed or fail 11 | export const randomFailure = (n: number) => 12 | pipe( 13 | Rand.nextIntBetween(100, 1000), 14 | T.chain((delay) => 15 | pipe( 16 | Rand.nextBoolean, 17 | T.chain((b) => (b ? T.unit : T.fail(new ProcessError(`failed at: ${n}`)))), 18 | T.delay(delay) 19 | ) 20 | ) 21 | ) 22 | 23 | // build up a n-tuple of computations 24 | export const program = T.tuplePar( 25 | randomFailure(0), 26 | randomFailure(1), 27 | randomFailure(2), 28 | randomFailure(3), 29 | randomFailure(4), 30 | randomFailure(5) 31 | ) 32 | 33 | // runs the program 34 | pipe( 35 | program, 36 | T.catchAll((_) => 37 | T.effectTotal(() => { 38 | console.log(`Process error: ${_.message}`) 39 | }) 40 | ), 41 | T.runMain 42 | ) 43 | -------------------------------------------------------------------------------- /src/persistence/transactions.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as L from "@effect-ts/core/Effect/Layer" 5 | import { tag } from "@effect-ts/core/Has" 6 | 7 | import { Db } from "../db" 8 | import type { Register } from "../model/api" 9 | import { CredentialPersistence } from "./credential" 10 | import { UserPersistence } from "./user" 11 | 12 | export const makeTransactions = ( 13 | { createCredential }: CredentialPersistence, 14 | { createUser }: UserPersistence, 15 | { transaction }: Db<"main"> 16 | ) => ({ 17 | register: ({ email, password }: Register) => 18 | createUser({ email }) 19 | ["|>"](T.tap((user) => createCredential({ userId: user.id, password }))) 20 | ["|>"](transaction) 21 | }) 22 | 23 | export interface Transactions extends ReturnType {} 24 | 25 | export const Transactions = tag() 26 | 27 | export const TransactionsLive = L.fromConstructor(Transactions)(makeTransactions)( 28 | CredentialPersistence, 29 | UserPersistence, 30 | Db("main") 31 | ) 32 | 33 | export const { register } = T.deriveLifted(Transactions)(["register"], [], []) 34 | -------------------------------------------------------------------------------- /articles/effect/11.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | import { tag } from "@effect-ts/core/Has" 4 | 5 | // define a module 6 | export interface ConsoleModule { 7 | prefix: string 8 | end: string 9 | log: (message: string) => T.UIO 10 | } 11 | 12 | // define a tag for the service 13 | export const ConsoleModule = tag() 14 | 15 | // access the module from environment 16 | export const { log } = T.deriveLifted(ConsoleModule)(["log"], [], []) 17 | 18 | export const { end: accessEndM, prefix: accessPrefixM } = T.deriveAccessM( 19 | ConsoleModule 20 | )(["end", "prefix"]) 21 | 22 | // T.Effect, never, void> 23 | export const program = pipe( 24 | accessPrefixM(log), 25 | T.andThen(log("world")), 26 | T.andThen(log("hello")), 27 | T.andThen(accessEndM(log)) 28 | ) 29 | 30 | // Run the program providing a concrete implementation 31 | pipe( 32 | program, 33 | T.provideService(ConsoleModule)({ 34 | log: (message) => 35 | T.effectTotal(() => { 36 | console.log(message) 37 | }), 38 | prefix: "hello", 39 | end: "earth" 40 | }), 41 | T.runMain 42 | ) 43 | -------------------------------------------------------------------------------- /src/model/validation.ts: -------------------------------------------------------------------------------- 1 | import * as A from "@effect-ts/core/Classic/Array" 2 | import type * as O from "@effect-ts/core/Classic/Option" 3 | import { pipe } from "@effect-ts/core/Function" 4 | import * as S from "@effect-ts/core/Sync" 5 | import type { M } from "@effect-ts/morphic" 6 | import type { DecodingError } from "@effect-ts/morphic/Decoder/common" 7 | import { encoder } from "@effect-ts/morphic/Encoder" 8 | import { strictDecoder } from "@effect-ts/morphic/StrictDecoder" 9 | 10 | export function validation( 11 | F: M<{}, E, A>, 12 | collector: (_: DecodingError) => O.Option 13 | ) { 14 | const decode = strictDecoder(F).decode 15 | const encode = encoder(F).encode 16 | 17 | return (u: A) => 18 | pipe( 19 | u, 20 | encode, 21 | S.chain(decode), 22 | S.mapError((d) => { 23 | const errors = A.filterMap_(d.errors, collector) 24 | return new ValidationError(errors.length > 0 ? errors.join(", ") : "") 25 | }) 26 | ) 27 | } 28 | 29 | export class ValidationError { 30 | readonly _tag = "ValidationError" 31 | readonly message: string 32 | constructor(message: string) { 33 | this.message = message.length === 0 ? "Unknown validation error" : message 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /articles/effect/08.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as R from "@effect-ts/core/Effect/Ref" 3 | import { pipe } from "@effect-ts/core/Function" 4 | 5 | // define a module 6 | export interface ConsoleModule { 7 | log: (message: string) => T.UIO 8 | } 9 | 10 | // access the module from environment 11 | export function log(message: string) { 12 | return T.accessM((_: ConsoleModule) => _.log(message)) 13 | } 14 | 15 | // T.Effect 16 | export const program = pipe( 17 | log("hello"), 18 | T.andThen(log("world")), 19 | T.andThen(log("hello")), 20 | T.andThen(log("eatrh")) 21 | ) 22 | 23 | // Run the program providing a concrete implementation 24 | pipe( 25 | T.do, 26 | T.bind("ref", () => R.makeRef([])), 27 | T.bind("prog", ({ ref }) => 28 | pipe( 29 | program, 30 | T.provide({ 31 | log: (message) => 32 | pipe( 33 | ref, 34 | R.update((messages) => [...messages, message]) 35 | ) 36 | }) 37 | ) 38 | ), 39 | T.chain(({ ref }) => ref.get), 40 | T.tap((messages) => 41 | messages.length === 4 ? T.succeed(messages) : T.fail("wrong number of messages") 42 | ), 43 | T.runMain 44 | ) 45 | -------------------------------------------------------------------------------- /src/api/authentication.ts: -------------------------------------------------------------------------------- 1 | import type { Option } from "@effect-ts/core/Classic/Option" 2 | import * as O from "@effect-ts/core/Classic/Option" 3 | import * as T from "@effect-ts/core/Effect" 4 | import { pipe } from "@effect-ts/core/Function" 5 | import type { Has } from "@effect-ts/core/Has" 6 | import { tag } from "@effect-ts/core/Has" 7 | 8 | import * as HTTP from "../http" 9 | 10 | export interface AuthSession { 11 | maybeUser: Option 12 | } 13 | 14 | export const AuthSession = tag() 15 | 16 | export const { maybeUser: accessMaybeUserM } = T.deriveAccessM(AuthSession)([ 17 | "maybeUser" 18 | ]) 19 | 20 | export const authenticatedUser = accessMaybeUserM( 21 | O.fold( 22 | () => 23 | T.fail({ 24 | _tag: "HTTPRouteException", 25 | status: 403, 26 | message: "Forbidden" 27 | }), 28 | T.succeed 29 | ) 30 | ) 31 | 32 | export function addAuthMiddleware(routes: HTTP.Routes, E>) { 33 | return pipe( 34 | routes, 35 | HTTP.addMiddleware((cont) => (request, next) => 36 | T.provideService(AuthSession)({ 37 | maybeUser: 38 | request.req.headers["authorization"] === "Secret" ? O.some("Michael") : O.none 39 | })(cont(request, next)) 40 | ) 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /articles/effect/03.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | import { matchTag } from "@effect-ts/core/Utils" 4 | 5 | interface InputA { 6 | x: number 7 | } 8 | interface InputB { 9 | y: number 10 | } 11 | interface ErrorA { 12 | _tag: "ErrorA" 13 | value: string 14 | } 15 | interface ErrorB { 16 | _tag: "ErrorB" 17 | value: string 18 | } 19 | 20 | // T.Effect 21 | const program = pipe( 22 | T.access((_: InputA) => _.x), 23 | T.chain((n) => 24 | n === 0 ? T.fail({ _tag: "ErrorA", value: "n === 0" }) : T.succeed(n) 25 | ), 26 | T.chain((n) => T.access((_: InputB) => _.y + n)), 27 | T.chain((n) => 28 | n === 10 ? T.fail({ _tag: "ErrorB", value: "n === 10" }) : T.succeed(n) 29 | ), 30 | T.chain((n) => 31 | T.effectTotal(() => { 32 | console.log(n) 33 | }) 34 | ) 35 | ) 36 | 37 | // T.Effect 38 | const programAfterErrorHandling = pipe( 39 | program, 40 | T.catchAll( 41 | matchTag( 42 | { 43 | ErrorA: ({ value }) => 44 | T.effectTotal(() => { 45 | console.log(`handling ErrorA: ${value}`) 46 | }) 47 | }, 48 | T.fail 49 | ) 50 | ) 51 | ) 52 | 53 | pipe(programAfterErrorHandling, T.provideAll({ x: 1, y: 1 }), T.runMain) 54 | -------------------------------------------------------------------------------- /src/dev/containers.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as L from "@effect-ts/core/Effect/Layer" 3 | import { tag } from "@effect-ts/core/Has" 4 | import { Duration, TemporalUnit } from "node-duration" 5 | import * as path from "path" 6 | import type { StartedDockerComposeEnvironment } from "testcontainers" 7 | import { DockerComposeEnvironment } from "testcontainers" 8 | 9 | export const ref = { 10 | integration: Symbol(), 11 | dev: Symbol() 12 | } 13 | 14 | export type Environments = keyof typeof ref 15 | 16 | export interface TestContainers { 17 | name: K 18 | env: StartedDockerComposeEnvironment 19 | } 20 | 21 | export const TestContainers = (_name: K) => 22 | tag>().setKey(ref[_name]) 23 | 24 | export const TestContainersLive = (_name: K) => 25 | L.create(TestContainers(_name)) 26 | .prepare( 27 | T.fromPromiseDie(async () => { 28 | const composeFilePath = path.resolve(__dirname, "../../environments") 29 | const composeFile = `${_name}.yaml` 30 | 31 | const env = await new DockerComposeEnvironment(composeFilePath, composeFile) 32 | .withStartupTimeout(new Duration(60, TemporalUnit.SECONDS)) 33 | .up() 34 | 35 | return { env, name: _name } 36 | }) 37 | ) 38 | .release(({ env }) => T.fromPromiseDie(() => env.down())) 39 | -------------------------------------------------------------------------------- /articles/effect/04.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | import { matchTag } from "@effect-ts/core/Utils" 4 | 5 | interface InputA { 6 | x: number 7 | } 8 | interface InputB { 9 | y: number 10 | } 11 | interface ErrorA { 12 | _tag: "ErrorA" 13 | value: string 14 | } 15 | 16 | // T.Effect 17 | const program = pipe( 18 | T.access((_: InputA) => _.x), 19 | T.chain((n) => 20 | n === 0 ? T.fail({ _tag: "ErrorA", value: "n === 0" }) : T.succeed(n) 21 | ), 22 | T.chain((n) => T.access((_: InputB) => _.y + n)), 23 | T.chain((n) => (n === 10 ? T.die("something very wrong happened") : T.succeed(n))), 24 | T.chain((n) => 25 | T.effectTotal(() => { 26 | console.log(n) 27 | }) 28 | ) 29 | ) 30 | 31 | // T.Effect 32 | const programAfterErrorHandling = pipe( 33 | program, 34 | T.catchAll( 35 | matchTag( 36 | { 37 | ErrorA: ({ value }) => 38 | T.effectTotal(() => { 39 | console.log(`handling ErrorA: ${value}`) 40 | }) 41 | }, 42 | (e) => 43 | pipe( 44 | T.effectTotal(() => { 45 | console.log(`Default Handler`) 46 | }), 47 | T.andThen(T.fail(e)) 48 | ) 49 | ) 50 | ) 51 | ) 52 | 53 | pipe(programAfterErrorHandling, T.provideAll({ x: 1, y: 9 }), T.runMain) 54 | -------------------------------------------------------------------------------- /migrations/main/1602764760081_credentials.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" 2 | 3 | export const shorthands: ColumnDefinitions | undefined = undefined 4 | 5 | export function up(pgm: MigrationBuilder) { 6 | pgm.createTable("credentials", { 7 | id: { 8 | type: "SERIAL", 9 | notNull: true, 10 | primaryKey: true 11 | }, 12 | userId: { 13 | type: "SERIAL", 14 | notNull: true, 15 | references: '"users"', 16 | onDelete: "CASCADE" 17 | }, 18 | hash: { type: "text", notNull: true }, 19 | createdAt: { 20 | type: "timestamp", 21 | notNull: true, 22 | default: pgm.func("current_timestamp") 23 | }, 24 | updatedAt: { 25 | type: "timestamp", 26 | notNull: true, 27 | default: pgm.func("current_timestamp") 28 | } 29 | }) 30 | 31 | pgm.createIndex("credentials", "userId") 32 | pgm.createTrigger( 33 | "credentials", 34 | "credentials_modified", 35 | { 36 | when: "BEFORE", 37 | level: "ROW", 38 | operation: "UPDATE", 39 | language: "plpgsql", 40 | replace: true 41 | }, 42 | `BEGIN 43 | NEW."updatedAt" = NOW(); 44 | RETURN NEW; 45 | END` 46 | ) 47 | } 48 | 49 | export function down(pgm: MigrationBuilder) { 50 | pgm.dropIndex("credentials", "userId") 51 | pgm.dropTrigger("credentials", "credentials_modified") 52 | pgm.dropTable("credentials") 53 | } 54 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.formatOnSave": true, 4 | "eslint.format.enable": true, 5 | "jest.debugMode": true, 6 | "jest.showCoverageOnLoad": true, 7 | "jest.autoEnable": true, 8 | "[javascript]": { 9 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 10 | }, 11 | "[javascriptreact]": { 12 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 13 | }, 14 | "[typescript]": { 15 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 19 | }, 20 | "prettier.disableLanguages": [ 21 | "javascript", 22 | "javascriptreact", 23 | "typescript", 24 | "typescriptreact" 25 | ], 26 | "eslint.validate": ["markdown", "javascript", "typescript"], 27 | "editor.codeActionsOnSave": { 28 | "source.fixAll.eslint": true 29 | }, 30 | "editor.quickSuggestions": { 31 | "other": true, 32 | "comments": false, 33 | "strings": false 34 | }, 35 | 36 | "editor.acceptSuggestionOnCommitCharacter": true, 37 | "editor.acceptSuggestionOnEnter": "on", 38 | "editor.quickSuggestionsDelay": 10, 39 | "editor.suggestOnTriggerCharacters": true, 40 | "editor.tabCompletion": "off", 41 | "editor.suggest.localityBonus": true, 42 | "editor.suggestSelection": "recentlyUsed", 43 | "editor.wordBasedSuggestions": true, 44 | "editor.parameterHints.enabled": true 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "articles", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "author": "Michael Arnaldi", 6 | "scripts": { 7 | "start": "node -r ts-node/register src/index.ts", 8 | "test": "jest", 9 | "migration:create": "node-pg-migrate create", 10 | "type-check": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@effect-ts/core": "^0.7.57", 14 | "@effect-ts/jest": "^0.1.3", 15 | "@effect-ts/monocle": "^0.6.58", 16 | "@effect-ts/morphic": "^0.7.28", 17 | "@effect-ts/system": "^0.5.42", 18 | "@types/jest": "^26.0.10", 19 | "@types/node": "^14.11.8", 20 | "@types/pg": "^7.14.5", 21 | "@typescript-eslint/eslint-plugin": "^4.0.0-alpha.12", 22 | "@typescript-eslint/parser": "^4.0.0-alpha.12", 23 | "eslint": "^7.7.0", 24 | "eslint-config-prettier": "^6.11.0", 25 | "eslint-import-resolver-typescript": "^2.2.1", 26 | "eslint-plugin-import": "^2.22.0", 27 | "eslint-plugin-jest": "^23.20.0", 28 | "eslint-plugin-prettier": "^3.1.4", 29 | "eslint-plugin-simple-import-sort": "^5.0.3", 30 | "eslint-plugin-sort-destructure-keys": "^1.3.5", 31 | "fast-check": "^2.4.0", 32 | "jest": "^26.4.1", 33 | "node-pg-migrate": "^5.8.0", 34 | "pg": "^8.4.1", 35 | "prettier": "^2.1.2", 36 | "reflect-metadata": "^0.1.13", 37 | "testcontainers": "^4.3.0", 38 | "ts-jest": "^26.2.0", 39 | "ts-node": "^9.0.0", 40 | "typescript": "^4.1.1-rc" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /articles/generators/02.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import { pipe } from "@effect-ts/core/Function" 3 | import type { _E, _R } from "@effect-ts/core/Utils" 4 | 5 | export class GenEffect { 6 | constructor(readonly op: K) {} 7 | 8 | *[Symbol.iterator](): Generator, A, any> { 9 | return yield this 10 | } 11 | } 12 | 13 | const adapter = (_: any) => { 14 | return new GenEffect(_) 15 | } 16 | 17 | export function gen, AEff>( 18 | f: (i: { 19 | (_: T.Effect): GenEffect, A> 20 | }) => Generator 21 | ): T.Effect<_R, _E, AEff> { 22 | return T.suspend(() => { 23 | const iterator = f(adapter as any) 24 | const state = iterator.next() 25 | 26 | function run( 27 | state: IteratorYieldResult | IteratorReturnResult 28 | ): T.Effect { 29 | if (state.done) { 30 | return T.succeed(state.value) 31 | } 32 | return T.chain_(state.value["op"], (val) => { 33 | const next = iterator.next(val) 34 | return run(next) 35 | }) 36 | } 37 | 38 | return run(state) 39 | }) 40 | } 41 | 42 | const program = gen(function* (_) { 43 | const a = yield* _(T.succeed(1)) 44 | const b = yield* _(T.succeed(2)) 45 | 46 | return a + b 47 | }) 48 | 49 | pipe( 50 | program, 51 | T.chain((n) => 52 | T.effectTotal(() => { 53 | console.log(n) 54 | }) 55 | ), 56 | T.runMain 57 | ) 58 | -------------------------------------------------------------------------------- /src/db/client.ts: -------------------------------------------------------------------------------- 1 | import type { Effect } from "@effect-ts/core/Effect" 2 | import * as T from "@effect-ts/core/Effect" 3 | import type { Clock } from "@effect-ts/core/Effect/Clock" 4 | import * as L from "@effect-ts/core/Effect/Layer" 5 | import * as M from "@effect-ts/core/Effect/Managed" 6 | import type { Has } from "@effect-ts/core/Has" 7 | import { tag } from "@effect-ts/core/Has" 8 | import type * as PG from "pg" 9 | 10 | import { deriveTenants } from "../tenants" 11 | import type { Databases } from "./database" 12 | import { databases } from "./database" 13 | import { PgPool, withPoolClientM } from "./pool" 14 | 15 | export const clients = deriveTenants(databases) 16 | 17 | export interface PgClient { 18 | _tag: K 19 | client: PG.ClientBase 20 | } 21 | 22 | export const PgClient = (db: K) => 23 | tag>().setKey(clients[db]) 24 | 25 | export function accessClient(db: K) { 26 | return T.deriveAccess(PgClient(db))(["client"]).client 27 | } 28 | 29 | export function accessClientM(db: K) { 30 | return T.deriveAccessM(PgClient(db))(["client"]).client 31 | } 32 | 33 | export function withPoolClient(db: K) { 34 | return ( 35 | self: Effect>, E, A> 36 | ): Effect & Has>, E, A> => 37 | withPoolClientM(db)((_) => 38 | T.provideService(PgClient(db))({ _tag: db, client: _ })(self) 39 | ) 40 | } 41 | 42 | export const PgClientLive = (db: K) => 43 | L.fromConstructorManaged(PgClient(db))(({ managedClient }: PgPool) => 44 | M.map_(managedClient, (client) => ({ client, _tag: db })) 45 | )(PgPool(db)) 46 | -------------------------------------------------------------------------------- /articles/managed/02.ts: -------------------------------------------------------------------------------- 1 | import * as Map from "@effect-ts/core/Classic/Map" 2 | import * as T from "@effect-ts/core/Effect" 3 | import * as M from "@effect-ts/core/Effect/Managed" 4 | import * as Ref from "@effect-ts/core/Effect/Ref" 5 | import { pipe } from "@effect-ts/core/Function" 6 | import type { NoSuchElementException } from "@effect-ts/system/GlobalExceptions" 7 | 8 | // simulate a database connection to a key-value store 9 | export interface DbConnection { 10 | readonly put: (k: string, v: string) => T.UIO 11 | readonly get: (k: string) => T.IO 12 | readonly clear: T.UIO 13 | } 14 | 15 | // connect to the database 16 | export const managedDb = pipe( 17 | Ref.makeRef(>Map.empty), 18 | T.chain((ref) => 19 | T.effectTotal( 20 | (): DbConnection => ({ 21 | get: (k) => pipe(ref.get, T.map(Map.lookup(k)), T.chain(T.getOrFail)), 22 | put: (k, v) => pipe(ref, Ref.update(Map.insert(k, v))), 23 | clear: ref.set(Map.empty) 24 | }) 25 | ) 26 | ), 27 | // release the connection via managed 28 | M.make((_) => _.clear) 29 | ) 30 | 31 | // write a program that use the database 32 | export const program = pipe( 33 | // use the managed DbConnection 34 | managedDb, 35 | M.use((_) => 36 | pipe( 37 | T.do, 38 | T.tap(() => _.put("ka", "a")), 39 | T.tap(() => _.put("kb", "b")), 40 | T.tap(() => _.put("kc", "c")), 41 | T.bind("a", () => _.get("ka")), 42 | T.bind("b", () => _.get("kb")), 43 | T.bind("c", () => _.get("kc")), 44 | T.map(({ a, b, c }) => `${a}-${b}-${c}`) 45 | ) 46 | ) 47 | ) 48 | 49 | // run the program and print the output 50 | pipe( 51 | program, 52 | T.chain((s) => 53 | T.effectTotal(() => { 54 | console.log(s) 55 | }) 56 | ), 57 | T.runMain 58 | ) 59 | -------------------------------------------------------------------------------- /articles/managed/01.ts: -------------------------------------------------------------------------------- 1 | import * as Map from "@effect-ts/core/Classic/Map" 2 | import * as T from "@effect-ts/core/Effect" 3 | import * as Ref from "@effect-ts/core/Effect/Ref" 4 | import { pipe } from "@effect-ts/core/Function" 5 | import type { NoSuchElementException } from "@effect-ts/system/GlobalExceptions" 6 | 7 | // simulate a database connection to a key-value store 8 | export interface DbConnection { 9 | readonly put: (k: string, v: string) => T.UIO 10 | readonly get: (k: string) => T.IO 11 | readonly clear: T.UIO 12 | } 13 | 14 | // connect to the database 15 | export const connectDb = pipe( 16 | Ref.makeRef(>Map.empty), 17 | T.chain((ref) => 18 | T.effectTotal( 19 | (): DbConnection => ({ 20 | get: (k) => pipe(ref.get, T.map(Map.lookup(k)), T.chain(T.getOrFail)), 21 | put: (k, v) => pipe(ref, Ref.update(Map.insert(k, v))), 22 | clear: ref.set(Map.empty) 23 | }) 24 | ) 25 | ) 26 | ) 27 | 28 | // write a program that use the database 29 | export const program = pipe( 30 | // acquire the database connection 31 | connectDb, 32 | T.bracket( 33 | // use the database connection 34 | (_) => 35 | pipe( 36 | T.do, 37 | T.tap(() => _.put("ka", "a")), 38 | T.tap(() => _.put("kb", "b")), 39 | T.tap(() => _.put("kc", "c")), 40 | T.bind("a", () => _.get("ka")), 41 | T.bind("b", () => _.get("kb")), 42 | T.bind("c", () => _.get("kc")), 43 | T.map(({ a, b, c }) => `${a}-${b}-${c}`) 44 | ), 45 | // release the database connection 46 | (_) => _.clear 47 | ) 48 | ) 49 | 50 | // run the program and print the output 51 | pipe( 52 | program, 53 | T.chain((s) => 54 | T.effectTotal(() => { 55 | console.log(s) 56 | }) 57 | ), 58 | T.runMain 59 | ) 60 | -------------------------------------------------------------------------------- /articles/effect/05.ts: -------------------------------------------------------------------------------- 1 | import * as A from "@effect-ts/core/Classic/Array" 2 | import * as T from "@effect-ts/core/Effect" 3 | import * as Cause from "@effect-ts/core/Effect/Cause" 4 | import { pipe } from "@effect-ts/core/Function" 5 | import { matchTag } from "@effect-ts/core/Utils" 6 | 7 | interface InputA { 8 | x: number 9 | } 10 | interface InputB { 11 | y: number 12 | } 13 | interface ErrorA { 14 | _tag: "ErrorA" 15 | value: string 16 | } 17 | 18 | // T.Effect 19 | const program = pipe( 20 | T.access((_: InputA) => _.x), 21 | T.chain((n) => 22 | n === 0 ? T.fail({ _tag: "ErrorA", value: "n === 0" }) : T.succeed(n) 23 | ), 24 | T.chain((n) => T.access((_: InputB) => _.y + n)), 25 | T.chain((n) => (n === 10 ? T.die("something very wrong happened") : T.succeed(n))), 26 | T.chain((n) => 27 | T.effectTotal(() => { 28 | console.log(n) 29 | }) 30 | ) 31 | ) 32 | 33 | // T.Effect 34 | const programAfterErrorHandling = pipe( 35 | program, 36 | T.catchAll( 37 | matchTag( 38 | { 39 | ErrorA: ({ value }) => 40 | T.effectTotal(() => { 41 | console.log(`handling ErrorA: ${value}`) 42 | }) 43 | }, 44 | (e) => 45 | pipe( 46 | T.effectTotal(() => { 47 | console.log(`Default Handler`) 48 | }), 49 | T.andThen(T.fail(e)) 50 | ) 51 | ) 52 | ) 53 | ) 54 | 55 | const handleFullCause = pipe( 56 | programAfterErrorHandling, 57 | T.sandbox, 58 | T.catchAll((cause) => { 59 | const defects = Cause.defects(cause) 60 | 61 | if (A.isNonEmpty(defects)) { 62 | return T.effectTotal(() => { 63 | console.log("Handle:", ...defects) 64 | }) 65 | } 66 | 67 | return T.halt(cause) 68 | }) 69 | ) 70 | 71 | pipe(handleFullCause, T.provideAll({ x: 1, y: 9 }), T.runMain) 72 | -------------------------------------------------------------------------------- /src/tenants/tool.ts: -------------------------------------------------------------------------------- 1 | /** from ts-toolbelt, minimal port of Compute */ 2 | 3 | export type Depth = "flat" | "deep" 4 | 5 | type Errors = Error 6 | // | EvalError 7 | // | RangeError 8 | // | ReferenceError 9 | // | SyntaxError 10 | // | TypeError 11 | // | URIError 12 | 13 | type Numeric = 14 | // | Number 15 | // | BigInt // not needed 16 | // | Math 17 | Date 18 | 19 | type Textual = 20 | // | String 21 | RegExp 22 | 23 | type Arrays = 24 | // | Array 25 | // | ReadonlyArray 26 | | Int8Array 27 | | Uint8Array 28 | | Uint8ClampedArray 29 | | Int16Array 30 | | Uint16Array 31 | | Int32Array 32 | | Uint32Array 33 | | Float32Array 34 | | Float64Array 35 | // | BigInt64Array 36 | // | BigUint64Array 37 | 38 | type Maps = 39 | // | Map 40 | // | Set 41 | | ReadonlyMap 42 | | ReadonlySet 43 | | WeakMap 44 | | WeakSet 45 | 46 | type Structures = 47 | | ArrayBuffer 48 | // | SharedArrayBuffer 49 | // | Atomics 50 | | DataView 51 | // | JSON 52 | 53 | type Abstractions = Function | Promise | Generator 54 | // | GeneratorFunction 55 | 56 | type WebAssembly = never 57 | 58 | export type BuiltInObject = 59 | | Errors 60 | | Numeric 61 | | Textual 62 | | Arrays 63 | | Maps 64 | | Structures 65 | | Abstractions 66 | | WebAssembly 67 | 68 | export type ComputeRaw = A extends Function 69 | ? A 70 | : { 71 | [K in keyof A]: A[K] 72 | } & {} 73 | 74 | export type ComputeFlat = A extends BuiltInObject 75 | ? A 76 | : { 77 | [K in keyof A]: A[K] 78 | } & {} 79 | 80 | export type ComputeDeep = A extends BuiltInObject 81 | ? A 82 | : { 83 | [K in keyof A]: ComputeDeep 84 | } & {} 85 | 86 | export type Compute = { 87 | flat: ComputeFlat 88 | deep: ComputeDeep 89 | }[depth] 90 | -------------------------------------------------------------------------------- /test/domain.test.ts: -------------------------------------------------------------------------------- 1 | import { left, right } from "@effect-ts/core/Classic/Either" 2 | import * as T from "@effect-ts/core/Sync" 3 | import { DecodeError } from "@effect-ts/morphic/Decoder/common" 4 | 5 | import { decodeId } from "../src/model/common" 6 | import { decodeUser } from "../src/model/user" 7 | 8 | it("decodes user", () => { 9 | expect( 10 | T.runEither( 11 | decodeUser({ 12 | createdAt: "2020-10-10T14:50:17.184Z", 13 | updatedAt: "2020-10-10T14:50:17.184Z", 14 | id: 1, 15 | email: "ma@example.org" 16 | }) 17 | ) 18 | ).toEqual( 19 | right({ 20 | createdAt: new Date("2020-10-10T14:50:17.184Z"), 21 | updatedAt: new Date("2020-10-10T14:50:17.184Z"), 22 | id: 1, 23 | email: "ma@example.org" 24 | }) 25 | ) 26 | }) 27 | 28 | it("fail to decode user", () => { 29 | expect( 30 | T.runEither( 31 | decodeUser({ 32 | createdAt: "2020-10-10T14:50:17.184Z", 33 | updatedAt: "2020-10-10T14:50:17.184Z", 34 | id: 1, 35 | email: "example.org" 36 | }) 37 | ) 38 | ).toEqual( 39 | left( 40 | new DecodeError([ 41 | { 42 | id: "email_shape", 43 | message: "email doesn't match the required pattern", 44 | name: "email", 45 | context: { 46 | key: "email", 47 | types: [], 48 | actual: "example.org" 49 | } 50 | } 51 | ]) 52 | ) 53 | ) 54 | }) 55 | 56 | it("decodes id", () => { 57 | expect( 58 | T.runEither( 59 | decodeId({ 60 | id: "bla" 61 | }) 62 | ) 63 | ).toEqual( 64 | left( 65 | new DecodeError([ 66 | { 67 | id: "bad_id_format", 68 | message: "id should be an integer", 69 | name: "id", 70 | context: { 71 | key: "id", 72 | types: [], 73 | actual: "bla" 74 | } 75 | } 76 | ]) 77 | ) 78 | ) 79 | }) 80 | -------------------------------------------------------------------------------- /src/persistence/user.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as L from "@effect-ts/core/Effect/Layer" 5 | import { flow } from "@effect-ts/core/Function" 6 | import { tag } from "@effect-ts/core/Has" 7 | import type { _A } from "@effect-ts/core/Utils" 8 | 9 | import { Db } from "../db" 10 | import { encodeId, validateId } from "../model/common" 11 | import { 12 | decodeUser, 13 | encodeCreateUser, 14 | encodeUser, 15 | validateCreateUser, 16 | validateUser 17 | } from "../model/user" 18 | 19 | export class UserNotFound { 20 | readonly _tag = "UserNotFound" 21 | } 22 | 23 | export const makeUserPersistence = T.gen(function* (_) { 24 | const { query } = yield* _(Db("main")) 25 | 26 | return { 27 | getUser: flow( 28 | validateId, 29 | T.chain(encodeId), 30 | T.chain(({ id }) => query(`SELECT * FROM users WHERE id = $1::integer`, id)), 31 | T.chain((_) => 32 | _.rows.length > 0 ? T.succeed(_.rows[0]) : T.fail(new UserNotFound()) 33 | ), 34 | T.chain(decodeUser[">>"](T.orDie)) 35 | ), 36 | createUser: flow( 37 | validateCreateUser, 38 | T.chain(encodeCreateUser), 39 | T.chain(({ email }) => 40 | query(`INSERT INTO users (email) VALUES ($1::text) RETURNING *`, email) 41 | ), 42 | T.map((_) => _.rows[0]), 43 | T.chain(decodeUser[">>"](T.orDie)) 44 | ), 45 | updateUser: flow( 46 | validateUser, 47 | T.chain(encodeUser), 48 | T.chain(({ email, id }) => 49 | query( 50 | `UPDATE users SET email = $1::text WHERE id = $2::integer RETURNING *`, 51 | email, 52 | id 53 | ) 54 | ), 55 | T.map((_) => _.rows[0]), 56 | T.chain(decodeUser[">>"](T.orDie)) 57 | ) 58 | } 59 | }) 60 | 61 | export interface UserPersistence extends _A {} 62 | 63 | export const UserPersistence = tag() 64 | 65 | export const UserPersistenceLive = L.fromEffect(UserPersistence)(makeUserPersistence) 66 | 67 | export const { createUser, getUser, updateUser } = T.deriveLifted(UserPersistence)( 68 | ["createUser", "updateUser", "getUser"], 69 | [], 70 | [] 71 | ) 72 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | ignorePatterns: ["dtslint/", "lib/", "es6/", "build/", "react/demo/"], 4 | parser: "@typescript-eslint/parser", 5 | parserOptions: { 6 | ecmaVersion: 2018, 7 | sourceType: "module" 8 | }, 9 | settings: { 10 | "import/parsers": { 11 | "@typescript-eslint/parser": [".ts", ".tsx"] 12 | }, 13 | "import/resolver": { 14 | typescript: { 15 | alwaysTryTypes: true 16 | } 17 | } 18 | }, 19 | extends: [ 20 | "eslint:recommended", 21 | "plugin:@typescript-eslint/eslint-recommended", 22 | "plugin:@typescript-eslint/recommended", 23 | "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 24 | "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. 25 | ], 26 | plugins: ["import", "sort-destructure-keys", "simple-import-sort"], 27 | rules: { 28 | // eslint built-in rules, sorted alphabetically 29 | "no-fallthrough": "off", 30 | "no-irregular-whitespace": "off", 31 | "object-shorthand": "error", 32 | "prefer-destructuring": "off", 33 | "sort-imports": "off", 34 | 35 | // all other rules, sorted alphabetically 36 | "@typescript-eslint/ban-ts-comment": "off", 37 | "@typescript-eslint/ban-types": "off", 38 | "@typescript-eslint/camelcase": "off", 39 | "@typescript-eslint/consistent-type-imports": "error", 40 | "@typescript-eslint/explicit-function-return-type": "off", 41 | "@typescript-eslint/explicit-module-boundary-types": "off", 42 | "@typescript-eslint/interface-name-prefix": "off", 43 | "@typescript-eslint/no-empty-interface": "off", 44 | "@typescript-eslint/no-explicit-any": "off", 45 | "@typescript-eslint/no-unused-vars": "off", 46 | "@typescript-eslint/no-use-before-define": "off", 47 | "import/first": "error", 48 | "import/newline-after-import": "error", 49 | "import/no-duplicates": "error", 50 | "import/no-unresolved": "error", 51 | "import/order": "off", 52 | "simple-import-sort/sort": "error", 53 | "sort-destructure-keys/sort-destructure-keys": "error" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/db/api.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import type { Clock } from "@effect-ts/core/Effect/Clock" 3 | import * as L from "@effect-ts/core/Effect/Layer" 4 | import type { Has } from "@effect-ts/core/Has" 5 | import { tag } from "@effect-ts/core/Has" 6 | import type { QueryResult, QueryResultRow } from "pg" 7 | 8 | import { deriveTenants } from "../tenants" 9 | import * as Pg from "./client" 10 | import type { Databases } from "./database" 11 | import { databases } from "./database" 12 | import { PgPool } from "./pool" 13 | 14 | export const apis = deriveTenants(databases) 15 | 16 | export type Supported = number | string | boolean | null | undefined 17 | 18 | export function makeLiveDb(db: K) { 19 | return T.gen(function* (_) { 20 | const { withPoolClientM } = yield* _(PgPool(db)) 21 | 22 | const query = (queryString: string, ...args: Supported[]) => 23 | Pg.accessClientM(db)((client) => 24 | T.fromPromiseDie( 25 | (): Promise> => client.query(queryString, args) 26 | ) 27 | ) 28 | 29 | const transaction = (body: T.Effect) => 30 | T.bracketExit_( 31 | query("BEGIN"), 32 | () => body, 33 | (_, e) => (e._tag === "Success" ? query("COMMIT") : query("ROLLBACK")) 34 | ) 35 | 36 | function withPoolClient(self: T.Effect>, E, A>) { 37 | return withPoolClientM((_) => 38 | T.provideService(Pg.PgClient(db))({ _tag: db, client: _ })(self) 39 | ) 40 | } 41 | 42 | return { 43 | query, 44 | transaction, 45 | withPoolClient 46 | } 47 | }) 48 | } 49 | 50 | export interface Db { 51 | query: ( 52 | queryString: string, 53 | ...args: Supported[] 54 | ) => T.RIO>, QueryResult> 55 | 56 | transaction: ( 57 | body: T.Effect 58 | ) => T.Effect>, E, A> 59 | 60 | withPoolClient: ( 61 | self: T.Effect>, E, A> 62 | ) => T.Effect & Has>, E, A> 63 | } 64 | 65 | export const Db = (db: K) => tag>().setKey(apis[db]) 66 | 67 | export const DbLive = (db: K) => 68 | L.fromEffect(Db(db))(makeLiveDb(db)) 69 | -------------------------------------------------------------------------------- /src/program/index.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as T from "@effect-ts/core/Effect" 4 | import { pipe } from "@effect-ts/core/Function" 5 | 6 | import { addAuthMiddleware, addRegistration } from "../api" 7 | import { CryptoLive, PBKDF2ConfigLive } from "../crypto" 8 | import { DbLive, PgClient, PgPoolLive, TestMigration, withPoolClient } from "../db" 9 | import { TestContainersLive } from "../dev/containers" 10 | import { PgConfigTest } from "../dev/db" 11 | import * as HTTP from "../http" 12 | import { CredentialPersistenceLive } from "../persistence/credential" 13 | import { TransactionsLive } from "../persistence/transactions" 14 | import { UserPersistenceLive } from "../persistence/user" 15 | 16 | export const addHome = HTTP.addRoute((r) => r.req.url === "/")(({ res }) => 17 | T.gen(function* (_) { 18 | const { client } = yield* _(PgClient("main")) 19 | 20 | const result = yield* _( 21 | T.fromPromiseDie(() => 22 | client.query( 23 | "SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1::text;", 24 | ["users"] 25 | ) 26 | ) 27 | ) 28 | 29 | return result.rows 30 | }) 31 | ["|>"](withPoolClient("main")) 32 | ["|>"](T.result) 33 | ["|>"]( 34 | T.chain((ex) => 35 | T.effectTotal(() => { 36 | res.end(JSON.stringify(ex)) 37 | }) 38 | ) 39 | ) 40 | ) 41 | 42 | export const Main = pipe( 43 | HTTP.create, 44 | addHome, 45 | addRegistration, 46 | addAuthMiddleware, 47 | HTTP.drain 48 | ) 49 | 50 | export const CryptoMain = CryptoLive["<<<"](PBKDF2ConfigLive) 51 | 52 | export const PersistenceMain = TransactionsLive["<+<"]( 53 | UserPersistenceLive["+++"](CredentialPersistenceLive) 54 | ) 55 | 56 | export const DbMain = DbLive("main") 57 | ["<<<"](TestMigration("main")) 58 | ["<+<"](PgPoolLive("main")) 59 | ["<<<"](PgConfigTest("main")("dev")) 60 | ["<<<"](TestContainersLive("dev")) 61 | 62 | export const ServerConfigMain = HTTP.serverConfig({ 63 | host: "0.0.0.0", 64 | port: 8081 65 | }) 66 | 67 | export const ServerMain = HTTP.LiveHTTP["<<<"](ServerConfigMain) 68 | 69 | export const BootstrapMain = PersistenceMain["<+<"](DbMain) 70 | ["+++"](ServerMain) 71 | ["<<<"](CryptoMain) 72 | 73 | // main function (unsafe) 74 | export function main() { 75 | return Main["|>"](T.provideSomeLayer(BootstrapMain))["|>"](T.runMain) 76 | } 77 | -------------------------------------------------------------------------------- /test/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as Ex from "@effect-ts/core/Effect/Exit" 3 | import { testRuntime } from "@effect-ts/jest/Runtime" 4 | 5 | import { 6 | Crypto, 7 | CryptoLive, 8 | InvalidPassword, 9 | PBKDF2ConfigLive, 10 | PBKDF2ConfigTest 11 | } from "../src/crypto" 12 | 13 | describe("Crypto Suite", () => { 14 | describe("Live", () => { 15 | const { it } = testRuntime(CryptoLive["<<<"](PBKDF2ConfigLive)) 16 | 17 | it("should hash and verify password", () => 18 | T.gen(function* (_) { 19 | const { hashPassword, verifyPassword } = yield* _(Crypto) 20 | 21 | const password = "wuihfjierngjkrnjgwrgn" 22 | const hash = yield* _(hashPassword(password)) 23 | const verify = yield* _(T.result(verifyPassword(password, hash))) 24 | 25 | expect(verify).toEqual(Ex.unit) 26 | })) 27 | 28 | it("should hash and not verify password", () => 29 | T.gen(function* (_) { 30 | const { hashPassword, verifyPassword } = yield* _(Crypto) 31 | 32 | const password = "wuihfjierngjkrnjgwrgn" 33 | const passwordBad = "wuIhfjierngjkrnjgwrgn" 34 | const hash = yield* _(hashPassword(password)) 35 | const verify = yield* _(T.result(verifyPassword(passwordBad, hash))) 36 | 37 | expect(verify).toEqual(Ex.fail(new InvalidPassword())) 38 | })) 39 | }) 40 | 41 | describe("Test", () => { 42 | const { it } = testRuntime(CryptoLive["<<<"](PBKDF2ConfigTest)) 43 | 44 | it("should hash and verify password", () => 45 | T.gen(function* (_) { 46 | const { hashPassword, verifyPassword } = yield* _(Crypto) 47 | 48 | const password = "wuihfjierngjkrnjgwrgn" 49 | const hash = yield* _(hashPassword(password)) 50 | const verify = yield* _(T.result(verifyPassword(password, hash))) 51 | 52 | expect(verify).toEqual(Ex.unit) 53 | })) 54 | 55 | it("should hash and not verify password", () => 56 | T.gen(function* (_) { 57 | const { hashPassword, verifyPassword } = yield* _(Crypto) 58 | 59 | const password = "wuihfjierngjkrnjgwrgn" 60 | const passwordBad = "wuIhfjierngjkrnjgwrgn" 61 | const hash = yield* _(hashPassword(password)) 62 | const verify = yield* _(T.result(verifyPassword(passwordBad, hash))) 63 | 64 | expect(verify).toEqual(Ex.fail(new InvalidPassword())) 65 | })) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/db/pool.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import type * as C from "@effect-ts/core/Effect/Clock" 3 | import * as L from "@effect-ts/core/Effect/Layer" 4 | import type { Managed } from "@effect-ts/core/Effect/Managed" 5 | import * as M from "@effect-ts/core/Effect/Managed" 6 | import * as S from "@effect-ts/core/Effect/Schedule" 7 | import { identity, pipe } from "@effect-ts/core/Function" 8 | import { tag } from "@effect-ts/core/Has" 9 | import * as PG from "pg" 10 | 11 | import { deriveTenants } from "../tenants" 12 | import { withConfig } from "./config" 13 | import type { Databases } from "./database" 14 | import { databases } from "./database" 15 | 16 | export const pools = deriveTenants(databases) 17 | 18 | export interface PgPool { 19 | _tag: K 20 | withPoolClientM: ( 21 | body: (_: PG.PoolClient) => T.Effect 22 | ) => T.Effect 23 | managedClient: Managed 24 | } 25 | 26 | export const PgPool = (_: K) => tag>().setKey(pools[_]) 27 | 28 | export function withPoolClientM(_: K) { 29 | return (body: (_: PG.PoolClient) => T.Effect) => 30 | T.accessServiceM(PgPool(_))((_) => _.withPoolClientM(body)) 31 | } 32 | 33 | export function clientFromPool(db: K) { 34 | return pipe( 35 | M.fromEffect(T.accessService(PgPool(db))((_) => _.managedClient)), 36 | M.chain(identity) 37 | ) 38 | } 39 | 40 | export const PgPoolLive = (db: K) => 41 | pipe( 42 | withConfig(db)((_) => new PG.Pool(_)), 43 | M.make((_) => T.fromPromiseDie(() => _.end())), 44 | M.map( 45 | (pool): PgPool => ({ 46 | _tag: db, 47 | withPoolClientM: (body) => 48 | T.bracket_( 49 | T.orDie( 50 | T.retry_( 51 | T.fromPromise(() => pool.connect()), 52 | S.whileOutput_(S.exponential(100), (n) => n < 1000) 53 | ) 54 | ), 55 | body, 56 | (p) => T.effectTotal(() => p.release()) 57 | ), 58 | managedClient: M.makeExit_( 59 | T.orDie( 60 | T.retry_( 61 | T.fromPromise(() => pool.connect()), 62 | S.whileOutput_(S.exponential(100), (n) => n < 1000) 63 | ) 64 | ), 65 | (p) => T.effectTotal(() => p.release()) 66 | ) 67 | }) 68 | ), 69 | L.fromManaged(PgPool(db)) 70 | ) 71 | -------------------------------------------------------------------------------- /src/http/api/index.ts: -------------------------------------------------------------------------------- 1 | import * as A from "@effect-ts/core/Classic/Array" 2 | import type { Option } from "@effect-ts/core/Classic/Option" 3 | import * as T from "@effect-ts/core/Effect" 4 | import { flow, pipe } from "@effect-ts/core/Function" 5 | import type * as M from "@effect-ts/morphic" 6 | import { decoder } from "@effect-ts/morphic/Decoder" 7 | import type { DecodingError } from "@effect-ts/morphic/Decoder/common" 8 | import { encoder } from "@effect-ts/morphic/Encoder" 9 | 10 | import { allErrors } from "../../model/collectors" 11 | import type { HTTPRouteException } from "../exceptions" 12 | import { accessReqM, accessResM } from "../server" 13 | 14 | export const readBody = accessReqM((req) => 15 | T.effectAsyncInterrupt((cb) => { 16 | const body: Uint8Array[] = [] 17 | 18 | function onData(chunk: Uint8Array) { 19 | body.push(chunk) 20 | } 21 | 22 | const onEnd = () => { 23 | cb(T.succeed(Buffer.concat(body))) 24 | } 25 | 26 | req.on("data", onData) 27 | req.on("end", onEnd) 28 | 29 | return T.effectTotal(() => { 30 | req.removeListener("data", onData) 31 | req.removeListener("end", onEnd) 32 | }) 33 | }) 34 | ) 35 | 36 | export const readJsonBody = pipe( 37 | readBody, 38 | T.chain((b) => 39 | T.effectPartial( 40 | (): HTTPRouteException => ({ 41 | _tag: "HTTPRouteException", 42 | status: 400, 43 | message: "body cannot be parsed as json" 44 | }) 45 | )(() => JSON.parse(b.toString("utf-8"))) 46 | ) 47 | ) 48 | 49 | export function jsonBody( 50 | _: M.M<{}, L, A>, 51 | collector: (_: DecodingError) => Option = allErrors 52 | ) { 53 | const decode = decoder(_).decode 54 | 55 | return pipe( 56 | readJsonBody, 57 | T.chain( 58 | flow( 59 | decode, 60 | T.catchAll((_) => { 61 | const errors = A.filterMap_(_.errors, collector) 62 | return T.fail({ 63 | _tag: "HTTPRouteException", 64 | status: 400, 65 | message: JSON.stringify({ 66 | error: errors.length > 0 ? errors.join(", ") : "malformed body" 67 | }) 68 | }) 69 | }) 70 | ) 71 | ) 72 | ) 73 | } 74 | 75 | export function jsonResponse(_: M.M<{}, L, A>) { 76 | const encode = encoder(_).encode 77 | 78 | return flow( 79 | encode, 80 | T.chain((l) => 81 | accessResM((res) => 82 | T.effectTotal(() => { 83 | res.setHeader("content-type", "application/json") 84 | res.end(JSON.stringify(l)) 85 | }) 86 | ) 87 | ) 88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/model/common.ts: -------------------------------------------------------------------------------- 1 | import * as O from "@effect-ts/core/Classic/Option" 2 | import type { Endomorphism } from "@effect-ts/core/Function" 3 | import { pipe } from "@effect-ts/core/Function" 4 | import * as S from "@effect-ts/core/Sync" 5 | import type { AType, EType } from "@effect-ts/morphic" 6 | import { DecoderURI, make, opaque } from "@effect-ts/morphic" 7 | import { decoder } from "@effect-ts/morphic/Decoder" 8 | import type { 9 | Decoder, 10 | DecodingError, 11 | Validate 12 | } from "@effect-ts/morphic/Decoder/common" 13 | import { fail } from "@effect-ts/morphic/Decoder/common" 14 | import { encoder } from "@effect-ts/morphic/Encoder" 15 | 16 | import { validation } from "./validation" 17 | 18 | export const commonErrorIds = { 19 | bad_id_format: "bad_id_format" 20 | } 21 | 22 | const Id_ = make((F) => 23 | F.interface({ 24 | id: F.number({ 25 | conf: { 26 | [DecoderURI]: (_) => ({ 27 | validate: (u, c) => 28 | pipe( 29 | _.validate(u, c), 30 | S.catchAll(() => 31 | fail([ 32 | { 33 | id: commonErrorIds.bad_id_format, 34 | name: "id", 35 | message: "id should be an integer", 36 | context: { 37 | ...c, 38 | actual: u 39 | } 40 | } 41 | ]) 42 | ) 43 | ) 44 | }) 45 | } 46 | }) 47 | }) 48 | ) 49 | 50 | export interface Id extends AType {} 51 | export interface IdRaw extends EType {} 52 | 53 | export const Id = opaque()(Id_) 54 | 55 | export const commonErrors = (_: DecodingError) => 56 | _.id != null && _.id in commonErrorIds && _.message != null && _.message.length > 0 57 | ? O.some(_.message) 58 | : O.none 59 | 60 | export const encodeId = encoder(Id).encode 61 | export const decodeId = decoder(Id).decode 62 | export const validateId = validation(Id, commonErrors) 63 | 64 | const dateDecoderConfig: Endomorphism> = (_) => ({ 65 | validate: (u, c) => (u instanceof Date ? S.succeed(u) : _.validate(u, c)) 66 | }) 67 | 68 | const Common_ = make((F) => 69 | F.interface({ 70 | createdAt: F.date({ 71 | conf: { 72 | [DecoderURI]: dateDecoderConfig 73 | } 74 | }), 75 | updatedAt: F.date({ 76 | conf: { 77 | [DecoderURI]: dateDecoderConfig 78 | } 79 | }) 80 | }) 81 | ) 82 | 83 | export interface Common extends AType {} 84 | export interface CommonRaw extends EType {} 85 | 86 | export const Common = opaque()(Common_) 87 | -------------------------------------------------------------------------------- /articles/generators/05.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as Array from "@effect-ts/core/Classic/Array" 4 | import * as Map from "@effect-ts/core/Classic/Map" 5 | import * as T from "@effect-ts/core/Effect" 6 | import * as L from "@effect-ts/core/Effect/Layer" 7 | import * as M from "@effect-ts/core/Effect/Managed" 8 | import * as Ref from "@effect-ts/core/Effect/Ref" 9 | import type { _A } from "@effect-ts/core/Utils" 10 | import { tag } from "@effect-ts/system/Has" 11 | 12 | // make Database Live 13 | export const makeDbLive = M.gen(function* (_) { 14 | const ref = yield* _( 15 | Ref.makeRef>(Map.empty)["|>"]( 16 | M.make((ref) => ref.set(Map.empty)) 17 | ) 18 | ) 19 | 20 | return { 21 | get: (k: string) => ref.get["|>"](T.map(Map.lookup(k)))["|>"](T.chain(T.getOrFail)), 22 | put: (k: string, v: string) => ref["|>"](Ref.update(Map.insert(k, v))) 23 | } 24 | }) 25 | 26 | // simulate a database connection to a key-value store 27 | export interface DbConnection extends _A {} 28 | 29 | // Tag 30 | export const DbConnection = tag() 31 | 32 | // Database Live Layer 33 | export const DbLive = L.fromManaged(DbConnection)(makeDbLive) 34 | 35 | // make Broker Live 36 | export const makeBrokerLive = M.gen(function* (_) { 37 | const ref = yield* _( 38 | Ref.makeRef>(Array.empty)["|>"]( 39 | M.make((ref) => 40 | ref.get["|>"]( 41 | T.chain((messages) => 42 | T.effectTotal(() => { 43 | console.log(`Flush:`) 44 | messages.forEach((message) => { 45 | console.log("- " + message) 46 | }) 47 | }) 48 | ) 49 | ) 50 | ) 51 | ) 52 | ) 53 | 54 | return { 55 | send: (message: string) => ref["|>"](Ref.update(Array.snoc(message))) 56 | } 57 | }) 58 | 59 | // simulate a connection to a message broker 60 | export interface BrokerConnection extends _A {} 61 | 62 | // Tag 63 | export const BrokerConnection = tag() 64 | 65 | // Broker Live Layer 66 | export const BrokerLive = L.fromManaged(BrokerConnection)(makeBrokerLive) 67 | 68 | // Main Live Layer 69 | export const ProgramLive = L.all(DbLive, BrokerLive) 70 | 71 | // Program Entry 72 | export const main = T.gen(function* (_) { 73 | const { get, put } = yield* _(DbConnection) 74 | const { send } = yield* _(BrokerConnection) 75 | 76 | yield* _(put("ka", "a")) 77 | yield* _(put("kb", "b")) 78 | yield* _(put("kc", "c")) 79 | 80 | const a = yield* _(get("ka")) 81 | const b = yield* _(get("kb")) 82 | const c = yield* _(get("kc")) 83 | 84 | const s = `${a}-${b}-${c}` 85 | 86 | yield* _(send(s)) 87 | 88 | return s 89 | }) 90 | 91 | // run the program and print the output 92 | main["|>"](T.provideSomeLayer(ProgramLive))["|>"](T.runMain) 93 | -------------------------------------------------------------------------------- /articles/managed/03.ts: -------------------------------------------------------------------------------- 1 | import * as Array from "@effect-ts/core/Classic/Array" 2 | import * as Map from "@effect-ts/core/Classic/Map" 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as M from "@effect-ts/core/Effect/Managed" 5 | import * as Ref from "@effect-ts/core/Effect/Ref" 6 | import { pipe } from "@effect-ts/core/Function" 7 | import type { NoSuchElementException } from "@effect-ts/system/GlobalExceptions" 8 | 9 | // simulate a database connection to a key-value store 10 | export interface DbConnection { 11 | readonly put: (k: string, v: string) => T.UIO 12 | readonly get: (k: string) => T.IO 13 | readonly clear: T.UIO 14 | } 15 | 16 | // simulate a connection to a message broker 17 | export interface BrokerConnection { 18 | readonly send: (message: string) => T.UIO 19 | readonly clear: T.UIO 20 | } 21 | 22 | // connect to the database 23 | export const managedDb = pipe( 24 | Ref.makeRef(>Map.empty), 25 | T.chain((ref) => 26 | T.effectTotal( 27 | (): DbConnection => ({ 28 | get: (k) => pipe(ref.get, T.map(Map.lookup(k)), T.chain(T.getOrFail)), 29 | put: (k, v) => pipe(ref, Ref.update(Map.insert(k, v))), 30 | clear: ref.set(Map.empty) 31 | }) 32 | ) 33 | ), 34 | // release the connection via managed 35 | M.make((_) => _.clear) 36 | ) 37 | 38 | // connect to the database 39 | export const managedBroker = pipe( 40 | Ref.makeRef(>Array.empty), 41 | T.chain((ref) => 42 | T.effectTotal( 43 | (): BrokerConnection => ({ 44 | send: (message) => 45 | pipe(ref, Ref.update>(Array.snoc(message))), 46 | clear: pipe( 47 | ref.get, 48 | T.chain((messages) => 49 | T.effectTotal(() => { 50 | console.log(`Flush:`) 51 | messages.forEach((message) => { 52 | console.log("- " + message) 53 | }) 54 | }) 55 | ) 56 | ) 57 | }) 58 | ) 59 | ), 60 | // release the connection via managed 61 | M.make((_) => _.clear) 62 | ) 63 | 64 | // write a program that use the database 65 | export const program = pipe( 66 | // use the managed DbConnection 67 | managedDb, 68 | M.zip(managedBroker), 69 | M.use(([{ get, put }, { send }]) => 70 | pipe( 71 | T.do, 72 | T.tap(() => put("ka", "a")), 73 | T.tap(() => put("kb", "b")), 74 | T.tap(() => put("kc", "c")), 75 | T.bind("a", () => get("ka")), 76 | T.bind("b", () => get("kb")), 77 | T.bind("c", () => get("kc")), 78 | T.map(({ a, b, c }) => `${a}-${b}-${c}`), 79 | T.tap(send) 80 | ) 81 | ) 82 | ) 83 | 84 | // run the program and print the output 85 | pipe( 86 | program, 87 | T.chain((s) => 88 | T.effectTotal(() => { 89 | console.log(`Done: ${s}`) 90 | }) 91 | ), 92 | T.runMain 93 | ) 94 | -------------------------------------------------------------------------------- /articles/node/01.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as A from "@effect-ts/core/Classic/Associative" 4 | import * as T from "@effect-ts/core/Effect" 5 | import * as M from "@effect-ts/core/Effect/Managed" 6 | import * as S from "@effect-ts/core/Effect/Stream" 7 | import * as O from "@effect-ts/system/Option" 8 | import { makeRef } from "@effect-ts/system/Ref" 9 | import type { Transducer } from "@effect-ts/system/Stream/Transducer" 10 | import { transducer } from "@effect-ts/system/Stream/Transducer" 11 | import * as fs from "fs" 12 | import * as path from "path" 13 | 14 | export const readFileStream = (path: string) => 15 | S.gen(function* (_) { 16 | const fileStream = yield* _( 17 | T.effectTotal(() => fs.createReadStream(path))["|>"]( 18 | S.bracket((fs) => 19 | T.effectTotal(() => { 20 | fs.close() 21 | }) 22 | ) 23 | ) 24 | ) 25 | 26 | const bufferStream = yield* _( 27 | S.effectAsync((cb) => { 28 | fileStream.on("data", (data: Buffer) => { 29 | cb(T.succeed([data.toString("utf-8")])) 30 | }) 31 | fileStream.on("end", () => { 32 | cb(T.fail(O.none)) 33 | }) 34 | fileStream.on("error", (err) => { 35 | cb(T.die(err)) 36 | }) 37 | }) 38 | ) 39 | 40 | return bufferStream 41 | }) 42 | 43 | const getLines: Transducer = transducer( 44 | M.gen(function* (_) { 45 | const left = yield* _(makeRef("")) 46 | 47 | return O.fold( 48 | () => 49 | T.gen(function* (_) { 50 | const currentLeft = yield* _(left.get) 51 | 52 | yield* _(left.set("")) 53 | 54 | return currentLeft.length > 0 ? [currentLeft] : [] 55 | }), 56 | (value) => 57 | T.gen(function* (_) { 58 | const currentLeft = yield* _(left.get) 59 | const concat = A.fold(A.string)(currentLeft)(value) 60 | 61 | if (concat.length === 0) { 62 | return [] 63 | } 64 | 65 | if (concat.endsWith("\n")) { 66 | yield* _(left.set("")) 67 | return concat.split("\n").filter((_) => _.length > 0) 68 | } else { 69 | const all = concat.split("\n") 70 | 71 | if (all.length > 0) { 72 | const last = all.splice(all.length - 1)[0] 73 | 74 | yield* _(left.set(last)) 75 | 76 | return all.filter((_) => _.length > 0) 77 | } else { 78 | return [] 79 | } 80 | } 81 | }) 82 | ) 83 | }) 84 | ) 85 | 86 | readFileStream(path.join(__dirname, "../../tsconfig.json")) 87 | ["|>"](S.aggregate(getLines)) 88 | ["|>"](S.filter((_) => _.includes("strict"))) 89 | ["|>"](S.map((_) => _.trim())) 90 | ["|>"](S.runCollect) 91 | ["|>"]( 92 | T.chain((a) => 93 | T.effectTotal(() => { 94 | console.log(a) 95 | }) 96 | ) 97 | ) 98 | ["|>"](T.runMain) 99 | -------------------------------------------------------------------------------- /src/http/server/index.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as Ex from "@effect-ts/core/Effect/Exit" 3 | import * as L from "@effect-ts/core/Effect/Layer" 4 | import * as M from "@effect-ts/core/Effect/Managed" 5 | import * as Q from "@effect-ts/core/Effect/Queue" 6 | import { pipe } from "@effect-ts/core/Function" 7 | import type { Has } from "@effect-ts/core/Has" 8 | import { tag } from "@effect-ts/core/Has" 9 | import { intersect } from "@effect-ts/core/Utils" 10 | import * as http from "http" 11 | 12 | export interface HTTPServerConfig { 13 | config: { 14 | host: string 15 | port: number 16 | } 17 | } 18 | 19 | export const HTTPServerConfig = tag() 20 | 21 | export const { config: accessServerConfigM } = T.deriveAccessM(HTTPServerConfig)([ 22 | "config" 23 | ]) 24 | 25 | export function serverConfig( 26 | config: HTTPServerConfig["config"] 27 | ): L.Layer> { 28 | return L.create(HTTPServerConfig).pure({ config }) 29 | } 30 | 31 | export interface Request { 32 | req: http.IncomingMessage 33 | res: http.ServerResponse 34 | } 35 | 36 | export const Request = tag() 37 | 38 | export const { req: accessReqM, res: accessResM } = T.deriveAccessM(Request)([ 39 | "req", 40 | "res" 41 | ]) 42 | 43 | export interface Server { 44 | server: http.Server 45 | } 46 | 47 | export interface RequestQueue { 48 | queue: Q.Queue 49 | } 50 | 51 | export const Server = tag() 52 | export const RequestQueue = tag() 53 | 54 | export const { queue: accessQueueM } = T.deriveAccessM(RequestQueue)(["queue"]) 55 | export const { server: accessServerM } = T.deriveAccessM(Server)(["server"]) 56 | 57 | export const LiveHTTP = pipe( 58 | Q.makeUnbounded(), 59 | T.chain((queue) => 60 | pipe( 61 | T.effectTotal(() => 62 | http.createServer((req, res) => { 63 | T.run(queue.offer({ req, res })) 64 | }) 65 | ), 66 | T.map((server): Server & RequestQueue => ({ server, queue })) 67 | ) 68 | ), 69 | T.tap(({ server }) => 70 | accessServerConfigM(({ host, port }) => 71 | T.effectAsync((cb) => { 72 | function clean() { 73 | server.removeListener("error", onErr) 74 | server.removeListener("listening", onDone) 75 | } 76 | function onErr(err: Error) { 77 | clean() 78 | cb(T.die(err)) 79 | } 80 | function onDone() { 81 | clean() 82 | cb(T.unit) 83 | } 84 | server.listen(port, host) 85 | 86 | server.once("error", onErr) 87 | server.once("listening", onDone) 88 | }) 89 | ) 90 | ), 91 | M.make(({ queue, server }) => 92 | pipe( 93 | T.tuple( 94 | T.result( 95 | T.effectAsync((cb) => { 96 | server.close((err) => { 97 | if (err) { 98 | cb(T.die(err)) 99 | } else { 100 | cb(T.unit) 101 | } 102 | }) 103 | }) 104 | ), 105 | T.result(queue.shutdown) 106 | ), 107 | T.chain(([ea, eb]) => T.done(Ex.zip(eb)(ea))) 108 | ) 109 | ), 110 | M.map((_) => intersect(Server.of(_), RequestQueue.of(_))), 111 | L.fromRawManaged 112 | ) 113 | -------------------------------------------------------------------------------- /articles/layer/01.ts: -------------------------------------------------------------------------------- 1 | import * as Array from "@effect-ts/core/Classic/Array" 2 | import * as Map from "@effect-ts/core/Classic/Map" 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as L from "@effect-ts/core/Effect/Layer" 5 | import * as M from "@effect-ts/core/Effect/Managed" 6 | import * as Ref from "@effect-ts/core/Effect/Ref" 7 | import { pipe } from "@effect-ts/core/Function" 8 | import type { NoSuchElementException } from "@effect-ts/system/GlobalExceptions" 9 | 10 | // simulate a database connection to a key-value store 11 | export interface DbConnection { 12 | readonly put: (k: string, v: string) => T.UIO 13 | readonly get: (k: string) => T.IO 14 | readonly clear: T.UIO 15 | } 16 | 17 | // simulate a connection to a message broker 18 | export interface BrokerConnection { 19 | readonly send: (message: string) => T.UIO 20 | readonly clear: T.UIO 21 | } 22 | 23 | // connect to the database 24 | export const DbLive = pipe( 25 | Ref.makeRef(>Map.empty), 26 | T.chain((ref) => 27 | T.effectTotal( 28 | (): DbConnection => ({ 29 | get: (k) => pipe(ref.get, T.map(Map.lookup(k)), T.chain(T.getOrFail)), 30 | put: (k, v) => pipe(ref, Ref.update(Map.insert(k, v))), 31 | clear: ref.set(Map.empty) 32 | }) 33 | ) 34 | ), 35 | // release the connection via managed 36 | M.make((_) => _.clear), 37 | // construct the layer 38 | L.fromRawManaged 39 | ) 40 | 41 | // connect to the database 42 | export const BrokerLive = pipe( 43 | Ref.makeRef(>Array.empty), 44 | T.chain((ref) => 45 | T.effectTotal( 46 | (): BrokerConnection => ({ 47 | send: (message) => 48 | pipe(ref, Ref.update>(Array.snoc(message))), 49 | clear: pipe( 50 | ref.get, 51 | T.chain((messages) => 52 | T.effectTotal(() => { 53 | console.log(`Flush:`) 54 | messages.forEach((message) => { 55 | console.log("- " + message) 56 | }) 57 | }) 58 | ) 59 | ) 60 | }) 61 | ) 62 | ), 63 | // release the connection via managed 64 | M.make((_) => _.clear), 65 | // construct the layer 66 | L.fromRawManaged 67 | ) 68 | 69 | export const ProgramLive = L.all(DbLive, BrokerLive) 70 | 71 | // write a program that use the database 72 | export const program = pipe( 73 | // access Db from environment 74 | T.access(({ get, put }: DbConnection) => ({ get, put })), 75 | // access Broker from environment 76 | T.zip(T.access(({ send }: BrokerConnection) => ({ send }))), 77 | // use both 78 | T.chain(([{ get, put }, { send }]) => 79 | pipe( 80 | T.do, 81 | T.tap(() => put("ka", "a")), 82 | T.tap(() => put("kb", "b")), 83 | T.tap(() => put("kc", "c")), 84 | T.bind("a", () => get("ka")), 85 | T.bind("b", () => get("kb")), 86 | T.bind("c", () => get("kc")), 87 | T.map(({ a, b, c }) => `${a}-${b}-${c}`), 88 | T.tap(send) 89 | ) 90 | ) 91 | ) 92 | 93 | // run the program and print the output 94 | pipe( 95 | program, 96 | T.chain((s) => 97 | T.effectTotal(() => { 98 | console.log(`Done: ${s}`) 99 | }) 100 | ), 101 | // provide the layer to program 102 | T.provideSomeLayer(ProgramLive), 103 | T.runMain 104 | ) 105 | -------------------------------------------------------------------------------- /articles/layer/02.ts: -------------------------------------------------------------------------------- 1 | import * as Array from "@effect-ts/core/Classic/Array" 2 | import * as Map from "@effect-ts/core/Classic/Map" 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as L from "@effect-ts/core/Effect/Layer" 5 | import * as M from "@effect-ts/core/Effect/Managed" 6 | import * as Ref from "@effect-ts/core/Effect/Ref" 7 | import { pipe } from "@effect-ts/core/Function" 8 | import type { NoSuchElementException } from "@effect-ts/system/GlobalExceptions" 9 | import { tag } from "@effect-ts/system/Has" 10 | 11 | // simulate a database connection to a key-value store 12 | export interface DbConnection { 13 | readonly put: (k: string, v: string) => T.UIO 14 | readonly get: (k: string) => T.IO 15 | readonly clear: T.UIO 16 | } 17 | 18 | export const DbConnection = tag() 19 | 20 | // simulate a connection to a message broker 21 | export interface BrokerConnection { 22 | readonly send: (message: string) => T.UIO 23 | readonly clear: T.UIO 24 | } 25 | 26 | export const BrokerConnection = tag() 27 | 28 | // connect to the database 29 | export const DbLive = pipe( 30 | Ref.makeRef(>Map.empty), 31 | T.chain((ref) => 32 | T.effectTotal( 33 | (): DbConnection => ({ 34 | get: (k) => pipe(ref.get, T.map(Map.lookup(k)), T.chain(T.getOrFail)), 35 | put: (k, v) => pipe(ref, Ref.update(Map.insert(k, v))), 36 | clear: ref.set(Map.empty) 37 | }) 38 | ) 39 | ), 40 | // release the connection via managed 41 | M.make((_) => _.clear), 42 | // construct the layer 43 | L.fromManaged(DbConnection) 44 | ) 45 | 46 | // connect to the database 47 | export const BrokerLive = pipe( 48 | Ref.makeRef(>Array.empty), 49 | T.chain((ref) => 50 | T.effectTotal( 51 | (): BrokerConnection => ({ 52 | send: (message) => 53 | pipe(ref, Ref.update>(Array.snoc(message))), 54 | clear: pipe( 55 | ref.get, 56 | T.chain((messages) => 57 | T.effectTotal(() => { 58 | console.log(`Flush:`) 59 | messages.forEach((message) => { 60 | console.log("- " + message) 61 | }) 62 | }) 63 | ) 64 | ) 65 | }) 66 | ) 67 | ), 68 | // release the connection via managed 69 | M.make((_) => _.clear), 70 | // construct the layer 71 | L.fromManaged(BrokerConnection) 72 | ) 73 | 74 | export const ProgramLive = L.all(DbLive, BrokerLive) 75 | 76 | export const { get, put } = T.deriveLifted(DbConnection)(["get", "put"], [], []) 77 | 78 | export const { send } = T.deriveLifted(BrokerConnection)(["send"], [], []) 79 | 80 | // write a program that use the database 81 | export const program = pipe( 82 | T.do, 83 | T.tap(() => put("ka", "a")), 84 | T.tap(() => put("kb", "b")), 85 | T.tap(() => put("kc", "c")), 86 | T.bind("a", () => get("ka")), 87 | T.bind("b", () => get("kb")), 88 | T.bind("c", () => get("kc")), 89 | T.map(({ a, b, c }) => `${a}-${b}-${c}`), 90 | T.tap(send) 91 | ) 92 | 93 | // run the program and print the output 94 | pipe( 95 | program, 96 | T.chain((s) => 97 | T.effectTotal(() => { 98 | console.log(`Done: ${s}`) 99 | }) 100 | ), 101 | // provide the layer to program 102 | T.provideSomeLayer(ProgramLive), 103 | T.runMain 104 | ) 105 | -------------------------------------------------------------------------------- /src/db/migration.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as L from "@effect-ts/core/Effect/Layer" 3 | import * as M from "@effect-ts/core/Effect/Managed" 4 | import { tag } from "@effect-ts/core/Has" 5 | import * as PGM from "node-pg-migrate" 6 | import type * as MIG from "node-pg-migrate/dist/migration" 7 | import * as path from "path" 8 | 9 | import { deriveTenants } from "../tenants" 10 | import type { Databases } from "./database" 11 | import { databases } from "./database" 12 | import { PgPool } from "./pool" 13 | 14 | export const migrations = deriveTenants(databases) 15 | 16 | export interface PgMigration { 17 | _tag: K 18 | migrations: MIG.RunMigration[] 19 | } 20 | 21 | export const PgMigration = (db: K) => 22 | tag>().setKey(migrations[db]) 23 | 24 | export function migrateUpDown(db: K) { 25 | return ({ withPoolClientM }: PgPool) => 26 | M.makeExit_( 27 | withPoolClientM((dbClient) => { 28 | const opts: PGM.RunnerOption = { 29 | migrationsTable: "migration", 30 | dir: path.join(__dirname, `../../migrations/${db}`), 31 | count: Number.MIN_SAFE_INTEGER, 32 | direction: "up", 33 | dbClient, 34 | verbose: false, 35 | logger: { 36 | ...console, 37 | info: () => { 38 | // 39 | } 40 | } 41 | } 42 | 43 | return T.fromPromiseDie(async () => { 44 | const migrations = await PGM.default(opts) 45 | return { 46 | _tag: db, 47 | migrations 48 | } 49 | }) 50 | }), 51 | () => 52 | withPoolClientM((dbClient) => { 53 | const opts: PGM.RunnerOption = { 54 | migrationsTable: "migration", 55 | dir: path.join(__dirname, `../../migrations/${db}`), 56 | count: Number.MIN_SAFE_INTEGER, 57 | direction: "down", 58 | dbClient, 59 | verbose: false, 60 | logger: { 61 | ...console, 62 | info: () => { 63 | // 64 | } 65 | } 66 | } 67 | 68 | return T.fromPromiseDie(() => PGM.default(opts)) 69 | }) 70 | ) 71 | } 72 | 73 | export function migrateUp(db: K) { 74 | return ({ withPoolClientM }: PgPool) => 75 | M.fromEffect( 76 | withPoolClientM((dbClient) => { 77 | const opts: PGM.RunnerOption = { 78 | migrationsTable: "migration", 79 | dir: path.join(__dirname, `../../migrations/${db}`), 80 | count: Number.MIN_SAFE_INTEGER, 81 | direction: "up", 82 | dbClient, 83 | verbose: false, 84 | logger: { 85 | ...console, 86 | info: () => { 87 | // 88 | } 89 | } 90 | } 91 | 92 | return T.fromPromiseDie(async () => { 93 | const migrations = await PGM.default(opts) 94 | return { _tag: db, migrations } 95 | }) 96 | }) 97 | ) 98 | } 99 | 100 | export const TestMigration = (db: K) => 101 | L.fromConstructorManaged(PgMigration(db))(migrateUpDown(db))(PgPool(db)) 102 | 103 | export const LiveMigration = (db: K) => 104 | L.fromConstructorManaged(PgMigration(db))(migrateUp(db))(PgPool(db)) 105 | -------------------------------------------------------------------------------- /src/persistence/credential.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as L from "@effect-ts/core/Effect/Layer" 5 | import { tag } from "@effect-ts/core/Has" 6 | import type { _A } from "@effect-ts/core/Utils" 7 | 8 | import { Crypto } from "../crypto" 9 | import { Db } from "../db" 10 | import type { Id } from "../model/common" 11 | import { encodeId, validateId } from "../model/common" 12 | import type { CreateCredential, UpdateCredential } from "../model/credential" 13 | import { 14 | decodeCredential, 15 | validateCreateCredential, 16 | validateUpdateCredential 17 | } from "../model/credential" 18 | 19 | export class CredentialNotFound { 20 | readonly _tag = "CredentialNotFound" 21 | } 22 | 23 | export const makeCredentialPersistence = T.gen(function* (_) { 24 | const { hashPassword } = yield* _(Crypto) 25 | const { query } = yield* _(Db("main")) 26 | 27 | return { 28 | getCredential: (i: Id) => 29 | T.gen(function* (_) { 30 | yield* _(validateId(i)) 31 | const { id } = yield* _(encodeId(i)) 32 | const result = yield* _( 33 | query(`SELECT * FROM "public"."credentials" WHERE "id" = $1::integer`, id) 34 | ) 35 | const credential = yield* _( 36 | result.rows.length > 0 37 | ? T.succeed(result.rows[0]) 38 | : T.fail(new CredentialNotFound()) 39 | ) 40 | return yield* _(decodeCredential(credential)["|>"](T.orDie)) 41 | }), 42 | getCredentialByUserId: (userId: number) => 43 | T.gen(function* (_) { 44 | const result = yield* _( 45 | query( 46 | `SELECT * FROM "public"."credentials" WHERE "userId" = $1::integer`, 47 | userId 48 | ) 49 | ) 50 | const credential = yield* _( 51 | result.rows.length > 0 52 | ? T.succeed(result.rows[0]) 53 | : T.fail(new CredentialNotFound()) 54 | ) 55 | return yield* _(decodeCredential(credential)["|>"](T.orDie)) 56 | }), 57 | createCredential: (i: CreateCredential) => 58 | T.gen(function* (_) { 59 | yield* _(validateCreateCredential(i)) 60 | const hash = yield* _(hashPassword(i.password)) 61 | const result = yield* _( 62 | query( 63 | `INSERT INTO "public"."credentials" ("userId", "hash") VALUES ($1::integer, $2::text) RETURNING *`, 64 | i.userId, 65 | hash 66 | ) 67 | ) 68 | return yield* _(decodeCredential(result.rows[0])["|>"](T.orDie)) 69 | }), 70 | updateCredential: (i: UpdateCredential) => 71 | T.gen(function* (_) { 72 | yield* _(validateUpdateCredential(i)) 73 | const hash = yield* _(hashPassword(i.password)) 74 | const result = yield* _( 75 | query( 76 | `UPDATE "public"."credentials" SET "hash" = $1::text WHERE "id" = $2::integer RETURNING *`, 77 | hash, 78 | i.id 79 | ) 80 | ) 81 | return yield* _(decodeCredential(result.rows[0])["|>"](T.orDie)) 82 | }) 83 | } 84 | }) 85 | 86 | export interface CredentialPersistence extends _A {} 87 | 88 | export const CredentialPersistence = tag() 89 | 90 | export const CredentialPersistenceLive = L.fromEffect(CredentialPersistence)( 91 | makeCredentialPersistence 92 | ) 93 | 94 | export const { createCredential, getCredential, updateCredential } = T.deriveLifted( 95 | CredentialPersistence 96 | )(["createCredential", "updateCredential", "getCredential"], [], []) 97 | -------------------------------------------------------------------------------- /.vscode/common-imports.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Effect": { 3 | "prefix": "+T", 4 | "body": ["import * as T from \"@effect-ts/core/Effect\""], 5 | "description": "Effect as T" 6 | }, 7 | "Managed": { 8 | "prefix": "+M", 9 | "body": ["import * as M from \"@effect-ts/core/Effect/Managed\""], 10 | "description": "Managed as M" 11 | }, 12 | "Layer": { 13 | "prefix": "+L", 14 | "body": ["import * as L from \"@effect-ts/core/Effect/Layer\""], 15 | "description": "Layer as L" 16 | }, 17 | "Ref": { 18 | "prefix": "+Ref", 19 | "body": ["import * as Ref from \"@effect-ts/core/Effect/Ref\""], 20 | "description": "Ref" 21 | }, 22 | "RefM": { 23 | "prefix": "+RefM", 24 | "body": ["import * as RefM from \"@effect-ts/core/Effect/RefM\""], 25 | "description": "RefM" 26 | }, 27 | "Map": { 28 | "prefix": "+Map", 29 | "body": ["import * as Map from \"@effect-ts/core/Classic/Map\""], 30 | "description": "Map" 31 | }, 32 | "Array": { 33 | "prefix": "+A", 34 | "body": ["import * as A from \"@effect-ts/core/Classic/Map\""], 35 | "description": "Array as A" 36 | }, 37 | "Record": { 38 | "prefix": "+R", 39 | "body": ["import * as R from \"@effect-ts/core/Classic/Record\""], 40 | "description": "Record as R" 41 | }, 42 | "NonEmptyArray": { 43 | "prefix": "+NA", 44 | "body": ["import * as NA from \"@effect-ts/core/Classic/NonEmptyArray\""], 45 | "description": "NonEmptyArray as NA" 46 | }, 47 | "Prelude": { 48 | "prefix": "+P", 49 | "body": ["import * as P from \"@effect-ts/core/Prelude\""], 50 | "description": "Prelude as NA" 51 | }, 52 | "DSL": { 53 | "prefix": "+DSL", 54 | "body": ["import * as DSL from \"@effect-ts/core/Prelude/DSL\""], 55 | "description": "Prelude DSL as DSL" 56 | }, 57 | "Queue": { 58 | "prefix": "+Q", 59 | "body": ["import * as Q from \"@effect-ts/core/Effect/Queue\""], 60 | "description": "Queue as Q" 61 | }, 62 | "Semaphore": { 63 | "prefix": "+Q", 64 | "body": ["import * as Sem from \"@effect-ts/core/Effect/Semaphore\""], 65 | "description": "Semaphore as Sem" 66 | }, 67 | "Stream": { 68 | "prefix": "+S", 69 | "body": ["import * as S from \"@effect-ts/core/Effect/Stream\""], 70 | "description": "Semaphore as S" 71 | }, 72 | "Async": { 73 | "prefix": "+As", 74 | "body": ["import * as As from \"@effect-ts/core/Async\""], 75 | "description": "Async as As" 76 | }, 77 | "Sync": { 78 | "prefix": "+Sy", 79 | "body": ["import * as Sy from \"@effect-ts/core/Sync\""], 80 | "description": "Sync as Sy" 81 | }, 82 | "Either": { 83 | "prefix": "+E", 84 | "body": ["import * as E from \"@effect-ts/core/Classic/Either\""], 85 | "description": "Either as E" 86 | }, 87 | "Exit": { 88 | "prefix": "+Ex", 89 | "body": ["import * as Ex from \"@effect-ts/core/Effect/Exit\""], 90 | "description": "Exit as Ex" 91 | }, 92 | "Cause": { 93 | "prefix": "+C", 94 | "body": ["import * as C from \"@effect-ts/core/Effect/Cause\""], 95 | "description": "Cause as C" 96 | }, 97 | "Option": { 98 | "prefix": "+O", 99 | "body": ["import * as O from \"@effect-ts/core/Classic/Option\""], 100 | "description": "Option as O" 101 | }, 102 | "Fiber": { 103 | "prefix": "+F", 104 | "body": ["import * as F from \"@effect-ts/core/Effect/Fiber\""], 105 | "description": "Fiber as F" 106 | }, 107 | "Iso": { 108 | "prefix": "+I", 109 | "body": ["import * as I from \"@effect-ts/monocle/Iso\""], 110 | "description": "Iso as I" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /articles/layer/03.ts: -------------------------------------------------------------------------------- 1 | import * as Array from "@effect-ts/core/Classic/Array" 2 | import * as Map from "@effect-ts/core/Classic/Map" 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as L from "@effect-ts/core/Effect/Layer" 5 | import * as M from "@effect-ts/core/Effect/Managed" 6 | import * as Ref from "@effect-ts/core/Effect/Ref" 7 | import { pipe } from "@effect-ts/core/Function" 8 | import type { NoSuchElementException } from "@effect-ts/system/GlobalExceptions" 9 | import { tag } from "@effect-ts/system/Has" 10 | 11 | // simulate a database connection to a key-value store 12 | export interface DbConnection { 13 | readonly put: (k: string, v: string) => T.UIO 14 | readonly get: (k: string) => T.IO 15 | readonly clear: T.UIO 16 | } 17 | 18 | export const DbConnection = tag() 19 | 20 | // simulate a connection to a message broker 21 | export interface BrokerConnection { 22 | readonly send: (message: string) => T.UIO 23 | readonly clear: T.UIO 24 | } 25 | 26 | export const BrokerConnection = tag() 27 | 28 | // connect to the database 29 | export const DbLive = pipe( 30 | Ref.makeRef(>Map.empty), 31 | T.chain((ref) => 32 | T.effectTotal( 33 | (): DbConnection => ({ 34 | get: (k) => pipe(ref.get, T.map(Map.lookup(k)), T.chain(T.getOrFail)), 35 | put: (k, v) => pipe(ref, Ref.update(Map.insert(k, v))), 36 | clear: ref.set(Map.empty) 37 | }) 38 | ) 39 | ), 40 | // release the connection via managed 41 | M.make((_) => _.clear), 42 | // construct the layer 43 | L.fromManaged(DbConnection) 44 | ) 45 | 46 | // connect to the database 47 | export const BrokerLive = pipe( 48 | Ref.makeRef(>Array.empty), 49 | T.chain((ref) => 50 | T.effectTotal( 51 | (): BrokerConnection => ({ 52 | send: (message) => 53 | pipe(ref, Ref.update>(Array.snoc(message))), 54 | clear: pipe( 55 | ref.get, 56 | T.chain((messages) => 57 | T.effectTotal(() => { 58 | console.log(`Flush:`) 59 | messages.forEach((message) => { 60 | console.log("- " + message) 61 | }) 62 | }) 63 | ) 64 | ) 65 | }) 66 | ) 67 | ), 68 | // release the connection via managed 69 | M.make((_) => _.clear), 70 | // construct the layer 71 | L.fromManaged(BrokerConnection) 72 | ) 73 | 74 | export function makeProgram({ get, put }: DbConnection, { send }: BrokerConnection) { 75 | return { 76 | main: pipe( 77 | T.do, 78 | T.tap(() => put("ka", "a")), 79 | T.tap(() => put("kb", "b")), 80 | T.tap(() => put("kc", "c")), 81 | T.bind("a", () => get("ka")), 82 | T.bind("b", () => get("kb")), 83 | T.bind("c", () => get("kc")), 84 | T.map(({ a, b, c }) => `${a}-${b}-${c}`), 85 | T.tap(send) 86 | ) 87 | } 88 | } 89 | 90 | export interface Program extends ReturnType {} 91 | 92 | export const Program = tag() 93 | 94 | export const ProgramLive = L.fromConstructor(Program)(makeProgram)( 95 | DbConnection, 96 | BrokerConnection 97 | ) 98 | 99 | export const MainLive = pipe(ProgramLive, L.using(L.all(DbLive, BrokerLive))) 100 | 101 | export const { main } = T.deriveLifted(Program)([], ["main"], []) 102 | 103 | // run the program and print the output 104 | pipe( 105 | main, 106 | T.chain((s) => 107 | T.effectTotal(() => { 108 | console.log(`Done: ${s}`) 109 | }) 110 | ), 111 | // provide the layer to program 112 | T.provideSomeLayer(MainLive), 113 | T.runMain 114 | ) 115 | -------------------------------------------------------------------------------- /src/model/credential.ts: -------------------------------------------------------------------------------- 1 | import * as O from "@effect-ts/core/Classic/Option" 2 | import { pipe } from "@effect-ts/core/Function" 3 | import * as S from "@effect-ts/core/Sync" 4 | import type { AType, EType } from "@effect-ts/morphic" 5 | import { DecoderURI, FastCheckURI, make, opaque } from "@effect-ts/morphic" 6 | import type { DecodingError } from "@effect-ts/morphic/Decoder/common" 7 | import { fail } from "@effect-ts/morphic/Decoder/common" 8 | import { encoder } from "@effect-ts/morphic/Encoder" 9 | import { strictDecoder } from "@effect-ts/morphic/StrictDecoder" 10 | 11 | import { Common, commonErrorIds, Id } from "./common" 12 | import { UserIdField } from "./user" 13 | import { validation } from "./validation" 14 | 15 | export const credentialErrorIds = { 16 | ...commonErrorIds, 17 | password_length: "password_length" 18 | } 19 | 20 | const PasswordField_ = make((F) => 21 | F.interface({ 22 | password: F.string({ 23 | conf: { 24 | [FastCheckURI]: (_, { module: fc }) => 25 | fc.string({ minLength: 8, maxLength: 32 }), 26 | [DecoderURI]: (_) => ({ 27 | validate: (u, c) => 28 | pipe( 29 | _.validate(u, c), 30 | S.chain((s) => 31 | s.length >= 8 && s.length <= 32 32 | ? S.succeed(s) 33 | : fail([ 34 | { 35 | id: credentialErrorIds.password_length, 36 | name: "password", 37 | message: "password should have between 8 and 32 characters", 38 | context: { 39 | ...c, 40 | actual: s 41 | } 42 | } 43 | ]) 44 | ) 45 | ) 46 | }) 47 | } 48 | }) 49 | }) 50 | ) 51 | 52 | export interface PasswordField extends AType {} 53 | export interface PasswordFieldRaw extends EType {} 54 | 55 | export const PasswordField = opaque()(PasswordField_) 56 | 57 | const CreateCredential_ = make((F) => 58 | F.intersection(UserIdField(F), PasswordField(F))() 59 | ) 60 | 61 | export interface CreateCredential extends AType {} 62 | export interface CreateCredentialRaw extends EType {} 63 | 64 | export const CreateCredential = opaque()( 65 | CreateCredential_ 66 | ) 67 | 68 | const CredentialHash_ = make((F) => 69 | F.interface({ 70 | hash: F.string() 71 | }) 72 | ) 73 | 74 | export interface CredentialHash extends AType {} 75 | export interface CredentialHashRaw extends EType {} 76 | 77 | export const CredentialHash = opaque()( 78 | CredentialHash_ 79 | ) 80 | 81 | const Credential_ = make((F) => F.intersection(Id(F), CredentialHash(F), Common(F))()) 82 | 83 | export interface Credential extends AType {} 84 | export interface CredentialRaw extends EType {} 85 | 86 | export const Credential = opaque()(Credential_) 87 | 88 | const UpdateCredential_ = make((F) => F.intersection(Id(F), CreateCredential(F))()) 89 | 90 | export interface UpdateCredential extends AType {} 91 | export interface UpdateCredentialRaw extends EType {} 92 | 93 | export const UpdateCredential = opaque()( 94 | UpdateCredential_ 95 | ) 96 | 97 | export const decodeCredential = strictDecoder(Credential).decode 98 | export const encodeCredential = encoder(Credential).encode 99 | export const decodeUpdateCredential = strictDecoder(UpdateCredential).decode 100 | export const encodeUpdateCredential = encoder(UpdateCredential).encode 101 | export const encodeCreateCredential = encoder(CreateCredential).encode 102 | export const decodeCreateCredential = strictDecoder(CreateCredential).decode 103 | 104 | export const credentialErrors = (_: DecodingError) => 105 | _.id != null && 106 | _.id in credentialErrorIds && 107 | _.message != null && 108 | _.message.length > 0 109 | ? O.some(_.message) 110 | : O.none 111 | 112 | export const validateCredential = validation(Credential, credentialErrors) 113 | export const validateUpdateCredential = validation(UpdateCredential, credentialErrors) 114 | export const validateCreateCredential = validation(CreateCredential, credentialErrors) 115 | -------------------------------------------------------------------------------- /src/crypto/index.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as L from "@effect-ts/core/Effect/Layer" 3 | import { tag } from "@effect-ts/core/Has" 4 | import type { _A } from "@effect-ts/core/Utils" 5 | import * as crypto from "crypto" 6 | 7 | // larger numbers mean better security, less 8 | const defaultConfig = { 9 | // size of the generated hash 10 | hashBytes: 32, 11 | // larger salt means hashed passwords are more resistant to rainbow table, but 12 | // you get diminishing returns pretty fast 13 | saltBytes: 16, 14 | // more iterations means an attacker has to take longer to brute force an 15 | // individual password, so larger is better. however, larger also means longer 16 | // to hash the password. tune so that hashing the password takes about a 17 | // second 18 | iterations: 872791, 19 | // digest function 20 | digest: "sha512" 21 | } 22 | 23 | type _config = typeof defaultConfig 24 | 25 | export interface PBKDF2Config extends _config {} 26 | 27 | export const PBKDF2Config = tag() 28 | 29 | export const PBKDF2ConfigLive = L.create(PBKDF2Config).pure(defaultConfig) 30 | export const PBKDF2ConfigTest = L.create(PBKDF2Config).pure({ 31 | ...defaultConfig, 32 | iterations: 1 33 | }) 34 | 35 | export class InvalidPassword { 36 | readonly _tag = "InvalidPassword" 37 | } 38 | 39 | export const makeCrypto = T.gen(function* (_) { 40 | const config = yield* _(PBKDF2Config) 41 | 42 | return { 43 | hashPassword: (password: string): T.UIO => 44 | T.effectAsync((cb) => { 45 | // generate a salt for pbkdf2 46 | crypto.randomBytes(config.saltBytes, function (err, salt) { 47 | if (err) { 48 | return cb(T.die(err)) 49 | } 50 | 51 | crypto.pbkdf2( 52 | password, 53 | salt, 54 | config.iterations, 55 | config.hashBytes, 56 | config.digest, 57 | function (err, hash) { 58 | if (err) { 59 | cb(T.die(err)) 60 | return 61 | } 62 | 63 | const combined = Buffer.alloc(hash.length + salt.length + 8) 64 | 65 | // include the size of the salt so that we can, during verification, 66 | // figure out how much of the hash is salt 67 | combined.writeUInt32BE(salt.length, 0) 68 | // similarly, include the iteration count 69 | combined.writeUInt32BE(config.iterations, 4) 70 | 71 | salt.copy(combined, 8) 72 | hash.copy(combined, salt.length + 8) 73 | 74 | cb(T.succeed(combined.toString("base64"))) 75 | } 76 | ) 77 | }) 78 | }), 79 | verifyPassword: (password: string, hashText: string): T.IO => 80 | T.effectAsync((cb) => { 81 | const combined = Buffer.from(hashText, "base64") 82 | 83 | // extract the salt and hash from the combined buffer 84 | const saltBytes = combined.readUInt32BE(0) 85 | const hashBytes = combined.length - saltBytes - 8 86 | const iterations = combined.readUInt32BE(4) 87 | const salt = combined.slice(8, saltBytes + 8) 88 | const hash = combined.toString("binary", saltBytes + 8) 89 | 90 | // verify the salt and hash against the password 91 | crypto.pbkdf2(password, salt, iterations, hashBytes, config.digest, function ( 92 | err, 93 | verify 94 | ) { 95 | if (err) { 96 | cb(T.fail(new InvalidPassword())) 97 | } else { 98 | if (verify.toString("binary") === hash) { 99 | cb(T.unit) 100 | } else { 101 | cb(T.fail(new InvalidPassword())) 102 | } 103 | } 104 | }) 105 | }) 106 | } 107 | }) 108 | 109 | export interface Crypto extends _A {} 110 | 111 | export const Crypto = tag() 112 | 113 | export const { 114 | /** 115 | * Hash a password using Node's asynchronous pbkdf2 (key derivation) function. 116 | * 117 | * Returns a self-contained buffer which can be arbitrarily encoded for storage 118 | * that contains all the data needed to verify a password. 119 | */ 120 | hashPassword, 121 | /** 122 | * Verify a password using Node's asynchronous pbkdf2 (key derivation) function. 123 | * 124 | * Accepts a hash and salt generated by hashPassword, and returns whether the 125 | * hash matched the password (as a boolean). 126 | */ 127 | verifyPassword 128 | } = T.deriveLifted(Crypto)(["hashPassword", "verifyPassword"], [], []) 129 | 130 | export const CryptoLive = L.fromEffect(Crypto)(makeCrypto) 131 | -------------------------------------------------------------------------------- /src/model/user.ts: -------------------------------------------------------------------------------- 1 | import * as O from "@effect-ts/core/Classic/Option" 2 | import { flow, pipe } from "@effect-ts/core/Function" 3 | import type { TypeOf } from "@effect-ts/core/Newtype" 4 | import { newtype, typeDef } from "@effect-ts/core/Newtype" 5 | import * as S from "@effect-ts/core/Sync" 6 | import * as I from "@effect-ts/monocle/Iso" 7 | import type { AType, EType } from "@effect-ts/morphic" 8 | import { DecoderURI, FastCheckURI, make, opaque } from "@effect-ts/morphic" 9 | import type { DecodingError } from "@effect-ts/morphic/Decoder/common" 10 | import { fail } from "@effect-ts/morphic/Decoder/common" 11 | import { encoder } from "@effect-ts/morphic/Encoder" 12 | import { strictDecoder } from "@effect-ts/morphic/StrictDecoder" 13 | 14 | import { Common, commonErrorIds, Id } from "./common" 15 | import { validation } from "./validation" 16 | 17 | export const userErrorIds = { 18 | ...commonErrorIds, 19 | email_length: "email_length", 20 | email_shape: "email_shape", 21 | user_id_negative: "user_id_negative" 22 | } 23 | 24 | const Email_ = typeDef()("Email") 25 | export interface Email extends TypeOf {} 26 | export const Email = newtype()(Email_) 27 | 28 | const EmailField_ = make((F) => 29 | F.interface({ 30 | email: F.newtypeIso( 31 | I.newtype(), 32 | F.string({ 33 | conf: { 34 | [FastCheckURI]: (_, { module: fc }) => 35 | fc.emailAddress().filter((_) => _.length > 0 && _.length <= 255), 36 | [DecoderURI]: (_) => ({ 37 | validate: (u, c) => 38 | pipe( 39 | _.validate(u, c), 40 | S.chain((s) => 41 | s.length > 0 && s.length <= 255 42 | ? /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/.test( 43 | s 44 | ) 45 | ? S.succeed(s) 46 | : fail([ 47 | { 48 | id: userErrorIds.email_shape, 49 | name: "email", 50 | message: "email doesn't match the required pattern", 51 | context: { 52 | ...c, 53 | actual: s 54 | } 55 | } 56 | ]) 57 | : fail([ 58 | { 59 | id: userErrorIds.email_length, 60 | name: "email", 61 | message: "email should be between 0 and 255 characters long", 62 | context: { 63 | ...c, 64 | actual: s 65 | } 66 | } 67 | ]) 68 | ) 69 | ) 70 | }) 71 | } 72 | }) 73 | ) 74 | }) 75 | ) 76 | 77 | export interface EmailField extends AType {} 78 | export interface EmailFieldRaw extends EType {} 79 | 80 | export const EmailField = opaque()(EmailField_) 81 | 82 | const CreateUser_ = make((F) => F.intersection(EmailField(F))()) 83 | 84 | export interface CreateUser extends AType {} 85 | export interface CreateUserRaw extends EType {} 86 | 87 | export const CreateUser = opaque()(CreateUser_) 88 | 89 | const User_ = make((F) => F.intersection(Id(F), EmailField(F), Common(F))()) 90 | 91 | export interface User extends AType {} 92 | export interface UserRaw extends EType {} 93 | 94 | export const User = opaque()(User_) 95 | 96 | export const decodeUser = strictDecoder(User).decode 97 | export const encodeUser = encoder(User).encode 98 | export const encodeCreateUser = encoder(CreateUser).encode 99 | export const decodeCreateUser = strictDecoder(CreateUser).decode 100 | 101 | export const userErrors = (_: DecodingError) => 102 | _.id != null && _.id in userErrorIds && _.message != null && _.message.length > 0 103 | ? O.some(_.message) 104 | : O.none 105 | 106 | export const validateUser = validation(User, userErrors) 107 | export const validateCreateUser = validation(CreateUser, userErrors) 108 | 109 | export const UserIdField = make((F) => 110 | F.interface({ 111 | userId: F.number({ 112 | conf: { 113 | [FastCheckURI]: (_, { module: fc }) => fc.integer(1, 1000000), 114 | [DecoderURI]: (_) => ({ 115 | validate: (u, c) => 116 | pipe( 117 | _.validate(u, c), 118 | S.chain((s) => 119 | s > 0 120 | ? S.succeed(s) 121 | : fail([ 122 | { 123 | id: userErrorIds.user_id_negative, 124 | name: "userId", 125 | message: "userId should be positive", 126 | context: { 127 | ...c, 128 | actual: s 129 | } 130 | } 131 | ]) 132 | ) 133 | ) 134 | }) 135 | } 136 | }) 137 | }) 138 | ) 139 | -------------------------------------------------------------------------------- /src/http/router/index.ts: -------------------------------------------------------------------------------- 1 | import * as A from "@effect-ts/core/Classic/Array" 2 | import * as FA from "@effect-ts/core/Classic/FreeAssociative" 3 | import * as T from "@effect-ts/core/Effect" 4 | import * as F from "@effect-ts/core/Effect/FiberRef" 5 | import type { Predicate } from "@effect-ts/core/Function" 6 | import { flow, identity, pipe } from "@effect-ts/core/Function" 7 | import type { Has } from "@effect-ts/core/Has" 8 | 9 | import { accessQueueM, Request } from "../server" 10 | 11 | export class Empty { 12 | readonly _R!: (_: R) => void 13 | readonly _E!: () => E 14 | readonly _tag = "Empty" 15 | } 16 | 17 | export type RouteFn = (_: Request, next: T.IO) => T.Effect 18 | 19 | export type MiddleFn = (route: RouteFn) => RouteFn 20 | 21 | export class Middleware { 22 | constructor(readonly middle: MiddleFn) {} 23 | } 24 | 25 | export class Route { 26 | readonly _tag = "Route" 27 | readonly _E!: () => E 28 | constructor( 29 | readonly route: RouteFn, 30 | readonly middlewares = FA.init>() 31 | ) {} 32 | middleware(): readonly Middleware[] { 33 | return FA.toArray(this.middlewares) 34 | } 35 | } 36 | 37 | export class Concat { 38 | readonly _tag = "Concat" 39 | constructor(readonly left: Routes, readonly right: Routes) {} 40 | } 41 | 42 | export type Routes = Route | Concat | Empty 43 | 44 | export function route( 45 | f: (request: Request, next: T.Effect) => T.Effect 46 | ) { 47 | return (self: Routes): Routes => 48 | new Concat(self, new Route(f as any) as any) as any 49 | } 50 | 51 | export function addRoute(path: Predicate) { 52 | return (f: (request: Request) => T.Effect, E, void>) => < 53 | R2, 54 | E2 55 | >( 56 | self: Routes 57 | ): Routes => 58 | pipe( 59 | self, 60 | route( 61 | (_, n): T.Effect => 62 | _.req.url ? (path(_) ? T.provideService(Request)(_)(f(_)) : n) : n 63 | ) 64 | ) 65 | } 66 | 67 | export function addRouteM(path: (_: Request) => T.RIO) { 68 | return (f: (request: Request) => T.Effect, E, void>) => < 69 | R2, 70 | E2 71 | >( 72 | self: Routes 73 | ): Routes => 74 | pipe( 75 | self, 76 | route( 77 | (_, n): T.Effect => 78 | T.chain_( 79 | path(_), 80 | (b): T.Effect => 81 | b ? T.provideService(Request)(_)(f(_)) : n 82 | ) 83 | ) 84 | ) 85 | } 86 | 87 | export function addMiddleware( 88 | middle: ( 89 | cont: RouteFn 90 | ) => (_: Request, next: T.IO) => T.Effect 91 | ) { 92 | return (self: Routes): Routes => { 93 | switch (self._tag) { 94 | case "Empty": { 95 | return self as any 96 | } 97 | case "Route": { 98 | return new Route( 99 | self.route, 100 | FA.append(new Middleware(middle as any))(self.middlewares) 101 | ) as any 102 | } 103 | case "Concat": { 104 | return new Concat( 105 | addMiddleware(middle)(self.left), 106 | addMiddleware(middle)(self.right) 107 | ) 108 | } 109 | } 110 | } 111 | } 112 | 113 | export type ProcessFn = (_: Request) => T.UIO 114 | 115 | function toArray(_: Routes): readonly RouteFn[] { 116 | switch (_._tag) { 117 | case "Empty": { 118 | return [] 119 | } 120 | case "Route": { 121 | const middlewares = _.middleware() 122 | if (A.isNonEmpty(middlewares)) { 123 | return [A.reduce_(middlewares, _.route, (b, m) => (r, n) => m.middle(b)(r, n))] 124 | } 125 | return [_.route] 126 | } 127 | case "Concat": { 128 | return [...toArray(_.left), ...toArray(_.right)] 129 | } 130 | } 131 | } 132 | 133 | export const create: Routes = new Empty() 134 | 135 | export const isRouterDraining = new F.FiberRef( 136 | false, 137 | identity, 138 | (a, b) => a && b 139 | ) 140 | 141 | export function drain(_: Routes) { 142 | const routes = toArray(_) 143 | 144 | const processFn = T.accessM((r: R) => 145 | T.effectTotal(() => 146 | A.reduce_( 147 | routes, 148 | ((_: Request) => 149 | T.effectTotal(() => { 150 | _.res.statusCode = 404 151 | _.res.end() 152 | })), 153 | (b, a) => (_) => T.provideAll_(a(_, b(_)), r) 154 | ) 155 | ) 156 | ) 157 | 158 | return pipe( 159 | processFn, 160 | T.chain((process) => 161 | accessQueueM((queue) => 162 | pipe( 163 | isRouterDraining, 164 | F.set(true), 165 | T.andThen(pipe(queue.take, T.chain(flow(process, T.fork)), T.forever)) 166 | ) 167 | ) 168 | ) 169 | ) 170 | } 171 | 172 | export type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE" | "OPTIONS" 173 | 174 | export function matchRegex(url: RegExp, methods: Method[] = []) { 175 | return (r: Request) => 176 | r.req.url 177 | ? methods.length === 0 178 | ? url.test(r.req.url) 179 | : r.req.method 180 | ? url.test(r.req.url) && 181 | (methods).includes(r.req.method.toUpperCase()) 182 | : false 183 | : false 184 | } 185 | -------------------------------------------------------------------------------- /articles/node/02.ts: -------------------------------------------------------------------------------- 1 | import "@effect-ts/core/Operators" 2 | 3 | import * as Arr from "@effect-ts/core/Classic/Array" 4 | import * as O from "@effect-ts/core/Classic/Option" 5 | import * as T from "@effect-ts/core/Effect" 6 | import * as F from "@effect-ts/core/Effect/Fiber" 7 | import * as L from "@effect-ts/core/Effect/Layer" 8 | import * as M from "@effect-ts/core/Effect/Managed" 9 | import * as Q from "@effect-ts/core/Effect/Queue" 10 | import * as Ref from "@effect-ts/core/Effect/Ref" 11 | import * as S from "@effect-ts/core/Effect/Stream" 12 | import { flow, pipe } from "@effect-ts/core/Function" 13 | import { tag } from "@effect-ts/core/Has" 14 | import type { _A } from "@effect-ts/core/Utils" 15 | import { transducer } from "@effect-ts/system/Stream/Transducer" 16 | import * as fs from "fs" 17 | import * as path from "path" 18 | 19 | export function readFileStreamBuffer(path: string) { 20 | return new S.Stream( 21 | M.gen(function* ($) { 22 | const nodeStream = yield* $( 23 | T.effectTotal(() => fs.createReadStream(path, { highWaterMark: 100 }))["|>"]( 24 | M.makeExit((rs) => 25 | T.effectTotal(() => { 26 | rs.close() 27 | console.debug("CLOSE CALLED") 28 | }) 29 | ) 30 | ) 31 | ) 32 | 33 | const queue = yield* $( 34 | Q.makeUnbounded, [Buffer]>>()["|>"]( 35 | M.makeExit((q) => q.shutdown) 36 | ) 37 | ) 38 | 39 | yield* $( 40 | T.effectTotal(() => { 41 | nodeStream.on("data", (chunk: Buffer) => { 42 | T.run(queue.offer(T.succeed([chunk]))) 43 | }) 44 | nodeStream.on("end", () => { 45 | T.run(queue.offer(T.fail(O.none))) 46 | }) 47 | nodeStream.on("error", (err) => { 48 | T.run(queue.offer(T.fail(O.some(err)))) 49 | }) 50 | })["|>"]( 51 | M.makeExit(() => 52 | T.effectTotal(() => { 53 | nodeStream.removeAllListeners() 54 | }) 55 | ) 56 | ) 57 | ) 58 | 59 | return queue.take["|>"](T.flatten) 60 | }) 61 | ) 62 | } 63 | 64 | const transduceMessages = transducer( 65 | M.gen(function* ($) { 66 | const leftover = yield* $(Ref.makeRef("")) 67 | 68 | return (o) => 69 | T.gen(function* ($) { 70 | if (O.isSome(o)) { 71 | yield* $( 72 | leftover["|>"]( 73 | Ref.update( 74 | (l) => `${l}${Buffer.concat(o.value as Buffer[]).toString("utf-8")}` 75 | ) 76 | ) 77 | ) 78 | } 79 | 80 | const current = yield* $(leftover.get) 81 | 82 | if (current.length === 0) { 83 | return [] 84 | } 85 | 86 | if (current.endsWith("\n")) { 87 | const output = current.split("\n") 88 | 89 | yield* $(leftover.set("")) 90 | 91 | return output 92 | } 93 | 94 | const split = current.split("\n") 95 | 96 | if (split.length === 1) { 97 | yield* $(leftover.set(split[0])) 98 | 99 | return [] 100 | } else { 101 | yield* $(leftover.set(split[split.length - 1])) 102 | 103 | const init = Arr.init(split) 104 | 105 | if (O.isSome(init)) { 106 | return init.value 107 | } 108 | } 109 | 110 | return [] 111 | }) 112 | }) 113 | ) 114 | 115 | export const makeMessageQueue = (path: string) => 116 | M.gen(function* ($) { 117 | const queue = yield* $( 118 | Q.makeUnbounded>()["|>"](M.makeExit((q) => q.shutdown)) 119 | ) 120 | 121 | const reader = yield* $( 122 | readFileStreamBuffer(path) 123 | ["|>"](S.aggregate(transduceMessages)) 124 | ["|>"](S.chain(flow(O.some, queue.offer, S.fromEffect))) 125 | ["|>"](S.runDrain) 126 | ["|>"](T.tap(() => queue.offer(O.none))) 127 | ["|>"](T.interruptible) 128 | ["|>"](T.fork) 129 | ["|>"](M.makeExit(F.interrupt)) 130 | ) 131 | 132 | return { queue, reader } 133 | }) 134 | 135 | export interface MessageQueue extends _A> {} 136 | 137 | export const MessageQueue = tag() 138 | 139 | export const LiveMessageQueue = (path: string) => 140 | L.fromManaged(MessageQueue)(makeMessageQueue(path)) 141 | 142 | export const makeProcessor = M.gen(function* ($) { 143 | const { queue } = yield* $(MessageQueue) 144 | 145 | const processor = yield* $( 146 | T.gen(function* (_) { 147 | while (true) { 148 | const message = yield* $(queue.take) 149 | 150 | switch (message._tag) { 151 | case "None": { 152 | return 153 | } 154 | case "Some": { 155 | yield* $( 156 | T.effectTotal(() => { 157 | console.log(message.value) 158 | }) 159 | ) 160 | } 161 | } 162 | } 163 | }) 164 | ["|>"](T.interruptible) 165 | ["|>"](T.fork) 166 | ["|>"](M.makeExit(F.interrupt)) 167 | ) 168 | 169 | return { processor } 170 | }) 171 | 172 | export interface Processor extends _A {} 173 | 174 | export const Processor = tag() 175 | 176 | export const LiveProcessor = L.fromManaged(Processor)(makeProcessor) 177 | 178 | export const program = T.gen(function* ($) { 179 | const { reader } = yield* $(MessageQueue) 180 | const { processor } = yield* $(Processor) 181 | 182 | yield* $( 183 | T.effectTotal(() => { 184 | console.log("RUNNING") 185 | }) 186 | ) 187 | 188 | yield* $(T.collectAllUnitPar([F.join(processor), F.join(reader)])) 189 | }) 190 | 191 | const cancel = pipe( 192 | program, 193 | T.provideSomeLayer( 194 | LiveProcessor["<+<"](LiveMessageQueue(path.join(__dirname, "messages.log"))) 195 | ), 196 | T.runMain 197 | ) 198 | 199 | process.on("SIGTERM", () => { 200 | cancel() 201 | }) 202 | 203 | process.on("SIGINT", () => { 204 | cancel() 205 | }) 206 | -------------------------------------------------------------------------------- /test/integration.test.ts: -------------------------------------------------------------------------------- 1 | import * as T from "@effect-ts/core/Effect" 2 | import * as Ex from "@effect-ts/core/Effect/Exit" 3 | import * as F from "@effect-ts/core/Effect/Fiber" 4 | import * as L from "@effect-ts/core/Effect/Layer" 5 | import * as M from "@effect-ts/core/Effect/Managed" 6 | import type { _A } from "@effect-ts/core/Utils" 7 | import { testRuntime } from "@effect-ts/jest/Runtime" 8 | import * as Lens from "@effect-ts/monocle/Lens" 9 | import { arbitrary } from "@effect-ts/morphic/FastCheck" 10 | import { tag } from "@effect-ts/system/Has" 11 | import * as fc from "fast-check" 12 | 13 | import { Crypto, CryptoLive, PBKDF2ConfigTest, verifyPassword } from "../src/crypto" 14 | import { 15 | Db, 16 | DbLive, 17 | PgClient, 18 | PgPoolLive, 19 | TestMigration, 20 | withPoolClient 21 | } from "../src/db" 22 | import { TestContainersLive } from "../src/dev/containers" 23 | import { PgConfigTest } from "../src/dev/db" 24 | import { isRouterDraining } from "../src/http" 25 | import { Credential, PasswordField } from "../src/model/credential" 26 | import { Email, EmailField, User } from "../src/model/user" 27 | import { ValidationError } from "../src/model/validation" 28 | import { 29 | createCredential, 30 | CredentialPersistence, 31 | updateCredential 32 | } from "../src/persistence/credential" 33 | import { register } from "../src/persistence/transactions" 34 | import { createUser, getUser, updateUser } from "../src/persistence/user" 35 | import { Main, PersistenceMain, ServerMain } from "../src/program" 36 | import { assertSuccess } from "./utils/assertions" 37 | 38 | export function makeAppFiber() { 39 | return Main["|>"](T.fork) 40 | ["|>"](M.makeInterruptible(F.interrupt)) 41 | ["|>"](M.map((fiber) => ({ fiber }))) 42 | } 43 | 44 | export interface AppFiber extends _A> {} 45 | 46 | export const AppFiber = tag() 47 | 48 | export const AppFiberTest = L.fromConstructorManaged(AppFiber)(makeAppFiber)() 49 | 50 | const CryptoTest = CryptoLive["<<<"](PBKDF2ConfigTest) 51 | 52 | const DbTest = DbLive("main") 53 | ["<<<"](TestMigration("main")) 54 | ["<+<"](PgPoolLive("main")) 55 | ["<<<"](PgConfigTest("main")("integration")) 56 | ["<<<"](TestContainersLive("integration")) 57 | 58 | const BootstrapTest = AppFiberTest["<+<"](PersistenceMain)["<+<"]( 59 | DbTest["+++"](ServerMain)["+++"](CryptoTest) 60 | ) 61 | 62 | describe("Integration Suite", () => { 63 | const { runPromiseExit } = testRuntime(BootstrapTest, { 64 | open: 30_000, 65 | close: 30_000 66 | }) 67 | 68 | describe("Bootstrap", () => { 69 | it("run simple query", async () => { 70 | const result = await T.gen(function* (_) { 71 | const { client } = yield* _(PgClient("main")) 72 | 73 | const result = yield* _( 74 | T.fromPromiseDie(() => client.query("SELECT $1::text as name", ["Michael"])) 75 | ) 76 | 77 | return result.rows[0].name 78 | }) 79 | ["|>"](withPoolClient("main")) 80 | ["|>"](runPromiseExit) 81 | 82 | expect(result).toEqual(Ex.succeed("Michael")) 83 | }) 84 | 85 | it("http server fiber is running", async () => { 86 | const result = await T.accessServiceM(AppFiber)((_) => 87 | _.fiber.getRef(isRouterDraining) 88 | )["|>"](runPromiseExit) 89 | 90 | expect(result).toEqual(Ex.succeed(true)) 91 | }) 92 | 93 | it("check users table structure", async () => { 94 | const result = await T.gen(function* (_) { 95 | const { client } = yield* _(PgClient("main")) 96 | 97 | const { rows } = yield* _( 98 | T.fromPromiseDie(() => 99 | client.query( 100 | "SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1::text;", 101 | ["users"] 102 | ) 103 | ) 104 | ) 105 | 106 | return rows 107 | }) 108 | ["|>"](withPoolClient("main")) 109 | ["|>"](runPromiseExit) 110 | 111 | expect(result).toEqual( 112 | Ex.succeed([ 113 | { table_name: "users", column_name: "id", data_type: "integer" }, 114 | { 115 | table_name: "users", 116 | column_name: "email", 117 | data_type: "text" 118 | }, 119 | { 120 | table_name: "users", 121 | column_name: "createdAt", 122 | data_type: "timestamp without time zone" 123 | }, 124 | { 125 | table_name: "users", 126 | column_name: "updatedAt", 127 | data_type: "timestamp without time zone" 128 | } 129 | ]) 130 | ) 131 | }) 132 | 133 | it("check credentials table structure", async () => { 134 | const result = await T.gen(function* (_) { 135 | const { client } = yield* _(PgClient("main")) 136 | 137 | const { rows } = yield* _( 138 | T.fromPromiseDie(() => 139 | client.query( 140 | "SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1::text;", 141 | ["credentials"] 142 | ) 143 | ) 144 | ) 145 | 146 | return rows 147 | }) 148 | ["|>"](withPoolClient("main")) 149 | ["|>"](runPromiseExit) 150 | 151 | expect(result).toEqual( 152 | Ex.succeed([ 153 | { 154 | column_name: "id", 155 | data_type: "integer", 156 | table_name: "credentials" 157 | }, 158 | { 159 | column_name: "userId", 160 | data_type: "integer", 161 | table_name: "credentials" 162 | }, 163 | { 164 | column_name: "hash", 165 | data_type: "text", 166 | table_name: "credentials" 167 | }, 168 | { 169 | column_name: "createdAt", 170 | data_type: "timestamp without time zone", 171 | table_name: "credentials" 172 | }, 173 | { 174 | table_name: "credentials", 175 | column_name: "updatedAt", 176 | data_type: "timestamp without time zone" 177 | } 178 | ]) 179 | ) 180 | }) 181 | }) 182 | 183 | describe("User Api", () => { 184 | it("creates a new user", async () => { 185 | const result = await createUser({ email: Email.wrap("ma@example.org") }) 186 | ["|>"](withPoolClient("main")) 187 | ["|>"](runPromiseExit) 188 | 189 | const nameAndId = User.lens["|>"](Lens.props("email", "id")) 190 | 191 | expect(result["|>"](Ex.map(nameAndId.get))).toEqual( 192 | Ex.succeed({ id: 1, email: "ma@example.org" }) 193 | ) 194 | }) 195 | 196 | it("fail to create a new user with an empty email", async () => { 197 | const result = await createUser({ email: Email.wrap("") }) 198 | ["|>"](withPoolClient("main")) 199 | ["|>"](runPromiseExit) 200 | 201 | expect(result).toEqual( 202 | Ex.fail( 203 | new ValidationError("email should be between 0 and 255 characters long") 204 | ) 205 | ) 206 | }) 207 | 208 | it("transactional dsl handles success/failure with commit/rollback", async () => { 209 | const result = await T.gen(function* (_) { 210 | const { transaction } = yield* _(Db("main")) 211 | 212 | return yield* _( 213 | T.tuple( 214 | createUser({ email: Email.wrap("USER_0@example.org") }), 215 | createUser({ email: Email.wrap("USER_1@example.org") }), 216 | createUser({ email: Email.wrap("USER_2@example.org") }) 217 | ) 218 | ["|>"](T.tap(() => T.fail("error"))) 219 | ["|>"](transaction) 220 | ) 221 | }) 222 | ["|>"](withPoolClient("main")) 223 | ["|>"](runPromiseExit) 224 | 225 | expect(result).toEqual(Ex.fail("error")) 226 | 227 | const userCount = T.gen(function* (_) { 228 | const { client } = yield* _(PgClient("main")) 229 | 230 | const result = yield* _( 231 | T.fromPromiseDie(() => 232 | client.query("SELECT COUNT(*) FROM users WHERE email LIKE 'USER_%'") 233 | ) 234 | ) 235 | 236 | return parseInt(result.rows[0].count) 237 | })["|>"](withPoolClient("main")) 238 | 239 | const count = await userCount["|>"](runPromiseExit) 240 | 241 | expect(count).toEqual(Ex.succeed(0)) 242 | 243 | const resultSuccess = await T.gen(function* (_) { 244 | const { transaction } = yield* _(Db("main")) 245 | 246 | return yield* _( 247 | transaction( 248 | T.tuple( 249 | createUser({ email: Email.wrap("USER_0@example.org") }), 250 | createUser({ email: Email.wrap("USER_1@example.org") }), 251 | createUser({ email: Email.wrap("USER_2@example.org") }) 252 | ) 253 | ) 254 | ) 255 | }) 256 | ["|>"](withPoolClient("main")) 257 | ["|>"](runPromiseExit) 258 | 259 | assertSuccess(resultSuccess) 260 | expect(resultSuccess.value.map((_) => [_.email, _.id])).toEqual([ 261 | ["USER_0@example.org", 5], 262 | ["USER_1@example.org", 6], 263 | ["USER_2@example.org", 7] 264 | ]) 265 | 266 | const countSuccess = await userCount["|>"](runPromiseExit) 267 | 268 | assertSuccess(countSuccess) 269 | expect(countSuccess.value).toEqual(3) 270 | }) 271 | 272 | it("get user", async () => { 273 | const result = await getUser({ id: 5 }) 274 | ["|>"](T.map((_) => _.email)) 275 | ["|>"](withPoolClient("main")) 276 | ["|>"](runPromiseExit) 277 | 278 | expect(result).toEqual(Ex.succeed("USER_0@example.org")) 279 | }) 280 | 281 | it("creates and updates user", async () => { 282 | const result = await createUser({ 283 | email: Email.wrap("OldName@example.org") 284 | }) 285 | ["|>"]( 286 | T.chain((user) => 287 | updateUser({ ...user, email: Email.wrap("NewEmail@example.org") }) 288 | ) 289 | ) 290 | ["|>"](T.map((_) => _.email)) 291 | ["|>"](withPoolClient("main")) 292 | ["|>"](runPromiseExit) 293 | 294 | expect(result).toEqual(Ex.succeed("NewEmail@example.org")) 295 | }) 296 | }) 297 | 298 | describe("Credential Api", () => { 299 | it("creates a credential", async () => { 300 | const result = await createCredential({ userId: 5, password: "helloworld000" }) 301 | ["|>"](withPoolClient("main")) 302 | ["|>"](runPromiseExit) 303 | 304 | const id = Credential.lens["|>"](Lens.prop("id")) 305 | const hash = Credential.lens["|>"](Lens.prop("hash")) 306 | 307 | expect(result["|>"](Ex.map(id.get))).toEqual(Ex.succeed(1)) 308 | 309 | const verify = await T.done(result) 310 | ["|>"](T.map(hash.get)) 311 | ["|>"](T.chain((_) => verifyPassword("helloworld000", _))) 312 | ["|>"](runPromiseExit) 313 | 314 | expect(verify).toEqual(Ex.unit) 315 | }) 316 | 317 | it("update a credential", async () => { 318 | const result = await updateCredential({ 319 | id: 1, 320 | userId: 105, 321 | password: "helloworld001" 322 | }) 323 | ["|>"](withPoolClient("main")) 324 | ["|>"](runPromiseExit) 325 | 326 | const id = Credential.lens["|>"](Lens.prop("id")) 327 | const hash = Credential.lens["|>"](Lens.prop("hash")) 328 | 329 | expect(result["|>"](Ex.map(id.get))).toEqual(Ex.succeed(1)) 330 | 331 | const verify = await T.done(result) 332 | ["|>"](T.map(hash.get)) 333 | ["|>"](T.chain((_) => verifyPassword("helloworld001", _))) 334 | ["|>"](runPromiseExit) 335 | 336 | expect(verify).toEqual(Ex.unit) 337 | }) 338 | }) 339 | 340 | describe("Generative", () => { 341 | it("create arbitrary users with credentials", () => 342 | fc.assert( 343 | fc.asyncProperty( 344 | arbitrary(EmailField), 345 | arbitrary(PasswordField), 346 | async ({ email }, { password }) => { 347 | const verify = await runPromiseExit( 348 | T.gen(function* (_) { 349 | const { getCredentialByUserId } = yield* _(CredentialPersistence) 350 | const { verifyPassword } = yield* _(Crypto) 351 | 352 | const user = yield* _(register({ email, password })) 353 | const cred = yield* _(getCredentialByUserId(user.id)) 354 | 355 | yield* _(verifyPassword(password, cred.hash)) 356 | })["|>"](withPoolClient("main")) 357 | ) 358 | 359 | expect(verify).toEqual(Ex.unit) 360 | } 361 | ), 362 | { endOnFailure: true, timeout: 1000 } 363 | )) 364 | }) 365 | }) 366 | --------------------------------------------------------------------------------