├── .envrc ├── .eslintrc.cjs ├── .gitignore ├── .gitpod.yml ├── .vscode ├── settings.json └── snippets.code-snippets ├── LICENSE ├── README.md ├── flake.lock ├── flake.nix ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── src ├── app │ ├── globals.css │ ├── icon1.png │ ├── icon2.png │ ├── layout.tsx │ └── page.tsx ├── assets │ ├── deferred.png │ ├── effect-days.svg │ ├── effect_runtime.png │ ├── maxwell_brown.jpeg │ ├── michael_arnaldi.png │ ├── queue.png │ ├── scope_extend.png │ ├── scope_fork.png │ ├── tim_smart.jpg │ ├── trace_span.png │ └── trace_waterfall.png └── components │ ├── CodeSample.tsx │ ├── EffectDaysIcon.tsx │ ├── InlineCode.tsx │ ├── Presentation.tsx │ ├── SessionSchedule.tsx │ └── atom-one-dark.css ├── tailwind.config.ts ├── tsconfig.json └── workshop ├── .eslintrc.cjs ├── exercises ├── session-01 │ ├── exercise-00.ts │ ├── exercise-01.ts │ └── project.ts ├── session-02 │ ├── exercise-00.ts │ ├── exercise-01.ts │ ├── exercise-02.ts │ ├── exercise-03.ts │ └── project.ts ├── session-03 │ ├── exercise-00.ts │ ├── exercise-01.ts │ ├── exercise-02.ts │ ├── exercise-03.ts │ └── project.ts └── session-04 │ ├── exercise-00.ts │ ├── exercise-01.ts │ └── exercise-02.ts ├── samples ├── session-01 │ ├── execution-boundaries.ts │ ├── multi-shot-callbacks.ts │ ├── single-shot-callbacks.ts │ └── wrappers.ts ├── session-02 │ └── deferred.ts └── session-04 │ ├── counter.ts │ ├── frequency.ts │ ├── gauge.ts │ ├── histogram.ts │ └── summary.ts ├── solutions ├── session-01 │ ├── exercise-00.ts │ ├── exercise-01.ts │ └── project │ │ ├── advanced.ts │ │ ├── stage-01.ts │ │ ├── stage-02.ts │ │ └── stage-03.ts ├── session-02 │ ├── exercise-00.ts │ ├── exercise-01.ts │ ├── exercise-02 │ │ ├── advanced.ts │ │ └── basic.ts │ ├── exercise-03.ts │ └── project │ │ ├── stage-01.ts │ │ ├── stage-02.ts │ │ └── stage-03.ts ├── session-03 │ ├── exercise-00 │ │ ├── basic.ts │ │ └── improved.ts │ ├── exercise-01.ts │ ├── exercise-02.ts │ ├── exercise-03.ts │ └── project │ │ ├── stage-01.ts │ │ ├── stage-02.ts │ │ └── stage-03.ts └── session-04 │ ├── exercise-00.ts │ ├── exercise-01.ts │ └── exercise-02.ts └── tsconfig.json /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | ignorePatterns: ["dist", "*.mjs", "docs", "*.md", "*.mdx"], 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 | "next/core-web-vitals", 22 | "plugin:@typescript-eslint/eslint-recommended", 23 | "plugin:@typescript-eslint/recommended", 24 | "plugin:@effect/recommended" 25 | ], 26 | plugins: ["deprecation", "import", "sort-destructure-keys", "simple-import-sort", "codegen"], 27 | rules: { 28 | "codegen/codegen": "error", 29 | "no-fallthrough": "off", 30 | "no-irregular-whitespace": "off", 31 | "object-shorthand": "error", 32 | "prefer-destructuring": "off", 33 | "sort-imports": "off", 34 | "no-unused-vars": "off", 35 | "prefer-rest-params": "off", 36 | "prefer-spread": "off", 37 | "import/first": "error", 38 | "import/no-cycle": "error", 39 | "import/newline-after-import": "error", 40 | "import/no-duplicates": "error", 41 | "import/no-unresolved": "off", 42 | "import/order": "off", 43 | "simple-import-sort/imports": "off", 44 | "sort-destructure-keys/sort-destructure-keys": "error", 45 | "deprecation/deprecation": "off", 46 | "@typescript-eslint/array-type": ["warn", { "default": "generic", "readonly": "generic" }], 47 | "@typescript-eslint/member-delimiter-style": 0, 48 | "@typescript-eslint/no-non-null-assertion": "off", 49 | "@typescript-eslint/ban-types": "off", 50 | "@typescript-eslint/no-explicit-any": "off", 51 | "@typescript-eslint/no-empty-interface": "off", 52 | "@typescript-eslint/consistent-type-imports": "warn", 53 | "@typescript-eslint/no-unused-vars": ["error", { 54 | "argsIgnorePattern": "^_", 55 | "varsIgnorePattern": "^_" 56 | }], 57 | "@typescript-eslint/ban-ts-comment": "off", 58 | "@typescript-eslint/camelcase": "off", 59 | "@typescript-eslint/explicit-function-return-type": "off", 60 | "@typescript-eslint/explicit-module-boundary-types": "off", 61 | "@typescript-eslint/interface-name-prefix": "off", 62 | "@typescript-eslint/no-array-constructor": "off", 63 | "@typescript-eslint/no-use-before-define": "off", 64 | "@typescript-eslint/no-namespace": "off", 65 | "@effect/dprint": [ 66 | "error", 67 | { 68 | config: { 69 | "indentWidth": 2, 70 | "lineWidth": 100, 71 | "semiColons": "asi", 72 | "quoteStyle": "alwaysDouble", 73 | "trailingCommas": "never", 74 | "operatorPosition": "maintain", 75 | "arrowFunction.useParentheses": "force" 76 | } 77 | } 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # direnv 10 | .direnv 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | store 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: Install Packages 3 | init: pnpm install 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.preferences.importModuleSpecifier": "non-relative", 4 | "typescript.enablePromptUseWorkspaceTsdk": true, 5 | "editor.formatOnSave": true, 6 | "eslint.format.enable": true, 7 | "[json]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[markdown]": { 11 | "editor.defaultFormatter": "yzhang.markdown-all-in-one" 12 | }, 13 | "[javascript]": { 14 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 15 | }, 16 | "[javascriptreact]": { 17 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 18 | }, 19 | "[typescript]": { 20 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 21 | }, 22 | "[typescriptreact]": { 23 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 24 | }, 25 | "eslint.validate": [ 26 | "markdown", 27 | "javascript", 28 | "typescript" 29 | ], 30 | "editor.codeActionsOnSave": { 31 | "source.fixAll.eslint": "explicit" 32 | }, 33 | "editor.quickSuggestions": { 34 | "other": true, 35 | "comments": false, 36 | "strings": false 37 | }, 38 | "editor.acceptSuggestionOnCommitCharacter": true, 39 | "editor.acceptSuggestionOnEnter": "on", 40 | "editor.quickSuggestionsDelay": 10, 41 | "editor.suggestOnTriggerCharacters": true, 42 | "editor.tabCompletion": "off", 43 | "editor.suggest.localityBonus": true, 44 | "editor.suggestSelection": "recentlyUsed", 45 | "editor.wordBasedSuggestions": "matchingDocuments", 46 | "editor.parameterHints.enabled": true, 47 | "files.watcherExclude": { 48 | "**/target": true 49 | }, 50 | "files.insertFinalNewline": true 51 | } 52 | -------------------------------------------------------------------------------- /.vscode/snippets.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Gen Function _": { 3 | "prefix": "gg", 4 | "body": [ 5 | "Effect.gen(function*(_) {", 6 | " $0", 7 | "})" 8 | ], 9 | "description": "Generator Function with a _ parameter" 10 | }, 11 | "Gen Yield _": { 12 | "prefix": "yy", 13 | "body": [ 14 | "yield* _($0)" 15 | ], 16 | "description": "Yield generator calling _()" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present The Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Advanced Effect Workshop 2 | 3 | Welcome to the Advanced Effect Workshop! We’re extremely excited to have you all here! 4 | 5 | The goal of this workshop is to explore advanced patterns and strategies that we have observed are frequently utilized when building non-trivial Effect programs and applications. We will focus on learning common design patterns that will build in complexity throughout the workshop and will provide you with advanced building blocks to power your Effect applications. 6 | 7 | ## Get the Code 8 | 9 | To be able to participate in the workshop exercises, it is best that you clone the project. 10 | 11 | Once cloned, all of the content relevant to the workshop can be found under the [workshop](workshop/) directory. 12 | 13 | There are three primary methods which you can use to get access to this project: 14 | 15 | | Method | Description | 16 | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | 17 | | Clone Locally | Clone the project locally (optionally use Nix to install dependencies) | 18 | | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/IMax153/advanced-effect-workshop) | Open the project in [Gitpod](https://gitpod.io/) | 19 | | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/fork/github/IMax153/advanced-effect-workshop) | Open the project in [Stackblitz](https://stackblitz.com/) | 20 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1694529238, 9 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1698931758, 24 | "narHash": "sha256-pwl9xS9JFMXXR1lUP/QOqO9hiZKukEcVUU1A0DKQwi4=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "b644d97bda6dae837d577e28383c10aa51e5e2d2", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs = { 4 | url = "github:nixos/nixpkgs/nixpkgs-unstable"; 5 | }; 6 | 7 | flake-utils = { 8 | url = "github:numtide/flake-utils"; 9 | }; 10 | }; 11 | 12 | outputs = { 13 | self, 14 | nixpkgs, 15 | flake-utils, 16 | ... 17 | }: 18 | flake-utils.lib.eachDefaultSystem (system: let 19 | pkgs = nixpkgs.legacyPackages.${system}; 20 | corepackEnable = pkgs.runCommand "corepack-enable" {} '' 21 | mkdir -p $out/bin 22 | ${pkgs.nodejs_20}/bin/corepack enable --install-directory $out/bin 23 | ''; 24 | in { 25 | formatter = pkgs.alejandra; 26 | 27 | devShells = { 28 | default = pkgs.mkShell { 29 | buildInputs = with pkgs; [ 30 | nodejs_20 31 | corepackEnable 32 | ]; 33 | }; 34 | }; 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("next").NextConfig} */ 2 | const nextConfig = { 3 | // Optionally, add any other Next.js config below 4 | } 5 | 6 | export default nextConfig 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "effect-advanced-workshop", 3 | "version": "0.0.0", 4 | "private": true, 5 | "packageManager": "pnpm@8.15.0", 6 | "description": "An OpenAI client written with Effect", 7 | "engines": { 8 | "node": ">=18.0.0" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/effect-ts/openai.git" 13 | }, 14 | "author": "Maxwell Brown", 15 | "license": "MIT", 16 | "bugs": { 17 | "url": "https://github.com/effect-ts/openai/issues" 18 | }, 19 | "homepage": "https://github.com/effect-ts/openai", 20 | "scripts": { 21 | "dev": "next dev", 22 | "build": "next build", 23 | "start": "next start", 24 | "lint": "next lint" 25 | }, 26 | "dependencies": { 27 | "@effect/experimental": "^0.9.17", 28 | "@effect/opentelemetry": "^0.31.10", 29 | "@effect/platform": "^0.46.0", 30 | "@effect/platform-node": "^0.44.8", 31 | "@effect/schema": "^0.63.0", 32 | "@opentelemetry/api": "^1.7.0", 33 | "@opentelemetry/exporter-prometheus": "^0.48.0", 34 | "@opentelemetry/sdk-metrics": "^1.21.0", 35 | "@opentelemetry/sdk-trace-node": "^1.21.0", 36 | "@opentelemetry/sdk-trace-web": "^1.21.0", 37 | "@parcel/watcher": "^2.4.0", 38 | "@tailwindcss/typography": "^0.5.10", 39 | "body-parser": "^1.20.2", 40 | "classnames": "^2.5.1", 41 | "effect": "2.4.0", 42 | "express": "^4.18.2", 43 | "graphql-request": "^6.1.0", 44 | "html-react-parser": "^5.1.7", 45 | "next": "14.1.0", 46 | "openai": "^4.28.0", 47 | "react": "^18", 48 | "react-dom": "^18", 49 | "reveal.js": "^5.0.4", 50 | "sharp": "^0.33.2", 51 | "swr": "^2.2.4" 52 | }, 53 | "devDependencies": { 54 | "@changesets/changelog-github": "^0.5.0", 55 | "@changesets/cli": "^2.27.1", 56 | "@effect/eslint-plugin": "^0.1.2", 57 | "@effect/language-service": "^0.1.0", 58 | "@types/body-parser": "^1.19.5", 59 | "@types/express": "^4.17.21", 60 | "@types/node": "^20.11.19", 61 | "@types/react": "^18.2.57", 62 | "@types/react-dom": "^18", 63 | "@types/reveal.js": "^4.4.8", 64 | "@typescript-eslint/eslint-plugin": "^7.0.2", 65 | "@typescript-eslint/parser": "^7.0.2", 66 | "autoprefixer": "^10.0.1", 67 | "eslint": "^8.56.0", 68 | "eslint-config-next": "14.1.0", 69 | "eslint-import-resolver-typescript": "^3.6.1", 70 | "eslint-plugin-codegen": "^0.23.0", 71 | "eslint-plugin-deprecation": "^2.0.0", 72 | "eslint-plugin-import": "^2.29.1", 73 | "eslint-plugin-simple-import-sort": "^12.0.0", 74 | "eslint-plugin-sort-destructure-keys": "^1.5.0", 75 | "postcss": "^8", 76 | "rimraf": "^5.0.5", 77 | "tailwindcss": "^3.3.0", 78 | "tsx": "^4.7.1", 79 | "typescript": "^5.3.3" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | @layer utilities { 20 | .text-balance { 21 | text-wrap: balance; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/icon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/app/icon1.png -------------------------------------------------------------------------------- /src/app/icon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/app/icon2.png -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Roboto_Mono } from "next/font/google" 4 | import React from "react" 5 | import "./globals.css" 6 | 7 | const inter = Roboto_Mono({ subsets: ["latin"] }) 8 | 9 | export default function RootLayout({ 10 | children 11 | }: Readonly<{ 12 | children: React.ReactNode 13 | }>) { 14 | return ( 15 | 16 | 17 | {children} 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/assets/deferred.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/deferred.png -------------------------------------------------------------------------------- /src/assets/effect-days.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/assets/effect_runtime.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/effect_runtime.png -------------------------------------------------------------------------------- /src/assets/maxwell_brown.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/maxwell_brown.jpeg -------------------------------------------------------------------------------- /src/assets/michael_arnaldi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/michael_arnaldi.png -------------------------------------------------------------------------------- /src/assets/queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/queue.png -------------------------------------------------------------------------------- /src/assets/scope_extend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/scope_extend.png -------------------------------------------------------------------------------- /src/assets/scope_fork.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/scope_fork.png -------------------------------------------------------------------------------- /src/assets/tim_smart.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/tim_smart.jpg -------------------------------------------------------------------------------- /src/assets/trace_span.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/trace_span.png -------------------------------------------------------------------------------- /src/assets/trace_waterfall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IMax153/advanced-effect-workshop/7f366837319f9088ecf3c1ef4469cdd00a017150/src/assets/trace_waterfall.png -------------------------------------------------------------------------------- /src/components/CodeSample.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames" 2 | import React from "react" 3 | 4 | export declare namespace CodeSample { 5 | export interface Props { 6 | readonly className?: string 7 | readonly lineNumbers?: string | boolean 8 | } 9 | } 10 | 11 | const CodeSample: React.FC> = ({ 12 | children, 13 | className = "", 14 | lineNumbers = true 15 | }) => ( 16 |
17 |     
23 |       {children}
24 |     
25 |   
26 | ) 27 | 28 | CodeSample.displayName = "CodeSample" 29 | 30 | export default CodeSample 31 | -------------------------------------------------------------------------------- /src/components/EffectDaysIcon.tsx: -------------------------------------------------------------------------------- 1 | import EffectDaysSvg from "@/assets/effect-days.svg" 2 | import Image from "next/image" 3 | 4 | export declare namespace EffectDaysIcon { 5 | export interface Props { 6 | readonly className?: string 7 | } 8 | } 9 | 10 | const EffectDaysIcon: React.FC = ({ className }) => ( 11 | The Effect logo followed by the text 'Effect Days' 16 | ) 17 | 18 | EffectDaysIcon.displayName = "EffectDaysIcon" 19 | 20 | export default EffectDaysIcon 21 | -------------------------------------------------------------------------------- /src/components/InlineCode.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export declare namespace InlineCode { 4 | export interface Props {} 5 | } 6 | 7 | const InlineCode: React.FC> = ({ children }) => ( 8 | {children} 9 | ) 10 | 11 | InlineCode.displayName = "InlineCode" 12 | 13 | export default InlineCode 14 | -------------------------------------------------------------------------------- /src/components/Presentation.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import "reveal.js/dist/reveal.css" 4 | import "reveal.js/dist/theme/black.css" 5 | import "./atom-one-dark.css" 6 | import React from "react" 7 | import RevealJS from "reveal.js" 8 | import EffectDaysIcon from "./EffectDaysIcon" 9 | // @ts-expect-error 10 | import RevealHighlight from "reveal.js/plugin/highlight/highlight.esm.js" 11 | // @ts-expect-error 12 | import RevealMath from "reveal.js/plugin/math/math.esm.js" 13 | // @ts-expect-error 14 | import RevealNotes from "reveal.js/plugin/notes/notes.esm.js" 15 | // @ts-expect-error 16 | import RevealZoom from "reveal.js/plugin/zoom/zoom.esm.js" 17 | 18 | export declare namespace Presentation { 19 | export interface Props {} 20 | } 21 | 22 | const Presentation: React.FC> = ({ children }) => { 23 | const ref = React.useRef(null) 24 | const reveal = React.useRef() 25 | 26 | React.useEffect(() => { 27 | const isInitializing = ref.current?.classList.contains("reveal") 28 | 29 | if (isInitializing) { 30 | return 31 | } 32 | 33 | ref.current!.classList.add("reveal") 34 | 35 | reveal.current = new RevealJS(ref.current!, { 36 | hash: true, 37 | controls: false, 38 | progress: false, 39 | slideNumber: false, 40 | transition: "none", 41 | pdfSeparateFragments: false, 42 | plugins: [ 43 | RevealHighlight, 44 | RevealZoom, 45 | RevealNotes, 46 | RevealMath 47 | ] 48 | }) 49 | 50 | reveal.current.initialize().then(() => { 51 | // good place for event handlers and plugin setups 52 | }) 53 | 54 | return () => { 55 | if (!reveal.current) { 56 | return 57 | } 58 | try { 59 | reveal.current!.destroy() 60 | } catch (e) { 61 | console.warn("Could not destroy RevealJS instance") 62 | } 63 | } 64 | }, []) 65 | 66 | return ( 67 |
68 |
69 | {children} 70 |
71 | 72 |
73 | ) 74 | } 75 | 76 | Presentation.displayName = "Presentation" 77 | 78 | export default Presentation 79 | -------------------------------------------------------------------------------- /src/components/SessionSchedule.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export declare namespace SessionSchedule { 4 | export interface Props { 5 | readonly title: string 6 | readonly from: string 7 | readonly to: string 8 | readonly objectives: ReadonlyArray 9 | readonly project: string 10 | } 11 | } 12 | 13 | const SessionSchedule: React.FC = ({ 14 | from, 15 | objectives, 16 | project, 17 | title, 18 | to 19 | }) => ( 20 |
21 |
22 |

{title}

23 |
24 |
25 |
26 |
27 |
Time
28 |
{from} - {to}
29 |
30 |
31 |
Goals
32 |
33 |
    34 | {objectives.map((objective, index) => ( 35 |
  • {objective}
  • 36 | ))} 37 |
38 |
39 |
40 |
41 |
Session Project
42 |
{project}
43 |
44 |
45 |
46 |
47 | ) 48 | 49 | SessionSchedule.displayName = "SessionSchedule" 50 | 51 | export default SessionSchedule 52 | -------------------------------------------------------------------------------- /src/components/atom-one-dark.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Atom One Dark by Daniel Gamage 4 | Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax 5 | 6 | base: #282c34 7 | mono-1: #abb2bf 8 | mono-2: #818896 9 | mono-3: #5c6370 10 | hue-1: #56b6c2 11 | hue-2: #61aeee 12 | hue-3: #c678dd 13 | hue-4: #98c379 14 | hue-5: #e06c75 15 | hue-5-2: #be5046 16 | hue-6: #d19a66 17 | hue-6-2: #e6c07b 18 | 19 | */ 20 | 21 | .hljs { 22 | color: #abb2bf; 23 | background: #191919; 24 | } 25 | 26 | .hljs-comment, 27 | .hljs-quote { 28 | color: #5c6370; 29 | font-style: italic; 30 | } 31 | 32 | .hljs-doctag, 33 | .hljs-keyword, 34 | .hljs-formula { 35 | color: #c678dd; 36 | } 37 | 38 | .hljs-section, 39 | .hljs-name, 40 | .hljs-selector-tag, 41 | .hljs-deletion, 42 | .hljs-subst { 43 | color: #e06c75; 44 | } 45 | 46 | .hljs-literal { 47 | color: #56b6c2; 48 | } 49 | 50 | .hljs-string, 51 | .hljs-regexp, 52 | .hljs-addition, 53 | .hljs-attribute, 54 | .hljs-meta .hljs-string { 55 | color: #98c379; 56 | } 57 | 58 | .hljs-attr, 59 | .hljs-variable, 60 | .hljs-template-variable, 61 | .hljs-type, 62 | .hljs-selector-class, 63 | .hljs-selector-attr, 64 | .hljs-selector-pseudo, 65 | .hljs-number { 66 | color: #d19a66; 67 | } 68 | 69 | .hljs-symbol, 70 | .hljs-bullet, 71 | .hljs-link, 72 | .hljs-meta, 73 | .hljs-selector-id, 74 | .hljs-title { 75 | color: #61aeee; 76 | } 77 | 78 | .hljs-built_in, 79 | .hljs-title.class_, 80 | .hljs-class .hljs-title { 81 | color: #e6c07b; 82 | } 83 | 84 | .hljs-emphasis { 85 | font-style: italic; 86 | } 87 | 88 | .hljs-strong { 89 | font-weight: bold; 90 | } 91 | 92 | .hljs-link { 93 | text-decoration: underline; 94 | } 95 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}" 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))" 14 | } 15 | } 16 | }, 17 | plugins: [ 18 | require("@tailwindcss/typography") 19 | ] 20 | } 21 | 22 | export default config 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": [ 26 | "./src/*" 27 | ] 28 | } 29 | }, 30 | "include": [ 31 | "next-env.d.ts", 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | ".next/types/**/*.ts" 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /workshop/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | ignorePatterns: ["dist", "*.mjs", "docs", "*.md", "*.mdx"], 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 | "next/core-web-vitals", 22 | "plugin:@typescript-eslint/eslint-recommended", 23 | "plugin:@typescript-eslint/recommended", 24 | "plugin:@effect/recommended" 25 | ], 26 | plugins: ["deprecation", "import", "sort-destructure-keys", "simple-import-sort", "codegen"], 27 | rules: { 28 | "codegen/codegen": "error", 29 | "no-fallthrough": "off", 30 | "no-irregular-whitespace": "off", 31 | "object-shorthand": "error", 32 | "prefer-destructuring": "off", 33 | "sort-imports": "off", 34 | "no-unused-vars": "off", 35 | "prefer-rest-params": "off", 36 | "prefer-spread": "off", 37 | "import/first": "error", 38 | "import/no-cycle": "error", 39 | "import/newline-after-import": "error", 40 | "import/no-duplicates": "error", 41 | "import/no-unresolved": "off", 42 | "import/order": "off", 43 | "simple-import-sort/imports": "off", 44 | "sort-destructure-keys/sort-destructure-keys": "error", 45 | "deprecation/deprecation": "off", 46 | "@typescript-eslint/array-type": ["warn", { "default": "generic", "readonly": "generic" }], 47 | "@typescript-eslint/member-delimiter-style": 0, 48 | "@typescript-eslint/no-non-null-assertion": "off", 49 | "@typescript-eslint/ban-types": "off", 50 | "@typescript-eslint/no-explicit-any": "off", 51 | "@typescript-eslint/no-empty-interface": "off", 52 | "@typescript-eslint/consistent-type-imports": "warn", 53 | "@typescript-eslint/no-unused-vars": ["error", { 54 | "argsIgnorePattern": "^_", 55 | "varsIgnorePattern": "^_" 56 | }], 57 | "@typescript-eslint/ban-ts-comment": "off", 58 | "@typescript-eslint/camelcase": "off", 59 | "@typescript-eslint/explicit-function-return-type": "off", 60 | "@typescript-eslint/explicit-module-boundary-types": "off", 61 | "@typescript-eslint/interface-name-prefix": "off", 62 | "@typescript-eslint/no-array-constructor": "off", 63 | "@typescript-eslint/no-use-before-define": "off", 64 | "@typescript-eslint/no-namespace": "off", 65 | "@effect/dprint": [ 66 | "error", 67 | { 68 | config: { 69 | "indentWidth": 2, 70 | "lineWidth": 100, 71 | "semiColons": "asi", 72 | "quoteStyle": "alwaysDouble", 73 | "trailingCommas": "never", 74 | "operatorPosition": "maintain", 75 | "arrowFunction.useParentheses": "force" 76 | } 77 | } 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /workshop/exercises/session-01/exercise-00.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "effect" 2 | 3 | // Exercise Summary: 4 | // 5 | // The following exercise will explore how we can utilize the `Effect.async*` 6 | // family of constructors to import asynchronous callbacks into an Effect. You will 7 | // need to implement a `sleep` function which suspends a fiber for the specified 8 | // number of milliseconds before resuming execution. 9 | 10 | export const MAX_SET_TIMEOUT_MILLIS = 2 ** 31 - 1 11 | 12 | declare const sleep: (millis: number) => Effect.Effect 13 | // Implement the logic to suspend the fiber for the specified number of 14 | // milliseconds before allowing execution to resume. Your implementation should: 15 | // - utilize `setTimeout` to implement the delay 16 | // - utilize the `Effect.async*` combinators to handle the `setTimeout` callback 17 | // Bonus: 18 | // - for bonus points, your implementation should also properly handle if the 19 | // fiber that is sleeping is interrupted 20 | 21 | const program = Effect.gen(function*(_) { 22 | const millis = 1_000 23 | yield* _(Effect.log(`Sleeping for ${millis} milliseconds...`)) 24 | yield* _(sleep(millis)) 25 | yield* _(Effect.log("Resuming execution!")) 26 | }) 27 | 28 | program.pipe( 29 | Effect.tapErrorCause(Effect.logError), 30 | Effect.runFork 31 | ) 32 | -------------------------------------------------------------------------------- /workshop/exercises/session-01/exercise-01.ts: -------------------------------------------------------------------------------- 1 | import { Console, Data, Effect, Random, Schedule, Stream } from "effect" 2 | import { EventEmitter } from "node:events" 3 | 4 | // Exercise Summary: 5 | // 6 | // The following exercise will explore how we can utilize the `Stream.async*` 7 | // family of constructors to import asynchronous callbacks into a Stream. You will 8 | // need to implement a `captureEvents` function which takes an `EventEmitter` 9 | // and captures both the errors and values emitted by the `EventEmitter`'s 10 | // `"emission"` event. 11 | 12 | declare const captureEvents: ( 13 | emitter: EventEmitter, 14 | eventName: string 15 | ) => Stream.Stream 16 | // Complete the implementation of the `captureEvents` method. Your implementation should: 17 | // - Handle pushing successful `Event` emissions to the stream 18 | // - Failing the stream if an `EmissionError` is emitted 19 | 20 | // ============================================================================= 21 | // Event Emitter 22 | // ============================================================================= 23 | 24 | class Event extends Data.TaggedClass("Event")<{ 25 | readonly value: number 26 | }> {} 27 | 28 | class EmissionError extends Data.TaggedError("EmissionError")<{ 29 | readonly message: string 30 | }> {} 31 | 32 | const emitEvents = (emitter: EventEmitter, eventName: string, eventCount: number) => 33 | Random.next.pipe( 34 | Effect.flatMap((value) => 35 | Effect.sync(() => { 36 | if (value < 0.1) { 37 | const error = new EmissionError({ 38 | message: `Received invalid value: ${value}` 39 | }) 40 | emitter.emit(eventName, error) 41 | } else { 42 | emitter.emit(eventName, null, new Event({ value })) 43 | } 44 | }) 45 | ), 46 | Effect.schedule( 47 | Schedule.recurs(eventCount).pipe( 48 | Schedule.intersect(Schedule.exponential("10 millis")) 49 | ) 50 | ) 51 | ) 52 | 53 | const program = Effect.gen(function*(_) { 54 | const emitter = yield* _(Effect.sync(() => new EventEmitter())) 55 | 56 | yield* _(Effect.fork(emitEvents(emitter, "emission", 20))) 57 | 58 | yield* _( 59 | captureEvents(emitter, "emission"), 60 | Stream.tap((event) => Console.log(event)), 61 | Stream.tapError((error) => Console.log(error)), 62 | Stream.runDrain 63 | ) 64 | }) 65 | 66 | program.pipe( 67 | Effect.tapErrorCause(Effect.logError), 68 | Effect.runFork 69 | ) 70 | -------------------------------------------------------------------------------- /workshop/exercises/session-01/project.ts: -------------------------------------------------------------------------------- 1 | import * as Schema from "@effect/schema/Schema" 2 | import bodyParser from "body-parser" 3 | import { Cause, Context, Effect, HashMap, Layer, Option, ReadonlyArray, Ref } from "effect" 4 | import express from "express" 5 | 6 | // In this session project, we will build a simple Express REST API server that 7 | // performs CRUD operations against a `TodoRepository`. The implementations of 8 | // the `Todo` models, the `TodoRepository`, and the `Express` service have already 9 | // been provided for you. 10 | // 11 | // This project will be divided into three stages: 12 | // 13 | // Stage 1: 14 | // 15 | // In this stage, the goal will be to implement the listening functionality of 16 | // the Express server. Your implementation should: 17 | // 18 | // - Use the `Express` service to gain access to the Express application 19 | // - Properly manage the open / close lifecycle of the Express server 20 | // - Utilize `Effect.log` to log a message to the console after the server 21 | // has started listening for requests 22 | // 23 | // Hints: 24 | // - To be able to `Effect.log` inside the `listen` callback, it may be helpful 25 | // to use `Runtime` 26 | // 27 | // Stage 2: 28 | // 29 | // In this stage, the goal will be to implement a single `"GET /todos/:id"` route 30 | // for our server. Your implementation should: 31 | // 32 | // - Implement the layer which adds the specified route to the Express application 33 | // - If the `Todo` specified in the request is found, return the `Todo` as JSON 34 | // - If the `Todo` specified in the request is not found, return a `404` status 35 | // code along with the message `"Todo ${todoId} not found"` 36 | // 37 | // Hints: 38 | // - To be able to implement Effect logic inside an Express request handler, it 39 | // may be helpful to use `Runtime` 40 | // 41 | // Stage 3: 42 | // 43 | // In this stage, the goal will be to finish implementing the routes of your 44 | // Express server. You can use whatever logic you think appropriate within each 45 | // of the remaining routes! The only requirement of your implementation is: 46 | // 47 | // - Each of the remaining routes of our Express server should be completed 48 | // 49 | // Bonus: 50 | // - Use a `FiberSet` instead of `Runtime` to run Effects within your Express 51 | // request handlers 52 | // 53 | // Some useful cURL commands for testing your server (modify the port as applicable): 54 | // 55 | // Query a Todo by ID: 56 | // curl -X GET http://localhost:8888/todos/0 57 | // 58 | // Query all Todos: 59 | // curl -X GET http://localhost:8888/todos 60 | // 61 | // Create a Todo: 62 | // curl -X POST -H 'Content-Type: application/json' -d '{"title":"mytodo","completed":false}' http://localhost:8888/todos 63 | // 64 | // Update a Todo: 65 | // curl -X PUT -H 'Content-Type: application/json' -d '{"completed":true}' http://localhost:8888/todos/0 66 | // 67 | // Delete a Todo by ID: 68 | // curl -X DELETE http://localhost:8888/todos/0 69 | 70 | // ============================================================================= 71 | // Server 72 | // ============================================================================= 73 | 74 | const ServerLive = Layer.scopedDiscard( 75 | Effect.gen(function*(_) { 76 | // ============================================================================= 77 | // Stage 1 78 | // ============================================================================= 79 | // Start an Express server on your local host machine 80 | // - Hint: you may want to consider utilizing `Runtime` given this is an execution boundary 81 | // - Hint: starting / stopping the Express server is a resourceful operation 82 | return yield* _(Effect.unit) // Delete me 83 | }) 84 | ) 85 | 86 | // ============================================================================= 87 | // Routes 88 | // ============================================================================= 89 | 90 | const GetTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 91 | // ============================================================================= 92 | // Stage 2 93 | // ============================================================================= 94 | // Create the `GET /todos/:id` route 95 | // - If the todo exists, return the todo as JSON 96 | // - If the todo does not exist return a 404 status code with the message `"Todo ${id} not found"` 97 | })) 98 | 99 | // ============================================================================= 100 | // Stage 3 101 | // ============================================================================= 102 | const GetAllTodosRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 103 | // Create the `GET /todos` route 104 | // - Should return all todos from the `TodoRepository` 105 | })) 106 | 107 | const CreateTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 108 | // Create the `POST /todos` route 109 | // - Should create a new todo and return the todo ID in the response 110 | // - If the request JSON body is not valid return a 400 status code with the message `"Invalid todo"` 111 | })) 112 | 113 | const UpdateTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 114 | // Create the `PUT /todos/:id` route 115 | // - Should update an existing todo and return the updated todo as JSON 116 | // - If the request JSON body is not valid return a 400 status code with the message `"Invalid todo"` 117 | // - If the todo does not exist return a 404 with the message `"Todo ${id} not found"` 118 | })) 119 | 120 | const DeleteTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 121 | // Create the `DELETE /todos` route 122 | // - Should delete the todo by id and return a boolean indicating if a todo was deleted 123 | })) 124 | 125 | // ============================================================================= 126 | // Todo 127 | // ============================================================================= 128 | 129 | class Todo extends Schema.Class()({ 130 | id: Schema.number, 131 | title: Schema.string, 132 | completed: Schema.boolean 133 | }) {} 134 | 135 | const CreateTodoParams = Todo.struct.pipe(Schema.omit("id")) 136 | type CreateTodoParams = Schema.Schema.To 137 | 138 | const UpdateTodoParams = Schema.partial(Todo.struct, { exact: true }).pipe(Schema.omit("id")) 139 | type UpdateTodoParams = Schema.Schema.To 140 | 141 | // ============================================================================= 142 | // TodoRepository 143 | // ============================================================================= 144 | 145 | const makeTodoRepository = Effect.gen(function*(_) { 146 | const nextIdRef = yield* _(Ref.make(0)) 147 | const todosRef = yield* _(Ref.make(HashMap.empty())) 148 | 149 | const getTodo = (id: number): Effect.Effect> => 150 | Ref.get(todosRef).pipe(Effect.map(HashMap.get(id))) 151 | 152 | const getTodos: Effect.Effect> = Ref.get(todosRef).pipe( 153 | Effect.map((map) => ReadonlyArray.fromIterable(HashMap.values(map))) 154 | ) 155 | 156 | const createTodo = (params: CreateTodoParams): Effect.Effect => 157 | Ref.getAndUpdate(nextIdRef, (n) => n + 1).pipe( 158 | Effect.flatMap((id) => 159 | Ref.modify(todosRef, (map) => { 160 | const newTodo = new Todo({ ...params, id }) 161 | const updated = HashMap.set(map, newTodo.id, newTodo) 162 | return [newTodo.id, updated] 163 | }) 164 | ) 165 | ) 166 | 167 | const updateTodo = ( 168 | id: number, 169 | params: UpdateTodoParams 170 | ): Effect.Effect => 171 | Ref.get(todosRef).pipe(Effect.flatMap((map) => { 172 | const maybeTodo = HashMap.get(map, id) 173 | if (Option.isNone(maybeTodo)) { 174 | return Effect.fail(new Cause.NoSuchElementException()) 175 | } 176 | const newTodo = new Todo({ ...maybeTodo.value, ...params }) 177 | const updated = HashMap.set(map, id, newTodo) 178 | return Ref.set(todosRef, updated).pipe(Effect.as(newTodo)) 179 | })) 180 | 181 | const deleteTodo = (id: number): Effect.Effect => 182 | Ref.get(todosRef).pipe(Effect.flatMap((map) => 183 | HashMap.has(map, id) 184 | ? Ref.set(todosRef, HashMap.remove(map, id)).pipe(Effect.as(true)) 185 | : Effect.succeed(false) 186 | )) 187 | 188 | return { 189 | getTodo, 190 | getTodos, 191 | createTodo, 192 | updateTodo, 193 | deleteTodo 194 | } as const 195 | }) 196 | 197 | class TodoRepository extends Context.Tag("TodoRepository")< 198 | TodoRepository, 199 | Effect.Effect.Success 200 | >() { 201 | static readonly Live = Layer.effect(TodoRepository, makeTodoRepository) 202 | } 203 | 204 | // ============================================================================= 205 | // Express 206 | // ============================================================================= 207 | 208 | class Express extends Context.Tag("Express")>() { 209 | static readonly Live = Layer.sync(Express, () => { 210 | const app = express() 211 | app.use(bodyParser.json()) 212 | return app 213 | }) 214 | } 215 | 216 | // ============================================================================= 217 | // Program 218 | // ============================================================================= 219 | 220 | const MainLive = ServerLive.pipe( 221 | Layer.merge(GetTodoRouteLive), 222 | Layer.merge(GetAllTodosRouteLive), 223 | Layer.merge(CreateTodoRouteLive), 224 | Layer.merge(UpdateTodoRouteLive), 225 | Layer.merge(DeleteTodoRouteLive), 226 | Layer.provide(Express.Live), 227 | Layer.provide(TodoRepository.Live) 228 | ) 229 | 230 | Layer.launch(MainLive).pipe( 231 | Effect.tapErrorCause(Effect.logError), 232 | Effect.runFork 233 | ) 234 | -------------------------------------------------------------------------------- /workshop/exercises/session-02/exercise-00.ts: -------------------------------------------------------------------------------- 1 | import { Console, Deferred, Effect, Random } from "effect" 2 | 3 | // Exercise Summary: 4 | // 5 | // The following exercise will explore how we can utilize a Deferred to 6 | // propagate the result of an Effect between fibers. Your implementation 7 | // should have the same semantics as the `Effect.intoDeferred` combinator, but 8 | // it should NOT utilize said combinator. 9 | 10 | const maybeFail = Random.next.pipe(Effect.filterOrFail( 11 | (n) => n > 0.5, 12 | (n) => `Failed with ${n}` 13 | )) 14 | 15 | const program = Effect.gen(function*(_) { 16 | const deferred = yield* _(Deferred.make()) 17 | yield* _( 18 | maybeFail, 19 | // Implement the logic to propagate the full result of `maybeFail` back to 20 | // the parent fiber utilizing the Deferred without `Effect.intoDeferred`. 21 | Effect.fork 22 | ) 23 | const result = yield* _(Deferred.await(deferred)) 24 | yield* _(Console.log(result)) 25 | }) 26 | 27 | program.pipe( 28 | Effect.tapErrorCause(Effect.logError), 29 | Effect.runFork 30 | ) 31 | -------------------------------------------------------------------------------- /workshop/exercises/session-02/exercise-01.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Queue, ReadonlyArray, Schedule } from "effect" 2 | 3 | // Exercise Summary: 4 | // 5 | // The following exercise will explore how we can distribute work between 6 | // multiple fibers using Queue. We will create three separate implementations 7 | // of "workers" that take a value from a Queue and perform some work on the 8 | // value. 9 | 10 | // The below function simulates performing some non-trivial work 11 | export const doSomeWork = (value: number) => 12 | Effect.log(`Consuming value '${value}'`).pipe( 13 | Effect.delay("20 millis") 14 | ) 15 | 16 | const program = Effect.gen(function*(_) { 17 | // The following will offer the numbers [0-100] to the Queue every second 18 | const queue = yield* _(Queue.unbounded()) 19 | yield* _( 20 | Queue.offerAll(queue, ReadonlyArray.range(0, 100)), 21 | Effect.schedule(Schedule.fixed("1 seconds")), 22 | Effect.fork 23 | ) 24 | // Implementation #1 - Sequential 25 | yield* _( 26 | Effect.unit, // Remove me 27 | // Implement an Effect pipeline which continuously takes from the Queue 28 | // and calls `doSomeWork` on the taken value. Your implementation should 29 | // perform work on each taken value sequentially. 30 | Effect.annotateLogs("concurrency", "none"), 31 | Effect.fork 32 | ) 33 | // Implementation #2 - Unbounded Concurrency 34 | yield* _( 35 | Effect.unit, // Remove me 36 | // Implement an Effect pipeline which continuously takes from the Queue 37 | // and calls `doSomeWork` on the taken value. Your implementation should 38 | // perform work on each taken value with unbounded concurrency. 39 | Effect.annotateLogs("concurrency", "unbounded"), 40 | Effect.fork 41 | ) 42 | // Implementation #3 - Bounded Concurrency 43 | const concurrencyLimit = 4 44 | yield* _( 45 | Effect.unit, // Remove me 46 | // Implement an Effect pipeline which continuously takes from the Queue 47 | // and calls `doSomeWork` on the taken value. Your implementation should 48 | // perform work on each taken value with concurrency bounded to the above 49 | // concurrency limit. 50 | Effect.annotateLogs("concurrency", "bounded"), 51 | Effect.fork 52 | ) 53 | }) 54 | 55 | program.pipe( 56 | Effect.awaitAllChildren, 57 | Effect.tapErrorCause(Effect.logError), 58 | Effect.runFork 59 | ) 60 | -------------------------------------------------------------------------------- /workshop/exercises/session-02/exercise-02.ts: -------------------------------------------------------------------------------- 1 | import type { Deferred } from "effect" 2 | import { Effect, Fiber, Queue, ReadonlyArray } from "effect" 3 | 4 | // Exercise Summary: 5 | // 6 | // The following exercise will explore how we can distribute work between 7 | // multiple fibers using `Queue` and retrieve the result of said work for further 8 | // processing via `Deferred`. Our sample program will setup a classic producer / 9 | // consumer relationship between fibers. Once completed we can tweak the 10 | // concurrency of our program to demonstrate the flexibility of this pattern. 11 | 12 | // The below function simulates performing some non-trivial work 13 | export const performWork = (value: number) => 14 | Effect.log(`Consuming value: '${value}'`).pipe( 15 | Effect.delay("20 millis"), 16 | Effect.as(`Processed value: '${value}'`) 17 | ) 18 | 19 | const program = Effect.gen(function*(_) { 20 | // Our queue will contain pairs of (number, Deferred) 21 | const queue = yield* _(Queue.unbounded<[number, Deferred.Deferred]>()) 22 | 23 | const produceWork = (value: number): Effect.Effect => 24 | Effect.succeed("Remove me") 25 | // Complete the implementation of `produceWork`. Your implementation should: 26 | // - Offer entries of work into the Queue 27 | // - Wait for the result of the work to be available 28 | 29 | const consumeWork: Effect.Effect = Effect.unit // Remove me 30 | // Complete the implementation of `consumeWork`. Your implementation should: 31 | // - Take entries of work from the Queue 32 | // - Utilize `performWork` to perform some work on the value taken from the Queue 33 | // - Communicate the result of `performWork` back to the producer 34 | // - Work should be consumed continuously and with unbounded concurrency 35 | 36 | // The following fiber utilizes `consumeWork` to take entries from the Queue, 37 | // perform some work on the taken value, and communicate the result of the 38 | // work back to the producer 39 | const consumerFiber = yield* _( 40 | consumeWork, 41 | Effect.annotateLogs("role", "consumer"), 42 | Effect.fork 43 | ) 44 | 45 | // The following Effect pipeline performs work on ten numbers and then logs 46 | // the result of the work to the console 47 | yield* _( 48 | Effect.forEach(ReadonlyArray.range(0, 10), (value) => 49 | produceWork(value).pipe( 50 | Effect.flatMap((result) => Effect.log(result)) 51 | )), 52 | Effect.annotateLogs("role", "producer") 53 | ) 54 | 55 | yield* _(Fiber.join(consumerFiber)) 56 | }) 57 | 58 | program.pipe( 59 | Effect.tapErrorCause(Effect.logError), 60 | Effect.runFork 61 | ) 62 | -------------------------------------------------------------------------------- /workshop/exercises/session-02/exercise-03.ts: -------------------------------------------------------------------------------- 1 | import type * as ParcelWatcher from "@parcel/watcher" 2 | import { Chunk, Console, Data, Effect, Stream } from "effect" 3 | 4 | // Exercise Summary: 5 | // 6 | // The following exercise will explore how we can wrap the `@parcel/watcher` 7 | // `subscribe` function using Effect. In the default implementation, we will 8 | // utilize a `Queue` to manage file system events emitted by `@parcel/watcher`. 9 | // 10 | // As a bonus, you can attempt a second implementation of wrapping `subscribe` 11 | // without using a `Queue` (hint: take a look at `Stream.asyncScoped`). 12 | 13 | declare const watch: ( 14 | directory: string, 15 | options?: ParcelWatcher.Options 16 | ) => Stream.Stream 17 | // Complete the implementation of `watch`. Your implementation should: 18 | // - Properly manage the subscription resource returned from `ParcelWatcher.subscribe` 19 | // - Write file system events emitted by the subscription into a `Queue` 20 | // - Starve the queue using a `Stream` 21 | 22 | watch("./src").pipe( 23 | Stream.tap((event) => Console.log(event)), 24 | Stream.runDrain, 25 | Effect.runFork 26 | ) 27 | 28 | // Bonus Exercise: 29 | // - Implement the same functionality as above without using `Queue` 30 | // declare const watchStream: ( 31 | // directory: string, 32 | // options?: ParcelWatcher.Options 33 | // ) => Stream.Stream => 34 | 35 | // ============================================================================= 36 | // File Watcher Models 37 | // ============================================================================= 38 | 39 | export class FileWatcherError extends Data.TaggedError("FileWatcherError")<{ 40 | readonly error: Error 41 | }> {} 42 | 43 | export type FileSystemEvent = Data.TaggedEnum<{ 44 | readonly FileCreated: FileSystemEventInfo 45 | readonly FileUpdated: FileSystemEventInfo 46 | readonly FileDeleted: FileSystemEventInfo 47 | }> 48 | 49 | export const FileSystemEvent = Data.taggedEnum() 50 | 51 | export interface FileSystemEventInfo { 52 | readonly path: string 53 | } 54 | 55 | const normalizeEvents = ( 56 | events: ReadonlyArray 57 | ): Chunk.Chunk => 58 | Chunk.fromIterable(events).pipe(Chunk.map((event) => normalizeEvent(event))) 59 | 60 | const normalizeEvent = (event: ParcelWatcher.Event) => { 61 | switch (event.type) { 62 | case "create": { 63 | return FileSystemEvent.FileCreated({ path: event.path }) 64 | } 65 | case "update": { 66 | return FileSystemEvent.FileUpdated({ path: event.path }) 67 | } 68 | case "delete": { 69 | return FileSystemEvent.FileDeleted({ path: event.path }) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /workshop/exercises/session-02/project.ts: -------------------------------------------------------------------------------- 1 | import type { Duration, Scope } from "effect" 2 | import { Context, Effect, Layer } from "effect" 3 | 4 | // In this session project, we will build a simple `RateLimiter` factory service. 5 | // The `RateLimiter` and `RateLimiter.Factory` service interfaces, as well as the 6 | // corresponding `Context` definitions are provided for you. Your job will be to 7 | // implement the `RateLimiter.make` constructor. 8 | // 9 | // This project will be divided into three stages: 10 | // 11 | // Stage 1: 12 | // 13 | // In this stage, the goal will be to implement the `take` method on `RateLimiter`. 14 | // which functionally "takes" from the `RateLimiter` until `RateLimiter`'s `limit` 15 | // is reached. Your implementation should: 16 | // 17 | // - Functionally "take" from the `RateLimiter`'s available request limit 18 | // - Allow the request to proceed if the limit is not reached 19 | // - Wait for the reset window to pass if the request limit is reached 20 | // 21 | // Hints: 22 | // - It may be useful to model the internal collection of "takers" as a `Queue` 23 | // of `Deferred` 24 | // 25 | // Stage 2: 26 | // 27 | // In this stage, the goal will be to implement the reset functionality of the 28 | // `RateLimiter`. The `RateLimiter` should be reset after the window duration 29 | // has passed. Your implementation should: 30 | // 31 | // - Track the current count of takers against the `RateLimiter`'s limit 32 | // - Reset the `RateLimiter` after the window duration has passed 33 | // 34 | // Hints: 35 | // - It may be useful to model the current count of takers with a `Ref` 36 | // - It may be useful to setup the reset as a `Fiber` which resets the counter after a delay 37 | // - It may be useful to store the running `Fiber` in a `Ref` 38 | // 39 | // Stage 3: 40 | // 41 | // In this stage, the goal will be to implement the `RateLimiter`'s worker, which 42 | // does the work of continuously checking the current count of takers against the 43 | // `RateLimiter`'s limit, controlling which requests are able to execute, and 44 | // executing the reset when necessary. Your implementation should: 45 | // 46 | // - Continuously poll the count of takers `RateLimiter` to determine if the 47 | // `RateLimiter` needs to be reset 48 | // - Reset the `RateLimiter`, if required, once the window has passed 49 | // - Allow requests to execute if the limit has not been reached 50 | // - Prevent requests from executing unless if the limit has been reached 51 | // 52 | // Hints: 53 | // - The `RateLimiter`'s worker should be forked into a background process 54 | 55 | export interface RateLimiter { 56 | readonly take: Effect.Effect 57 | } 58 | 59 | export declare namespace RateLimiter { 60 | export interface Factory { 61 | readonly make: ( 62 | limit: number, 63 | window: Duration.DurationInput 64 | ) => Effect.Effect 65 | } 66 | } 67 | 68 | class Factory extends Context.Tag("RateLimiter.Factory")() { 69 | static readonly Live = Layer.sync(Factory, () => factory) 70 | } 71 | 72 | export const make = Effect.serviceFunctionEffect(Factory, (factory) => factory.make) 73 | 74 | const factory = Factory.of({ 75 | make: (limit, window) => 76 | Effect.gen(function*(_) { 77 | // ======================================================================= 78 | // Stage 2 - Implement the reset functionality of the `RateLimiter` 79 | // ======================================================================= 80 | 81 | // ======================================================================= 82 | // Stage 3 - Implement the `RateLimiter` worker background process 83 | // ======================================================================= 84 | 85 | return { 86 | // ======================================================================= 87 | // Stage 1 - Implement the `RateLimiter.take` method 88 | // ======================================================================= 89 | take: 90 | } 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /workshop/exercises/session-03/exercise-00.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@effect/schema" 2 | import { 3 | Console, 4 | Context, 5 | Data, 6 | Effect, 7 | Layer, 8 | ReadonlyArray, 9 | Request, 10 | RequestResolver 11 | } from "effect" 12 | import { gql, GraphQLClient } from "graphql-request" 13 | 14 | // Exercise Summary: 15 | // 16 | // The following exercise will explore how we can utilize `RequestResolver` to 17 | // take advantage of data sources that support batching requests. For example, 18 | // the Pokemon GraphQL API (see https://pokeapi.co/docs/graphql) supports 19 | // providing multiple Pokemon identifiers to retrieve in the `where` clause of 20 | // the input to the `getPokemonById` query type. Therefore, we can make many 21 | // queries for pokemon by identifier and utilize Effect's built-in request 22 | // batching capability to batch these requests together and only send a single 23 | // request to the data source. 24 | // 25 | // Your goal will be to complete the implementation of the `GetPokemonById` 26 | // resolver. 27 | // 28 | // As a bonus, you can attempt to improve the `Pokemon`, `PokemonError`, and 29 | // `GetPokemonById` models using `@effect/schema/Schema`. 30 | 31 | // ============================================================================= 32 | // PokemonRepo 33 | // ============================================================================= 34 | 35 | const makePokemonRepo = Effect.gen(function*(_) { 36 | const pokemonApi = yield* _(PokemonApi) 37 | 38 | const GetPokemonByIdResolver: RequestResolver.RequestResolver = 39 | RequestResolver.makeBatched((requests: ReadonlyArray) => { 40 | // ============================================================================= 41 | // Please provide your implementation here! 42 | // ============================================================================= 43 | }) 44 | 45 | const getById = (id: number) => Effect.request(new GetPokemonById({ id }), GetPokemonByIdResolver) 46 | 47 | return { getById } as const 48 | }) 49 | 50 | export class PokemonRepo extends Context.Tag("PokemonRepo")< 51 | PokemonRepo, 52 | Effect.Effect.Success 53 | >() { 54 | static readonly Live = Layer.effect(this, makePokemonRepo) 55 | } 56 | 57 | // ============================================================================= 58 | // Pokemon Models 59 | // ============================================================================= 60 | 61 | export class PokemonError extends Data.TaggedError("PokemonError")<{ 62 | readonly message: string 63 | }> {} 64 | 65 | export class Pokemon extends Data.Class<{ 66 | readonly id: number 67 | readonly name: string 68 | }> {} 69 | 70 | export class GetPokemonById extends Request.TaggedClass("GetPokemonById")< 71 | PokemonError, 72 | Pokemon, 73 | { readonly id: number } 74 | > {} 75 | 76 | // ============================================================================= 77 | // PokemonApi 78 | // ============================================================================= 79 | 80 | const makePokemonApi = Effect.sync(() => { 81 | const client = new GraphQLClient("https://beta.pokeapi.co/graphql/v1beta") 82 | 83 | const query = ( 84 | document: string, 85 | variables?: Record 86 | ) => 87 | Effect.tryPromise({ 88 | try: (signal) => 89 | client.request({ 90 | document, 91 | variables, 92 | signal 93 | }), 94 | catch: (error) => new PokemonError({ message: String(error) }) 95 | }) 96 | 97 | const pokemonById = gql` 98 | query pokemonById($ids: [Int!]!) { 99 | pokemon_v2_pokemon(where: { id: { _in: $ids } }) { 100 | id 101 | name 102 | } 103 | } 104 | ` 105 | 106 | const getByIds = (ids: ReadonlyArray) => 107 | query(pokemonById, { ids }).pipe( 108 | Effect.flatMap( 109 | Schema.decodeUnknown( 110 | Schema.struct({ 111 | pokemon_v2_pokemon: Schema.array(Schema.struct({ 112 | id: Schema.number, 113 | name: Schema.string 114 | })) 115 | }) 116 | ) 117 | ), 118 | Effect.map((response) => 119 | ReadonlyArray.map(response.pokemon_v2_pokemon, (params) => new Pokemon(params)) 120 | ), 121 | Effect.catchTag("ParseError", (e) => Effect.fail(new PokemonError({ message: e.toString() }))) 122 | ) 123 | 124 | return { getByIds } as const 125 | }) 126 | 127 | class PokemonApi extends Context.Tag("PokemonApi")< 128 | PokemonApi, 129 | Effect.Effect.Success 130 | >() { 131 | static readonly Live = Layer.effect(this, makePokemonApi) 132 | } 133 | 134 | // ============================================================================= 135 | // Program 136 | // ============================================================================= 137 | 138 | const program = Effect.gen(function*(_) { 139 | const repo = yield* _(PokemonRepo) 140 | 141 | const pokemon = yield* _( 142 | // Toggle batching on and off to see time difference 143 | Effect.forEach(ReadonlyArray.range(1, 100), repo.getById), 144 | Effect.timed, 145 | Effect.map(([duration, pokemon]) => ({ duration: duration.toString(), pokemon })) 146 | ) 147 | 148 | yield* _(Console.log(pokemon)) 149 | }) 150 | 151 | const MainLive = PokemonRepo.Live.pipe( 152 | Layer.provide(PokemonApi.Live) 153 | ) 154 | 155 | program.pipe( 156 | Effect.provide(MainLive), 157 | Effect.runFork 158 | ) 159 | -------------------------------------------------------------------------------- /workshop/exercises/session-03/exercise-01.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@effect/schema" 2 | import { 3 | Console, 4 | Context, 5 | Effect, 6 | Layer, 7 | ReadonlyArray, 8 | Request, 9 | RequestResolver, 10 | Schedule 11 | } from "effect" 12 | import { gql, GraphQLClient } from "graphql-request" 13 | 14 | // Exercise Summary: 15 | // 16 | // The following exercise will explore how we can utilize a `Request` cache to 17 | // cache the results of our requests. We will re-use our Pokemon API code for 18 | // this example. 19 | // 20 | // Your goal will be to enable request caching on the `PokemonRepo.getById` 21 | // method and then set the request cache to a custom request cache with: 22 | // - A `capacity` of `250` 23 | // - A `timeToLive` of `10 days` 24 | 25 | // ============================================================================= 26 | // PokemonRepo 27 | // ============================================================================= 28 | 29 | const makePokemonRepo = Effect.gen(function*(_) { 30 | const pokemonApi = yield* _(PokemonApi) 31 | 32 | const GetPokemonByIdResolver = RequestResolver.makeBatched(( 33 | requests: ReadonlyArray 34 | ) => 35 | Effect.gen(function*(_) { 36 | yield* _(Console.log(`Executing batch of ${requests.length} requests...`)) 37 | const ids = ReadonlyArray.map(requests, (request) => request.id) 38 | const pokemons = yield* _(pokemonApi.getByIds(ids)) 39 | yield* _(Effect.forEach(requests, (request) => { 40 | const pokemon = pokemons.find((pokemon) => pokemon.id === request.id)! 41 | return Request.completeEffect(request, Effect.succeed(pokemon)) 42 | }, { discard: true })) 43 | }).pipe( 44 | Effect.catchAll((error) => 45 | Effect.forEach(requests, (request) => Request.completeEffect(request, error)) 46 | ) 47 | ) 48 | ) 49 | 50 | const getById = (id: number) => 51 | Effect.request( 52 | new GetPokemonById({ id }), 53 | GetPokemonByIdResolver 54 | ) 55 | 56 | return { getById } as const 57 | }) 58 | 59 | export class PokemonRepo extends Context.Tag("PokemonRepo")< 60 | PokemonRepo, 61 | Effect.Effect.Success 62 | >() { 63 | static readonly Live = Layer.effect(this, makePokemonRepo) 64 | } 65 | 66 | // ============================================================================= 67 | // Pokemon Models 68 | // ============================================================================= 69 | 70 | export class PokemonError extends Schema.TaggedError()( 71 | "PokemonError", 72 | { message: Schema.string } 73 | ) {} 74 | 75 | export class Pokemon extends Schema.Class()({ 76 | id: Schema.number, 77 | name: Schema.string 78 | }) {} 79 | 80 | export class GetPokemonById extends Schema.TaggedRequest()( 81 | "GetPokemonById", 82 | PokemonError, 83 | Pokemon, 84 | { id: Schema.number } 85 | ) {} 86 | 87 | // ============================================================================= 88 | // PokemonApi 89 | // ============================================================================= 90 | 91 | const makePokemonApi = Effect.sync(() => { 92 | const client = new GraphQLClient("https://beta.pokeapi.co/graphql/v1beta") 93 | 94 | const query = ( 95 | document: string, 96 | variables?: Record 97 | ) => 98 | Effect.tryPromise({ 99 | try: (signal) => 100 | client.request({ 101 | document, 102 | variables, 103 | signal 104 | }), 105 | catch: (error) => new PokemonError({ message: String(error) }) 106 | }) 107 | 108 | const pokemonById = gql` 109 | query pokemonById($ids: [Int!]!) { 110 | pokemon_v2_pokemon(where: { id: { _in: $ids } }) { 111 | id 112 | name 113 | } 114 | } 115 | ` 116 | 117 | const getByIds = (ids: ReadonlyArray) => 118 | query(pokemonById, { ids }).pipe( 119 | Effect.flatMap( 120 | Schema.decodeUnknown( 121 | Schema.struct({ 122 | pokemon_v2_pokemon: Schema.array(Pokemon) 123 | }) 124 | ) 125 | ), 126 | Effect.map((response) => response.pokemon_v2_pokemon), 127 | Effect.catchTag("ParseError", (e) => Effect.fail(new PokemonError({ message: e.toString() }))) 128 | ) 129 | 130 | return { getByIds } as const 131 | }) 132 | 133 | class PokemonApi extends Context.Tag("PokemonApi")< 134 | PokemonApi, 135 | Effect.Effect.Success 136 | >() { 137 | static readonly Live = Layer.effect(this, makePokemonApi) 138 | } 139 | 140 | // ============================================================================= 141 | // Program 142 | // ============================================================================= 143 | 144 | const program = Effect.gen(function*(_) { 145 | const repo = yield* _(PokemonRepo) 146 | 147 | yield* _( 148 | Effect.forEach(ReadonlyArray.range(1, 100), repo.getById, { 149 | // Toggle batching on and off to see time difference 150 | batching: true 151 | }), 152 | Effect.tap((pokemon) => Console.log(`Got ${pokemon.length} pokemon`)), 153 | Effect.repeat(Schedule.fixed("2 seconds")) 154 | ) 155 | }) 156 | 157 | const MainLive = PokemonRepo.Live.pipe( 158 | Layer.provide(PokemonApi.Live) 159 | ) 160 | 161 | program.pipe( 162 | Effect.provide(MainLive), 163 | Effect.tapErrorCause(Effect.logError), 164 | Effect.runFork 165 | ) 166 | -------------------------------------------------------------------------------- /workshop/exercises/session-03/exercise-02.ts: -------------------------------------------------------------------------------- 1 | import { Console, Data, Effect } from "effect" 2 | 3 | // Exercise Summary: 4 | // 5 | // In long-lived applications, we often want to cache the results of certain 6 | // computations for a period of time. The following exercise will explore how we 7 | // can utilize Effect's `Cache` module to cache any effectful computation. 8 | // 9 | // Your goal will be to add the requisite code to the program below to ensure 10 | // that the following requirements are met: 11 | // 12 | // - The results of the `executeJob` should be cached so that subsequent calls 13 | // with the same `Job` will immediately return the previously computed value 14 | // - We can cache the results of 100 jobs 15 | // - A `Job` should be allowed to re-run once per day 16 | 17 | export class Job extends Data.Class<{ 18 | readonly id: number 19 | readonly text: string 20 | }> {} 21 | 22 | export const executeJob = (job: Job): Effect.Effect => 23 | Effect.log(`Running job ${job.id}...`).pipe( 24 | Effect.zipRight(Effect.sleep(`${job.text.length} seconds`)), 25 | Effect.as(job.text.length) 26 | ) 27 | 28 | const program = Effect.gen(function*(_) { 29 | const jobs = [ 30 | new Job({ id: 1, text: "I" }), 31 | new Job({ id: 2, text: "love" }), 32 | new Job({ id: 3, text: "Effect" }), 33 | new Job({ id: 4, text: "!" }) 34 | ] 35 | // =========================================================================== 36 | // Your code here 37 | // =========================================================================== 38 | yield* _(Effect.log("Starting job execution...")) 39 | const first = yield* _( 40 | Effect.forEach(jobs, (job) => executeJob(job), { concurrency: "unbounded" }) 41 | ) 42 | yield* _(Effect.log("Job execution complete")) 43 | yield* _(Console.log(first)) 44 | 45 | yield* _(Effect.log("Starting job execution...")) 46 | const second = yield* _( 47 | Effect.forEach(jobs, (job) => executeJob(job), { concurrency: "unbounded" }) 48 | ) 49 | yield* _(Effect.log("Job execution complete")) 50 | yield* _(Console.log(second)) 51 | }) 52 | 53 | program.pipe( 54 | Effect.tapErrorCause(Effect.logError), 55 | Effect.runFork 56 | ) 57 | -------------------------------------------------------------------------------- /workshop/exercises/session-03/exercise-03.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@effect/schema" 2 | import { 3 | Console, 4 | Context, 5 | Effect, 6 | Layer, 7 | ReadonlyArray, 8 | Request, 9 | RequestResolver, 10 | Schedule 11 | } from "effect" 12 | import { gql, GraphQLClient } from "graphql-request" 13 | 14 | // Optional Exercise Summary: 15 | // 16 | // The following exercise will explore how we can utilize some of the 17 | // experimental modules being developed for Effect to introduce a persistence 18 | // layer into our request resolvers. 19 | // 20 | // Your goal will be to add the requisite code to the program below to ensure 21 | // that the following requirements are met: 22 | // 23 | // - Utilize a `NodeKeyValueStore` (from `@effect/platform-node`) to persist 24 | // the results of requests between program invocations 25 | // 26 | // Hints: 27 | // - You probably want to make use of the `Persistence` and `RequestResolver` 28 | // modules from `@effect/experimental` 29 | 30 | // ============================================================================= 31 | // PokemonRepo 32 | // ============================================================================= 33 | 34 | const makePokemonRepo = Effect.gen(function*(_) { 35 | const pokemonApi = yield* _(PokemonApi) 36 | 37 | const resolver = RequestResolver.makeBatched(( 38 | requests: ReadonlyArray 39 | ) => 40 | Effect.gen(function*(_) { 41 | yield* _(Console.log(`Executing batch of ${requests.length} requests...`)) 42 | const ids = ReadonlyArray.map(requests, (request) => request.id) 43 | const pokemons = yield* _(pokemonApi.getByIds(ids)) 44 | yield* _(Effect.forEach(requests, (request) => { 45 | const pokemon = pokemons.find((pokemon) => pokemon.id === request.id)! 46 | return Request.completeEffect(request, Effect.succeed(pokemon)) 47 | }, { discard: true })) 48 | }).pipe( 49 | Effect.catchAll((error) => 50 | Effect.forEach(requests, (request) => Request.completeEffect(request, error)) 51 | ) 52 | ) 53 | ) 54 | 55 | const getById = (id: number) => Effect.request(new GetPokemonById({ id }), resolver) 56 | 57 | return { getById } as const 58 | }) 59 | 60 | export class PokemonRepo extends Context.Tag("PokemonRepo")< 61 | PokemonRepo, 62 | Effect.Effect.Success 63 | >() { 64 | static readonly Live = Layer.effect(this, makePokemonRepo) 65 | } 66 | 67 | // ============================================================================= 68 | // Pokemon Models 69 | // ============================================================================= 70 | 71 | export class PokemonError extends Schema.TaggedError()( 72 | "PokemonError", 73 | { message: Schema.string } 74 | ) {} 75 | 76 | export class Pokemon extends Schema.Class()({ 77 | id: Schema.number, 78 | name: Schema.string 79 | }) {} 80 | 81 | export class GetPokemonById extends Schema.TaggedRequest()( 82 | "GetPokemonById", 83 | PokemonError, 84 | Pokemon, 85 | { id: Schema.number } 86 | ) {} 87 | 88 | // ============================================================================= 89 | // PokemonApi 90 | // ============================================================================= 91 | 92 | const makePokemonApi = Effect.sync(() => { 93 | const client = new GraphQLClient("https://beta.pokeapi.co/graphql/v1beta") 94 | 95 | const query = ( 96 | document: string, 97 | variables?: Record 98 | ) => 99 | Effect.tryPromise({ 100 | try: (signal) => 101 | client.request({ 102 | document, 103 | variables, 104 | signal 105 | }), 106 | catch: (error) => new PokemonError({ message: String(error) }) 107 | }) 108 | 109 | const pokemonById = gql` 110 | query pokemonById($ids: [Int!]!) { 111 | pokemon_v2_pokemon(where: { id: { _in: $ids } }) { 112 | id 113 | name 114 | } 115 | } 116 | ` 117 | 118 | const getByIds = (ids: ReadonlyArray) => 119 | query(pokemonById, { ids }).pipe( 120 | Effect.flatMap( 121 | Schema.decodeUnknown( 122 | Schema.struct({ 123 | pokemon_v2_pokemon: Schema.array(Pokemon) 124 | }) 125 | ) 126 | ), 127 | Effect.map((response) => response.pokemon_v2_pokemon), 128 | Effect.catchTag("ParseError", (e) => Effect.fail(new PokemonError({ message: e.toString() }))) 129 | ) 130 | 131 | return { getByIds } as const 132 | }) 133 | 134 | class PokemonApi extends Context.Tag("PokemonApi")< 135 | PokemonApi, 136 | Effect.Effect.Success 137 | >() { 138 | static readonly Live = Layer.effect(this, makePokemonApi) 139 | } 140 | 141 | // ============================================================================= 142 | // Program 143 | // ============================================================================= 144 | 145 | const program = Effect.gen(function*(_) { 146 | const repo = yield* _(PokemonRepo) 147 | 148 | yield* _( 149 | Effect.forEach(ReadonlyArray.range(1, 100), repo.getById, { 150 | // Toggle batching on and off to see time difference 151 | batching: true 152 | }), 153 | Effect.tap((pokemon) => Console.log(`Got ${pokemon.length} pokemon`)), 154 | Effect.repeat(Schedule.fixed("2 seconds")) 155 | ) 156 | }) 157 | 158 | const MainLive = PokemonRepo.Live.pipe( 159 | Layer.provide(PokemonApi.Live) 160 | ) 161 | 162 | program.pipe( 163 | Effect.provide(MainLive), 164 | Effect.tapErrorCause(Effect.logError), 165 | Effect.runFork 166 | ) 167 | -------------------------------------------------------------------------------- /workshop/exercises/session-03/project.ts: -------------------------------------------------------------------------------- 1 | import type { Deferred, Duration, Request, Scope } from "effect" 2 | import { Effect, Queue, ReadonlyArray, Ref, RequestResolver } from "effect" 3 | 4 | // In this session project, we will build a method which will take in an 5 | // existing `RequestResolver` and return a higher-order `RequestResolver`. The 6 | // returned `RequestResolver` will implement a variant of the data-loader 7 | // pattern. 8 | // 9 | // The `RateLimiter` and `RateLimiter.Factory` service interfaces, as well as the 10 | // corresponding `Context` definitions are provided for you. Your job will be to 11 | // implement the `RateLimiter.make` constructor. 12 | // 13 | // This project will be divided into three stages: 14 | // 15 | // Stage 1: 16 | // 17 | // In this stage, the goal will be to implement the "inner" `RequestResolver`, 18 | // which will offer `DataLoaderItem`s to the inner queue. Your implementation 19 | // should: 20 | // 21 | // - Create and offer a `DataLoaderItem` to the inner queue 22 | // - Wait for the result of the `DataLoaderItem` request 23 | // 24 | // Hints: 25 | // - Remember what we learned about propagating interruption with `Deferred` 26 | // 27 | // Stage 2: 28 | // 29 | // In this stage, the goal will be to implement the item batching functionality 30 | // of the data loader. The data loader should take as many elements as possible 31 | // from the inner queue and add them to the batch while it has not reached the 32 | // maximum batch size or the end of the batch window. Your implementation should: 33 | // 34 | // - Add as many items from the queue to the batch 35 | // - Stop adding items if the max batch size is reached 36 | // - Stop adding items if the window duration elapses 37 | // 38 | // Hints: 39 | // - It may be useful to implement: 40 | // - One method which takes a single item from the queue and adds it to 41 | // the batch 42 | // - Another method which repeats the first method until the max batch size or window duration is reached 43 | // 44 | // Stage 3: 45 | // 46 | // In this stage, the goal will be to implement the data loader's worker, which 47 | // does the work of continuously pulling items off the inner queue, runs the 48 | // request, and reports the result. Your implementation should: 49 | // 50 | // - Continuously pull items off the inner queue 51 | // - Run the request contained within the item and report the result 52 | // 53 | // Hints: 54 | // - You should ensure batching is enabled 55 | // - You should ensure request caching is disabled 56 | // - You should consider how interruption may be propagated 57 | 58 | interface DataLoaderItem> { 59 | readonly request: A 60 | readonly deferred: Deferred.Deferred< 61 | Request.Request.Success, 62 | Request.Request.Error 63 | > 64 | } 65 | 66 | export const dataLoader = >( 67 | self: RequestResolver.RequestResolver, 68 | options: { 69 | readonly window: Duration.DurationInput 70 | readonly maxBatchSize?: number 71 | } 72 | ): Effect.Effect, never, Scope.Scope> => 73 | Effect.gen(function*(_) { 74 | const queue = yield* _( 75 | Effect.acquireRelease( 76 | Queue.unbounded>(), 77 | Queue.shutdown 78 | ) 79 | ) 80 | const batch = yield* _(Ref.make(ReadonlyArray.empty>())) 81 | 82 | // ======================================================================= 83 | // Stage 2 - Implement Item Batching 84 | // ======================================================================= 85 | 86 | // ======================================================================= 87 | // Stage 3 - Implement the Worker 88 | // ======================================================================= 89 | 90 | return RequestResolver.fromEffect( 91 | (request): Effect.Effect, Request.Request.Error> => { 92 | // ======================================================================= 93 | // Stage 1 - Implement the Inner RequestResolver 94 | // ======================================================================= 95 | } 96 | ) 97 | }) 98 | -------------------------------------------------------------------------------- /workshop/exercises/session-04/exercise-00.ts: -------------------------------------------------------------------------------- 1 | import { Console, Effect, FiberRef, HashSet, Layer, Logger, Schedule } from "effect" 2 | import type { DurationInput } from "effect/Duration" 3 | 4 | // Exercise Summary: 5 | // 6 | // The following exercise will explore how we can create custom `Logger`s with 7 | // Effect. We are going to alter the behavior of logging in our application by 8 | // creating a `BatchedLogger` which batches logs and emits them as a collection 9 | // after a fixed window. 10 | // 11 | // Your task will be to complete the implementation of `makeBatchedLogger`. The 12 | // only code provided to you is the addition of the custom logger to the logger 13 | // set of the application. 14 | 15 | const makeBatchedLogger = (config: { 16 | readonly window: DurationInput 17 | }) => 18 | Effect.gen(function*(_) { 19 | const logger: Logger.Logger = {} as any // Remove me 20 | 21 | // Implementation 22 | 23 | yield* _(Effect.locallyScopedWith(FiberRef.currentLoggers, HashSet.add(logger))) 24 | }) 25 | 26 | const schedule = Schedule.fixed("500 millis").pipe(Schedule.compose(Schedule.recurs(10))) 27 | 28 | const program = Effect.gen(function*(_) { 29 | yield* _(Console.log("Running logs!")) 30 | yield* _(Effect.logInfo("Info log")) 31 | yield* _(Effect.logWarning("Warning log")) 32 | yield* _(Effect.logError("Error log")) 33 | }).pipe(Effect.schedule(schedule)) 34 | 35 | const BatchedLoggerLive = Layer.scopedDiscard(makeBatchedLogger({ window: "2 seconds" })) 36 | 37 | const MainLive = Logger.remove(Logger.defaultLogger).pipe( 38 | Layer.merge(BatchedLoggerLive) 39 | ) 40 | 41 | program.pipe( 42 | Effect.provide(MainLive), 43 | Effect.runFork 44 | ) 45 | -------------------------------------------------------------------------------- /workshop/exercises/session-04/exercise-01.ts: -------------------------------------------------------------------------------- 1 | import * as Metrics from "@effect/opentelemetry/Metrics" 2 | import * as Resource from "@effect/opentelemetry/Resource" 3 | import { PrometheusExporter, PrometheusSerializer } from "@opentelemetry/exporter-prometheus" 4 | import { Console, Effect, Layer, Logger, Metric, Runtime, RuntimeFlags, Schedule } from "effect" 5 | 6 | // Exercise Summary: 7 | // 8 | // The following exercise will explore how we can create custom `Logger`s with 9 | // Effect. While traditionally loggers are used to log output to the console or 10 | // some other location, we can also perform other useful tasks with custom 11 | // `Logger`s. We will also see that multiple loggers can be added to an Effect 12 | // application to perform multiple logging tasks with a single call to an 13 | // `Effect.log*` combinator. 14 | // 15 | // Your task will be to implement a `MetricLogger` which takes a `Counter` and 16 | // a label for the `LogLevel` and constructs a custom `Logger` which updates the 17 | // `Counter` by `1` every time `log` is called. Additionally, the metric should 18 | // be tagged with the log level. 19 | // 20 | // For example, in Prometheus format the output might look like: 21 | // # HELP effect_log_total description missing 22 | // # UNIT effect_log_total 1 23 | // # TYPE effect_log_total gauge 24 | // effect_log_total{level="error"} 6 25 | // # HELP effect_log_total description missing 26 | // # UNIT effect_log_total 1 27 | // # TYPE effect_log_total gauge 28 | // effect_log_total{level="info"} 4 29 | 30 | declare const makeMetricLogger: ( 31 | counter: Metric.Metric.Counter, 32 | logLevelLabel: string 33 | ) => Logger.Logger 34 | // Implementation goes here 35 | 36 | const MetricLoggerLive = Logger.add(makeMetricLogger(Metric.counter("effect_log_total"), "level")) 37 | 38 | const ResourceLive = Resource.layer({ 39 | serviceName: "advanced-effect-workshop", 40 | serviceVersion: "1.0.0" 41 | }) 42 | 43 | const MetricReporterLive = Layer.scopedDiscard(Effect.gen(function*(_) { 44 | const serializer = new PrometheusSerializer() 45 | const producer = yield* _(Metrics.makeProducer) 46 | const reader = yield* _(Metrics.registerProducer(producer, () => new PrometheusExporter())) 47 | 48 | yield* _( 49 | Effect.promise(() => reader.collect()), 50 | Effect.flatMap(({ resourceMetrics }) => Console.log(serializer.serialize(resourceMetrics))), 51 | Effect.repeat({ 52 | schedule: Schedule.spaced("5 seconds") 53 | }), 54 | Effect.fork 55 | ) 56 | })) 57 | 58 | const program = Effect.gen(function*(_) { 59 | yield* _( 60 | Effect.log("Logging..."), 61 | Effect.schedule(Schedule.jitteredWith( 62 | Schedule.spaced("1 seconds"), 63 | { min: 0.5, max: 1.5 } 64 | )), 65 | Effect.fork 66 | ) 67 | yield* _( 68 | Effect.logError("Logging an error..."), 69 | Effect.schedule(Schedule.jitteredWith( 70 | Schedule.spaced("1 seconds"), 71 | { min: 0.5, max: 1.5 } 72 | )), 73 | Effect.fork 74 | ) 75 | }) 76 | 77 | const MainLive = MetricReporterLive.pipe( 78 | Layer.provide(ResourceLive), 79 | Layer.merge(MetricLoggerLive) 80 | ) 81 | 82 | // Disabling the `RuntimeMetrics` flag for cleaner output 83 | const runtime = Runtime.defaultRuntime.pipe( 84 | Runtime.disableRuntimeFlag(RuntimeFlags.RuntimeMetrics) 85 | ) 86 | 87 | program.pipe( 88 | Effect.awaitAllChildren, 89 | Effect.tapErrorCause(Effect.logError), 90 | Effect.scoped, 91 | Effect.provide(MainLive), 92 | Runtime.runFork(runtime) 93 | ) 94 | -------------------------------------------------------------------------------- /workshop/exercises/session-04/exercise-02.ts: -------------------------------------------------------------------------------- 1 | import { Metrics, Resource } from "@effect/opentelemetry" 2 | import { NodeContext, NodeHttpServer } from "@effect/platform-node" 3 | import * as Http from "@effect/platform/HttpServer" 4 | import { PrometheusExporter, PrometheusSerializer } from "@opentelemetry/exporter-prometheus" 5 | import { Console, Context, Effect, Layer, Runtime, RuntimeFlags, Schedule } from "effect" 6 | import { createServer } from "node:http" 7 | 8 | // Exercise Summary: 9 | // 10 | // The following exercise will explore how we can utilize metrics to gain deep 11 | // observability into the performance of our application. Effect allows for 12 | // extremely flexible composition of metrics with your Effect workflows. These 13 | // metrics can then be later captured and sent to a metrics server for later 14 | // investigation. 15 | // 16 | // Your task will be to implement two methods: `trackSuccessfulRequests` and 17 | // `trackMethodFrequency`. The requirements of each method are listed below. 18 | 19 | export declare const trackSuccessfulRequests: ( 20 | method: string, 21 | route: string 22 | ) => (self: Effect.Effect) => Effect.Effect 23 | // Your implementation should: 24 | // - Create a metric which tracks the **count** of successful requests made to our Http server 25 | // - NOTE: this metric should only capture successful requests 26 | // - Tag the metric with the request method name (i.e. `GET`) and the requested route 27 | // - Apply the metric to all routes 28 | 29 | export declare const trackMethodFrequency: ( 30 | method: string, 31 | route: string 32 | ) => (self: Effect.Effect) => Effect.Effect 33 | // Your implementation should: 34 | // - Create a metric which tracks the **frequency** of request methods (i.e. `GET`, `POST`) made to our Http server 35 | // - NOTE: this metric should capture both successful and failed requests 36 | // - Tag the metric with the requested route 37 | // - Apply the metric to all routes 38 | 39 | const router = Http.router.empty.pipe( 40 | Http.router.get( 41 | "/", 42 | Effect.map( 43 | Http.request.ServerRequest, 44 | (req) => Http.response.text(req.url) 45 | ) 46 | ), 47 | Http.router.get( 48 | "/healthz", 49 | Http.response.text("ok").pipe( 50 | Http.middleware.withLoggerDisabled 51 | ) 52 | ), 53 | Http.router.get( 54 | "/metrics", 55 | Effect.gen(function*(_) { 56 | const prometheusReporter = yield* _(PrometheusMetricReporter) 57 | const report = yield* _(prometheusReporter.report) 58 | return Http.response.text(report) 59 | }) 60 | ) 61 | ) 62 | 63 | class PrometheusMetricReporter extends Context.Tag("PrometheusMetricReporter")< 64 | PrometheusMetricReporter, 65 | { 66 | readonly report: Effect.Effect 67 | } 68 | >() { 69 | static readonly Live = Layer.scoped( 70 | PrometheusMetricReporter, 71 | Effect.gen(function*(_) { 72 | const serializer = new PrometheusSerializer() 73 | const producer = yield* _(Metrics.makeProducer) 74 | const reader = yield* _(Metrics.registerProducer(producer, () => new PrometheusExporter())) 75 | return { 76 | report: Effect.promise(() => reader.collect()).pipe( 77 | Effect.map(({ resourceMetrics }) => serializer.serialize(resourceMetrics)) 78 | ) 79 | } 80 | }) 81 | ) 82 | } 83 | 84 | class ConsoleMetricReporter extends Context.Tag("ConsoleReporter")() { 85 | static readonly Live = Layer.scopedDiscard(Effect.gen(function*(_) { 86 | const prometheusReporter = yield* _(PrometheusMetricReporter) 87 | yield* _( 88 | prometheusReporter.report, 89 | Effect.flatMap((report) => Console.log(report)), 90 | Effect.repeat({ schedule: Schedule.spaced("5 seconds") }), 91 | Effect.fork 92 | ) 93 | })) 94 | } 95 | 96 | const ServerLive = NodeHttpServer.server.layer(() => createServer(), { port: 8888 }) 97 | 98 | const MetricReportingLive = ConsoleMetricReporter.Live.pipe( 99 | Layer.provideMerge(PrometheusMetricReporter.Live), 100 | Layer.provide(Resource.layer({ 101 | serviceName: "advanced-effect-workshop", 102 | serviceVersion: "1.0.0" 103 | })) 104 | ) 105 | 106 | const HttpLive = router.pipe( 107 | Http.server.serve(Http.middleware.logger), 108 | Http.server.withLogAddress, 109 | Layer.provide(ServerLive), 110 | Layer.provide(NodeContext.layer) 111 | ) 112 | 113 | const MainLive = HttpLive.pipe( 114 | Layer.provide(MetricReportingLive) 115 | ) 116 | 117 | const runtime = Runtime.defaultRuntime.pipe( 118 | Runtime.disableRuntimeFlag(RuntimeFlags.RuntimeMetrics) 119 | ) 120 | 121 | Layer.launch(MainLive).pipe( 122 | Runtime.runFork(runtime) 123 | ) 124 | -------------------------------------------------------------------------------- /workshop/samples/session-01/execution-boundaries.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Runtime } from "effect" 2 | import * as express from "express" 3 | 4 | // ============================================================================= 5 | // Multi-Shot Callbacks 6 | // ============================================================================= 7 | 8 | export const startServer = (port: number) => 9 | Effect.gen(function*(_) { 10 | const app = yield* _(Effect.sync(() => express())) 11 | const runtime = yield* _(Effect.runtime()) 12 | const runFork = Runtime.runFork(runtime) 13 | app.listen(port, () => { 14 | runFork(Effect.log(`Server listening on port ${port}`)) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /workshop/samples/session-01/multi-shot-callbacks.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from "effect" 2 | import type { EventEmitter } from "node:events" 3 | 4 | // ============================================================================= 5 | // Multi-Shot Callbacks 6 | // ============================================================================= 7 | 8 | export const captureEvents = (emitter: EventEmitter, eventName: string) => 9 | Stream.async((emit) => { 10 | emitter.on(eventName, (data) => { 11 | emit.single(data) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /workshop/samples/session-01/single-shot-callbacks.ts: -------------------------------------------------------------------------------- 1 | import { Data, Effect } from "effect" 2 | import * as fs from "node:fs" 3 | 4 | // ============================================================================= 5 | // Single-Shot Callbacks 6 | // ============================================================================= 7 | 8 | export class ReadFileError extends Data.TaggedError("ReadFileError")<{ 9 | readonly error: Error 10 | }> {} 11 | 12 | export const readFile = (path: fs.PathOrFileDescriptor) => 13 | Effect.async((resume) => { 14 | fs.readFile(path, (error, data) => { 15 | if (error) { 16 | resume(Effect.fail(new ReadFileError({ error }))) 17 | } else { 18 | resume(Effect.succeed(data)) 19 | } 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /workshop/samples/session-01/wrappers.ts: -------------------------------------------------------------------------------- 1 | import { Data, Effect, Option, Secret, Stream } from "effect" 2 | import { OpenAI as OpenAIApi } from "openai" 3 | 4 | export interface OpenAIOptions { 5 | readonly apiKey: Secret.Secret 6 | readonly organization: Option.Option 7 | } 8 | 9 | export class OpenAIError extends Data.TaggedError("OpenAIError")<{ 10 | readonly error: unknown 11 | }> {} 12 | 13 | const handleError = (error: unknown) => 14 | new OpenAIError({ 15 | error: (error as any).error?.message ?? error 16 | }) 17 | 18 | // ============================================================================= 19 | // Option 1 - One-Off Approach 20 | // ============================================================================= 21 | 22 | export const oneOffApproach = ( 23 | client: OpenAIApi, 24 | body: OpenAIApi.ChatCompletionCreateParamsNonStreaming, 25 | options?: Omit 26 | ) => 27 | Effect.tryPromise({ 28 | try: (signal) => client.chat.completions.create(body, { ...options, signal }), 29 | catch: (error) => new OpenAIError({ error }) 30 | }) 31 | 32 | // ============================================================================= 33 | // Option 2 - Flexible Approach 34 | // ============================================================================= 35 | 36 | export const flexibleApproach = (options: OpenAIOptions) => 37 | Effect.gen(function*(_) { 38 | const client = yield* _(getClient(options)) 39 | 40 | const call = ( 41 | f: (client: OpenAIApi, signal: AbortSignal) => Promise 42 | ): Effect.Effect => 43 | Effect.tryPromise({ 44 | try: (signal) => f(client, signal), 45 | catch: (error) => new OpenAIError({ error }) 46 | }) 47 | 48 | return { 49 | call 50 | } 51 | }) 52 | 53 | // ============================================================================= 54 | // Option 3 - All-In Effect Approach 55 | // ============================================================================= 56 | 57 | export const comboApproach = (options: OpenAIOptions) => 58 | Effect.gen(function*(_) { 59 | const client = yield* _(getClient(options)) 60 | 61 | const call = ( 62 | f: (client: OpenAIApi, signal: AbortSignal) => Promise 63 | ): Effect.Effect => 64 | Effect.tryPromise({ 65 | try: (signal) => f(client, signal), 66 | catch: (error) => new OpenAIError({ error }) 67 | }) 68 | 69 | const completion = (options: { 70 | readonly model: string 71 | readonly system: string 72 | readonly maxTokens: number 73 | readonly messages: ReadonlyArray 74 | }) => 75 | call((_, signal) => 76 | _.chat.completions.create( 77 | { 78 | model: options.model, 79 | temperature: 0.1, 80 | max_tokens: options.maxTokens, 81 | messages: [ 82 | { 83 | role: "system", 84 | content: options.system 85 | }, 86 | ...options.messages 87 | ], 88 | stream: true 89 | }, 90 | { signal } 91 | ) 92 | ).pipe( 93 | Effect.map((stream) => Stream.fromAsyncIterable(stream, handleError)), 94 | Stream.unwrap, 95 | Stream.filterMap((chunk) => Option.fromNullable(chunk.choices[0].delta.content)) 96 | ) 97 | 98 | return { 99 | call, 100 | completion 101 | } 102 | }) 103 | 104 | // ============================================================================= 105 | // Internals 106 | // ============================================================================= 107 | 108 | const getClient = (options: OpenAIOptions): Effect.Effect => 109 | Effect.sync(() => 110 | new OpenAIApi({ 111 | apiKey: Secret.value(options.apiKey), 112 | organization: options.organization.pipe( 113 | Option.map(Secret.value), 114 | Option.getOrNull 115 | ) 116 | }) 117 | ) 118 | -------------------------------------------------------------------------------- /workshop/samples/session-02/deferred.ts: -------------------------------------------------------------------------------- 1 | import { Console, Deferred, Effect, Fiber } from "effect" 2 | 3 | const program = Effect.gen(function*(_) { 4 | // Create a deferred which can never fail and succeeds 5 | // with a string 6 | const deferred = yield* _(Deferred.make()) 7 | // Fork a fiber which will await the result of the deferred 8 | const fiber = yield* _( 9 | Effect.log("Waiting for deferred to complete..."), 10 | Effect.zipRight(Deferred.await(deferred)), 11 | Effect.zipLeft(Effect.log("Deferred complete!")), 12 | Effect.fork 13 | ) 14 | // Succeed the deferred after 1 second 15 | yield* _( 16 | Effect.log("Succeeding the deferred!"), 17 | Effect.zipRight(Deferred.succeed(deferred, "Hello, World!")), 18 | Effect.delay("1 seconds"), 19 | Effect.fork 20 | ) 21 | console.time("Fiber waiting") 22 | // Join the fiber to get its result 23 | const result = yield* _(Fiber.join(fiber)) 24 | console.timeEnd("Fiber waiting") 25 | // Log the result to the console 26 | yield* _(Console.log(result)) 27 | }) 28 | 29 | Effect.runFork(program) 30 | // After 1 second - "Hello, World!" 31 | -------------------------------------------------------------------------------- /workshop/samples/session-04/counter.ts: -------------------------------------------------------------------------------- 1 | import { Metric } from "effect" 2 | 3 | export const numberCounter = Metric.counter("request_count", { 4 | description: "A counter for tracking requests" 5 | }) 6 | 7 | export const bigintCounter = Metric.counter("error_count", { 8 | description: "A counter for tracking errors", 9 | bigint: true 10 | }) 11 | 12 | export const incrementalCounter = Metric.counter("count", { 13 | description: "a counter that only increases its value", 14 | incremental: true 15 | }) 16 | -------------------------------------------------------------------------------- /workshop/samples/session-04/frequency.ts: -------------------------------------------------------------------------------- 1 | import { Metric } from "effect" 2 | 3 | export const methodFrequency = Metric.frequency("http_method") 4 | -------------------------------------------------------------------------------- /workshop/samples/session-04/gauge.ts: -------------------------------------------------------------------------------- 1 | import { Metric } from "effect" 2 | 3 | export const numberGauge = Metric.gauge("memory_usage", { 4 | description: "A gauge for memory usage" 5 | }) 6 | 7 | export const bigintGauge = Metric.gauge("cpu_load", { 8 | description: "A gauge for CPU load", 9 | bigint: true 10 | }) 11 | -------------------------------------------------------------------------------- /workshop/samples/session-04/histogram.ts: -------------------------------------------------------------------------------- 1 | import { Metric, MetricBoundaries } from "effect" 2 | 3 | export const latencyHistogram = Metric.histogram( 4 | "request_latency", 5 | MetricBoundaries.linear({ start: 75, width: 25, count: 15 }) 6 | ) 7 | -------------------------------------------------------------------------------- /workshop/samples/session-04/summary.ts: -------------------------------------------------------------------------------- 1 | import { Metric } from "effect" 2 | 3 | export const responseTimeSummary = Metric.summary({ 4 | name: "response_time_summary", 5 | maxAge: "1 days", 6 | maxSize: 100, 7 | error: 0.03, 8 | quantiles: [0.1, 0.5, 0.9] 9 | }) 10 | -------------------------------------------------------------------------------- /workshop/solutions/session-01/exercise-00.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from "effect" 2 | 3 | export const MAX_SET_TIMEOUT_MILLIS = 2 ** 31 - 1 4 | 5 | const sleep = (millis: number): Effect.Effect => 6 | Effect.async((resume) => { 7 | const timeoutId = setTimeout(() => { 8 | resume(Effect.unit) 9 | }, Math.min(millis, MAX_SET_TIMEOUT_MILLIS)) 10 | return Effect.sync(() => { 11 | clearTimeout(timeoutId) 12 | }) 13 | }) 14 | 15 | const program = Effect.gen(function*(_) { 16 | const millis = 1_000 17 | yield* _(Effect.log(`Sleeping for ${millis} milliseconds...`)) 18 | yield* _(sleep(millis)) 19 | yield* _(Effect.log("Resuming execution!")) 20 | }) 21 | 22 | program.pipe( 23 | Effect.tapErrorCause(Effect.logError), 24 | Effect.runFork 25 | ) 26 | -------------------------------------------------------------------------------- /workshop/solutions/session-01/exercise-01.ts: -------------------------------------------------------------------------------- 1 | import { Console, Data, Effect, Random, Schedule, Stream } from "effect" 2 | import { EventEmitter } from "node:events" 3 | 4 | const captureEvents = ( 5 | emitter: EventEmitter, 6 | eventName: string 7 | ): Stream.Stream => 8 | Stream.async((emit) => { 9 | emitter.on(eventName, (error: EmissionError | null, value: Event) => { 10 | if (error) { 11 | emit.fail(error) 12 | } else { 13 | emit.single(value) 14 | } 15 | }) 16 | }) 17 | 18 | // ============================================================================= 19 | // Event Emitter 20 | // ============================================================================= 21 | 22 | class Event extends Data.TaggedClass("Event")<{ 23 | readonly value: number 24 | }> {} 25 | 26 | class EmissionError extends Data.TaggedError("EmissionError")<{ 27 | readonly message: string 28 | }> {} 29 | 30 | const emitEvents = (emitter: EventEmitter, eventName: string, eventCount: number) => 31 | Random.next.pipe( 32 | Effect.flatMap((value) => 33 | Effect.sync(() => { 34 | if (value < 0.1) { 35 | const error = new EmissionError({ message: `Received invalid value: ${value}` }) 36 | emitter.emit(eventName, error) 37 | } else { 38 | emitter.emit(eventName, null, new Event({ value })) 39 | } 40 | }) 41 | ), 42 | Effect.schedule( 43 | Schedule.recurs(eventCount).pipe( 44 | Schedule.intersect(Schedule.exponential("10 millis")) 45 | ) 46 | ) 47 | ) 48 | 49 | const program = Effect.gen(function*(_) { 50 | const emitter = yield* _(Effect.sync(() => new EventEmitter())) 51 | 52 | yield* _(Effect.fork(emitEvents(emitter, "emission", 20))) 53 | 54 | yield* _( 55 | captureEvents(emitter, "emission"), 56 | Stream.tap((event) => Console.log(event)), 57 | Stream.tapError((error) => Console.log(error)), 58 | Stream.runDrain 59 | ) 60 | }) 61 | 62 | program.pipe( 63 | Effect.tapErrorCause(Effect.logError), 64 | Effect.runFork 65 | ) 66 | -------------------------------------------------------------------------------- /workshop/solutions/session-01/project/advanced.ts: -------------------------------------------------------------------------------- 1 | import * as Schema from "@effect/schema/Schema" 2 | import bodyParser from "body-parser" 3 | import { 4 | Cause, 5 | Context, 6 | Data, 7 | Effect, 8 | Fiber, 9 | HashMap, 10 | Layer, 11 | MutableRef, 12 | Option, 13 | ReadonlyArray, 14 | Ref, 15 | Runtime, 16 | Supervisor 17 | } from "effect" 18 | import express from "express" 19 | import type * as NodeHttp from "node:http" 20 | import type * as NodeNet from "node:net" 21 | 22 | // ============================================================================= 23 | // Express Integration 24 | // ============================================================================= 25 | 26 | class Express extends Context.Tag("Express")< 27 | Express, 28 | Effect.Effect.Success> 29 | >() { 30 | static Live( 31 | hostname: string, 32 | port: number 33 | ): Layer.Layer 34 | static Live( 35 | hostname: string, 36 | port: number, 37 | exitHandler: ( 38 | req: express.Request, 39 | res: express.Response, 40 | next: express.NextFunction 41 | ) => (cause: Cause.Cause) => Effect.Effect 42 | ): Layer.Layer 43 | static Live( 44 | hostname: string, 45 | port: number, 46 | exitHandler?: ( 47 | req: express.Request, 48 | res: express.Response, 49 | next: express.NextFunction 50 | ) => (cause: Cause.Cause) => Effect.Effect 51 | ): Layer.Layer { 52 | return Layer.scoped(Express, makeExpress(hostname, port, exitHandler ?? defaultExitHandler)) 53 | } 54 | } 55 | 56 | export type ExitHandler = ( 57 | req: express.Request, 58 | res: express.Response, 59 | next: express.NextFunction 60 | ) => (cause: Cause.Cause) => Effect.Effect 61 | 62 | export class ServerError extends Data.TaggedError("ServerError")<{ 63 | readonly method: ServerMethod 64 | readonly error: Error 65 | }> {} 66 | 67 | export type ServerMethod = 68 | | "close" 69 | | "listen" 70 | 71 | export const makeExpress = ( 72 | hostname: string, 73 | port: number, 74 | exitHandler: ExitHandler 75 | ) => 76 | Effect.gen(function*(_) { 77 | // Create a ref to track whether or not the server is open to connections 78 | const open = yield* _(Effect.acquireRelease( 79 | Effect.succeed(MutableRef.make(true)), 80 | (ref) => Effect.succeed(MutableRef.set(ref, false)) 81 | )) 82 | 83 | // Create the Express Application 84 | const app = yield* _(Effect.sync(() => express())) 85 | 86 | // Create the Express Server 87 | const connections = new Set() 88 | const openServer = Effect.async((resume) => { 89 | const onError = (error: Error) => { 90 | resume(Effect.die(new ServerError({ method: "listen", error }))) 91 | } 92 | const server = app.listen(port, hostname, () => { 93 | resume(Effect.sync(() => { 94 | server.removeListener("error", onError) 95 | return server 96 | })) 97 | }) 98 | server.addListener("error", onError) 99 | server.on("connection", (connection) => { 100 | connections.add(connection) 101 | connection.on("close", () => { 102 | connections.delete(connection) 103 | }) 104 | }) 105 | }) 106 | const closeServer = (server: NodeHttp.Server) => 107 | Effect.async((resume) => { 108 | connections.forEach((connection) => { 109 | connection.end() 110 | connection.destroy() 111 | }) 112 | server.close((error) => { 113 | if (error) { 114 | resume(Effect.die(new ServerError({ method: "close", error }))) 115 | } else { 116 | resume(Effect.unit) 117 | } 118 | }) 119 | }) 120 | const server = yield* _(Effect.acquireRelease( 121 | openServer, 122 | (server) => closeServer(server) 123 | )) 124 | 125 | // Create a supervisor to properly track and propagate interruption 126 | const supervisor = yield* _(Effect.acquireRelease( 127 | Supervisor.track, 128 | (supervisor) => supervisor.value.pipe(Effect.flatMap(Fiber.interruptAll)) 129 | )) 130 | 131 | // Allow for providing route handlers to a custom Express runtime 132 | const runtime = < 133 | Handlers extends ReadonlyArray.NonEmptyReadonlyArray< 134 | EffectRequestHandler 135 | > 136 | >(handlers: Handlers) => 137 | Effect.runtime>>().pipe( 138 | Effect.map((runtime) => 139 | ReadonlyArray.map(handlers, (handler): express.RequestHandler => (req, res, next) => 140 | Runtime.runFork(runtime)( 141 | Effect.onError( 142 | MutableRef.get(open) ? handler(req, res, next) : Effect.interrupt, 143 | exitHandler(req, res, next) 144 | ) 145 | )) 146 | ), 147 | Effect.supervised(supervisor) 148 | ) 149 | 150 | return { 151 | app, 152 | server, 153 | runtime 154 | } 155 | }) 156 | 157 | export const withExpressApp = (f: (app: express.Express) => Effect.Effect) => 158 | Express.pipe(Effect.flatMap(({ app }) => f(app))) 159 | 160 | export const withExpressServer = ( 161 | f: (server: NodeHttp.Server) => Effect.Effect 162 | ) => Express.pipe(Effect.flatMap(({ server }) => f(server))) 163 | 164 | export const withExpressRuntime = Effect.serviceFunctionEffect(Express, ({ runtime }) => runtime) 165 | 166 | export const defaultExitHandler = 167 | (_: express.Request, res: express.Response, _next: express.NextFunction) => 168 | (cause: Cause.Cause): Effect.Effect => 169 | Cause.isDie(cause) 170 | ? Effect.logError(cause) 171 | : Effect.sync(() => res.status(500).end()) 172 | 173 | export const methods = [ 174 | "all", 175 | "get", 176 | "post", 177 | "put", 178 | "delete", 179 | "patch", 180 | "options", 181 | "head", 182 | "checkout", 183 | "connect", 184 | "copy", 185 | "lock", 186 | "merge", 187 | "mkactivity", 188 | "mkcol", 189 | "move", 190 | "m-search", 191 | "notify", 192 | "propfind", 193 | "proppatch", 194 | "purge", 195 | "report", 196 | "search", 197 | "subscribe", 198 | "trace", 199 | "unlock", 200 | "unsubscribe" 201 | ] as const 202 | 203 | export type Methods = typeof methods[number] 204 | 205 | export type PathParams = string | RegExp | Array 206 | 207 | export interface ParamsDictionary { 208 | [key: string]: string 209 | } 210 | 211 | export interface ParsedQs { 212 | [key: string]: undefined | string | Array | ParsedQs | Array 213 | } 214 | 215 | export interface EffectRequestHandler< 216 | R, 217 | P = ParamsDictionary, 218 | ResBody = any, 219 | ReqBody = any, 220 | ReqQuery = ParsedQs, 221 | Locals extends Record = Record 222 | > { 223 | ( 224 | req: express.Request, 225 | res: express.Response, 226 | next: express.NextFunction 227 | ): Effect.Effect 228 | } 229 | 230 | const match = (method: Methods) => 231 | < 232 | Handlers extends ReadonlyArray.NonEmptyReadonlyArray< 233 | EffectRequestHandler 234 | > 235 | >(path: PathParams, ...handlers: Handlers): Effect.Effect< 236 | void, 237 | never, 238 | | Express 239 | | Effect.Effect.Context> 240 | > => 241 | withExpressRuntime(handlers).pipe( 242 | Effect.flatMap((handlers) => 243 | withExpressApp((app) => 244 | Effect.sync(() => { 245 | app[method](path, ...handlers) 246 | }) 247 | ) 248 | ) 249 | ) 250 | 251 | export const all = match("all") 252 | export const get = match("get") 253 | export const post = match("post") 254 | export const put = match("put") 255 | const delete_ = match("delete") 256 | export { delete_ as delete } 257 | export const patch = match("patch") 258 | export const options = match("options") 259 | export const head = match("head") 260 | export const checkout = match("checkout") 261 | export const connect = match("connect") 262 | export const copy = match("copy") 263 | export const lock = match("lock") 264 | export const merge = match("merge") 265 | export const mkactivity = match("mkactivity") 266 | export const mkcol = match("mkcol") 267 | export const move = match("move") 268 | export const mSearch = match("m-search") 269 | export const notify = match("notify") 270 | export const propfind = match("propfind") 271 | export const proppatch = match("proppatch") 272 | export const purge = match("purge") 273 | export const report = match("report") 274 | export const search = match("search") 275 | export const subscribe = match("subscribe") 276 | export const trace = match("trace") 277 | export const unlock = match("unlock") 278 | export const unsubscribe = match("unsubscribe") 279 | 280 | export function use< 281 | Handlers extends ReadonlyArray.NonEmptyReadonlyArray< 282 | EffectRequestHandler 283 | > 284 | >( 285 | ...handlers: Handlers 286 | ): Effect.Effect< 287 | void, 288 | never, 289 | | Express 290 | | Effect.Effect.Context> 291 | > 292 | export function use< 293 | Handlers extends ReadonlyArray.NonEmptyReadonlyArray< 294 | EffectRequestHandler 295 | > 296 | >( 297 | path: PathParams, 298 | ...handlers: Handlers 299 | ): Effect.Effect< 300 | void, 301 | never, 302 | | Express 303 | | Effect.Effect.Context> 304 | > 305 | export function use(...args: Array): Effect.Effect { 306 | return withExpressApp((app) => { 307 | if (typeof args[0] === "function") { 308 | return withExpressRuntime( 309 | args as unknown as ReadonlyArray.NonEmptyReadonlyArray< 310 | EffectRequestHandler 311 | > 312 | ).pipe(Effect.flatMap((handlers) => Effect.sync(() => app.use(...handlers)))) 313 | } else { 314 | return withExpressRuntime( 315 | args.slice(1) as unknown as ReadonlyArray.NonEmptyReadonlyArray< 316 | EffectRequestHandler 317 | > 318 | ).pipe(Effect.flatMap((handlers) => Effect.sync(() => app.use(args[0], ...handlers)))) 319 | } 320 | }) 321 | } 322 | 323 | // ============================================================================= 324 | // Todo 325 | // ============================================================================= 326 | 327 | class Todo extends Schema.Class()({ 328 | id: Schema.number, 329 | title: Schema.string, 330 | completed: Schema.boolean 331 | }) {} 332 | 333 | const CreateTodoParams = Todo.struct.pipe(Schema.omit("id")) 334 | type CreateTodoParams = Schema.Schema.To 335 | 336 | const UpdateTodoParams = Schema.partial(Todo.struct, { exact: true }).pipe(Schema.omit("id")) 337 | type UpdateTodoParams = Schema.Schema.To 338 | 339 | // ============================================================================= 340 | // TodoRepository 341 | // ============================================================================= 342 | 343 | const makeTodoRepository = Effect.gen(function*(_) { 344 | const nextIdRef = yield* _(Ref.make(0)) 345 | const todosRef = yield* _(Ref.make(HashMap.empty())) 346 | 347 | const getTodo = (id: number): Effect.Effect, never, never> => 348 | Ref.get(todosRef).pipe(Effect.map(HashMap.get(id))) 349 | 350 | const getTodos: Effect.Effect, never, never> = Ref.get(todosRef).pipe( 351 | Effect.map((map) => ReadonlyArray.fromIterable(HashMap.values(map))) 352 | ) 353 | 354 | const createTodo = (params: CreateTodoParams): Effect.Effect => 355 | Ref.getAndUpdate(nextIdRef, (n) => n + 1).pipe( 356 | Effect.flatMap((id) => 357 | Ref.modify(todosRef, (map) => { 358 | const newTodo = new Todo({ ...params, id }) 359 | const updated = HashMap.set(map, newTodo.id, newTodo) 360 | return [newTodo.id, updated] 361 | }) 362 | ) 363 | ) 364 | 365 | const updateTodo = ( 366 | id: number, 367 | params: UpdateTodoParams 368 | ): Effect.Effect => 369 | Ref.get(todosRef).pipe(Effect.flatMap((map) => { 370 | const maybeTodo = HashMap.get(map, id) 371 | if (Option.isNone(maybeTodo)) { 372 | return Effect.fail(new Cause.NoSuchElementException()) 373 | } 374 | const newTodo = new Todo({ ...maybeTodo.value, ...params }) 375 | const updated = HashMap.set(map, id, newTodo) 376 | return Ref.set(todosRef, updated).pipe(Effect.as(newTodo)) 377 | })) 378 | 379 | const deleteTodo = (id: number): Effect.Effect => 380 | Ref.get(todosRef).pipe(Effect.flatMap((map) => 381 | HashMap.has(map, id) 382 | ? Ref.set(todosRef, HashMap.remove(map, id)).pipe(Effect.as(true)) 383 | : Effect.succeed(false) 384 | )) 385 | 386 | return { 387 | getTodo, 388 | getTodos, 389 | createTodo, 390 | updateTodo, 391 | deleteTodo 392 | } as const 393 | }) 394 | 395 | class TodoRepository extends Context.Tag("TodoRepository")< 396 | TodoRepository, 397 | Effect.Effect.Success 398 | >() { 399 | static readonly Live = Layer.effect(TodoRepository, makeTodoRepository) 400 | } 401 | 402 | // ============================================================================= 403 | // Application 404 | // ============================================================================= 405 | 406 | const server = Effect.all([ 407 | use((req, res, next) => Effect.sync(() => bodyParser.json()(req, res, next))), 408 | // GET /todos/id 409 | get("/todos/:id", (req, res) => { 410 | const id = req.params.id 411 | return TodoRepository.pipe( 412 | Effect.flatMap((repo) => repo.getTodo(Number(id))), 413 | Effect.flatMap(Option.match({ 414 | onNone: () => Effect.sync(() => res.status(404).json(`Todo ${id} not found`)), 415 | onSome: (todo) => Effect.sync(() => res.json(todo)) 416 | })) 417 | ) 418 | }), 419 | // GET /todos 420 | get("/todos", (_, res) => 421 | TodoRepository.pipe( 422 | Effect.flatMap((repo) => repo.getTodos), 423 | Effect.flatMap((todos) => Effect.sync(() => res.json(todos))) 424 | )), 425 | // POST /todos 426 | post("/todos", (req, res) => { 427 | const decodeBody = Schema.decodeUnknown(CreateTodoParams) 428 | return TodoRepository.pipe( 429 | Effect.flatMap((repo) => 430 | decodeBody(req.body).pipe(Effect.matchEffect({ 431 | onFailure: () => Effect.sync(() => res.status(400).json("Invalid Todo")), 432 | onSuccess: (todo) => 433 | repo.createTodo(todo).pipe( 434 | Effect.flatMap((id) => Effect.sync(() => res.json(id))) 435 | ) 436 | })) 437 | ) 438 | ) 439 | }), 440 | // PUT /todos/:id 441 | put("/todos/:id", (req, res) => { 442 | const id = req.params.id 443 | const decodeBody = Schema.decodeUnknown(UpdateTodoParams) 444 | return TodoRepository.pipe(Effect.flatMap((repo) => 445 | decodeBody(req.body).pipe( 446 | Effect.matchEffect({ 447 | onFailure: () => Effect.sync(() => res.status(400).json("Invalid todo")), 448 | onSuccess: (todo) => 449 | repo.updateTodo(Number(id), todo).pipe( 450 | Effect.matchEffect({ 451 | onFailure: () => Effect.sync(() => res.status(404).json(`Todo ${id} not found`)), 452 | onSuccess: (todo) => Effect.sync(() => res.json(todo)) 453 | }) 454 | ) 455 | }) 456 | ) 457 | )) 458 | }), 459 | // DELETE /todos/:id 460 | delete_("/todos/:id", (req, res) => { 461 | const id = req.params.id 462 | return TodoRepository.pipe( 463 | Effect.flatMap((repo) => repo.deleteTodo(Number(id))), 464 | Effect.flatMap((deleted) => Effect.sync(() => res.json({ deleted }))) 465 | ) 466 | }) 467 | ]) 468 | 469 | const MainLive = Express.Live("127.0.0.1", 8888).pipe( 470 | Layer.merge(TodoRepository.Live) 471 | ) 472 | 473 | server.pipe( 474 | Effect.zipRight(Effect.never), 475 | Effect.provide(MainLive), 476 | Effect.tapErrorCause(Effect.logError), 477 | Effect.runFork 478 | ) 479 | -------------------------------------------------------------------------------- /workshop/solutions/session-01/project/stage-01.ts: -------------------------------------------------------------------------------- 1 | import * as Schema from "@effect/schema/Schema" 2 | import bodyParser from "body-parser" 3 | import { Cause, Context, Effect, HashMap, Layer, Option, ReadonlyArray, Ref, Runtime } from "effect" 4 | import express from "express" 5 | 6 | // ============================================================================= 7 | // Server 8 | // ============================================================================= 9 | 10 | const ServerLive = Layer.scopedDiscard( 11 | Effect.gen(function*(_) { 12 | const port = 8888 13 | const app = yield* _(Express) 14 | const runtime = yield* _(Effect.runtime()) 15 | const runFork = Runtime.runFork(runtime) 16 | yield* _( 17 | Effect.acquireRelease( 18 | Effect.sync(() => 19 | app.listen(port, () => { 20 | runFork(Effect.log(`Server listening for requests on port: ${port}`)) 21 | }) 22 | ), 23 | (server) => Effect.sync(() => server.close()) 24 | ) 25 | ) 26 | }) 27 | ) 28 | 29 | // ============================================================================= 30 | // Routes 31 | // ============================================================================= 32 | 33 | const GetTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 34 | })) 35 | 36 | const GetAllTodosRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 37 | })) 38 | 39 | const CreateTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 40 | })) 41 | 42 | const UpdateTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 43 | })) 44 | 45 | const DeleteTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 46 | })) 47 | 48 | // ============================================================================= 49 | // Todo 50 | // ============================================================================= 51 | 52 | class Todo extends Schema.Class()({ 53 | id: Schema.number, 54 | title: Schema.string, 55 | completed: Schema.boolean 56 | }) {} 57 | 58 | const CreateTodoParams = Todo.struct.pipe(Schema.omit("id")) 59 | type CreateTodoParams = Schema.Schema.To 60 | 61 | const UpdateTodoParams = Schema.partial(Todo.struct, { exact: true }).pipe(Schema.omit("id")) 62 | type UpdateTodoParams = Schema.Schema.To 63 | 64 | // ============================================================================= 65 | // TodoRepository 66 | // ============================================================================= 67 | 68 | const makeTodoRepository = Effect.gen(function*(_) { 69 | const nextIdRef = yield* _(Ref.make(0)) 70 | const todosRef = yield* _(Ref.make(HashMap.empty())) 71 | 72 | const getTodo = (id: number): Effect.Effect> => 73 | Ref.get(todosRef).pipe(Effect.map(HashMap.get(id))) 74 | 75 | const getTodos: Effect.Effect> = Ref.get(todosRef).pipe( 76 | Effect.map((map) => ReadonlyArray.fromIterable(HashMap.values(map))) 77 | ) 78 | 79 | const createTodo = (params: CreateTodoParams): Effect.Effect => 80 | Ref.getAndUpdate(nextIdRef, (n) => n + 1).pipe( 81 | Effect.flatMap((id) => 82 | Ref.modify(todosRef, (map) => { 83 | const newTodo = new Todo({ ...params, id }) 84 | const updated = HashMap.set(map, newTodo.id, newTodo) 85 | return [newTodo.id, updated] 86 | }) 87 | ) 88 | ) 89 | 90 | const updateTodo = ( 91 | id: number, 92 | params: UpdateTodoParams 93 | ): Effect.Effect => 94 | Ref.get(todosRef).pipe(Effect.flatMap((map) => { 95 | const maybeTodo = HashMap.get(map, id) 96 | if (Option.isNone(maybeTodo)) { 97 | return Effect.fail(new Cause.NoSuchElementException()) 98 | } 99 | const newTodo = new Todo({ ...maybeTodo.value, ...params }) 100 | const updated = HashMap.set(map, id, newTodo) 101 | return Ref.set(todosRef, updated).pipe(Effect.as(newTodo)) 102 | })) 103 | 104 | const deleteTodo = (id: number): Effect.Effect => 105 | Ref.get(todosRef).pipe(Effect.flatMap((map) => 106 | HashMap.has(map, id) 107 | ? Ref.set(todosRef, HashMap.remove(map, id)).pipe(Effect.as(true)) 108 | : Effect.succeed(false) 109 | )) 110 | 111 | return { 112 | getTodo, 113 | getTodos, 114 | createTodo, 115 | updateTodo, 116 | deleteTodo 117 | } as const 118 | }) 119 | 120 | class TodoRepository extends Context.Tag("TodoRepository")< 121 | TodoRepository, 122 | Effect.Effect.Success 123 | >() { 124 | static readonly Live = Layer.effect(TodoRepository, makeTodoRepository) 125 | } 126 | 127 | // ============================================================================= 128 | // Express 129 | // ============================================================================= 130 | 131 | class Express extends Context.Tag("Express")>() { 132 | static readonly Live = Layer.sync(Express, () => { 133 | const app = express() 134 | app.use(bodyParser.json()) 135 | return app 136 | }) 137 | } 138 | 139 | // ============================================================================= 140 | // Program 141 | // ============================================================================= 142 | 143 | const MainLive = ServerLive.pipe( 144 | Layer.merge(GetTodoRouteLive), 145 | Layer.merge(GetAllTodosRouteLive), 146 | Layer.merge(CreateTodoRouteLive), 147 | Layer.merge(UpdateTodoRouteLive), 148 | Layer.merge(DeleteTodoRouteLive), 149 | Layer.provide(Express.Live), 150 | Layer.provide(TodoRepository.Live) 151 | ) 152 | 153 | Layer.launch(MainLive).pipe( 154 | Effect.tapErrorCause(Effect.logError), 155 | Effect.runFork 156 | ) 157 | -------------------------------------------------------------------------------- /workshop/solutions/session-01/project/stage-02.ts: -------------------------------------------------------------------------------- 1 | import * as Schema from "@effect/schema/Schema" 2 | import bodyParser from "body-parser" 3 | import { 4 | Cause, 5 | Context, 6 | Effect, 7 | FiberSet, 8 | HashMap, 9 | Layer, 10 | Option, 11 | ReadonlyArray, 12 | Ref, 13 | Runtime 14 | } from "effect" 15 | import express from "express" 16 | 17 | // ============================================================================= 18 | // Server 19 | // ============================================================================= 20 | 21 | const ServerLive = Layer.scopedDiscard( 22 | Effect.gen(function*(_) { 23 | const port = 8888 24 | const app = yield* _(Express) 25 | const runtime = yield* _(Effect.runtime()) 26 | const runFork = Runtime.runFork(runtime) 27 | yield* _( 28 | Effect.acquireRelease( 29 | Effect.sync(() => 30 | app.listen(port, () => { 31 | runFork(Effect.log(`Server listening for requests on port: ${port}`)) 32 | }) 33 | ), 34 | (server) => Effect.sync(() => server.close()) 35 | ) 36 | ) 37 | }) 38 | ) 39 | 40 | // ============================================================================= 41 | // Routes 42 | // ============================================================================= 43 | 44 | const GetTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 45 | const app = yield* _(Express) 46 | const runFork = yield* _(FiberSet.makeRuntime()) 47 | app.get("/todos/:id", (req, res) => { 48 | const id = req.params.id 49 | const program = TodoRepository.pipe( 50 | Effect.flatMap((repo) => repo.getTodo(Number(id))), 51 | Effect.flatMap(Option.match({ 52 | onNone: () => Effect.sync(() => res.status(404).json(`Todo ${id} not found`)), 53 | onSome: (todo) => Effect.sync(() => res.json(todo)) 54 | })), 55 | Effect.asUnit 56 | ) 57 | runFork(program) 58 | }) 59 | })) 60 | 61 | const GetAllTodosRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 62 | })) 63 | 64 | const CreateTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 65 | })) 66 | 67 | const UpdateTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 68 | })) 69 | 70 | const DeleteTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 71 | })) 72 | 73 | // ============================================================================= 74 | // Todo 75 | // ============================================================================= 76 | 77 | class Todo extends Schema.Class()({ 78 | id: Schema.number, 79 | title: Schema.string, 80 | completed: Schema.boolean 81 | }) {} 82 | 83 | const CreateTodoParams = Todo.struct.pipe(Schema.omit("id")) 84 | type CreateTodoParams = Schema.Schema.To 85 | 86 | const UpdateTodoParams = Schema.partial(Todo.struct, { exact: true }).pipe(Schema.omit("id")) 87 | type UpdateTodoParams = Schema.Schema.To 88 | 89 | // ============================================================================= 90 | // TodoRepository 91 | // ============================================================================= 92 | 93 | const makeTodoRepository = Effect.gen(function*(_) { 94 | const nextIdRef = yield* _(Ref.make(0)) 95 | const todosRef = yield* _(Ref.make(HashMap.empty())) 96 | 97 | const getTodo = (id: number): Effect.Effect> => 98 | Ref.get(todosRef).pipe(Effect.map(HashMap.get(id))) 99 | 100 | const getTodos: Effect.Effect> = Ref.get(todosRef).pipe( 101 | Effect.map((map) => ReadonlyArray.fromIterable(HashMap.values(map))) 102 | ) 103 | 104 | const createTodo = (params: CreateTodoParams): Effect.Effect => 105 | Ref.getAndUpdate(nextIdRef, (n) => n + 1).pipe( 106 | Effect.flatMap((id) => 107 | Ref.modify(todosRef, (map) => { 108 | const newTodo = new Todo({ ...params, id }) 109 | const updated = HashMap.set(map, newTodo.id, newTodo) 110 | return [newTodo.id, updated] 111 | }) 112 | ) 113 | ) 114 | 115 | const updateTodo = ( 116 | id: number, 117 | params: UpdateTodoParams 118 | ): Effect.Effect => 119 | Ref.get(todosRef).pipe(Effect.flatMap((map) => { 120 | const maybeTodo = HashMap.get(map, id) 121 | if (Option.isNone(maybeTodo)) { 122 | return Effect.fail(new Cause.NoSuchElementException()) 123 | } 124 | const newTodo = new Todo({ ...maybeTodo.value, ...params }) 125 | const updated = HashMap.set(map, id, newTodo) 126 | return Ref.set(todosRef, updated).pipe(Effect.as(newTodo)) 127 | })) 128 | 129 | const deleteTodo = (id: number): Effect.Effect => 130 | Ref.get(todosRef).pipe(Effect.flatMap((map) => 131 | HashMap.has(map, id) 132 | ? Ref.set(todosRef, HashMap.remove(map, id)).pipe(Effect.as(true)) 133 | : Effect.succeed(false) 134 | )) 135 | 136 | return { 137 | getTodo, 138 | getTodos, 139 | createTodo, 140 | updateTodo, 141 | deleteTodo 142 | } as const 143 | }) 144 | 145 | class TodoRepository extends Context.Tag("TodoRepository")< 146 | TodoRepository, 147 | Effect.Effect.Success 148 | >() { 149 | static readonly Live = Layer.effect(TodoRepository, makeTodoRepository) 150 | } 151 | 152 | // ============================================================================= 153 | // Express 154 | // ============================================================================= 155 | 156 | class Express extends Context.Tag("Express")>() { 157 | static readonly Live = Layer.sync(Express, () => { 158 | const app = express() 159 | app.use(bodyParser.json()) 160 | return app 161 | }) 162 | } 163 | 164 | // ============================================================================= 165 | // Program 166 | // ============================================================================= 167 | 168 | const MainLive = ServerLive.pipe( 169 | Layer.merge(GetTodoRouteLive), 170 | Layer.merge(GetAllTodosRouteLive), 171 | Layer.merge(CreateTodoRouteLive), 172 | Layer.merge(UpdateTodoRouteLive), 173 | Layer.merge(DeleteTodoRouteLive), 174 | Layer.provide(Express.Live), 175 | Layer.provide(TodoRepository.Live) 176 | ) 177 | 178 | Layer.launch(MainLive).pipe( 179 | Effect.tapErrorCause(Effect.logError), 180 | Effect.runFork 181 | ) 182 | -------------------------------------------------------------------------------- /workshop/solutions/session-01/project/stage-03.ts: -------------------------------------------------------------------------------- 1 | import * as Schema from "@effect/schema/Schema" 2 | import bodyParser from "body-parser" 3 | import { 4 | Cause, 5 | Context, 6 | Effect, 7 | FiberSet, 8 | HashMap, 9 | Layer, 10 | Option, 11 | ReadonlyArray, 12 | Ref, 13 | Runtime 14 | } from "effect" 15 | import express from "express" 16 | 17 | // ============================================================================= 18 | // Server 19 | // ============================================================================= 20 | 21 | const ServerLive = Layer.scopedDiscard( 22 | Effect.gen(function*(_) { 23 | const port = 8888 24 | const app = yield* _(Express) 25 | const runtime = yield* _(Effect.runtime()) 26 | const runFork = Runtime.runFork(runtime) 27 | yield* _( 28 | Effect.acquireRelease( 29 | Effect.sync(() => 30 | app.listen(port, () => { 31 | runFork(Effect.log(`Server listening for requests on port: ${port}`)) 32 | }) 33 | ), 34 | (server) => Effect.sync(() => server.close()) 35 | ) 36 | ) 37 | }) 38 | ) 39 | 40 | // ============================================================================= 41 | // Routes 42 | // ============================================================================= 43 | 44 | const GetTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 45 | const app = yield* _(Express) 46 | const runFork = yield* _(FiberSet.makeRuntime()) 47 | app.get("/todos/:id", (req, res) => { 48 | const id = req.params.id 49 | const program = TodoRepository.pipe( 50 | Effect.flatMap((repo) => repo.getTodo(Number(id))), 51 | Effect.flatMap(Option.match({ 52 | onNone: () => Effect.sync(() => res.status(404).json(`Todo ${id} not found`)), 53 | onSome: (todo) => Effect.sync(() => res.json(todo)) 54 | })), 55 | Effect.asUnit 56 | ) 57 | runFork(program) 58 | }) 59 | })) 60 | 61 | const GetAllTodosRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 62 | const app = yield* _(Express) 63 | const runFork = yield* _(FiberSet.makeRuntime()) 64 | app.get("/todos", (_, res) => { 65 | const program = TodoRepository.pipe( 66 | Effect.flatMap((repo) => repo.getTodos), 67 | Effect.flatMap((todos) => Effect.sync(() => res.json(todos))), 68 | Effect.asUnit 69 | ) 70 | runFork(program) 71 | }) 72 | })) 73 | 74 | const CreateTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 75 | const app = yield* _(Express) 76 | const runFork = yield* _(FiberSet.makeRuntime()) 77 | app.post("/todos", (req, res) => { 78 | const decodeBody = Schema.decodeUnknown(CreateTodoParams) 79 | const program = TodoRepository.pipe( 80 | Effect.flatMap((repo) => 81 | decodeBody(req.body).pipe(Effect.matchEffect({ 82 | onFailure: () => Effect.sync(() => res.status(400).json("Invalid Todo")), 83 | onSuccess: (todo) => 84 | repo.createTodo(todo).pipe( 85 | Effect.flatMap((id) => Effect.sync(() => res.json(id))) 86 | ) 87 | })) 88 | ), 89 | Effect.asUnit 90 | ) 91 | runFork(program) 92 | }) 93 | })) 94 | 95 | const UpdateTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 96 | const app = yield* _(Express) 97 | const runFork = yield* _(FiberSet.makeRuntime()) 98 | app.put("/todos/:id", (req, res) => { 99 | const id = req.params.id 100 | const decodeBody = Schema.decodeUnknown(UpdateTodoParams) 101 | const program = TodoRepository.pipe( 102 | Effect.flatMap((repo) => 103 | decodeBody(req.body).pipe( 104 | Effect.matchEffect({ 105 | onFailure: () => Effect.sync(() => res.status(400).json("Invalid todo")), 106 | onSuccess: (todo) => 107 | repo.updateTodo(Number(id), todo).pipe( 108 | Effect.matchEffect({ 109 | onFailure: () => Effect.sync(() => res.status(404).json(`Todo ${id} not found`)), 110 | onSuccess: (todo) => Effect.sync(() => res.json(todo)) 111 | }) 112 | ) 113 | }) 114 | ) 115 | ), 116 | Effect.asUnit 117 | ) 118 | runFork(program) 119 | }) 120 | })) 121 | 122 | const DeleteTodoRouteLive = Layer.scopedDiscard(Effect.gen(function*(_) { 123 | const app = yield* _(Express) 124 | const runFork = yield* _(FiberSet.makeRuntime()) 125 | app.delete("/todo/:id", (req, res) => { 126 | const id = req.params.id 127 | const program = TodoRepository.pipe( 128 | Effect.flatMap((repo) => repo.deleteTodo(Number(id))), 129 | Effect.flatMap((deleted) => Effect.sync(() => res.json({ deleted }))), 130 | Effect.asUnit 131 | ) 132 | runFork(program) 133 | }) 134 | })) 135 | 136 | // ============================================================================= 137 | // Todo 138 | // ============================================================================= 139 | 140 | class Todo extends Schema.Class()({ 141 | id: Schema.number, 142 | title: Schema.string, 143 | completed: Schema.boolean 144 | }) {} 145 | 146 | const CreateTodoParams = Todo.struct.pipe(Schema.omit("id")) 147 | type CreateTodoParams = Schema.Schema.To 148 | 149 | const UpdateTodoParams = Schema.partial(Todo.struct, { exact: true }).pipe(Schema.omit("id")) 150 | type UpdateTodoParams = Schema.Schema.To 151 | 152 | // ============================================================================= 153 | // TodoRepository 154 | // ============================================================================= 155 | 156 | const makeTodoRepository = Effect.gen(function*(_) { 157 | const nextIdRef = yield* _(Ref.make(0)) 158 | const todosRef = yield* _(Ref.make(HashMap.empty())) 159 | 160 | const getTodo = (id: number): Effect.Effect> => 161 | Ref.get(todosRef).pipe(Effect.map(HashMap.get(id))) 162 | 163 | const getTodos: Effect.Effect> = Ref.get(todosRef).pipe( 164 | Effect.map((map) => ReadonlyArray.fromIterable(HashMap.values(map))) 165 | ) 166 | 167 | const createTodo = (params: CreateTodoParams): Effect.Effect => 168 | Ref.getAndUpdate(nextIdRef, (n) => n + 1).pipe( 169 | Effect.flatMap((id) => 170 | Ref.modify(todosRef, (map) => { 171 | const newTodo = new Todo({ ...params, id }) 172 | const updated = HashMap.set(map, newTodo.id, newTodo) 173 | return [newTodo.id, updated] 174 | }) 175 | ) 176 | ) 177 | 178 | const updateTodo = ( 179 | id: number, 180 | params: UpdateTodoParams 181 | ): Effect.Effect => 182 | Ref.get(todosRef).pipe(Effect.flatMap((map) => { 183 | const maybeTodo = HashMap.get(map, id) 184 | if (Option.isNone(maybeTodo)) { 185 | return Effect.fail(new Cause.NoSuchElementException()) 186 | } 187 | const newTodo = new Todo({ ...maybeTodo.value, ...params }) 188 | const updated = HashMap.set(map, id, newTodo) 189 | return Ref.set(todosRef, updated).pipe(Effect.as(newTodo)) 190 | })) 191 | 192 | const deleteTodo = (id: number): Effect.Effect => 193 | Ref.get(todosRef).pipe(Effect.flatMap((map) => 194 | HashMap.has(map, id) 195 | ? Ref.set(todosRef, HashMap.remove(map, id)).pipe(Effect.as(true)) 196 | : Effect.succeed(false) 197 | )) 198 | 199 | return { 200 | getTodo, 201 | getTodos, 202 | createTodo, 203 | updateTodo, 204 | deleteTodo 205 | } as const 206 | }) 207 | 208 | class TodoRepository extends Context.Tag("TodoRepository")< 209 | TodoRepository, 210 | Effect.Effect.Success 211 | >() { 212 | static readonly Live = Layer.effect(TodoRepository, makeTodoRepository) 213 | } 214 | 215 | // ============================================================================= 216 | // Express 217 | // ============================================================================= 218 | 219 | class Express extends Context.Tag("Express")>() { 220 | static readonly Live = Layer.sync(Express, () => { 221 | const app = express() 222 | app.use(bodyParser.json()) 223 | return app 224 | }) 225 | } 226 | 227 | // ============================================================================= 228 | // Program 229 | // ============================================================================= 230 | 231 | const MainLive = ServerLive.pipe( 232 | Layer.merge(GetTodoRouteLive), 233 | Layer.merge(GetAllTodosRouteLive), 234 | Layer.merge(CreateTodoRouteLive), 235 | Layer.merge(UpdateTodoRouteLive), 236 | Layer.merge(DeleteTodoRouteLive), 237 | Layer.provide(Express.Live), 238 | Layer.provide(TodoRepository.Live) 239 | ) 240 | 241 | Layer.launch(MainLive).pipe( 242 | Effect.tapErrorCause(Effect.logError), 243 | Effect.runFork 244 | ) 245 | -------------------------------------------------------------------------------- /workshop/solutions/session-02/exercise-00.ts: -------------------------------------------------------------------------------- 1 | import { Console, Deferred, Effect, Random } from "effect" 2 | 3 | // Exercise Summary: 4 | // 5 | // The following exercise will explore how we can utilize a Deferred to 6 | // propagate the result of an Effect between fibers. Your implementation 7 | // should have the same semantics as the `Effect.intoDeferred` combinator, but 8 | // it should NOT utilize said combinator. 9 | 10 | const maybeFail = Random.next.pipe(Effect.filterOrFail( 11 | (n) => n > 0.5, 12 | (n) => `Failed with ${n}` 13 | )) 14 | 15 | const program = Effect.gen(function*(_) { 16 | const deferred = yield* _(Deferred.make()) 17 | yield* _( 18 | maybeFail, 19 | Effect.matchEffect({ 20 | onFailure: (error) => Deferred.fail(deferred, error), 21 | onSuccess: (value) => Deferred.succeed(deferred, value) 22 | }), 23 | // 24 | // Alternatively, you could use the exit value of `maybeFail`: 25 | // Effect.exit, 26 | // Effect.flatMap((exit) => Deferred.complete(deferred, exit)), 27 | // 28 | // Simulating interruption: 29 | // Effect.delay("1 seconds"), 30 | // Effect.raceFirst(Effect.interrupt), 31 | // 32 | Effect.onInterrupt(() => 33 | Effect.log("Interrupted!").pipe( 34 | Effect.zipRight(Deferred.interrupt(deferred)) 35 | ) 36 | ), 37 | Effect.fork 38 | ) 39 | const result = yield* _(Deferred.await(deferred)) 40 | yield* _(Console.log(result)) 41 | }) 42 | 43 | program.pipe( 44 | Effect.tapErrorCause(Effect.logError), 45 | Effect.runFork 46 | ) 47 | -------------------------------------------------------------------------------- /workshop/solutions/session-02/exercise-01.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Queue, ReadonlyArray, Schedule } from "effect" 2 | 3 | // Exercise Summary: 4 | // 5 | // The following exercise will explore how we can distribute work between 6 | // multiple fibers using Queue. We will create three separate implementations 7 | // of "workers" that take a value from a Queue and perform some work on the 8 | // value. 9 | 10 | // The below function simulates performing some non-trivial work 11 | const doSomeWork = (value: number) => 12 | Effect.log(`Consuming value '${value}'`).pipe( 13 | Effect.delay("20 millis") 14 | ) 15 | 16 | const program = Effect.gen(function*(_) { 17 | // The following will offer the numbers [0-100] to the Queue every second 18 | const queue = yield* _(Queue.unbounded()) 19 | yield* _( 20 | Queue.offerAll(queue, ReadonlyArray.range(0, 100)), 21 | Effect.schedule(Schedule.fixed("1 seconds")), 22 | Effect.fork 23 | ) 24 | // Implementation #1 - Sequential 25 | yield* _( 26 | Queue.take(queue), 27 | Effect.flatMap((n) => doSomeWork(n)), 28 | Effect.forever, 29 | Effect.annotateLogs("concurrency", "none"), 30 | Effect.fork 31 | ) 32 | // Implementation #2 - Unbounded Concurrency 33 | yield* _( 34 | Queue.take(queue), 35 | Effect.flatMap((n) => Effect.fork(doSomeWork(n))), 36 | Effect.forever, 37 | Effect.annotateLogs("concurrency", "unbounded"), 38 | Effect.fork 39 | ) 40 | // Implementation #3 - Bounded Concurrency 41 | const concurrencyLimit = 4 42 | yield* _( 43 | Queue.take(queue), 44 | Effect.flatMap((n) => doSomeWork(n)), 45 | Effect.forever, 46 | Effect.replicateEffect(concurrencyLimit, { 47 | concurrency: "unbounded", 48 | discard: true 49 | }), 50 | Effect.annotateLogs("concurrency", "bounded"), 51 | Effect.fork 52 | ) 53 | }) 54 | 55 | program.pipe( 56 | Effect.awaitAllChildren, 57 | Effect.tapErrorCause(Effect.logError), 58 | Effect.runFork 59 | ) 60 | -------------------------------------------------------------------------------- /workshop/solutions/session-02/exercise-02/advanced.ts: -------------------------------------------------------------------------------- 1 | import { Deferred, Effect, Fiber, Queue, ReadonlyArray } from "effect" 2 | 3 | const performWork = (value: number) => 4 | Effect.log(`Consuming value: '${value}'`).pipe( 5 | Effect.delay("20 millis"), 6 | Effect.as(`Processed value: '${value}'`), 7 | Effect.onInterrupt(() => Effect.log("Work was interrupted!")) 8 | ) 9 | 10 | const program = Effect.gen(function*(_) { 11 | const queue = yield* _(Queue.unbounded<[number, Deferred.Deferred]>()) 12 | 13 | const produceWork = (value: number): Effect.Effect => 14 | Deferred.make().pipe( 15 | Effect.flatMap((deferred) => 16 | Queue.offer(queue, [value, deferred]).pipe( 17 | Effect.zipRight(Deferred.await(deferred)), 18 | Effect.onInterrupt(() => 19 | Effect.log("Production of work was interrupted!").pipe( 20 | Effect.zipRight(Deferred.interrupt(deferred)) 21 | ) 22 | ) 23 | ) 24 | ) 25 | ) 26 | 27 | const consumeWork: Effect.Effect = Queue.take(queue).pipe( 28 | Effect.flatMap(([value, deferred]) => 29 | Effect.if(Deferred.isDone(deferred), { 30 | onTrue: Effect.unit, 31 | onFalse: performWork(value).pipe( 32 | Effect.zipLeft(Effect.sleep("1 seconds")), 33 | Effect.exit, 34 | Effect.flatMap((result) => Deferred.complete(deferred, result)), 35 | Effect.race(Deferred.await(deferred)), 36 | Effect.fork 37 | ) 38 | }) 39 | ), 40 | Effect.forever 41 | ) 42 | 43 | const fiber = yield* _( 44 | consumeWork, 45 | Effect.annotateLogs("role", "consumer"), 46 | Effect.fork 47 | ) 48 | 49 | yield* _( 50 | Effect.forEach(ReadonlyArray.range(0, 10), (value) => 51 | produceWork(value).pipe( 52 | Effect.flatMap((result) => Effect.log(result)) 53 | ), { concurrency: "unbounded" }), 54 | Effect.zipRight(produceWork(11).pipe(Effect.timeout("10 millis"))), 55 | Effect.annotateLogs("role", "producer") 56 | ) 57 | 58 | yield* _(Fiber.join(fiber)) 59 | }) 60 | 61 | program.pipe( 62 | Effect.tapErrorCause(Effect.logError), 63 | Effect.runFork 64 | ) 65 | -------------------------------------------------------------------------------- /workshop/solutions/session-02/exercise-02/basic.ts: -------------------------------------------------------------------------------- 1 | import { Deferred, Effect, Fiber, Queue, ReadonlyArray } from "effect" 2 | 3 | const performWork = (value: number) => 4 | Effect.log(`Consuming value: '${value}'`).pipe( 5 | Effect.delay("20 millis"), 6 | Effect.as(`Processed value: '${value}'`) 7 | ) 8 | 9 | const program = Effect.gen(function*(_) { 10 | const queue = yield* _(Queue.unbounded<[number, Deferred.Deferred]>()) 11 | 12 | const produceWork = (value: number): Effect.Effect => 13 | Deferred.make().pipe( 14 | Effect.flatMap((deferred) => 15 | Queue.offer(queue, [value, deferred]).pipe( 16 | Effect.zipRight(Deferred.await(deferred)) 17 | ) 18 | ) 19 | ) 20 | 21 | const consumeWork: Effect.Effect = Queue.take(queue).pipe( 22 | Effect.flatMap(([value, deferred]) => 23 | performWork(value).pipe( 24 | Effect.flatMap((result) => Deferred.succeed(deferred, result)), 25 | Effect.fork 26 | ) 27 | ), 28 | Effect.forever 29 | ) 30 | 31 | const fiber = yield* _( 32 | consumeWork, 33 | Effect.annotateLogs("role", "consumer"), 34 | Effect.fork 35 | ) 36 | 37 | yield* _( 38 | Effect.forEach(ReadonlyArray.range(0, 10), (value) => 39 | produceWork(value).pipe( 40 | Effect.flatMap((result) => Effect.log(result)) 41 | )), 42 | Effect.annotateLogs("role", "producer") 43 | ) 44 | 45 | yield* _(Fiber.join(fiber)) 46 | }) 47 | 48 | program.pipe( 49 | Effect.tapErrorCause(Effect.logError), 50 | Effect.runFork 51 | ) 52 | -------------------------------------------------------------------------------- /workshop/solutions/session-02/exercise-03.ts: -------------------------------------------------------------------------------- 1 | import * as ParcelWatcher from "@parcel/watcher" 2 | import { Chunk, Console, Data, Effect, Queue, Stream } from "effect" 3 | 4 | export const watch = ( 5 | directory: string, 6 | options?: ParcelWatcher.Options 7 | ): Stream.Stream => 8 | Effect.gen(function*(_) { 9 | const queue = yield* _(Effect.acquireRelease( 10 | Queue.unbounded, FileWatcherError>>(), 11 | (queue) => Queue.shutdown(queue) 12 | )) 13 | 14 | const handleSubscription = ( 15 | error: Error | null, 16 | events: ReadonlyArray 17 | ) => { 18 | if (error) { 19 | const failure = Effect.fail(new FileWatcherError({ error })) 20 | Queue.unsafeOffer(queue, failure) 21 | } else { 22 | const normalizedEvents = Effect.succeed(normalizeEvents(events)) 23 | Queue.unsafeOffer(queue, normalizedEvents) 24 | } 25 | } 26 | 27 | yield* _(Effect.acquireRelease( 28 | Effect.promise(() => ParcelWatcher.subscribe(directory, handleSubscription, options)), 29 | (subscription) => Effect.promise(() => subscription.unsubscribe()) 30 | )) 31 | 32 | return Stream.repeatEffectChunk(Effect.flatten(Queue.take(queue))) 33 | }).pipe(Stream.unwrapScoped) 34 | 35 | watch("./workshop").pipe( 36 | Stream.tap((event) => Console.log(event)), 37 | Stream.runDrain, 38 | Effect.runFork 39 | ) 40 | 41 | export const watchStream = ( 42 | directory: string, 43 | options?: ParcelWatcher.Options 44 | ): Stream.Stream => 45 | Stream.asyncScoped((emit) => 46 | Effect.acquireRelease( 47 | Effect.promise(() => 48 | ParcelWatcher.subscribe(directory, (error, events) => { 49 | if (error) { 50 | emit.fail(new FileWatcherError({ error })) 51 | } else { 52 | emit.chunk(normalizeEvents(events)) 53 | } 54 | }, options) 55 | ), 56 | (subscription) => Effect.promise(() => subscription.unsubscribe()) 57 | ) 58 | ) 59 | 60 | // watchStream("./workshop").pipe( 61 | // Stream.tap((event) => Console.log(event)), 62 | // Stream.runDrain, 63 | // Effect.runFork 64 | // ) 65 | 66 | // ============================================================================= 67 | // File Watcher Models 68 | // ============================================================================= 69 | 70 | export class FileWatcherError extends Data.TaggedError("FileWatcherError")<{ 71 | readonly error: Error 72 | }> {} 73 | 74 | export type FileSystemEvent = Data.TaggedEnum<{ 75 | readonly FileCreated: FileSystemEventInfo 76 | readonly FileUpdated: FileSystemEventInfo 77 | readonly FileDeleted: FileSystemEventInfo 78 | }> 79 | 80 | export const FileSystemEvent = Data.taggedEnum() 81 | 82 | export interface FileSystemEventInfo { 83 | readonly path: string 84 | } 85 | 86 | const normalizeEvents = ( 87 | events: ReadonlyArray 88 | ): Chunk.Chunk => 89 | Chunk.fromIterable(events).pipe(Chunk.map((event) => normalizeEvent(event))) 90 | 91 | const normalizeEvent = (event: ParcelWatcher.Event) => { 92 | switch (event.type) { 93 | case "create": { 94 | return FileSystemEvent.FileCreated({ path: event.path }) 95 | } 96 | case "update": { 97 | return FileSystemEvent.FileUpdated({ path: event.path }) 98 | } 99 | case "delete": { 100 | return FileSystemEvent.FileDeleted({ path: event.path }) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /workshop/solutions/session-02/project/stage-01.ts: -------------------------------------------------------------------------------- 1 | import type { Duration, Scope } from "effect" 2 | import { Context, Deferred, Effect, Layer, Queue } from "effect" 3 | 4 | export interface RateLimiter { 5 | readonly take: Effect.Effect 6 | } 7 | 8 | export declare namespace RateLimiter { 9 | export interface Factory { 10 | readonly make: ( 11 | limit: number, 12 | window: Duration.DurationInput 13 | ) => Effect.Effect 14 | } 15 | } 16 | 17 | class Factory extends Context.Tag("RateLimiter.Factory")() { 18 | static readonly Live = Layer.sync(Factory, () => factory) 19 | } 20 | 21 | export const make = Effect.serviceFunctionEffect(Factory, (factory) => factory.make) 22 | 23 | const factory = Factory.of({ 24 | make: (limit, window) => 25 | Effect.gen(function*(_) { 26 | const queue = yield* _(Effect.acquireRelease( 27 | Queue.unbounded>(), 28 | (queue) => Queue.shutdown(queue) 29 | )) 30 | 31 | return { 32 | take: Deferred.make().pipe( 33 | Effect.tap((deferred) => Queue.offer(queue, deferred)), 34 | Effect.flatMap((deferred) => 35 | Deferred.await(deferred).pipe( 36 | Effect.onInterrupt(() => Deferred.interrupt(deferred)) 37 | ) 38 | ) 39 | ) 40 | } 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /workshop/solutions/session-02/project/stage-02.ts: -------------------------------------------------------------------------------- 1 | import type { Duration, Scope } from "effect" 2 | import { Context, Deferred, Effect, Fiber, Layer, Option, Queue, Ref } from "effect" 3 | 4 | export interface RateLimiter { 5 | readonly take: Effect.Effect 6 | } 7 | 8 | export declare namespace RateLimiter { 9 | export interface Factory { 10 | readonly make: ( 11 | limit: number, 12 | window: Duration.DurationInput 13 | ) => Effect.Effect 14 | } 15 | } 16 | 17 | class Factory extends Context.Tag("RateLimiter.Factory")() { 18 | static readonly Live = Layer.sync(Factory, () => factory) 19 | } 20 | 21 | export const make = Effect.serviceFunctionEffect(Factory, (factory) => factory.make) 22 | 23 | const factory = Factory.of({ 24 | make: (limit, window) => 25 | Effect.gen(function*(_) { 26 | const counter = yield* _(Ref.make(limit)) 27 | const scope = yield* _(Effect.scope) 28 | 29 | const queue = yield* _(Effect.acquireRelease( 30 | Queue.unbounded>(), 31 | (queue) => Queue.shutdown(queue) 32 | )) 33 | 34 | const reset = Effect.delay(Ref.set(counter, limit), window) 35 | const resetRef = yield* _(Ref.make(Option.none>())) 36 | const maybeReset = Ref.get(resetRef).pipe( 37 | Effect.tap(Option.match({ 38 | onNone: () => 39 | reset.pipe( 40 | Effect.zipRight(Ref.set(resetRef, Option.none())), 41 | Effect.forkIn(scope), 42 | Effect.flatMap((fiber) => Ref.set(resetRef, Option.some(fiber))) 43 | ), 44 | onSome: () => Effect.unit 45 | })) 46 | ) 47 | 48 | return { 49 | take: Deferred.make().pipe( 50 | Effect.tap((deferred) => Queue.offer(queue, deferred)), 51 | Effect.flatMap((deferred) => 52 | Deferred.await(deferred).pipe( 53 | Effect.onInterrupt(() => Deferred.interrupt(deferred)) 54 | ) 55 | ) 56 | ) 57 | } 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /workshop/solutions/session-02/project/stage-03.ts: -------------------------------------------------------------------------------- 1 | import type { Duration, Scope } from "effect" 2 | import { Context, Deferred, Effect, Fiber, Layer, Option, Queue, Ref } from "effect" 3 | 4 | export interface RateLimiter { 5 | readonly take: Effect.Effect 6 | } 7 | 8 | export declare namespace RateLimiter { 9 | export interface Factory { 10 | readonly make: ( 11 | limit: number, 12 | window: Duration.DurationInput 13 | ) => Effect.Effect 14 | } 15 | } 16 | 17 | class Factory extends Context.Tag("RateLimiter.Factory")() { 18 | static readonly Live = Layer.sync(Factory, () => factory) 19 | } 20 | 21 | export const make = Effect.serviceFunctionEffect(Factory, (factory) => factory.make) 22 | 23 | const factory = Factory.of({ 24 | make: (limit, window) => 25 | Effect.gen(function*(_) { 26 | const counter = yield* _(Ref.make(limit)) 27 | const scope = yield* _(Effect.scope) 28 | 29 | const queue = yield* _(Effect.acquireRelease( 30 | Queue.unbounded>(), 31 | (queue) => Queue.shutdown(queue) 32 | )) 33 | 34 | const reset = Effect.delay(Ref.set(counter, limit), window) 35 | const resetRef = yield* _(Ref.make(Option.none>())) 36 | const maybeReset = Ref.get(resetRef).pipe( 37 | Effect.tap(Option.match({ 38 | onNone: () => 39 | reset.pipe( 40 | Effect.zipRight(Ref.set(resetRef, Option.none())), 41 | Effect.forkIn(scope), 42 | Effect.flatMap((fiber) => Ref.set(resetRef, Option.some(fiber))) 43 | ), 44 | onSome: () => Effect.unit 45 | })) 46 | ) 47 | 48 | const worker = Ref.get(counter).pipe( 49 | Effect.flatMap((count) => { 50 | if (count <= 0) { 51 | return Ref.get(resetRef).pipe( 52 | Effect.map(Option.match({ 53 | onNone: () => Effect.unit, 54 | onSome: (fiber) => Effect.asUnit(Fiber.await(fiber)) 55 | })), 56 | Effect.zipRight(Queue.takeBetween(queue, 1, limit)) 57 | ) 58 | } 59 | return Queue.takeBetween(queue, 1, count) 60 | }), 61 | Effect.flatMap(Effect.filter(Deferred.isDone, { negate: true })), 62 | Effect.tap((chunk) => Ref.update(counter, (count) => count - chunk.length)), 63 | Effect.zipLeft(maybeReset), 64 | Effect.flatMap(Effect.forEach( 65 | (deferred) => Deferred.complete(deferred, Effect.unit), 66 | { discard: true } 67 | )), 68 | Effect.forever 69 | ) 70 | 71 | yield* _(Effect.forkIn(worker, scope)) 72 | 73 | return { 74 | take: Deferred.make().pipe( 75 | Effect.tap((deferred) => Queue.offer(queue, deferred)), 76 | Effect.flatMap((deferred) => 77 | Deferred.await(deferred).pipe( 78 | Effect.onInterrupt(() => Deferred.interrupt(deferred)) 79 | ) 80 | ) 81 | ) 82 | } 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /workshop/solutions/session-03/exercise-00/basic.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@effect/schema" 2 | import { 3 | Console, 4 | Context, 5 | Data, 6 | Effect, 7 | Layer, 8 | ReadonlyArray, 9 | Request, 10 | RequestResolver 11 | } from "effect" 12 | import { gql, GraphQLClient } from "graphql-request" 13 | 14 | // ============================================================================= 15 | // PokemonRepo 16 | // ============================================================================= 17 | 18 | const makePokemonRepo = Effect.gen(function*(_) { 19 | const pokemonApi = yield* _(PokemonApi) 20 | 21 | const GetPokemonByIdResolver = RequestResolver.makeBatched(( 22 | requests: ReadonlyArray 23 | ) => 24 | Effect.gen(function*(_) { 25 | const ids = ReadonlyArray.map(requests, (request) => request.id) 26 | const pokemons = yield* _(pokemonApi.getByIds(ids)) 27 | const pokemonMap = new Map(ReadonlyArray.map(pokemons, (pokemon) => [pokemon.id, pokemon])) 28 | yield* _(Effect.forEach( 29 | requests, 30 | (request) => Request.succeed(request, pokemonMap.get(request.id)!), 31 | { discard: true } 32 | )) 33 | }).pipe( 34 | Effect.catchAll((error) => 35 | Effect.forEach(requests, (request) => Request.completeEffect(request, error)) 36 | ) 37 | ) 38 | ) 39 | 40 | const getById = (id: number) => Effect.request(new GetPokemonById({ id }), GetPokemonByIdResolver) 41 | 42 | return { getById } as const 43 | }) 44 | 45 | export class PokemonRepo extends Context.Tag("PokemonRepo")< 46 | PokemonRepo, 47 | Effect.Effect.Success 48 | >() { 49 | static readonly Live = Layer.effect(this, makePokemonRepo) 50 | } 51 | 52 | // ============================================================================= 53 | // Pokemon Models 54 | // ============================================================================= 55 | 56 | export class PokemonError extends Data.TaggedError("PokemonError")<{ 57 | readonly message: string 58 | }> {} 59 | 60 | export class Pokemon extends Data.Class<{ 61 | readonly id: number 62 | readonly name: string 63 | }> {} 64 | 65 | export class GetPokemonById extends Request.TaggedClass("GetPokemonById")< 66 | PokemonError, 67 | Pokemon, 68 | { readonly id: number } 69 | > {} 70 | 71 | // ============================================================================= 72 | // PokemonApi 73 | // ============================================================================= 74 | 75 | const makePokemonApi = Effect.sync(() => { 76 | const client = new GraphQLClient("https://beta.pokeapi.co/graphql/v1beta") 77 | 78 | const query = ( 79 | document: string, 80 | variables?: Record 81 | ) => 82 | Effect.tryPromise({ 83 | try: (signal) => 84 | client.request({ 85 | document, 86 | variables, 87 | signal 88 | }), 89 | catch: (error) => new PokemonError({ message: String(error) }) 90 | }) 91 | 92 | const pokemonById = gql` 93 | query pokemonById($ids: [Int!]!) { 94 | pokemon_v2_pokemon(where: { id: { _in: $ids } }) { 95 | id 96 | name 97 | } 98 | } 99 | ` 100 | 101 | const getByIds = (ids: ReadonlyArray) => 102 | query(pokemonById, { ids }).pipe( 103 | Effect.flatMap( 104 | Schema.decodeUnknown( 105 | Schema.struct({ 106 | pokemon_v2_pokemon: Schema.array(Schema.struct({ 107 | id: Schema.number, 108 | name: Schema.string 109 | })) 110 | }) 111 | ) 112 | ), 113 | Effect.map((response) => 114 | ReadonlyArray.map(response.pokemon_v2_pokemon, (params) => new Pokemon(params)) 115 | ), 116 | Effect.catchTag("ParseError", (e) => Effect.fail(new PokemonError({ message: e.toString() }))) 117 | ) 118 | 119 | return { getByIds } as const 120 | }) 121 | 122 | class PokemonApi extends Context.Tag("PokemonApi")< 123 | PokemonApi, 124 | Effect.Effect.Success 125 | >() { 126 | static readonly Live = Layer.effect(this, makePokemonApi) 127 | } 128 | 129 | // ============================================================================= 130 | // Program 131 | // ============================================================================= 132 | 133 | const program = Effect.gen(function*(_) { 134 | const repo = yield* _(PokemonRepo) 135 | 136 | const pokemon = yield* _( 137 | Effect.forEach(ReadonlyArray.range(1, 100), repo.getById, { 138 | // Toggle batching on and off to see time difference 139 | batching: true 140 | }), 141 | Effect.timed, 142 | Effect.map(([duration, pokemon]) => ({ duration: duration.toString(), pokemon })) 143 | ) 144 | 145 | yield* _(Console.log(pokemon)) 146 | }) 147 | 148 | const MainLive = PokemonRepo.Live.pipe( 149 | Layer.provide(PokemonApi.Live) 150 | ) 151 | 152 | program.pipe( 153 | Effect.provide(MainLive), 154 | Effect.tapErrorCause(Effect.logError), 155 | Effect.runFork 156 | ) 157 | -------------------------------------------------------------------------------- /workshop/solutions/session-03/exercise-00/improved.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@effect/schema" 2 | import { 3 | Console, 4 | Context, 5 | Effect, 6 | Layer, 7 | ReadonlyArray, 8 | Request, 9 | RequestResolver, 10 | Schedule 11 | } from "effect" 12 | import { gql, GraphQLClient } from "graphql-request" 13 | 14 | // ============================================================================= 15 | // PokemonRepo 16 | // ============================================================================= 17 | 18 | const makePokemonRepo = Effect.gen(function*(_) { 19 | const pokemonApi = yield* _(PokemonApi) 20 | 21 | const GetPokemonByIdResolver = RequestResolver.makeBatched(( 22 | requests: ReadonlyArray 23 | ) => 24 | Effect.gen(function*(_) { 25 | const ids = ReadonlyArray.map(requests, (request) => request.id) 26 | const pokemons = yield* _(pokemonApi.getByIds(ids)) 27 | const pokemonMap = new Map(ReadonlyArray.map(pokemons, (pokemon) => [pokemon.id, pokemon])) 28 | yield* _(Effect.forEach( 29 | requests, 30 | (request) => Request.succeed(request, pokemonMap.get(request.id)!), 31 | { discard: true } 32 | )) 33 | }).pipe( 34 | Effect.catchAll((error) => 35 | Effect.forEach(requests, (request) => Request.completeEffect(request, error)) 36 | ) 37 | ) 38 | ) 39 | 40 | const getById = (id: number) => 41 | Effect.request( 42 | new GetPokemonById({ id }), 43 | GetPokemonByIdResolver 44 | ) 45 | 46 | return { getById } as const 47 | }) 48 | 49 | export class PokemonRepo extends Context.Tag("PokemonRepo")< 50 | PokemonRepo, 51 | Effect.Effect.Success 52 | >() { 53 | static readonly Live = Layer.effect(this, makePokemonRepo) 54 | } 55 | 56 | // ============================================================================= 57 | // Pokemon Models 58 | // ============================================================================= 59 | 60 | export class PokemonError extends Schema.TaggedError()( 61 | "PokemonError", 62 | { message: Schema.string } 63 | ) {} 64 | 65 | export class Pokemon extends Schema.Class()({ 66 | id: Schema.number, 67 | name: Schema.string 68 | }) {} 69 | 70 | export class GetPokemonById extends Schema.TaggedRequest()( 71 | "GetPokemonById", 72 | PokemonError, 73 | Pokemon, 74 | { id: Schema.number } 75 | ) {} 76 | 77 | // ============================================================================= 78 | // PokemonApi 79 | // ============================================================================= 80 | 81 | const makePokemonApi = Effect.sync(() => { 82 | const client = new GraphQLClient("https://beta.pokeapi.co/graphql/v1beta") 83 | 84 | const query = ( 85 | document: string, 86 | variables?: Record 87 | ) => 88 | Effect.tryPromise({ 89 | try: (signal) => 90 | client.request({ 91 | document, 92 | variables, 93 | signal 94 | }), 95 | catch: (error) => new PokemonError({ message: String(error) }) 96 | }) 97 | 98 | const pokemonById = gql` 99 | query pokemonById($ids: [Int!]!) { 100 | pokemon_v2_pokemon(where: { id: { _in: $ids } }) { 101 | id 102 | name 103 | } 104 | } 105 | ` 106 | 107 | const getByIds = (ids: ReadonlyArray) => 108 | query(pokemonById, { ids }).pipe( 109 | Effect.flatMap( 110 | Schema.decodeUnknown( 111 | Schema.struct({ 112 | pokemon_v2_pokemon: Schema.array(Pokemon) 113 | }) 114 | ) 115 | ), 116 | Effect.map((response) => response.pokemon_v2_pokemon), 117 | Effect.catchTag("ParseError", (e) => Effect.fail(new PokemonError({ message: e.toString() }))) 118 | ) 119 | 120 | return { getByIds } as const 121 | }) 122 | 123 | class PokemonApi extends Context.Tag("PokemonApi")< 124 | PokemonApi, 125 | Effect.Effect.Success 126 | >() { 127 | static readonly Live = Layer.effect(this, makePokemonApi) 128 | } 129 | 130 | // ============================================================================= 131 | // Program 132 | // ============================================================================= 133 | 134 | const program = Effect.gen(function*(_) { 135 | const repo = yield* _(PokemonRepo) 136 | 137 | yield* _( 138 | Effect.forEach(ReadonlyArray.range(1, 100), repo.getById, { 139 | // Toggle batching on and off to see time difference 140 | batching: true 141 | }), 142 | Effect.tap((pokemon) => Console.log(`Got ${pokemon.length} pokemon`)), 143 | Effect.repeat(Schedule.fixed("2 seconds")) 144 | ) 145 | }) 146 | 147 | const MainLive = PokemonRepo.Live.pipe( 148 | Layer.provide(PokemonApi.Live) 149 | ) 150 | 151 | program.pipe( 152 | Effect.provide(MainLive), 153 | Effect.tapErrorCause(Effect.logError), 154 | Effect.runFork 155 | ) 156 | -------------------------------------------------------------------------------- /workshop/solutions/session-03/exercise-01.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@effect/schema" 2 | import { 3 | Console, 4 | Context, 5 | Effect, 6 | Layer, 7 | ReadonlyArray, 8 | Request, 9 | RequestResolver, 10 | Schedule 11 | } from "effect" 12 | import { gql, GraphQLClient } from "graphql-request" 13 | 14 | // ============================================================================= 15 | // PokemonRepo 16 | // ============================================================================= 17 | 18 | const makePokemonRepo = Effect.gen(function*(_) { 19 | const pokemonApi = yield* _(PokemonApi) 20 | 21 | const GetPokemonByIdResolver = RequestResolver.makeBatched(( 22 | requests: ReadonlyArray 23 | ) => 24 | Effect.gen(function*(_) { 25 | yield* _(Console.log(`Executing batch of ${requests.length} requests...`)) 26 | const ids = ReadonlyArray.map(requests, (request) => request.id) 27 | const pokemons = yield* _(pokemonApi.getByIds(ids)) 28 | yield* _(Effect.forEach(requests, (request) => { 29 | const pokemon = pokemons.find((pokemon) => pokemon.id === request.id)! 30 | return Request.completeEffect(request, Effect.succeed(pokemon)) 31 | }, { discard: true })) 32 | }).pipe( 33 | Effect.catchAll((error) => 34 | Effect.forEach(requests, (request) => Request.completeEffect(request, error)) 35 | ) 36 | ) 37 | ) 38 | 39 | const cache = yield* _(Request.makeCache({ 40 | capacity: 250, 41 | timeToLive: "10 days" 42 | })) 43 | 44 | const getById = (id: number) => 45 | Effect.request(new GetPokemonById({ id }), GetPokemonByIdResolver).pipe( 46 | Effect.withRequestCaching(true), 47 | Effect.withRequestCache(cache) 48 | ) 49 | 50 | return { getById } as const 51 | }) 52 | 53 | export class PokemonRepo extends Context.Tag("PokemonRepo")< 54 | PokemonRepo, 55 | Effect.Effect.Success 56 | >() { 57 | static readonly Live = Layer.effect(this, makePokemonRepo) 58 | } 59 | 60 | // ============================================================================= 61 | // Pokemon Models 62 | // ============================================================================= 63 | 64 | export class PokemonError extends Schema.TaggedError()( 65 | "PokemonError", 66 | { message: Schema.string } 67 | ) {} 68 | 69 | export class Pokemon extends Schema.Class()({ 70 | id: Schema.number, 71 | name: Schema.string 72 | }) {} 73 | 74 | export class GetPokemonById extends Schema.TaggedRequest()( 75 | "GetPokemonById", 76 | PokemonError, 77 | Pokemon, 78 | { id: Schema.number } 79 | ) {} 80 | 81 | // ============================================================================= 82 | // PokemonApi 83 | // ============================================================================= 84 | 85 | const makePokemonApi = Effect.sync(() => { 86 | const client = new GraphQLClient("https://beta.pokeapi.co/graphql/v1beta") 87 | 88 | const query = ( 89 | document: string, 90 | variables?: Record 91 | ) => 92 | Effect.tryPromise({ 93 | try: (signal) => 94 | client.request({ 95 | document, 96 | variables, 97 | signal 98 | }), 99 | catch: (error) => new PokemonError({ message: String(error) }) 100 | }) 101 | 102 | const pokemonById = gql` 103 | query pokemonById($ids: [Int!]!) { 104 | pokemon_v2_pokemon(where: { id: { _in: $ids } }) { 105 | id 106 | name 107 | } 108 | } 109 | ` 110 | 111 | const getByIds = (ids: ReadonlyArray) => 112 | query(pokemonById, { ids }).pipe( 113 | Effect.flatMap( 114 | Schema.decodeUnknown( 115 | Schema.struct({ 116 | pokemon_v2_pokemon: Schema.array(Pokemon) 117 | }) 118 | ) 119 | ), 120 | Effect.map((response) => response.pokemon_v2_pokemon), 121 | Effect.catchTag("ParseError", (e) => Effect.fail(new PokemonError({ message: e.toString() }))) 122 | ) 123 | 124 | return { getByIds } as const 125 | }) 126 | 127 | class PokemonApi extends Context.Tag("PokemonApi")< 128 | PokemonApi, 129 | Effect.Effect.Success 130 | >() { 131 | static readonly Live = Layer.effect(this, makePokemonApi) 132 | } 133 | 134 | // ============================================================================= 135 | // Program 136 | // ============================================================================= 137 | 138 | const program = Effect.gen(function*(_) { 139 | const repo = yield* _(PokemonRepo) 140 | 141 | yield* _( 142 | Effect.forEach(ReadonlyArray.range(1, 100), repo.getById, { 143 | // Toggle batching on and off to see time difference 144 | batching: true 145 | }), 146 | Effect.tap((pokemon) => Console.log(`Got ${pokemon.length} pokemon`)), 147 | Effect.repeat(Schedule.fixed("2 seconds")) 148 | ) 149 | }) 150 | 151 | const MainLive = PokemonRepo.Live.pipe( 152 | Layer.provide(PokemonApi.Live) 153 | ) 154 | 155 | program.pipe( 156 | Effect.provide(MainLive), 157 | Effect.tapErrorCause(Effect.logError), 158 | Effect.runFork 159 | ) 160 | -------------------------------------------------------------------------------- /workshop/solutions/session-03/exercise-02.ts: -------------------------------------------------------------------------------- 1 | import { Cache, Console, Data, Effect } from "effect" 2 | 3 | export class Job extends Data.Class<{ 4 | readonly id: number 5 | readonly text: string 6 | }> {} 7 | 8 | export const executeJob = (job: Job): Effect.Effect => 9 | Effect.log(`Running job ${job.id}...`).pipe( 10 | Effect.zipRight(Effect.sleep(`${job.text.length} seconds`)), 11 | Effect.as(job.text.length) 12 | ) 13 | 14 | const program = Effect.gen(function*(_) { 15 | const jobs = [ 16 | new Job({ id: 1, text: "I" }), 17 | new Job({ id: 2, text: "love" }), 18 | new Job({ id: 3, text: "Effect" }), 19 | new Job({ id: 4, text: "!" }) 20 | ] 21 | const cache = yield* _(Cache.make({ 22 | capacity: 100, 23 | timeToLive: "1 days", 24 | lookup: executeJob 25 | })) 26 | // =========================================================================== 27 | // Your code here 28 | // =========================================================================== 29 | yield* _(Effect.log("Starting job execution...")) 30 | const first = yield* _( 31 | Effect.forEach(jobs, (job) => cache.get(job), { concurrency: "unbounded" }) 32 | ) 33 | yield* _(Effect.log("Job execution complete")) 34 | yield* _(Console.log(first)) 35 | 36 | yield* _(Effect.log("Starting job execution...")) 37 | const second = yield* _( 38 | Effect.forEach(jobs, (job) => cache.get(job), { concurrency: "unbounded" }) 39 | ) 40 | yield* _(Effect.log("Job execution complete")) 41 | yield* _(Console.log(second)) 42 | }) 43 | 44 | program.pipe( 45 | Effect.tapErrorCause(Effect.logError), 46 | Effect.runFork 47 | ) 48 | -------------------------------------------------------------------------------- /workshop/solutions/session-03/exercise-03.ts: -------------------------------------------------------------------------------- 1 | import * as Persistence from "@effect/experimental/Persistence" 2 | import * as RRX from "@effect/experimental/RequestResolver" 3 | import * as NodeKeyValueStore from "@effect/platform-node/NodeKeyValueStore" 4 | import { Schema } from "@effect/schema" 5 | import { 6 | Console, 7 | Context, 8 | Effect, 9 | Layer, 10 | PrimaryKey, 11 | ReadonlyArray, 12 | Request, 13 | RequestResolver, 14 | Schedule 15 | } from "effect" 16 | import { gql, GraphQLClient } from "graphql-request" 17 | 18 | // ============================================================================= 19 | // PokemonRepo 20 | // ============================================================================= 21 | 22 | const makePokemonRepo = Effect.gen(function*(_) { 23 | const pokemonApi = yield* _(PokemonApi) 24 | 25 | const resolver = RequestResolver.makeBatched(( 26 | requests: ReadonlyArray 27 | ) => 28 | Effect.gen(function*(_) { 29 | yield* _(Console.log(`Executing batch of ${requests.length} requests...`)) 30 | const ids = ReadonlyArray.map(requests, (request) => request.id) 31 | const pokemons = yield* _(pokemonApi.getByIds(ids)) 32 | yield* _(Effect.forEach(requests, (request) => { 33 | const pokemon = pokemons.find((pokemon) => pokemon.id === request.id)! 34 | return Request.completeEffect(request, Effect.succeed(pokemon)) 35 | }, { discard: true })) 36 | }).pipe( 37 | Effect.catchAll((error) => 38 | Effect.forEach(requests, (request) => Request.completeEffect(request, error)) 39 | ) 40 | ) 41 | ) 42 | 43 | const persisted = yield* _(RRX.persisted(resolver, "GetPokemonById")) 44 | 45 | const getById = (id: number) => Effect.request(new GetPokemonById({ id }), persisted) 46 | 47 | return { getById } as const 48 | }) 49 | 50 | export class PokemonRepo extends Context.Tag("PokemonRepo")< 51 | PokemonRepo, 52 | Effect.Effect.Success 53 | >() { 54 | static readonly Live = Layer.scoped(this, makePokemonRepo).pipe( 55 | Layer.provide(Persistence.layerResultKeyValueStore), 56 | Layer.provide(NodeKeyValueStore.layerFileSystem("./store")) 57 | ) 58 | } 59 | 60 | // ============================================================================= 61 | // Pokemon Models 62 | // ============================================================================= 63 | 64 | export class PokemonError extends Schema.TaggedError()( 65 | "PokemonError", 66 | { message: Schema.string } 67 | ) {} 68 | 69 | export class Pokemon extends Schema.Class()({ 70 | id: Schema.number, 71 | name: Schema.string 72 | }) {} 73 | 74 | export class GetPokemonById extends Schema.TaggedRequest()( 75 | "GetPokemonById", 76 | PokemonError, 77 | Pokemon, 78 | { id: Schema.number } 79 | ) { 80 | [PrimaryKey.symbol]() { 81 | return `GetPokemonById-${this.id}` 82 | } 83 | } 84 | 85 | // ============================================================================= 86 | // PokemonApi 87 | // ============================================================================= 88 | 89 | const makePokemonApi = Effect.sync(() => { 90 | const client = new GraphQLClient("https://beta.pokeapi.co/graphql/v1beta") 91 | 92 | const query = ( 93 | document: string, 94 | variables?: Record 95 | ) => 96 | Effect.tryPromise({ 97 | try: (signal) => 98 | client.request({ 99 | document, 100 | variables, 101 | signal 102 | }), 103 | catch: (error) => new PokemonError({ message: String(error) }) 104 | }) 105 | 106 | const pokemonById = gql` 107 | query pokemonById($ids: [Int!]!) { 108 | pokemon_v2_pokemon(where: { id: { _in: $ids } }) { 109 | id 110 | name 111 | } 112 | } 113 | ` 114 | 115 | const getByIds = (ids: ReadonlyArray) => 116 | query(pokemonById, { ids }).pipe( 117 | Effect.flatMap( 118 | Schema.decodeUnknown( 119 | Schema.struct({ 120 | pokemon_v2_pokemon: Schema.array(Pokemon) 121 | }) 122 | ) 123 | ), 124 | Effect.map((response) => response.pokemon_v2_pokemon), 125 | Effect.catchTag("ParseError", (e) => Effect.fail(new PokemonError({ message: e.toString() }))) 126 | ) 127 | 128 | return { getByIds } as const 129 | }) 130 | 131 | class PokemonApi extends Context.Tag("PokemonApi")< 132 | PokemonApi, 133 | Effect.Effect.Success 134 | >() { 135 | static readonly Live = Layer.effect(this, makePokemonApi) 136 | } 137 | 138 | // ============================================================================= 139 | // Program 140 | // ============================================================================= 141 | 142 | const program = Effect.gen(function*(_) { 143 | const repo = yield* _(PokemonRepo) 144 | 145 | yield* _( 146 | Effect.forEach(ReadonlyArray.range(1, 100), repo.getById, { 147 | // Toggle batching on and off to see time difference 148 | batching: true 149 | }), 150 | Effect.tap((pokemon) => Console.log(`Got ${pokemon.length} pokemon`)), 151 | Effect.repeat(Schedule.fixed("2 seconds")) 152 | ) 153 | }) 154 | 155 | const MainLive = PokemonRepo.Live.pipe( 156 | Layer.provide(PokemonApi.Live) 157 | ) 158 | 159 | program.pipe( 160 | Effect.provide(MainLive), 161 | Effect.tapErrorCause(Effect.logError), 162 | Effect.runFork 163 | ) 164 | -------------------------------------------------------------------------------- /workshop/solutions/session-03/project/stage-01.ts: -------------------------------------------------------------------------------- 1 | import type { Duration, Request, Scope } from "effect" 2 | import { Deferred, Effect, Queue, ReadonlyArray, Ref, RequestResolver } from "effect" 3 | 4 | interface DataLoaderItem> { 5 | readonly request: A 6 | readonly deferred: Deferred.Deferred< 7 | Request.Request.Success, 8 | Request.Request.Error 9 | > 10 | } 11 | 12 | export const dataLoader = >( 13 | self: RequestResolver.RequestResolver, 14 | options: { 15 | readonly window: Duration.DurationInput 16 | readonly maxBatchSize?: number 17 | } 18 | ): Effect.Effect, never, Scope.Scope> => 19 | Effect.gen(function*(_) { 20 | const queue = yield* _( 21 | Effect.acquireRelease( 22 | Queue.unbounded>(), 23 | Queue.shutdown 24 | ) 25 | ) 26 | 27 | const batch = yield* _(Ref.make(ReadonlyArray.empty>())) 28 | 29 | return RequestResolver.fromEffect((request: A) => 30 | Effect.flatMap( 31 | Deferred.make, Request.Request.Error>(), 32 | (deferred) => 33 | Queue.offer(queue, { request, deferred }).pipe( 34 | Effect.zipRight(Deferred.await(deferred)), 35 | Effect.onInterrupt(() => Deferred.interrupt(deferred)) 36 | ) 37 | ) 38 | ) 39 | }) 40 | -------------------------------------------------------------------------------- /workshop/solutions/session-03/project/stage-02.ts: -------------------------------------------------------------------------------- 1 | import type { Duration, Request, Scope } from "effect" 2 | import { Deferred, Effect, Queue, ReadonlyArray, Ref, RequestResolver } from "effect" 3 | 4 | interface DataLoaderItem> { 5 | readonly request: A 6 | readonly deferred: Deferred.Deferred< 7 | Request.Request.Success, 8 | Request.Request.Error 9 | > 10 | } 11 | 12 | export const dataLoader = >( 13 | self: RequestResolver.RequestResolver, 14 | options: { 15 | readonly window: Duration.DurationInput 16 | readonly maxBatchSize?: number 17 | } 18 | ): Effect.Effect, never, Scope.Scope> => 19 | Effect.gen(function*(_) { 20 | const queue = yield* _( 21 | Effect.acquireRelease( 22 | Queue.unbounded>(), 23 | Queue.shutdown 24 | ) 25 | ) 26 | const batch = yield* _(Ref.make(ReadonlyArray.empty>())) 27 | 28 | const takeOne = Effect.flatMap( 29 | Queue.take(queue), 30 | (item) => Ref.updateAndGet(batch, ReadonlyArray.append(item)) 31 | ) 32 | const takeRest = takeOne.pipe( 33 | Effect.repeat({ 34 | until: (items) => 35 | options.maxBatchSize !== undefined && 36 | items.length >= options.maxBatchSize 37 | }), 38 | Effect.timeout(options.window), 39 | Effect.ignore, 40 | Effect.zipRight(Ref.getAndSet(batch, ReadonlyArray.empty())) 41 | ) 42 | 43 | return RequestResolver.fromEffect((request: A) => 44 | Effect.flatMap( 45 | Deferred.make, Request.Request.Error>(), 46 | (deferred) => 47 | Queue.offer(queue, { request, deferred }).pipe( 48 | Effect.zipRight(Deferred.await(deferred)), 49 | Effect.onInterrupt(() => Deferred.interrupt(deferred)) 50 | ) 51 | ) 52 | ) 53 | }) 54 | -------------------------------------------------------------------------------- /workshop/solutions/session-03/project/stage-03.ts: -------------------------------------------------------------------------------- 1 | import type { Duration, Request, Scope } from "effect" 2 | import { Deferred, Effect, Queue, ReadonlyArray, Ref, RequestResolver } from "effect" 3 | 4 | interface DataLoaderItem> { 5 | readonly request: A 6 | readonly deferred: Deferred.Deferred< 7 | Request.Request.Success, 8 | Request.Request.Error 9 | > 10 | } 11 | 12 | export const dataLoader = >( 13 | self: RequestResolver.RequestResolver, 14 | options: { 15 | readonly window: Duration.DurationInput 16 | readonly maxBatchSize?: number 17 | } 18 | ): Effect.Effect, never, Scope.Scope> => 19 | Effect.gen(function*(_) { 20 | const queue = yield* _( 21 | Effect.acquireRelease( 22 | Queue.unbounded>(), 23 | Queue.shutdown 24 | ) 25 | ) 26 | const batch = yield* _(Ref.make(ReadonlyArray.empty>())) 27 | 28 | const takeOne = Effect.flatMap( 29 | Queue.take(queue), 30 | (item) => Ref.updateAndGet(batch, ReadonlyArray.append(item)) 31 | ) 32 | const takeRest = takeOne.pipe( 33 | Effect.repeat({ 34 | until: (items) => 35 | options.maxBatchSize !== undefined && 36 | items.length >= options.maxBatchSize 37 | }), 38 | Effect.timeout(options.window), 39 | Effect.ignore, 40 | Effect.zipRight(Ref.getAndSet(batch, ReadonlyArray.empty())) 41 | ) 42 | 43 | yield* _( 44 | takeOne, 45 | Effect.zipRight(takeRest), 46 | Effect.flatMap( 47 | Effect.filter(({ deferred }) => Deferred.isDone(deferred), { 48 | negate: true 49 | }) 50 | ), 51 | Effect.flatMap( 52 | Effect.forEach( 53 | ({ deferred, request }) => 54 | Effect.flatMap( 55 | Effect.exit(Effect.request(request, self)), 56 | (exit) => Deferred.complete(deferred, exit) 57 | ), 58 | { batching: true, discard: true } 59 | ) 60 | ), 61 | Effect.forever, 62 | Effect.withRequestCaching(false), 63 | Effect.forkScoped, 64 | Effect.interruptible 65 | ) 66 | 67 | return RequestResolver.fromEffect((request: A) => 68 | Effect.flatMap( 69 | Deferred.make, Request.Request.Error>(), 70 | (deferred) => 71 | Queue.offer(queue, { request, deferred }).pipe( 72 | Effect.zipRight(Deferred.await(deferred)), 73 | Effect.onInterrupt(() => Deferred.interrupt(deferred)) 74 | ) 75 | ) 76 | ) 77 | }) 78 | -------------------------------------------------------------------------------- /workshop/solutions/session-04/exercise-00.ts: -------------------------------------------------------------------------------- 1 | import { Console, Effect, FiberRef, HashSet, Layer, Logger, Schedule } from "effect" 2 | import type { DurationInput } from "effect/Duration" 3 | 4 | const makeBatchedLogger = (config: { 5 | readonly window: DurationInput 6 | }) => 7 | Effect.gen(function*(_) { 8 | const logBuffer: Array = [] 9 | const resetBuffer = Effect.sync(() => { 10 | logBuffer.length = 0 11 | }) 12 | const outputBuffer = Effect.suspend(() => Console.log(logBuffer.join("\n"))).pipe( 13 | Effect.zipRight(resetBuffer) 14 | ) 15 | 16 | const schedule = Schedule.fixed(config.window).pipe( 17 | Schedule.compose(Schedule.repeatForever) 18 | ) 19 | yield* _( 20 | outputBuffer, 21 | Effect.schedule(schedule), 22 | Effect.ensuring(outputBuffer), 23 | Effect.fork, 24 | Effect.interruptible 25 | ) 26 | 27 | const logger = Logger.stringLogger.pipe(Logger.map((message) => { 28 | logBuffer.push(message) 29 | })) 30 | 31 | yield* _(Effect.locallyScopedWith(FiberRef.currentLoggers, HashSet.add(logger))) 32 | }) 33 | 34 | const schedule = Schedule.fixed("500 millis").pipe(Schedule.compose(Schedule.recurs(10))) 35 | 36 | const program = Effect.gen(function*(_) { 37 | yield* _(Console.log("Running logs!")) 38 | yield* _(Effect.logInfo("Info log")) 39 | yield* _(Effect.logWarning("Warning log")) 40 | yield* _(Effect.logError("Error log")) 41 | }).pipe(Effect.schedule(schedule)) 42 | 43 | const BatchedLoggerLive = Layer.scopedDiscard(makeBatchedLogger({ window: "2 seconds" })) 44 | 45 | const MainLive = Logger.remove(Logger.defaultLogger).pipe( 46 | Layer.merge(BatchedLoggerLive) 47 | ) 48 | 49 | program.pipe( 50 | Effect.provide(MainLive), 51 | Effect.runFork 52 | ) 53 | -------------------------------------------------------------------------------- /workshop/solutions/session-04/exercise-01.ts: -------------------------------------------------------------------------------- 1 | import * as Metrics from "@effect/opentelemetry/Metrics" 2 | import * as Resource from "@effect/opentelemetry/Resource" 3 | import { PrometheusExporter, PrometheusSerializer } from "@opentelemetry/exporter-prometheus" 4 | import { 5 | Console, 6 | Effect, 7 | FiberRef, 8 | FiberRefs, 9 | Layer, 10 | Logger, 11 | Metric, 12 | MetricLabel, 13 | Option, 14 | ReadonlyArray, 15 | Runtime, 16 | RuntimeFlags, 17 | Schedule 18 | } from "effect" 19 | 20 | const makeMetricLogger = (counter: Metric.Metric.Counter, logLevelLabel: string) => 21 | Logger.make(({ context, logLevel }): void => { 22 | const labels = FiberRefs.get(context, FiberRef.currentMetricLabels).pipe( 23 | Option.getOrElse(() => ReadonlyArray.empty()) 24 | ) 25 | const label = MetricLabel.make(logLevelLabel, logLevel.label.toLowerCase()) 26 | counter.unsafeUpdate(1, ReadonlyArray.append(labels, label)) 27 | }) 28 | 29 | const MetricLoggerLive = Logger.add(makeMetricLogger(Metric.counter("effect_log_total"), "level")) 30 | 31 | const ResourceLive = Resource.layer({ 32 | serviceName: "advanced-effect-workshop", 33 | serviceVersion: "1.0.0" 34 | }) 35 | 36 | const MetricReporterLive = Layer.scopedDiscard(Effect.gen(function*(_) { 37 | const serializer = new PrometheusSerializer() 38 | const producer = yield* _(Metrics.makeProducer) 39 | const reader = yield* _(Metrics.registerProducer(producer, () => new PrometheusExporter())) 40 | yield* _( 41 | Effect.promise(() => reader.collect()), 42 | Effect.flatMap(({ resourceMetrics }) => Console.log(serializer.serialize(resourceMetrics))), 43 | Effect.repeat({ 44 | schedule: Schedule.spaced("5 seconds") 45 | }), 46 | Effect.fork 47 | ) 48 | })) 49 | 50 | const program = Effect.gen(function*(_) { 51 | yield* _(Effect.all([ 52 | Effect.log("Logging...").pipe( 53 | Effect.schedule(Schedule.jitteredWith( 54 | Schedule.spaced("1 seconds"), 55 | { min: 0.5, max: 1.5 } 56 | )) 57 | ), 58 | Effect.logError("Logging an error...").pipe( 59 | Effect.schedule(Schedule.jitteredWith( 60 | Schedule.spaced("1 seconds"), 61 | { min: 0.5, max: 1.5 } 62 | )) 63 | ) 64 | ], { concurrency: "unbounded" })) 65 | }) 66 | 67 | const MainLive = MetricReporterLive.pipe( 68 | Layer.provide(ResourceLive), 69 | Layer.merge(MetricLoggerLive) 70 | ) 71 | 72 | const runtime = Runtime.defaultRuntime.pipe( 73 | Runtime.disableRuntimeFlag(RuntimeFlags.RuntimeMetrics) 74 | ) 75 | 76 | Effect.awaitAllChildren, 77 | program.pipe( 78 | Effect.tapErrorCause(Effect.logError), 79 | Effect.scoped, 80 | Effect.provide(MainLive), 81 | Runtime.runFork(runtime) 82 | ) 83 | -------------------------------------------------------------------------------- /workshop/solutions/session-04/exercise-02.ts: -------------------------------------------------------------------------------- 1 | import { Metrics, Resource } from "@effect/opentelemetry" 2 | import { NodeContext, NodeHttpServer } from "@effect/platform-node" 3 | import * as Middleware from "@effect/platform/Http/Middleware" 4 | import * as ServerRequest from "@effect/platform/Http/ServerRequest" 5 | import * as Http from "@effect/platform/HttpServer" 6 | import { PrometheusExporter, PrometheusSerializer } from "@opentelemetry/exporter-prometheus" 7 | import { 8 | Console, 9 | Context, 10 | Effect, 11 | flow, 12 | Layer, 13 | Metric, 14 | MetricLabel, 15 | Runtime, 16 | RuntimeFlags, 17 | Schedule 18 | } from "effect" 19 | import { createServer } from "node:http" 20 | 21 | const trackSuccessfulRequests = ( 22 | method: string, 23 | route: string 24 | ): (self: Effect.Effect) => Effect.Effect => 25 | Metric.counter("http_request_successes", { 26 | description: "Track the count of successful HTTP requests by method and route", 27 | incremental: true 28 | }).pipe( 29 | Metric.withConstantInput(1), 30 | Metric.taggedWithLabels([ 31 | MetricLabel.make("method", method), 32 | MetricLabel.make("route", route) 33 | ]) 34 | ) 35 | 36 | const trackMethodFrequency = ( 37 | method: string, 38 | route: string 39 | ): (self: Effect.Effect) => Effect.Effect => 40 | Metric.frequency( 41 | "http_method", 42 | "Track the frequency of all HTTP requests by method and route" 43 | ).pipe( 44 | Metric.tagged("route", route), 45 | Metric.trackAll(method) 46 | ) 47 | 48 | const trackRequestLatency = Metric.timerWithBoundaries( 49 | "request_latency", 50 | [50, 75, 100, 150, 200, 250, 300, 350, 400], 51 | "Track request latency" 52 | ) 53 | 54 | const metricMiddleware = Middleware.make((httpApp) => 55 | ServerRequest.ServerRequest.pipe(Effect.flatMap((request) => 56 | httpApp.pipe( 57 | trackSuccessfulRequests(request.method, request.url), 58 | trackMethodFrequency(request.method, request.url), 59 | Metric.trackDuration(trackRequestLatency) 60 | ) 61 | )) 62 | ) 63 | 64 | const router = Http.router.empty.pipe( 65 | Http.router.get( 66 | "/", 67 | Effect.flatMap( 68 | Http.request.ServerRequest, 69 | (req) => Effect.sleep("200 millis").pipe(Effect.as(Http.response.text(req.url))) 70 | ) 71 | ), 72 | Http.router.get( 73 | "/healthz", 74 | Http.response.text("ok").pipe( 75 | Http.middleware.withLoggerDisabled 76 | ) 77 | ), 78 | Http.router.get( 79 | "/metrics", 80 | Effect.gen(function*(_) { 81 | const prometheusReporter = yield* _(PrometheusMetricReporter) 82 | const report = yield* _(prometheusReporter.report) 83 | return Http.response.text(report) 84 | }) 85 | ) 86 | ) 87 | 88 | class PrometheusMetricReporter extends Context.Tag("PrometheusMetricReporter")< 89 | PrometheusMetricReporter, 90 | { 91 | readonly report: Effect.Effect 92 | } 93 | >() { 94 | static readonly Live = Layer.scoped( 95 | PrometheusMetricReporter, 96 | Effect.gen(function*(_) { 97 | const serializer = new PrometheusSerializer() 98 | const producer = yield* _(Metrics.makeProducer) 99 | const reader = yield* _(Metrics.registerProducer(producer, () => new PrometheusExporter())) 100 | return { 101 | report: Effect.promise(() => reader.collect()).pipe( 102 | Effect.map(({ resourceMetrics }) => serializer.serialize(resourceMetrics)) 103 | ) 104 | } 105 | }) 106 | ) 107 | } 108 | 109 | class ConsoleMetricReporter extends Context.Tag("ConsoleReporter")() { 110 | static readonly Live = Layer.scopedDiscard(Effect.gen(function*(_) { 111 | const prometheusReporter = yield* _(PrometheusMetricReporter) 112 | yield* _( 113 | prometheusReporter.report, 114 | Effect.flatMap((report) => Console.log(report)), 115 | Effect.repeat({ schedule: Schedule.spaced("5 seconds") }), 116 | Effect.fork 117 | ) 118 | })) 119 | } 120 | 121 | const ServerLive = NodeHttpServer.server.layer(() => createServer(), { port: 8888 }) 122 | 123 | const MetricReportingLive = ConsoleMetricReporter.Live.pipe( 124 | Layer.provideMerge(PrometheusMetricReporter.Live), 125 | Layer.provide(Resource.layer({ 126 | serviceName: "advanced-effect-workshop", 127 | serviceVersion: "1.0.0" 128 | })) 129 | ) 130 | 131 | const middleware = flow(Http.middleware.logger, metricMiddleware) 132 | 133 | const HttpLive = router.pipe( 134 | Http.server.serve(middleware), 135 | Http.server.withLogAddress, 136 | Layer.provide(ServerLive), 137 | Layer.provide(NodeContext.layer) 138 | ) 139 | 140 | const MainLive = HttpLive.pipe( 141 | Layer.provide(MetricReportingLive) 142 | ) 143 | 144 | const runtime = Runtime.defaultRuntime.pipe( 145 | Runtime.disableRuntimeFlag(RuntimeFlags.RuntimeMetrics) 146 | ) 147 | 148 | Layer.launch(MainLive).pipe( 149 | Runtime.runFork(runtime) 150 | ) 151 | -------------------------------------------------------------------------------- /workshop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "exercises", 4 | "samples", 5 | "solutions" 6 | ], 7 | "compilerOptions": { 8 | "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", 9 | "strict": true, 10 | "allowSyntheticDefaultImports": true, 11 | "moduleDetection": "force", 12 | "downlevelIteration": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": false, 15 | "skipLibCheck": true, 16 | "exactOptionalPropertyTypes": true, 17 | "emitDecoratorMetadata": false, 18 | "experimentalDecorators": true, 19 | "moduleResolution": "NodeNext", 20 | "lib": [ 21 | "ES2022", 22 | "DOM" 23 | ], 24 | "isolatedModules": true, 25 | "sourceMap": true, 26 | "noImplicitReturns": false, 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": false, 29 | "noFallthroughCasesInSwitch": true, 30 | "noEmitOnError": false, 31 | "noErrorTruncation": false, 32 | "allowJs": false, 33 | "checkJs": false, 34 | "forceConsistentCasingInFileNames": true, 35 | "stripInternal": true, 36 | "noImplicitAny": true, 37 | "noImplicitThis": true, 38 | "noUncheckedIndexedAccess": false, 39 | "strictNullChecks": true, 40 | "baseUrl": ".", 41 | "target": "ES2022", 42 | "module": "NodeNext", 43 | "incremental": true, 44 | "removeComments": false, 45 | "types": [ 46 | "node" 47 | ], 48 | "plugins": [ 49 | { 50 | "name": "@effect/language-service" 51 | } 52 | ] 53 | } 54 | } 55 | --------------------------------------------------------------------------------