├── .clean-publish ├── .editorconfig ├── .github └── workflows │ ├── deploy-site.yaml │ └── tests.yaml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .size-limit.json ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── knip.json ├── package-lock.json ├── package.json ├── sandbox.ts ├── src ├── compose │ ├── __fixtures__ │ │ └── createRandomContainer.ts │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── compose-up.spec.ts.snap │ │ ├── compose-up.spec.ts │ │ └── index.spec-d.ts │ ├── commands │ │ ├── diff │ │ │ ├── __tests__ │ │ │ │ └── index.spec.ts │ │ │ ├── getSkippedContainers.ts │ │ │ └── index.ts │ │ ├── graph │ │ │ ├── __tests__ │ │ │ │ ├── example.spec.ts │ │ │ │ ├── index.spec.ts │ │ │ │ └── transformToDomainsGraph.spec.ts │ │ │ ├── computeTransitiveDependencies.ts │ │ │ ├── createViewMapper.ts │ │ │ ├── index.ts │ │ │ ├── relations │ │ │ │ ├── __tests__ │ │ │ │ │ ├── dependsOn.spec-d.ts │ │ │ │ │ ├── dependsOn.spec.ts │ │ │ │ │ ├── requiredBy.spec-d.ts │ │ │ │ │ └── requiredBy.spec.ts │ │ │ │ ├── dependsOn.ts │ │ │ │ ├── index.ts │ │ │ │ └── requiredBy.ts │ │ │ ├── transformToDomainsGraph.ts │ │ │ └── types.ts │ │ └── up │ │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── debug.spec.ts.snap │ │ │ ├── debug.spec.ts │ │ │ ├── index.spec.ts │ │ │ └── normalize-сonfig.spec.ts │ │ │ ├── createStageUpFn.ts │ │ │ ├── index.ts │ │ │ ├── normalizeConfig.ts │ │ │ └── validateStageUp │ │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── startupFailedError.spec.ts.snap │ │ │ ├── startupFailedError.spec.ts │ │ │ └── validateStageUp.spec.ts │ │ │ ├── index.ts │ │ │ └── startupFailedError.ts │ ├── index.ts │ └── prepareStages │ │ ├── __tests__ │ │ ├── get-containers-to-boot.spec.ts │ │ ├── prepare-stages.spec.ts │ │ ├── traverse-containers.spec.ts │ │ ├── uniq-container-id.spec.ts │ │ └── uniq-stage-id.spec.ts │ │ ├── getContainersToBoot.ts │ │ ├── index.ts │ │ ├── partitionOptionalDeps.ts │ │ ├── traverseContainers.ts │ │ ├── types.ts │ │ └── validators.ts ├── createContainer │ ├── __tests__ │ │ ├── deps.spec-d.ts │ │ ├── domain.spec-d.ts │ │ ├── id.spec-d.ts │ │ ├── start.spec-d.ts │ │ ├── types.ts │ │ └── validate.spec.ts │ ├── index.ts │ ├── types.ts │ └── validate.ts ├── index.ts └── shared │ ├── colors.ts │ ├── index.ts │ ├── isNil.ts │ └── pick.ts ├── tsconfig.json ├── vitest.config.ts ├── vitest.setup.ts └── website ├── .gitignore ├── README.md ├── astro.config.mjs ├── package-lock.json ├── package.json ├── public └── favicon.svg ├── src ├── content │ ├── config.ts │ └── docs │ │ ├── how-to-guides │ │ ├── debug.mdx │ │ ├── dynamically-load.mdx │ │ ├── handle-asynchronous-operations.mdx │ │ ├── handle-container-failures.mdx │ │ ├── share-data-between-containers.mdx │ │ ├── stages-required.mdx │ │ ├── use-with-react.mdx │ │ ├── use-with.mdx │ │ ├── visualize-the-system.mdx │ │ └── with-feature-toggle.mdx │ │ ├── index.mdx │ │ ├── reference │ │ ├── changelog.mdx │ │ ├── create-container.mdx │ │ └── glossary.mdx │ │ └── tutorials │ │ ├── basic-usage.mdx │ │ ├── dependencies.mdx │ │ ├── enable.mdx │ │ ├── getting-started.mdx │ │ ├── stages.mdx │ │ └── summary.mdx └── env.d.ts └── tsconfig.json /.clean-publish: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | ".editorconfig", 4 | ".gitignore", 5 | ".prettierrc", 6 | "lefthook.yml", 7 | "src", 8 | ".npmrc", 9 | "knip.json", 10 | "tsconfig.json", 11 | "Makefile" 12 | ], 13 | "fields": ["devDependencies", "scripts", "engines", "volta" ], 14 | "packageManager": "npm", 15 | "access": "public" 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | 6 | indent_style = space 7 | 8 | indent_size = 2 9 | 10 | end_of_line = lf 11 | 12 | charset = utf-8 13 | 14 | trim_trailing_whitespace = true 15 | 16 | insert_final_newline = true 17 | 18 | [Makefile] 19 | indent_style = tab 20 | -------------------------------------------------------------------------------- /.github/workflows/deploy-site.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout your repository using git 18 | uses: actions/checkout@v4 19 | - name: Install, build, and upload your site 20 | uses: withastro/action@v3 21 | with: 22 | path: ./website 23 | node-version: 22.9.0 24 | package-manager: pnpm@latest 25 | 26 | deploy: 27 | needs: build 28 | runs-on: ubuntu-latest 29 | environment: 30 | name: github-pages 31 | url: ${{ steps.deployment.outputs.page_url }} 32 | steps: 33 | - name: Deploy to GitHub Pages 34 | id: deployment 35 | uses: actions/deploy-pages@v4 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Install and Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - '**/*.md' 8 | - '**/*.mdx' 9 | - './website/**/*.*' 10 | pull_request: 11 | branches: [main] 12 | paths-ignore: 13 | - '**/*.md' 14 | - '**/*.mdx' 15 | - './website/**/*.*' 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Set up Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 22.9.0 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | 33 | - name: Prepublish 34 | run: make prepublish 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | dist/ 4 | .cache/ 5 | build/ 6 | .DS_Store 7 | coverage/ 8 | **/*.zip 9 | .vscode 10 | .swc/ 11 | .nx 12 | tsconfig.vitest-temp.json 13 | ./sandbox.ts 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-organize-imports"], 3 | "printWidth": 120, 4 | "parser": "typescript", 5 | "useTabs": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "experimentalTernaries": true, 9 | "trailingComma": "all", 10 | "overrides": [{ 11 | "files": ["*.json"], 12 | "options": { 13 | "parser": "json" 14 | } 15 | }, { 16 | "files": ["*.yml"], 17 | "options": { 18 | "parser": "yaml" 19 | } 20 | }, { 21 | "files": ["*.mdx", "*.md"], 22 | "options": { 23 | "parser": "markdown" 24 | } 25 | }], 26 | "organizeImportsSkipDestructiveCodeActions": true 27 | } 28 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [{ "path": "dist/index.js", "limit": "4 kB" }] 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](http://semver.org). 5 | 6 | [Here](https://grlt-hub.github.io/app-compose/reference/changelog/) 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 grlt-hub 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | rm -rf ./dist && npx tsc --noEmit --project ./tsconfig.json && npx tsup src/index.ts --minify terser --format esm --dts 3 | 4 | test: 5 | npx vitest --coverage 6 | 7 | lint: 8 | npx knip && npx size-limit 9 | 10 | prepublish: 11 | make build && make lint && make test 12 | 13 | publish: 14 | npm i && make prepublish && npx clean-publish 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 |

7 | App Compose 8 |

9 |

10 | Compose modules into apps. 11 |

12 |

13 | 14 |

15 | 16 |

17 | Documentation | Get involved! 18 |

19 | 20 |

21 | 22 |

23 |
24 | 25 | ## What is it? 26 | 27 | `app-compose` is a library for module-based applications. 28 | It helps developers easily connect different parts of an application — features, entities, services, and so on — so they work together as a single system. 29 | 30 | With `app-compose`, you can: 31 | 32 | - Simplify the management of complex dependencies. 33 | - Control the order in which modules run. 34 | - Intuitively enable or disable parts of the application. 35 | - Clearly visualize the parts of the application and their connections. 36 | 37 | Instead of manually managing the chaos of modules, `app-compose` turns them into a well-organized and scalable application. 38 | 39 | ## Cooking Up Your Application 40 | 41 | An application is like a dish: a collection of features, entities, and services. But by themselves, they don’t make an application. 42 | To bring everything to life, you need to combine them properly: at the right time, in the right order, and without anything extra. 43 | One misstep, and instead of a pizza, you might end up with a cake. 44 | 45 | If you’re unsure how to connect modules into a single system, [app-compose](https://grlt-hub.github.io/app-compose/) can simplify the process for you. 46 | 47 | ### Example 48 | 49 | ```ts 50 | import { createContainer, compose } from '@grlt-hub/app-compose'; 51 | 52 | // Imagine we are cooking a dish in our restaurant kitchen. 53 | // There are three steps: 54 | // 1. hire the chef 55 | // 2. order the ingredients, 56 | // 3. and cook the pizza. 57 | 58 | // First: prepare the "chef" 59 | // it’s like hiring the chef to start cooking. 60 | const chef = createContainer({ 61 | // The name of our chef. 62 | id: 'John Doe', 63 | // This chef specializes in Italian cuisine. 64 | domain: 'italian-chef', 65 | start: async () => { 66 | // For example, we are hiring a chef. 67 | const hiredChef = await hireChef(); 68 | 69 | // We return our chef. 70 | return { api: hiredChef }; 71 | }, 72 | }); 73 | 74 | // Second: if the chef is hired, 75 | // we need to order the ingredients. 76 | const ingredients = createContainer({ 77 | id: 'ingredients', 78 | domain: 'shop', 79 | // The ingredients ordering depends on the chef. 80 | dependencies: [chef], 81 | // If the chef is on break, 82 | // we can't proceed with the order. 83 | enable: (api) => api['John Doe'].hasBreak === false, 84 | start: async (api) => { 85 | // We order the ingredients. 86 | const orderedIngredients = await orderIngredients(); 87 | 88 | // We return the ordered ingredients. 89 | return { api: { orderedIngredients } }; 90 | }, 91 | }); 92 | 93 | // Third: we make the pizza. 94 | const pizza = createContainer({ 95 | id: 'pizza', 96 | domain: 'dish', 97 | dependencies: [chef, ingredients], 98 | start: (api) => { 99 | // The chef uses the ingredients 100 | // to make the pizza. 101 | const pepperoniPizza = api['John Doe'].makePizza({ 102 | ingredients: api.ingredients.orderedIngredients, 103 | }); 104 | 105 | // The pizza is ready! 106 | return { api: pepperoniPizza }; 107 | }, 108 | }); 109 | 110 | // Now the stages: we split the process into steps. 111 | // 1: "prepare" — hiring the chef and ordering the ingredients. 112 | // 2: "cooking" — making the pizza. 113 | const cmd = await compose({ 114 | stages: [ 115 | ['prepare', [chef, ingredients]], 116 | ['cooking', [pizza]], 117 | ], 118 | // We require everything to be ready. 119 | required: 'all', 120 | }); 121 | 122 | // The cooking process has started! 123 | await cmd.up(); 124 | ``` 125 | 126 | #### Example Status Flow 127 | 128 | Here’s how the statuses change during the cooking process: 129 | 130 | 1. **Initial state**: 131 | 132 | - `chef: 'idle', ingredients: 'idle'` — Everything is waiting. 133 | - `chef: 'pending', ingredients: 'idle'` — The chef is on the way to the kitchen. 134 | 135 | 2. **If the chef is ready to work**: 136 | 137 | - `chef: 'done', ingredients: 'pending'` — Ordering the ingredients. 138 | - `chef: 'done', ingredients: 'done', pizza: 'idle'` — All ingredients have been delivered. 139 | - `chef: 'done', ingredients: 'done', pizza: 'pending'` — Starting to make the pizza. 140 | - `chef: 'done', ingredients: 'done', pizza: 'done'` — The pizza is ready! 141 | 142 | 3. **If the chef is here, but taking a break**: 143 | 144 | - `chef: 'done', ingredients: 'off', pizza: 'off'` — Cooking is canceled. 145 | 146 | ## Strengths of the Library 147 | 148 | - Automatically resolves dependencies, removing the need to manually specify all containers. 149 | - Simplifies working with feature-toggles by eliminating excessive `if/else` logic for disabled functionality. 150 | - Allows you to define which parts of the application to run and in what order, prioritizing more important and less important dependencies. 151 | - Offers the ability to visualize the system composed of containers effectively (including transitive dependencies and their paths). 152 | - Provides a simple and intuitive developer experience (DX). 153 | - Ensures high performance, suitable for scalable applications. 154 | - Includes debugging tools to facilitate the development process. 155 | - Covered by 100% tests, including type tests. 156 | 157 | ## What app-compose is NOT 158 | 159 | - It does not tell you how to build a module. You choose how your modules work. app-compose only helps you put them together in one app. 160 | - It does not manage data or state. If you need state (like Effector or Redux), you add it inside your modules. app-compose only starts them. 161 | 162 | ## Documentation 163 | 164 | Ready to get started? Check out the full [documentation](https://grlt-hub.github.io/app-compose/) to dive deeper. 165 | 166 | ## Community 167 | 168 | Have questions or want to contribute? Join our community to connect with other developers. 169 | 170 | - [Discord](https://discord.gg/ajv8eHzm) 171 | - [Telegram](https://t.me/grlt_hub_app_compose) 172 | -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | "**/dist/**", 4 | "**/__tests__/**", 5 | "**/__fixtures__/**", 6 | "vitest.config.ts", 7 | "tsconfig.json", 8 | "**/website/**", 9 | "./sandbox.ts" 10 | ], 11 | "entry": ["**/__tests__/**", "./src/index.ts"], 12 | "exclude": ["dependencies", "binaries"] 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@grlt-hub/app-compose", 3 | "version": "2.0.2", 4 | "type": "module", 5 | "private": false, 6 | "main": "dist/index.js", 7 | "module": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "files": ["/README.md", "/package.json", "/dist"], 10 | "author": "Viktor Pasynok", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "@size-limit/preset-small-lib": "11.2.0", 14 | "@vitest/coverage-v8": "3.1.1", 15 | "clean-publish": "5.1.0", 16 | "knip": "5.47.0", 17 | "prettier": "3.5.3", 18 | "prettier-plugin-organize-imports": "4.1.0", 19 | "size-limit": "11.2.0", 20 | "terser": "5.39.0", 21 | "tslib": "2.8.1", 22 | "tsup": "8.4.0", 23 | "typescript": "5.8.3", 24 | "vite-tsconfig-paths": "5.1.4", 25 | "vitest": "3.1.1" 26 | }, 27 | "peerDependencies": { 28 | "effector": "23" 29 | }, 30 | "publishConfig": { 31 | "access": "public" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/grlt-hub/app-compose.git" 36 | }, 37 | "homepage": "https://grlt-hub.github.io/app-compose/", 38 | "description": "Compose modules into apps", 39 | "keywords": [ 40 | "grlt", 41 | "grlt-hub", 42 | "modular", 43 | "scalable", 44 | "application-composition", 45 | "module-based", 46 | "app-composition", 47 | "dependency-injection", 48 | "dependency-management", 49 | "framework", 50 | "application-assembly", 51 | "modularity", 52 | "app-development", 53 | "composable-architecture", 54 | "container-management", 55 | "software-architecture", 56 | "reusable-components", 57 | "app-structure", 58 | "composable-framework", 59 | "module-integration", 60 | "project-structure", 61 | "container-composition", 62 | "frontend di", 63 | "front-end di" 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /sandbox.ts: -------------------------------------------------------------------------------- 1 | import { compose, createContainer } from './src'; 2 | 3 | const start = () => ({ api: null }); 4 | 5 | const a = createContainer({ id: 'a', domain: 'a', start }); 6 | const b = createContainer({ id: 'b', domain: 'b', dependencies: [a], start }); 7 | const c = createContainer({ id: 'c', domain: 'c', optionalDependencies: [b], start }); 8 | const d = createContainer({ id: 'd', domain: 'd', dependencies: [c], optionalDependencies: [b], start }); 9 | 10 | const cmd = await compose({ 11 | stages: [['_', [a, b, c, d]]], 12 | }); 13 | 14 | const { graph } = await cmd.graph({ view: 'domains' }); 15 | 16 | console.log(graph); 17 | -------------------------------------------------------------------------------- /src/compose/__fixtures__/createRandomContainer.ts: -------------------------------------------------------------------------------- 1 | import { createContainer, type AnyContainer, type ContainerStatus } from '@createContainer'; 2 | import { createStore } from 'effector'; 3 | import { randomUUID } from 'node:crypto'; 4 | 5 | type Overrides = Partial< 6 | Pick & { 7 | status: ContainerStatus; 8 | } 9 | >; 10 | 11 | export const createRandomContainer = (overrides: Overrides = {}): AnyContainer => { 12 | const contaier = createContainer({ 13 | // @ts-expect-error 14 | id: randomUUID(), 15 | domain: randomUUID(), 16 | start: () => ({ api: null }), 17 | ...overrides, 18 | }) as AnyContainer; 19 | 20 | return overrides.status ? { ...contaier, $status: createStore(overrides.status) } : contaier; 21 | }; 22 | -------------------------------------------------------------------------------- /src/compose/__tests__/__snapshots__/compose-up.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`compose.up > required=all | fail 1`] = ` 4 | [Error: [app-compose] Application startup failed. 5 | Required container(s) "featureAccountsList" did not up in stage "first-order-features-stage". 6 | 7 | Startup Log: 8 | 9 | topology-stage: 10 | allDone: true 11 | containerStatuses: 12 | topology: done 13 | 14 | entities-stage: 15 | allDone: true 16 | containerStatuses: 17 | entityAccount: done 18 | entityLocale: done 19 | 20 | first-order-features-stage: 21 | allDone: false 22 | containerStatuses: 23 | featureAccountsList: off 24 | featureDeposit: off 25 | 26 | 27 | Recommendations: 28 | - Verify if the container(s) "featureAccountsList" are truly required. 29 | - If not, consider removing them from the required list in "up.required". 30 | - Ensure all dependencies for the container(s) are correct and their logic works as expected.] 31 | `; 32 | 33 | exports[`compose.up > required=list | fail | enable: () => false 1`] = ` 34 | [Error: [app-compose] Application startup failed. 35 | Required container(s) "featureDeposit" did not up in stage "first-order-features-stage". 36 | 37 | Startup Log: 38 | 39 | topology-stage: 40 | allDone: true 41 | containerStatuses: 42 | topology: done 43 | 44 | entities-stage: 45 | allDone: true 46 | containerStatuses: 47 | entityAccount: done 48 | entityLocale: done 49 | 50 | first-order-features-stage: 51 | allDone: false 52 | containerStatuses: 53 | featureAccountsList: off 54 | featureDeposit: off 55 | 56 | 57 | Recommendations: 58 | - Verify if the container(s) "featureDeposit" are truly required. 59 | - If not, consider removing them from the required list in "up.required". 60 | - Ensure all dependencies for the container(s) are correct and their logic works as expected.] 61 | `; 62 | 63 | exports[`compose.up > required=list | fail | throw new Error("oops") 1`] = ` 64 | [Error: [app-compose] Application startup failed. 65 | Required container(s) "featureDeposit" did not up in stage "first-order-features-stage". 66 | 67 | Startup Log: 68 | 69 | topology-stage: 70 | allDone: true 71 | containerStatuses: 72 | topology: done 73 | 74 | entities-stage: 75 | allDone: true 76 | containerStatuses: 77 | entityAccount: done 78 | entityLocale: done 79 | 80 | first-order-features-stage: 81 | allDone: false 82 | containerStatuses: 83 | featureAccountsList: fail 84 | featureDeposit: fail 85 | 86 | 87 | Recommendations: 88 | - Verify if the container(s) "featureDeposit" are truly required. 89 | - If not, consider removing them from the required list in "up.required". 90 | - Ensure all dependencies for the container(s) are correct and their logic works as expected.] 91 | `; 92 | -------------------------------------------------------------------------------- /src/compose/__tests__/compose-up.spec.ts: -------------------------------------------------------------------------------- 1 | import type { StageTuples } from '@prepareStages'; 2 | import { createRandomContainer } from '@randomContainer'; 3 | import { compose } from '../index'; 4 | 5 | const topology = createRandomContainer({ id: 'topology' }); 6 | const entityAccount = createRandomContainer({ id: 'entityAccount', dependencies: [topology] }); 7 | const entityLocale = createRandomContainer({ id: 'entityLocale', dependencies: [topology] }); 8 | const createFeatureAccountsList = (params?: Parameters[0]) => 9 | createRandomContainer({ dependencies: [entityAccount], id: 'featureAccountsList', ...params }); 10 | const featureLanguageSelector = createRandomContainer({ id: 'featureLanguageSelector', dependencies: [entityLocale] }); 11 | 12 | describe('compose.up', () => { 13 | const featureAccountsList = createFeatureAccountsList(); 14 | const featureDeposit = createRandomContainer({ dependencies: [featureAccountsList] }); 15 | const featureSupportChat = createRandomContainer({ 16 | dependencies: [entityLocale], 17 | optionalDependencies: [featureAccountsList], 18 | }); 19 | 20 | test('required=all | success', async () => { 21 | const stages: StageTuples = [ 22 | ['topology-stage', [topology]], 23 | ['entities-stage', [entityAccount, entityLocale]], 24 | ['first-order-features-stage', [featureAccountsList, featureDeposit]], 25 | ['second-order-features-stage', [featureLanguageSelector]], 26 | ['other-features-stage', [featureSupportChat]], 27 | ]; 28 | 29 | const cmd = await compose({ stages, required: 'all' }); 30 | const app = await cmd.up({ debug: false }); 31 | 32 | expect(app.allDone).toBe(true); 33 | expect(app.stages).toStrictEqual({ 34 | 'topology-stage': { allDone: true, containerStatuses: { [topology.id]: 'done' } }, 35 | 'entities-stage': { 36 | allDone: true, 37 | containerStatuses: { [entityAccount.id]: 'done', [entityLocale.id]: 'done' }, 38 | }, 39 | 'first-order-features-stage': { 40 | allDone: true, 41 | containerStatuses: { 42 | [featureAccountsList.id]: 'done', 43 | [featureDeposit.id]: 'done', 44 | }, 45 | }, 46 | 'second-order-features-stage': { 47 | allDone: true, 48 | containerStatuses: { [featureLanguageSelector.id]: 'done' }, 49 | }, 50 | 'other-features-stage': { 51 | allDone: true, 52 | containerStatuses: { [featureSupportChat.id]: 'done' }, 53 | }, 54 | }); 55 | }); 56 | 57 | test('required=all | fail', async () => { 58 | const featureAccountsList = createFeatureAccountsList({ 59 | enable: () => false, 60 | }); 61 | const featureDeposit = createRandomContainer({ id: 'featureDeposit', dependencies: [featureAccountsList] }); 62 | const featureSupportChat = createRandomContainer({ 63 | id: 'featureSupportChat', 64 | dependencies: [entityLocale], 65 | optionalDependencies: [featureAccountsList], 66 | }); 67 | 68 | const stages: StageTuples = [ 69 | ['topology-stage', [topology]], 70 | ['entities-stage', [entityAccount, entityLocale]], 71 | ['first-order-features-stage', [featureAccountsList, featureDeposit]], 72 | ['second-order-features-stage', [featureLanguageSelector]], 73 | ['other-features-stage', [featureSupportChat]], 74 | ]; 75 | 76 | const cmd = await compose({ stages, required: 'all' }); 77 | 78 | await expect(cmd.up()).rejects.toMatchSnapshot(); 79 | }); 80 | 81 | test('required=undefined | success', async () => { 82 | const featureAccountsList = createFeatureAccountsList({ 83 | enable: () => false, 84 | }); 85 | const featureDeposit = createRandomContainer({ id: 'featureDeposit', dependencies: [featureAccountsList] }); 86 | const featureSupportChat = createRandomContainer({ 87 | id: 'featureSupportChat', 88 | dependencies: [entityLocale], 89 | optionalDependencies: [featureAccountsList], 90 | }); 91 | 92 | const stages: StageTuples = [ 93 | ['topology-stage', [topology]], 94 | ['entities-stage', [entityAccount, entityLocale]], 95 | ['first-order-features-stage', [featureAccountsList, featureDeposit]], 96 | ['second-order-features-stage', [featureLanguageSelector]], 97 | ['other-features-stage', [featureSupportChat]], 98 | ]; 99 | 100 | const cmd = await compose({ stages }); 101 | const app = await cmd.up(); 102 | 103 | expect(app.allDone).toBe(false); 104 | expect(app.stages).toStrictEqual({ 105 | 'topology-stage': { allDone: true, containerStatuses: { [topology.id]: 'done' } }, 106 | 'entities-stage': { 107 | allDone: true, 108 | containerStatuses: { [entityAccount.id]: 'done', [entityLocale.id]: 'done' }, 109 | }, 110 | 'first-order-features-stage': { 111 | allDone: false, 112 | containerStatuses: { 113 | [featureAccountsList.id]: 'off', 114 | [featureDeposit.id]: 'off', 115 | }, 116 | }, 117 | 'second-order-features-stage': { 118 | allDone: true, 119 | containerStatuses: { [featureLanguageSelector.id]: 'done' }, 120 | }, 121 | 'other-features-stage': { 122 | allDone: true, 123 | containerStatuses: { [featureSupportChat.id]: 'done' }, 124 | }, 125 | }); 126 | }); 127 | 128 | test('required=list | success', async () => { 129 | const featureAccountsList = createFeatureAccountsList({ 130 | enable: () => false, 131 | }); 132 | const featureDeposit = createRandomContainer({ id: 'featureDeposit', dependencies: [featureAccountsList] }); 133 | const featureSupportChat = createRandomContainer({ 134 | id: 'featureSupportChat', 135 | dependencies: [entityLocale], 136 | optionalDependencies: [featureAccountsList], 137 | }); 138 | 139 | const stages: StageTuples = [ 140 | ['topology-stage', [topology]], 141 | ['entities-stage', [entityAccount, entityLocale]], 142 | ['first-order-features-stage', [featureAccountsList, featureDeposit]], 143 | ['second-order-features-stage', [featureLanguageSelector]], 144 | ['other-features-stage', [featureSupportChat]], 145 | ]; 146 | 147 | const cmd = await compose({ stages, required: [topology, [featureLanguageSelector, featureAccountsList]] }); 148 | const app = await cmd.up(); 149 | 150 | expect(app.allDone).toBe(false); 151 | expect(app.stages).toStrictEqual({ 152 | 'topology-stage': { allDone: true, containerStatuses: { [topology.id]: 'done' } }, 153 | 'entities-stage': { 154 | allDone: true, 155 | containerStatuses: { [entityAccount.id]: 'done', [entityLocale.id]: 'done' }, 156 | }, 157 | 'first-order-features-stage': { 158 | allDone: false, 159 | containerStatuses: { 160 | [featureAccountsList.id]: 'off', 161 | [featureDeposit.id]: 'off', 162 | }, 163 | }, 164 | 'second-order-features-stage': { 165 | allDone: true, 166 | containerStatuses: { [featureLanguageSelector.id]: 'done' }, 167 | }, 168 | 'other-features-stage': { 169 | allDone: true, 170 | containerStatuses: { [featureSupportChat.id]: 'done' }, 171 | }, 172 | }); 173 | }); 174 | 175 | test('required=list | fail | enable: () => false', async () => { 176 | const featureAccountsList = createFeatureAccountsList({ enable: () => false }); 177 | const featureDeposit = createRandomContainer({ id: 'featureDeposit', dependencies: [featureAccountsList] }); 178 | const featureSupportChat = createRandomContainer({ 179 | id: 'featureSupportChat', 180 | dependencies: [entityLocale], 181 | optionalDependencies: [featureAccountsList], 182 | }); 183 | 184 | const stages: StageTuples = [ 185 | ['topology-stage', [topology]], 186 | ['entities-stage', [entityAccount, entityLocale]], 187 | ['first-order-features-stage', [featureAccountsList, featureDeposit]], 188 | ['second-order-features-stage', [featureLanguageSelector]], 189 | ['other-features-stage', [featureSupportChat]], 190 | ]; 191 | 192 | const cmd = await compose({ stages, required: [topology, featureDeposit] }); 193 | 194 | await expect(cmd.up()).rejects.toMatchSnapshot(); 195 | }); 196 | 197 | test('required=list | fail | throw new Error("oops")', async () => { 198 | const featureAccountsList = createFeatureAccountsList({ 199 | enable: () => { 200 | throw new Error('oops'); 201 | }, 202 | }); 203 | const featureDeposit = createRandomContainer({ id: 'featureDeposit', dependencies: [featureAccountsList] }); 204 | const featureSupportChat = createRandomContainer({ 205 | id: 'featureSupportChat', 206 | dependencies: [entityLocale], 207 | optionalDependencies: [featureAccountsList], 208 | }); 209 | 210 | const stages: StageTuples = [ 211 | ['topology-stage', [topology]], 212 | ['entities-stage', [entityAccount, entityLocale]], 213 | ['first-order-features-stage', [featureAccountsList, featureDeposit]], 214 | ['second-order-features-stage', [featureLanguageSelector]], 215 | ['other-features-stage', [featureSupportChat]], 216 | ]; 217 | 218 | const cmd = await compose({ stages, required: [topology, featureDeposit] }); 219 | 220 | await expect(cmd.up()).rejects.toMatchSnapshot(); 221 | }); 222 | 223 | test('required=list | success | one of', async () => { 224 | const featureAccountsList = createFeatureAccountsList({ 225 | enable: () => { 226 | throw new Error('oops'); 227 | }, 228 | }); 229 | const featureDeposit = createRandomContainer({ id: 'featureDeposit', dependencies: [featureAccountsList] }); 230 | const featureSupportChat = createRandomContainer({ 231 | id: 'featureSupportChat', 232 | dependencies: [entityLocale], 233 | optionalDependencies: [featureAccountsList], 234 | }); 235 | 236 | const stages: StageTuples = [ 237 | ['topology-stage', [topology]], 238 | ['entities-stage', [entityAccount, entityLocale]], 239 | ['first-order-features-stage', [featureAccountsList, featureDeposit]], 240 | ['second-order-features-stage', [featureLanguageSelector]], 241 | ['other-features-stage', [featureSupportChat]], 242 | ]; 243 | 244 | const cmd = await compose({ stages, required: [topology, [featureDeposit, featureSupportChat]] }); 245 | const app = await cmd.up(); 246 | 247 | expect(app.allDone).toBe(false); 248 | expect(app.stages).toStrictEqual({ 249 | 'topology-stage': { allDone: true, containerStatuses: { [topology.id]: 'done' } }, 250 | 'entities-stage': { 251 | allDone: true, 252 | containerStatuses: { [entityAccount.id]: 'done', [entityLocale.id]: 'done' }, 253 | }, 254 | 'first-order-features-stage': { 255 | allDone: false, 256 | containerStatuses: { 257 | [featureAccountsList.id]: 'fail', 258 | [featureDeposit.id]: 'fail', 259 | }, 260 | }, 261 | 'second-order-features-stage': { 262 | allDone: true, 263 | containerStatuses: { [featureLanguageSelector.id]: 'done' }, 264 | }, 265 | 'other-features-stage': { 266 | allDone: true, 267 | containerStatuses: { [featureSupportChat.id]: 'done' }, 268 | }, 269 | }); 270 | }); 271 | }); 272 | -------------------------------------------------------------------------------- /src/compose/__tests__/index.spec-d.ts: -------------------------------------------------------------------------------- 1 | import { type AnyContainer, type ContainerDomain, type ContainerId, type ContainerStatus } from '@createContainer'; 2 | import { type NonEmptyTuple, type Stage } from '@shared'; 3 | import { compose } from '../index'; 4 | 5 | describe('compose fn', () => { 6 | { 7 | type Params = Parameters; 8 | 9 | expectTypeOf().toEqualTypeOf<1>(); 10 | expectTypeOf().toEqualTypeOf<{ 11 | stages: [string, NonEmptyTuple][]; 12 | required?: (AnyContainer | NonEmptyTuple)[] | 'all'; 13 | }>(); 14 | } 15 | 16 | { 17 | type Result = Awaited>; 18 | 19 | type Diff = () => Promise; 20 | expectTypeOf().toEqualTypeOf(); 21 | 22 | type Up = (_?: { 23 | debug?: boolean; 24 | onContainerFail?: (_: { 25 | container: { id: ContainerId; domain: ContainerDomain }; 26 | stageId: Stage['id']; 27 | error: Error; 28 | }) => unknown; 29 | }) => Promise<{ 30 | allDone: boolean; 31 | stages: Record; allDone: boolean }>; 32 | }>; 33 | 34 | expectTypeOf().toEqualTypeOf(); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /src/compose/commands/diff/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { diff } from '../index'; 3 | 4 | describe('diff cmd', () => { 5 | afterEach(() => { 6 | vi.resetAllMocks(); 7 | }); 8 | 9 | test('zero changes', async () => { 10 | const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 11 | 12 | const a = createRandomContainer({}); 13 | const b = createRandomContainer({ 14 | dependencies: [a], 15 | }); 16 | const c = createRandomContainer({ 17 | dependencies: [b], 18 | }); 19 | 20 | diff({ 21 | expected: [['x', [a, b, c]]], 22 | received: [{ id: 'x', containersToBoot: [a, b, c], skippedContainers: {} }], 23 | }); 24 | 25 | expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` 26 | [ 27 | [ 28 | "[app-compose] | diff command 29 | All skipped containers are optional. If they are expected to work, please include them in the list when calling \`compose\` function 30 | 31 | Stages:", 32 | ], 33 | [ 34 | "- x: 35 | expected: [ ${a.id}, ${b.id}, ${c.id} ] 36 | received: [ ${a.id}, ${b.id}, ${c.id} ]", 37 | ], 38 | ] 39 | `); 40 | }); 41 | 42 | test('with changes', async () => { 43 | const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 44 | 45 | const a = createRandomContainer(); 46 | const b = createRandomContainer({ 47 | dependencies: [a], 48 | }); 49 | const c = createRandomContainer({ 50 | dependencies: [b], 51 | }); 52 | 53 | diff({ 54 | expected: [['x', [b, c]]], 55 | received: [{ id: 'x', containersToBoot: [a, b, c], skippedContainers: {} }], 56 | }); 57 | 58 | expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` 59 | [ 60 | [ 61 | "[app-compose] | diff command 62 | All skipped containers are optional. If they are expected to work, please include them in the list when calling \`compose\` function 63 | 64 | Stages:", 65 | ], 66 | [ 67 | "- x: 68 | expected: [ ${b.id}, ${c.id} ] 69 | received: [ ${a.id}, ${b.id}, ${c.id} ]", 70 | ], 71 | ] 72 | `); 73 | }); 74 | 75 | test('edge case', async () => { 76 | const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 77 | 78 | const a = createRandomContainer(); 79 | const b = createRandomContainer({ 80 | dependencies: [a], 81 | }); 82 | const c = createRandomContainer({ 83 | dependencies: [b], 84 | }); 85 | 86 | diff({ 87 | expected: [['x', [b, c]]], 88 | received: [{ id: 'y', containersToBoot: [a, b, c], skippedContainers: {} }], 89 | }); 90 | 91 | expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` 92 | [ 93 | [ 94 | "[app-compose] | diff command 95 | All skipped containers are optional. If they are expected to work, please include them in the list when calling \`compose\` function 96 | 97 | Stages:", 98 | ], 99 | ] 100 | `); 101 | }); 102 | 103 | test('with skipped', async () => { 104 | const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 105 | 106 | const a = createRandomContainer(); 107 | const b = createRandomContainer({ 108 | dependencies: [a], 109 | }); 110 | const c = createRandomContainer({ 111 | dependencies: [b], 112 | }); 113 | 114 | diff({ 115 | expected: [['x', [b, c]]], 116 | received: [{ id: 'x', containersToBoot: [a, b, c], skippedContainers: { [a.id]: ['skippedId'] } }], 117 | }); 118 | 119 | expect(consoleLogSpy.mock.calls).toMatchInlineSnapshot(` 120 | [ 121 | [ 122 | "[app-compose] | diff command 123 | All skipped containers are optional. If they are expected to work, please include them in the list when calling \`compose\` function 124 | 125 | Stages:", 126 | ], 127 | [ 128 | "- x: 129 | expected: [ ${b.id}, ${c.id} ] 130 | received: [ ${a.id}, ${b.id}, ${c.id} ] 131 | skipped: 132 | - skippedId: [${a.id}]", 133 | ], 134 | ] 135 | `); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/compose/commands/diff/getSkippedContainers.ts: -------------------------------------------------------------------------------- 1 | import { colors, type SkippedContainers } from '@shared'; 2 | 3 | type Params = { 4 | skippedContainers: SkippedContainers; 5 | INDENT: string; 6 | }; 7 | 8 | const getSkippedContainers = ({ skippedContainers, INDENT }: Params) => { 9 | if (Object.keys(skippedContainers).length === 0) { 10 | return ''; 11 | } 12 | 13 | const reversedSkipped: SkippedContainers = {}; 14 | 15 | for (const containerId in skippedContainers) { 16 | for (const dependency of skippedContainers[containerId]!) { 17 | (reversedSkipped[dependency] ||= []).push(containerId); 18 | } 19 | } 20 | 21 | return Object.entries(reversedSkipped) 22 | .map(([depId, containers]) => { 23 | return `- ${colors.yellow(depId)}: [${containers.join(', ')}]`; 24 | }) 25 | .join(`\n${INDENT}`); 26 | }; 27 | 28 | export { getSkippedContainers }; 29 | -------------------------------------------------------------------------------- /src/compose/commands/diff/index.ts: -------------------------------------------------------------------------------- 1 | import type { ContainerId } from '@createContainer'; 2 | import type { StageTuples } from '@prepareStages'; 3 | import { colors, LIBRARY_NAME, type SkippedContainers, type Stage } from '@shared'; 4 | import { getSkippedContainers } from './getSkippedContainers'; 5 | 6 | const INDENT = ' '; 7 | const SKIPPED_INDENT = `${INDENT}${INDENT}${INDENT}`; 8 | const SKIPPED_MSG = 9 | 'All skipped containers are optional. If they are expected to work, please include them in the list when calling `compose` function'; 10 | 11 | type Params = { 12 | expected: StageTuples; 13 | received: (Stage & { skippedContainers: SkippedContainers })[]; 14 | }; 15 | 16 | const diff = ({ expected, received }: Params) => { 17 | console.log(`${LIBRARY_NAME} | diff command` + '\n' + SKIPPED_MSG + '\n\n' + 'Stages:'); 18 | 19 | received.forEach(({ id: stageId, containersToBoot, skippedContainers }) => { 20 | const original = expected.find((x) => x[0] === stageId); 21 | 22 | if (!original) { 23 | return; 24 | } 25 | 26 | const colorizedStage = containersToBoot.reduce((acc, container) => { 27 | const exists = original[1].some((x) => x.id === container.id); 28 | 29 | acc.push(exists ? container.id : colors.bgGreen(container.id)); 30 | 31 | return acc; 32 | }, []); 33 | 34 | const skipped = getSkippedContainers({ skippedContainers, INDENT: SKIPPED_INDENT }); 35 | const skippedBlock = skipped.length ? `\n${INDENT}skipped:\n${SKIPPED_INDENT}${skipped}` : ''; 36 | 37 | console.log( 38 | `- ${colors.magenta(stageId)}:` + 39 | '\n' + 40 | `${INDENT}expected: [ ${original[1].map((x) => x.id).join(', ')} ]` + 41 | '\n' + 42 | `${INDENT}received: [ ${colorizedStage.join(', ')} ]` + 43 | skippedBlock, 44 | ); 45 | }); 46 | }; 47 | 48 | export { diff }; 49 | -------------------------------------------------------------------------------- /src/compose/commands/graph/__tests__/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { graph } from '../index'; 3 | 4 | test('example from doc', () => { 5 | const a = createRandomContainer({ id: 'a' }); 6 | const b = createRandomContainer({ id: 'b', dependencies: [a] }); 7 | const c = createRandomContainer({ id: 'c', optionalDependencies: [b] }); 8 | const d = createRandomContainer({ id: 'd', dependencies: [c], optionalDependencies: [b] }); 9 | 10 | const result = graph( 11 | { 12 | stages: [{ id: '_', containersToBoot: [a, b, c, d] }], 13 | }, 14 | { view: 'containers' }, 15 | ); 16 | 17 | expect(result.graph).toStrictEqual({ 18 | [a.id]: { 19 | domain: a.domain, 20 | dependencies: [], 21 | optionalDependencies: [], 22 | transitive: { dependencies: [], optionalDependencies: [] }, 23 | }, 24 | [b.id]: { 25 | domain: b.domain, 26 | dependencies: [a.id], 27 | optionalDependencies: [], 28 | transitive: { dependencies: [], optionalDependencies: [] }, 29 | }, 30 | [c.id]: { 31 | domain: c.domain, 32 | dependencies: [], 33 | optionalDependencies: [b.id], 34 | transitive: { 35 | dependencies: [], 36 | optionalDependencies: [ 37 | { 38 | id: a.id, 39 | path: `${c.id} -> ${b.id} -> ${a.id}`, 40 | }, 41 | ], 42 | }, 43 | }, 44 | [d.id]: { 45 | domain: d.domain, 46 | dependencies: [c.id], 47 | optionalDependencies: [b.id], 48 | transitive: { 49 | dependencies: [], 50 | optionalDependencies: [ 51 | { 52 | id: a.id, 53 | path: `${d.id} -> ${b.id} -> ${a.id}`, 54 | }, 55 | ], 56 | }, 57 | }, 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/compose/commands/graph/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { graph } from '../index'; 3 | 4 | test('handles all variations of dependencies', () => { 5 | // containers without dependencies 6 | const noDeps1 = createRandomContainer(); 7 | const noDeps2 = createRandomContainer(); 8 | 9 | // containers with strict dependencies 10 | const strict1 = createRandomContainer({ dependencies: [noDeps1] }); 11 | const strict2 = createRandomContainer({ dependencies: [strict1] }); 12 | 13 | // containers with optional dependencies 14 | const optional1 = createRandomContainer({ optionalDependencies: [noDeps2] }); 15 | const optional2 = createRandomContainer({ optionalDependencies: [strict2] }); 16 | 17 | // containers with mixed dependencies 18 | const mixed = createRandomContainer({ dependencies: [strict1], optionalDependencies: [optional1] }); 19 | 20 | const { graph: result } = graph( 21 | { 22 | stages: [{ id: '_', containersToBoot: [noDeps1, noDeps2, strict1, strict2, optional1, optional2, mixed] }], 23 | }, 24 | { view: 'containers' }, 25 | ); 26 | 27 | expect(result).toStrictEqual({ 28 | [noDeps1.id]: { 29 | domain: noDeps1.domain, 30 | dependencies: [], 31 | optionalDependencies: [], 32 | transitive: { dependencies: [], optionalDependencies: [] }, 33 | }, 34 | [noDeps2.id]: { 35 | domain: noDeps2.domain, 36 | dependencies: [], 37 | optionalDependencies: [], 38 | transitive: { dependencies: [], optionalDependencies: [] }, 39 | }, 40 | [strict1.id]: { 41 | domain: strict1.domain, 42 | dependencies: [noDeps1.id], 43 | optionalDependencies: [], 44 | transitive: { dependencies: [], optionalDependencies: [] }, 45 | }, 46 | [strict2.id]: { 47 | domain: strict2.domain, 48 | dependencies: [strict1.id], 49 | optionalDependencies: [], 50 | transitive: { 51 | dependencies: [ 52 | { 53 | id: noDeps1.id, 54 | path: `${strict2.id} -> ${strict1.id} -> ${noDeps1.id}`, 55 | }, 56 | ], 57 | optionalDependencies: [], 58 | }, 59 | }, 60 | [optional1.id]: { 61 | domain: optional1.domain, 62 | dependencies: [], 63 | optionalDependencies: [noDeps2.id], 64 | transitive: { dependencies: [], optionalDependencies: [] }, 65 | }, 66 | [optional2.id]: { 67 | domain: optional2.domain, 68 | dependencies: [], 69 | optionalDependencies: [strict2.id], 70 | transitive: { 71 | dependencies: [], 72 | optionalDependencies: [ 73 | { 74 | id: strict1.id, 75 | path: `${optional2.id} -> ${strict2.id} -> ${strict1.id}`, 76 | }, 77 | { 78 | id: noDeps1.id, 79 | path: `${optional2.id} -> ${strict2.id} -> ${strict1.id} -> ${noDeps1.id}`, 80 | }, 81 | ], 82 | }, 83 | }, 84 | [mixed.id]: { 85 | domain: mixed.domain, 86 | dependencies: [strict1.id], 87 | optionalDependencies: [optional1.id], 88 | transitive: { 89 | dependencies: [ 90 | { 91 | id: noDeps1.id, 92 | path: `${mixed.id} -> ${strict1.id} -> ${noDeps1.id}`, 93 | }, 94 | ], 95 | optionalDependencies: [ 96 | { 97 | id: noDeps2.id, 98 | path: `${mixed.id} -> ${optional1.id} -> ${noDeps2.id}`, 99 | }, 100 | ], 101 | }, 102 | }, 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/compose/commands/graph/__tests__/transformToDomainsGraph.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { graph } from '../index'; 3 | 4 | describe('transformToDomainsGraph', () => { 5 | test('basic', () => { 6 | const a = createRandomContainer(); 7 | const b = createRandomContainer({ domain: a.domain, dependencies: [a] }); 8 | const c = createRandomContainer({ dependencies: [b] }); 9 | const d = createRandomContainer({ optionalDependencies: [c] }); 10 | const x = createRandomContainer(); 11 | const e = createRandomContainer({ dependencies: [c], optionalDependencies: [x] }); 12 | 13 | const { graph: result } = graph( 14 | { 15 | stages: [{ id: '_', containersToBoot: [a, b, c, d, e, x] }], 16 | }, 17 | { view: 'domains' }, 18 | ); 19 | 20 | expect(result).toStrictEqual({ 21 | [a.domain]: { 22 | containers: [a.id, b.id], 23 | strict: [], 24 | optional: [], 25 | transitive: { strict: [], optional: [] }, 26 | }, 27 | [c.domain]: { 28 | containers: [c.id], 29 | strict: [b.domain], 30 | optional: [], 31 | transitive: { 32 | strict: [], 33 | optional: [], 34 | }, 35 | }, 36 | [d.domain]: { 37 | containers: [d.id], 38 | strict: [], 39 | optional: [c.domain], 40 | transitive: { 41 | strict: [], 42 | optional: [ 43 | { 44 | id: b.domain, 45 | path: `${d.domain}:${d.id} -> ${c.domain}:${c.id} -> ${b.domain}:${b.id}`, 46 | }, 47 | { 48 | id: a.domain, 49 | path: `${d.domain}:${d.id} -> ${c.domain}:${c.id} -> ${b.domain}:${b.id} -> ${a.domain}:${a.id}`, 50 | }, 51 | ], 52 | }, 53 | }, 54 | [x.domain]: { 55 | containers: [x.id], 56 | strict: [], 57 | optional: [], 58 | transitive: { strict: [], optional: [] }, 59 | }, 60 | [e.domain]: { 61 | containers: [e.id], 62 | strict: [c.domain], 63 | optional: [x.domain], 64 | transitive: { 65 | strict: [ 66 | { 67 | id: a.domain, 68 | path: `${e.domain}:${e.id} -> ${c.domain}:${c.id} -> ${b.domain}:${b.id}`, 69 | }, 70 | { 71 | id: a.domain, 72 | path: `${e.domain}:${e.id} -> ${c.domain}:${c.id} -> ${b.domain}:${b.id} -> ${a.domain}:${a.id}`, 73 | }, 74 | ], 75 | optional: [], 76 | }, 77 | }, 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/compose/commands/graph/computeTransitiveDependencies.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer } from '@createContainer'; 2 | import type { ViewMapper } from './createViewMapper'; 3 | import type { TransitiveDependency } from './types'; 4 | 5 | type Params = { 6 | container: AnyContainer; 7 | viewMapper: ViewMapper; 8 | }; 9 | 10 | const computeTransitiveDependencies = ({ container, viewMapper }: Params) => { 11 | const visited = new Set(); 12 | const strict: TransitiveDependency[] = []; 13 | const optional: TransitiveDependency[] = []; 14 | const stack: [AnyContainer, string[], 'strict' | 'optional'][] = []; 15 | 16 | // add only transitive dependencies to the stack 17 | (container.dependencies || []).forEach((dep) => 18 | stack.push([dep, [viewMapper.path(container), viewMapper.path(dep)], 'strict']), 19 | ); 20 | (container.optionalDependencies || []).forEach((dep) => 21 | stack.push([dep, [viewMapper.path(container), viewMapper.path(dep)], 'optional']), 22 | ); 23 | 24 | while (stack.length > 0) { 25 | const [current, path, currentType] = stack.pop()!; 26 | if (!visited.has(current.id)) { 27 | visited.add(current.id); 28 | 29 | // check if the dependency is transitive 30 | if (!container.dependencies?.includes(current) && !container.optionalDependencies?.includes(current)) { 31 | if (currentType === 'strict') { 32 | strict.push({ id: viewMapper.id(current), path: path.join(' -> ') }); 33 | } else { 34 | optional.push({ id: viewMapper.id(current), path: path.join(' -> ') }); 35 | } 36 | } 37 | 38 | // add the next dependencies to the stack 39 | (current.dependencies || []).forEach((nextDep) => { 40 | stack.push([nextDep, [...path, viewMapper.path(nextDep)], currentType]); 41 | }); 42 | (current.optionalDependencies || []).forEach((nextOptDep) => { 43 | stack.push([nextOptDep, [...path, viewMapper.path(nextOptDep)], 'optional']); 44 | }); 45 | } 46 | } 47 | 48 | return { dependencies: strict, optionalDependencies: optional }; 49 | }; 50 | 51 | export { computeTransitiveDependencies }; 52 | -------------------------------------------------------------------------------- /src/compose/commands/graph/createViewMapper.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer } from '@createContainer'; 2 | import type { View } from './types'; 3 | 4 | type Argument = Pick; 5 | 6 | type ViewMapper = { 7 | id: (_: Argument) => string; 8 | path: (_: Argument) => string; 9 | }; 10 | 11 | const createViewMapper = (view: View): ViewMapper => 12 | view === 'domains' ? 13 | { 14 | id: (x: Argument) => x.domain, 15 | path: (x: Argument) => `${x.domain}:${x.id}`, 16 | } 17 | : { 18 | id: (x: Argument) => x.id, 19 | path: (x: Argument) => x.id, 20 | }; 21 | 22 | export { createViewMapper, type ViewMapper }; 23 | -------------------------------------------------------------------------------- /src/compose/commands/graph/index.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer, ContainerDomain } from '@createContainer'; 2 | import type { Stage } from '@shared'; 3 | import { computeTransitiveDependencies } from './computeTransitiveDependencies'; 4 | import { createViewMapper } from './createViewMapper'; 5 | import { createDependsOn, createRequiredBy } from './relations'; 6 | import { transformToDomainsGraph } from './transformToDomainsGraph'; 7 | import type { ContainersGraph, DomainsGraph, View } from './types'; 8 | 9 | type Params = { 10 | stages: Stage[]; 11 | }; 12 | 13 | type Result = 14 | T extends 'domains' ? 15 | { 16 | graph: DomainsGraph; 17 | dependsOn: (_: ContainerDomain[]) => DomainsGraph; 18 | requiredBy: (_: ContainerDomain[]) => DomainsGraph; 19 | } 20 | : { 21 | graph: ContainersGraph; 22 | dependsOn: (_: AnyContainer[]) => ContainersGraph; 23 | requiredBy: (_: AnyContainer[]) => ContainersGraph; 24 | }; 25 | 26 | const graph = (params: Params, config: { view: T }): Result => { 27 | const containersToBoot = params.stages.map((x) => x.containersToBoot).flat(); 28 | const viewMapper = createViewMapper(config.view); 29 | 30 | const containersGraph = containersToBoot.reduce((acc, container) => { 31 | const dependencies = container.dependencies?.map(viewMapper.id) || []; 32 | const optionalDependencies = container.optionalDependencies?.map(viewMapper.id) || []; 33 | 34 | const transitiveDependencies = computeTransitiveDependencies({ container, viewMapper }); 35 | 36 | acc[container.id] = { 37 | domain: container.domain, 38 | dependencies, 39 | optionalDependencies, 40 | transitive: { 41 | dependencies: transitiveDependencies.dependencies, 42 | optionalDependencies: transitiveDependencies.optionalDependencies, 43 | }, 44 | }; 45 | 46 | return acc; 47 | }, {}); 48 | 49 | const graph = config.view === 'domains' ? transformToDomainsGraph(containersGraph) : containersGraph; 50 | const dependsOn = createDependsOn(graph); 51 | const requiredBy = createRequiredBy(graph); 52 | 53 | return { 54 | graph, 55 | dependsOn, 56 | requiredBy, 57 | } as typeof config.view extends 'domains' ? Result<'domains'> : Result<'containers'>; 58 | }; 59 | 60 | export { graph }; 61 | -------------------------------------------------------------------------------- /src/compose/commands/graph/relations/__tests__/dependsOn.spec-d.ts: -------------------------------------------------------------------------------- 1 | import { type AnyContainer, type ContainerDomain } from '@createContainer'; 2 | import { createRandomContainer } from '@randomContainer'; 3 | import { graph } from '../../index'; 4 | 5 | const a = createRandomContainer(); 6 | 7 | { 8 | const { dependsOn } = graph({ stages: [{ id: 'a', containersToBoot: [a] }] }, { view: 'containers' }); 9 | 10 | expectTypeOf[0]>().toEqualTypeOf(); 11 | } 12 | 13 | { 14 | const { dependsOn } = graph({ stages: [{ id: 'a', containersToBoot: [a] }] }, { view: 'domains' }); 15 | 16 | expectTypeOf[0]>().toEqualTypeOf(); 17 | } 18 | -------------------------------------------------------------------------------- /src/compose/commands/graph/relations/__tests__/dependsOn.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | 3 | import { graph } from '../../index'; 4 | 5 | const a = createRandomContainer(); 6 | const b = createRandomContainer({ domain: a.domain, dependencies: [a] }); 7 | const c = createRandomContainer({ dependencies: [b] }); 8 | const d = createRandomContainer({ optionalDependencies: [c] }); 9 | 10 | test('dependsOn | containers', () => { 11 | const { dependsOn } = graph( 12 | { 13 | stages: [{ id: '_', containersToBoot: [a, b, c, d] }], 14 | }, 15 | { view: 'containers' }, 16 | ); 17 | 18 | expect(Object.keys(dependsOn([c, d]))).toStrictEqual([c.id, d.id]); 19 | }); 20 | 21 | test('dependsOn | domains', () => { 22 | const { dependsOn } = graph( 23 | { 24 | stages: [{ id: '_', containersToBoot: [a, b, c, d] }], 25 | }, 26 | { view: 'domains' }, 27 | ); 28 | 29 | expect(Object.keys(dependsOn([c.domain, d.domain]))).toStrictEqual([c.domain, d.domain]); 30 | }); 31 | -------------------------------------------------------------------------------- /src/compose/commands/graph/relations/__tests__/requiredBy.spec-d.ts: -------------------------------------------------------------------------------- 1 | import { type AnyContainer, type ContainerDomain } from '@createContainer'; 2 | import { createRandomContainer } from '@randomContainer'; 3 | import { graph } from '../../'; 4 | 5 | const a = createRandomContainer(); 6 | 7 | { 8 | const { requiredBy } = graph( 9 | { 10 | stages: [{ id: '_', containersToBoot: [a] }], 11 | }, 12 | { view: 'containers' }, 13 | ); 14 | 15 | expectTypeOf[0]>().toEqualTypeOf(); 16 | } 17 | 18 | { 19 | const { requiredBy } = graph( 20 | { 21 | stages: [{ id: '_', containersToBoot: [a] }], 22 | }, 23 | { view: 'domains' }, 24 | ); 25 | 26 | expectTypeOf[0]>().toEqualTypeOf(); 27 | } 28 | -------------------------------------------------------------------------------- /src/compose/commands/graph/relations/__tests__/requiredBy.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { graph } from '../../'; 3 | 4 | const a = createRandomContainer(); 5 | const b = createRandomContainer({ domain: a.domain, dependencies: [a] }); 6 | const c = createRandomContainer({ optionalDependencies: [b] }); 7 | const d = createRandomContainer({ optionalDependencies: [c] }); 8 | 9 | test('requiredBy | containers', () => { 10 | const { requiredBy } = graph( 11 | { 12 | stages: [{ id: '_', containersToBoot: [a, b, c, d] }], 13 | }, 14 | { view: 'containers' }, 15 | ); 16 | 17 | expect(Object.keys(requiredBy([a, b]))).toStrictEqual([b.id, c.id, d.id]); 18 | }); 19 | 20 | test('requiredBy | domains', () => { 21 | const { requiredBy } = graph( 22 | { 23 | stages: [{ id: '_', containersToBoot: [a, b, c, d] }], 24 | }, 25 | { view: 'domains' }, 26 | ); 27 | 28 | expect(Object.keys(requiredBy([a.domain, b.domain]))).toStrictEqual([c.domain, d.domain]); 29 | }); 30 | -------------------------------------------------------------------------------- /src/compose/commands/graph/relations/dependsOn.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer, ContainerDomain } from '@createContainer'; 2 | import { pick } from '@shared'; 3 | import type { ContainersGraph, DomainsGraph } from '../types'; 4 | 5 | const createDependsOn = 6 | (graph: T) => 7 | (list: T extends ContainersGraph ? AnyContainer[] : ContainerDomain[]) => { 8 | if (typeof list[0] === 'string') { 9 | return pick(graph, list as ContainerDomain[]); 10 | } 11 | 12 | return pick( 13 | graph, 14 | (list as AnyContainer[]).map((item) => item.id), 15 | ); 16 | }; 17 | 18 | export { createDependsOn }; 19 | -------------------------------------------------------------------------------- /src/compose/commands/graph/relations/index.ts: -------------------------------------------------------------------------------- 1 | export { createDependsOn } from './dependsOn'; 2 | export { createRequiredBy } from './requiredBy'; 3 | -------------------------------------------------------------------------------- /src/compose/commands/graph/relations/requiredBy.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer, ContainerDomain } from '@createContainer'; 2 | import type { ContainersGraph, DomainsGraph } from '../types'; 3 | 4 | const parseAsContainer = (keys: string[], val: ContainersGraph[number]) => ({ 5 | dependencies: val.dependencies.filter((x) => keys.includes(x)), 6 | optionalDepenencies: val.optionalDependencies.filter((x) => keys.includes(x)), 7 | transitive: { 8 | dependencies: val.transitive.dependencies.filter((x) => keys.includes(x.id)), 9 | optionalDepenencies: val.transitive.optionalDependencies.filter((x) => keys.includes(x.id)), 10 | }, 11 | }); 12 | 13 | const parseAsDomain = (keys: string[], val: DomainsGraph[number]) => ({ 14 | strict: val.strict.filter((x) => keys.includes(x)), 15 | optional: val.optional.filter((x) => keys.includes(x)), 16 | transtivie: { 17 | strict: val.transitive.strict.filter((x) => keys.includes(x.id)), 18 | optional: val.transitive.optional.filter((x) => keys.includes(x.id)), 19 | }, 20 | }); 21 | 22 | const createRequiredBy = 23 | (graph: T) => 24 | (list: T extends ContainersGraph ? AnyContainer[] : ContainerDomain[]) => { 25 | const keys = 26 | typeof list[0] === 'string' ? (list as ContainerDomain[]) : (list as AnyContainer[]).map((item) => item.id); 27 | 28 | const entries = Object.entries(graph) as [keyof typeof graph, (typeof graph)[keyof typeof graph]][]; 29 | 30 | const result = {}; 31 | 32 | for (const [key, val] of entries) { 33 | if ('domain' in val) { 34 | const { dependencies, optionalDepenencies, transitive } = parseAsContainer(keys, val); 35 | 36 | if ( 37 | dependencies.length || 38 | optionalDepenencies.length || 39 | transitive.dependencies.length || 40 | transitive.optionalDepenencies.length 41 | ) { 42 | // @ts-expect-error :c 43 | result[key] = { 44 | domain: val.domain, 45 | dependencies, 46 | optionalDepenencies, 47 | transitive: { 48 | dependencies: transitive.dependencies, 49 | optionalDepenencies: transitive.optionalDepenencies, 50 | }, 51 | }; 52 | } 53 | } else { 54 | const { strict, optional, transtivie } = parseAsDomain(keys, val); 55 | 56 | if (strict.length || optional.length || transtivie.strict.length || transtivie.optional.length) { 57 | // @ts-expect-error :c 58 | result[key] = { 59 | containers: val.containers, 60 | strict, 61 | optional, 62 | transitive: { 63 | strict: transtivie.strict, 64 | optional: transtivie.optional, 65 | }, 66 | }; 67 | } 68 | } 69 | } 70 | 71 | return result as T; 72 | }; 73 | 74 | export { createRequiredBy }; 75 | -------------------------------------------------------------------------------- /src/compose/commands/graph/transformToDomainsGraph.ts: -------------------------------------------------------------------------------- 1 | import type { ContainersGraph, DomainsGraph } from './types'; 2 | 3 | const transformToDomainsGraph = (graph: ContainersGraph) => 4 | Object.entries(graph).reduce((acc, [id, data]) => { 5 | const domainName = data.domain; 6 | 7 | acc[domainName] ??= { 8 | containers: [], 9 | strict: [], 10 | optional: [], 11 | transitive: { 12 | strict: [], 13 | optional: [], 14 | }, 15 | }; 16 | 17 | const strictDeps = data.dependencies.filter((x) => x !== domainName); 18 | const optionalDeps = data.optionalDependencies.filter((x) => x !== domainName && !strictDeps.includes(x)); 19 | const transitiveStrict = data.transitive.dependencies.filter( 20 | (x) => x.id !== domainName && !strictDeps.includes(x.id) && !optionalDeps.includes(x.id), 21 | ); 22 | const transitiveOptional = data.transitive.optionalDependencies.filter( 23 | (x) => x.id !== domainName && !strictDeps.includes(x.id) && !optionalDeps.includes(x.id), 24 | ); 25 | 26 | acc[domainName].containers.push(id); 27 | acc[domainName].strict.push(...strictDeps); 28 | acc[domainName].optional.push(...optionalDeps); 29 | acc[domainName].transitive.strict.push(...transitiveStrict); 30 | acc[domainName].transitive.optional.push(...transitiveOptional); 31 | 32 | return acc; 33 | }, {}); 34 | 35 | export { transformToDomainsGraph }; 36 | -------------------------------------------------------------------------------- /src/compose/commands/graph/types.ts: -------------------------------------------------------------------------------- 1 | import type { ContainerDomain, ContainerId } from '@createContainer'; 2 | 3 | type View = 'domains' | 'containers'; 4 | 5 | type TransitiveDependency = { id: Id; path: string }; 6 | 7 | type ContainersGraph = Record< 8 | ContainerId, 9 | { 10 | domain: ContainerDomain; 11 | dependencies: ContainerId[]; 12 | optionalDependencies: ContainerId[]; 13 | transitive: { 14 | dependencies: TransitiveDependency[]; 15 | optionalDependencies: TransitiveDependency[]; 16 | }; 17 | } 18 | >; 19 | 20 | type DomainsGraph = Record< 21 | ContainerDomain, 22 | { 23 | containers: ContainerId[]; 24 | strict: ContainerDomain[]; 25 | optional: ContainerDomain[]; 26 | transitive: { 27 | strict: TransitiveDependency[]; 28 | optional: TransitiveDependency[]; 29 | }; 30 | } 31 | >; 32 | 33 | export type { ContainersGraph, DomainsGraph, TransitiveDependency, View }; 34 | -------------------------------------------------------------------------------- /src/compose/commands/up/__tests__/__snapshots__/debug.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`up.debug = true 1`] = ` 4 | [ 5 | [ 6 | "%c>> _", 7 | "color: #E2A03F; font-weight: bold;", 8 | ], 9 | [ 10 | "• a = idle", 11 | ], 12 | [ 13 | "• b = idle", 14 | ], 15 | [ 16 | "• c = idle", 17 | ], 18 | [ 19 | "%c>> _", 20 | "color: #E2A03F; font-weight: bold;", 21 | ], 22 | [ 23 | "• a = pending", 24 | ], 25 | [ 26 | "• b = idle", 27 | ], 28 | [ 29 | "• c = idle", 30 | ], 31 | [ 32 | "%c>> _", 33 | "color: #E2A03F; font-weight: bold;", 34 | ], 35 | [ 36 | "• a = pending", 37 | ], 38 | [ 39 | "• b = idle", 40 | ], 41 | [ 42 | "• c = pending", 43 | ], 44 | [ 45 | "%c>> _", 46 | "color: #E2A03F; font-weight: bold;", 47 | ], 48 | [ 49 | "• a = done", 50 | ], 51 | [ 52 | "• b = idle", 53 | ], 54 | [ 55 | "• c = pending", 56 | ], 57 | [ 58 | "%c>> _", 59 | "color: #E2A03F; font-weight: bold;", 60 | ], 61 | [ 62 | "• a = done", 63 | ], 64 | [ 65 | "• b = idle", 66 | ], 67 | [ 68 | "• c = done", 69 | ], 70 | [ 71 | "%c>> _", 72 | "color: #E2A03F; font-weight: bold;", 73 | ], 74 | [ 75 | "• a = done", 76 | ], 77 | [ 78 | "• b = off", 79 | ], 80 | [ 81 | "• c = done", 82 | ], 83 | ] 84 | `; 85 | -------------------------------------------------------------------------------- /src/compose/commands/up/__tests__/debug.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { createStageUpFn } from '../createStageUpFn'; 3 | 4 | test('up.debug = true', async () => { 5 | const consoleLogSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); 6 | 7 | const a = createRandomContainer({ id: 'a' }); 8 | const b = createRandomContainer({ id: 'b', dependencies: [a], enable: () => false }); 9 | const c = createRandomContainer({ id: 'c' }); 10 | 11 | const stage = { 12 | id: '_', 13 | containersToBoot: [a, b, c], 14 | }; 15 | 16 | await createStageUpFn({ debug: true })(stage, {}); 17 | 18 | expect(consoleLogSpy.mock.calls).toMatchSnapshot(); 19 | 20 | consoleLogSpy.mockRestore(); 21 | }); 22 | -------------------------------------------------------------------------------- /src/compose/commands/up/__tests__/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { CONTAINER_STATUS, type AnyContainer } from '@createContainer'; 2 | import { createRandomContainer } from '@randomContainer'; 3 | import { createStageUpFn } from '../createStageUpFn'; 4 | 5 | const T = () => true; 6 | 7 | const shuffle = (list: T): T => { 8 | const result = list.slice(); 9 | for (let i = result.length - 1; i > 0; i--) { 10 | const j = Math.floor(Math.random() * (i + 1)); 11 | // @ts-expect-error 12 | [result[i], result[j]] = [result[j], result[i]]; 13 | } 14 | // @ts-expect-error 15 | return result; 16 | }; 17 | 18 | describe('upFn like in real world', () => { 19 | test('like real app example', async () => { 20 | const userEntity = createRandomContainer({ 21 | id: 'user', 22 | start: () => ({ api: { id: '777' } }), 23 | }); 24 | const registration = createRandomContainer({ 25 | id: 'registration', 26 | dependencies: [userEntity], 27 | start: () => ({ api: { register: null } }), 28 | enable: (d) => d.user.id === null, 29 | }); 30 | const quotesEntity = createRandomContainer({ 31 | id: 'quotesEntity', 32 | optionalDependencies: [userEntity], 33 | start: () => ({ api: { register: null } }), 34 | enable: async (d) => { 35 | if (d.user?.id === '777') { 36 | return false; 37 | } 38 | 39 | await new Promise((res) => setTimeout(res, 1500)); 40 | return true; 41 | }, 42 | }); 43 | const accountsEntity = createRandomContainer({ 44 | id: 'accounts', 45 | dependencies: [userEntity], 46 | start: () => ({ api: { list: ['usd', 'eur'] } }), 47 | }); 48 | const accountsList = createRandomContainer({ 49 | id: 'accounts-list', 50 | dependencies: [accountsEntity], 51 | start: () => ({ api: { select: (x: number) => x } }), 52 | enable: (d) => d.accounts.list.length > 0, 53 | }); 54 | const accountTransfers = createRandomContainer({ 55 | id: 'account-transfers', 56 | dependencies: [accountsEntity], 57 | start: () => ({ api: { transfer: null } }), 58 | enable: (d) => d.accounts.list.includes('usdt'), 59 | }); 60 | const marketplace = createRandomContainer({ 61 | id: 'marketplace', 62 | dependencies: [userEntity], 63 | optionalDependencies: [accountsEntity], 64 | start: () => { 65 | throw new Error('ooops'); 66 | }, 67 | enable: T, 68 | }); 69 | const purchases = createRandomContainer({ 70 | id: 'purchases', 71 | dependencies: [marketplace], 72 | start: () => ({ api: { list: ['one', 'two'] } }), 73 | }); 74 | const idk = createRandomContainer({ 75 | id: 'idk', 76 | start: () => { 77 | throw new Error('_'); 78 | }, 79 | }); 80 | const hiddenEntity = createRandomContainer({ 81 | id: 'hidden-entity', 82 | start: () => ({ api: null }), 83 | enable: () => false, 84 | }); 85 | const hiddenFeature = createRandomContainer({ 86 | id: 'hidden-feature', 87 | dependencies: [hiddenEntity], 88 | start: () => ({ api: null }), 89 | }); 90 | 91 | const containersToBoot = shuffle([ 92 | userEntity, 93 | registration, 94 | quotesEntity, 95 | accountsEntity, 96 | accountsList, 97 | accountTransfers, 98 | marketplace, 99 | purchases, 100 | idk, 101 | hiddenFeature, 102 | hiddenEntity, 103 | ]); 104 | 105 | const stageUpFn = createStageUpFn({ debug: false }); 106 | 107 | await expect(stageUpFn({ id: 'my-perfect-stage', containersToBoot }, {})).resolves.toStrictEqual({ 108 | allDone: false, 109 | containerStatuses: { 110 | [userEntity.id]: CONTAINER_STATUS.done, 111 | [registration.id]: CONTAINER_STATUS.off, 112 | [quotesEntity.id]: CONTAINER_STATUS.off, 113 | [accountsEntity.id]: CONTAINER_STATUS.done, 114 | [accountsList.id]: CONTAINER_STATUS.done, 115 | [accountTransfers.id]: CONTAINER_STATUS.off, 116 | [marketplace.id]: CONTAINER_STATUS.fail, 117 | [purchases.id]: CONTAINER_STATUS.fail, 118 | [idk.id]: CONTAINER_STATUS.fail, 119 | [hiddenEntity.id]: CONTAINER_STATUS.off, 120 | [hiddenFeature.id]: CONTAINER_STATUS.off, 121 | }, 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/compose/commands/up/__tests__/normalize-сonfig.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { LIBRARY_NAME } from '@shared'; 3 | import { randomUUID } from 'node:crypto'; 4 | import { describe, expect, it, vi } from 'vitest'; 5 | import { normalizeConfig } from '../normalizeConfig'; 6 | 7 | describe('normalizeConfig', () => { 8 | it('should return default values if no config is provided', () => { 9 | const result = normalizeConfig(undefined); 10 | 11 | expect(result).toEqual({ 12 | debug: false, 13 | onContainerFail: expect.any(Function), 14 | }); 15 | }); 16 | 17 | it('should override default values with provided config', () => { 18 | const customConfig = { 19 | debug: true, 20 | onContainerFail: vi.fn(() => '_'), 21 | }; 22 | 23 | const result = normalizeConfig(customConfig); 24 | 25 | expect(result).toEqual(customConfig); 26 | expect(result.onContainerFail).toBe(customConfig.onContainerFail); 27 | }); 28 | 29 | it('should use default onContainerFail if not provided', () => { 30 | const customConfig = { debug: true }; 31 | 32 | const result = normalizeConfig(customConfig); 33 | 34 | expect(result.debug).toBe(true); 35 | expect(result.onContainerFail).toEqual(expect.any(Function)); 36 | }); 37 | 38 | it('should call default onContainerFail with correct parameters', () => { 39 | const mockError = new Error('Test error'); 40 | const mockContainer = createRandomContainer(); 41 | const stageId = randomUUID(); 42 | 43 | const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); 44 | 45 | const result = normalizeConfig(undefined); 46 | result.onContainerFail({ 47 | container: mockContainer, 48 | stageId, 49 | error: mockError, 50 | }); 51 | 52 | expect(consoleErrorSpy).toHaveBeenCalledWith( 53 | `${LIBRARY_NAME} Container "${mockContainer.id}" failed with error: Test error on stage "${stageId}"`, 54 | ); 55 | if (mockError.stack) { 56 | expect(consoleErrorSpy).toHaveBeenCalledWith(`Stack trace:\n${mockError.stack}`); 57 | } 58 | 59 | consoleErrorSpy.mockRestore(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/compose/commands/up/createStageUpFn.ts: -------------------------------------------------------------------------------- 1 | import { CONTAINER_STATUS, type AnyContainer, type ContainerId, type ContainerStatus } from '@createContainer'; 2 | import type { Stage } from '@shared'; 3 | import { clearNode, combine, createDomain, launch, sample } from 'effector'; 4 | import { normalizeConfig, type Config } from './normalizeConfig'; 5 | 6 | const statusIs = { 7 | off: (s: ContainerStatus | undefined) => s === CONTAINER_STATUS.off, 8 | fail: (s: ContainerStatus | undefined) => s === CONTAINER_STATUS.fail, 9 | pending: (s: ContainerStatus | undefined) => s === CONTAINER_STATUS.pending, 10 | done: (s: ContainerStatus | undefined) => s === CONTAINER_STATUS.done, 11 | idle: (s: ContainerStatus | undefined) => s === CONTAINER_STATUS.idle, 12 | failedOrOff: (s: ContainerStatus | undefined) => s === CONTAINER_STATUS.fail || s === CONTAINER_STATUS.off, 13 | }; 14 | 15 | type APIs = Record>['api']>; 16 | 17 | type UpResult = { 18 | allDone: boolean; 19 | containerStatuses: Record; 20 | }; 21 | 22 | // have no idea how-to use attach 23 | const collectDepsEnabled = (strict: AnyContainer[], optional: AnyContainer[]) => 24 | [...strict, ...optional].reduce>((acc, { id, $status }) => { 25 | acc[id] = statusIs.done($status.getState()); 26 | return acc; 27 | }, {}); 28 | 29 | const createStageUpFn = (__config?: Config) => { 30 | const config = normalizeConfig(__config); 31 | 32 | return async (stage: Stage, apis: APIs): Promise => { 33 | const domain = createDomain(`up.${stage.id}`); 34 | const containerFailFx = domain.createEffect(config.onContainerFail); 35 | let nodesToClear: Parameters[0][] = [domain]; 36 | 37 | type ContainersStatuses = Record; 38 | 39 | const containersStatuses = stage.containersToBoot.reduce((acc, x) => { 40 | acc[x.id] = x.$status; 41 | return acc; 42 | }, {}); 43 | 44 | const $stageResult = combine(containersStatuses, (kv) => { 45 | const statusList = Object.values(kv); 46 | const done = statusList.every((s) => /^(done|fail|off)$/.test(s)); 47 | 48 | return { done, statuses: kv }; 49 | }); 50 | 51 | if (config.debug) { 52 | $stageResult.watch((x) => { 53 | console.debug(`%c>> ${stage.id}`, 'color: #E2A03F; font-weight: bold;'); 54 | Object.entries(x.statuses).forEach(([id, status]) => console.debug(`• ${id} = ${status}`)); 55 | }); 56 | } 57 | 58 | await Promise.allSettled( 59 | stage.containersToBoot.map((container) => { 60 | const containerDependencies = container.dependencies ?? []; 61 | const containerOptionalDependencies = container.optionalDependencies ?? []; 62 | 63 | const $strictDepsResolving = combine( 64 | containerDependencies.map((d) => d.$status), 65 | (x) => { 66 | if (x.some(statusIs.off)) return CONTAINER_STATUS.off; 67 | if (x.some(statusIs.fail)) return CONTAINER_STATUS.fail; 68 | if (x.some(statusIs.pending)) return CONTAINER_STATUS.pending; 69 | 70 | if (x.every(statusIs.done) || x.length === 0) return CONTAINER_STATUS.done; 71 | 72 | return CONTAINER_STATUS.idle; 73 | }, 74 | ); 75 | const $optionalDepsResolving = combine( 76 | containerOptionalDependencies.map((d) => d.$status), 77 | (l) => (l.some(statusIs.pending) || l.some(statusIs.idle) ? CONTAINER_STATUS.idle : CONTAINER_STATUS.done), 78 | ); 79 | const $depsDone = combine([$strictDepsResolving, $optionalDepsResolving], (l) => l.every(statusIs.done)); 80 | 81 | const enableFx = domain.createEffect(async () => 82 | container.enable ? 83 | await container.enable(apis, collectDepsEnabled(containerDependencies, containerOptionalDependencies)) 84 | : true, 85 | ); 86 | 87 | const startFx = domain.createEffect(async () => { 88 | apis[container.id] = ( 89 | await container.start(apis, collectDepsEnabled(containerDependencies, containerOptionalDependencies)) 90 | )['api']; 91 | }); 92 | 93 | sample({ 94 | clock: enableFx.doneData, 95 | fn: (x) => (x ? CONTAINER_STATUS.pending : CONTAINER_STATUS.off), 96 | target: container.$status, 97 | }); 98 | sample({ clock: enableFx.failData, fn: () => CONTAINER_STATUS.fail, target: container.$status }); 99 | sample({ clock: container.$status, filter: statusIs.pending, target: startFx }); 100 | sample({ clock: startFx.finally, fn: (x) => x.status, target: container.$status }); 101 | sample({ 102 | clock: [startFx.fail, enableFx.fail], 103 | fn: (x) => ({ 104 | container: { id: container.id, domain: container.domain }, 105 | error: x.error, 106 | stageId: stage.id, 107 | }), 108 | target: containerFailFx, 109 | }); 110 | 111 | $strictDepsResolving.watch((s) => { 112 | if (statusIs.fail(s) || statusIs.off(s)) { 113 | launch(container.$status, s); 114 | } 115 | }); 116 | 117 | $depsDone.watch((x) => { 118 | if (x) enableFx(); 119 | }); 120 | 121 | nodesToClear.push($strictDepsResolving, $optionalDepsResolving, $depsDone, $stageResult); 122 | }), 123 | ); 124 | 125 | return new Promise((resolve) => { 126 | $stageResult.watch((x) => { 127 | if (!x.done) return; 128 | 129 | nodesToClear.forEach((x) => clearNode(x, { deep: true })); 130 | nodesToClear = []; 131 | 132 | const result = { 133 | allDone: Object.values(x.statuses).every((x) => statusIs.done(x)), 134 | containerStatuses: x.statuses, 135 | }; 136 | 137 | resolve(result); 138 | }); 139 | }); 140 | }; 141 | }; 142 | 143 | export { createStageUpFn, statusIs }; 144 | -------------------------------------------------------------------------------- /src/compose/commands/up/index.ts: -------------------------------------------------------------------------------- 1 | import { type ContainerId } from '@createContainer'; 2 | import { type Stage } from '@shared'; 3 | import { clearNode } from 'effector'; 4 | import { createStageUpFn } from './createStageUpFn'; 5 | import { throwStartupFailedError, validateStageUp } from './validateStageUp'; 6 | 7 | type Params = { 8 | stages: Stage[]; 9 | required?: Parameters[0]['required']; 10 | }; 11 | 12 | type Config = Parameters[0]; 13 | type StageUpFn = ReturnType; 14 | 15 | const up = async (params: Params, config: Config) => { 16 | const stageUpFn = createStageUpFn(config); 17 | let apis: Parameters[1] = {}; 18 | 19 | const executedStages: Record>> = {}; 20 | 21 | for (const stage of params.stages) { 22 | const stageUpResult = await stageUpFn(stage, apis); 23 | 24 | executedStages[stage.id] = stageUpResult; 25 | 26 | const validationResult = validateStageUp({ 27 | required: params.required, 28 | containerStatuses: stageUpResult.containerStatuses, 29 | }); 30 | 31 | if (!validationResult.ok) { 32 | throwStartupFailedError({ 33 | id: validationResult.id, 34 | stageId: stage.id, 35 | log: executedStages, 36 | }); 37 | } 38 | } 39 | 40 | params.stages.forEach((s) => s.containersToBoot.forEach((c) => clearNode(c.$status, { deep: true }))); 41 | apis = {}; 42 | 43 | const allDone = Object.values(executedStages).every((x) => x.allDone); 44 | 45 | return { allDone, stages: executedStages }; 46 | }; 47 | 48 | export { up }; 49 | -------------------------------------------------------------------------------- /src/compose/commands/up/normalizeConfig.ts: -------------------------------------------------------------------------------- 1 | import type { ContainerDomain, ContainerId } from '@createContainer'; 2 | import { LIBRARY_NAME, type Stage } from '@shared'; 3 | 4 | type Config = { 5 | debug?: boolean; 6 | onContainerFail?: (_: { 7 | container: { id: ContainerId; domain: ContainerDomain }; 8 | stageId: Stage['id']; 9 | error: Error; 10 | }) => unknown; 11 | }; 12 | 13 | const defaultOnContainerFail: Config['onContainerFail'] = (x) => { 14 | console.error( 15 | `${LIBRARY_NAME} Container "${x.container.id}" failed with error: ${x.error.message} on stage "${x.stageId}"`, 16 | ); 17 | if (x.error.stack) { 18 | console.error(`Stack trace:\n${x.error.stack}`); 19 | } 20 | }; 21 | 22 | const normalizeConfig = (config: Config | undefined): Required> => 23 | Object.assign({ debug: false, onContainerFail: defaultOnContainerFail }, config ?? {}); 24 | 25 | export { normalizeConfig, type Config }; 26 | -------------------------------------------------------------------------------- /src/compose/commands/up/validateStageUp/__tests__/__snapshots__/startupFailedError.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`throwStartupFailedError > should handle multiple container IDs correctly 1`] = ` 4 | [Error: [app-compose] Application startup failed. 5 | Required container(s) "container1,container2" did not up in stage "another-stage". 6 | 7 | Startup Log: 8 | 9 | entities-stage: 10 | allSucceeded: false 11 | containerStatuses: 12 | container1: idle 13 | container2: pending 14 | 15 | 16 | Recommendations: 17 | - Verify if the container(s) "container1,container2" are truly required. 18 | - If not, consider removing them from the required list in "up.required". 19 | - Ensure all dependencies for the container(s) are correct and their logic works as expected.] 20 | `; 21 | 22 | exports[`throwStartupFailedError > should throw an error with the correct message 1`] = ` 23 | [Error: [app-compose] Application startup failed. 24 | Required container(s) "test-container" did not up in stage "test-stage". 25 | 26 | Startup Log: 27 | 28 | entities-stage: 29 | allSucceeded: true 30 | containerStatuses: 31 | entities: done 32 | 33 | notifications-stage: 34 | allSucceeded: false 35 | containerStatuses: 36 | notifications: fail 37 | 38 | 39 | Recommendations: 40 | - Verify if the container(s) "test-container" are truly required. 41 | - If not, consider removing them from the required list in "up.required". 42 | - Ensure all dependencies for the container(s) are correct and their logic works as expected.] 43 | `; 44 | -------------------------------------------------------------------------------- /src/compose/commands/up/validateStageUp/__tests__/startupFailedError.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { throwStartupFailedError } from '../startupFailedError'; 3 | 4 | describe('throwStartupFailedError', () => { 5 | it('should throw an error with the correct message', () => { 6 | const id = 'test-container'; 7 | const stageId = 'test-stage'; 8 | const log = { 9 | 'entities-stage': { 10 | allSucceeded: true, 11 | containerStatuses: { 12 | entities: 'done', 13 | }, 14 | }, 15 | 'notifications-stage': { 16 | allSucceeded: false, 17 | containerStatuses: { 18 | notifications: 'fail', 19 | }, 20 | }, 21 | }; 22 | 23 | expect(() => throwStartupFailedError({ id, stageId, log })).toThrowErrorMatchingSnapshot(); 24 | }); 25 | 26 | it('should handle multiple container IDs correctly', () => { 27 | const id = ['container1', 'container2']; 28 | const stageId = 'another-stage'; 29 | const log = { 30 | 'entities-stage': { 31 | allSucceeded: false, 32 | containerStatuses: { 33 | container1: 'idle', 34 | container2: 'pending', 35 | }, 36 | }, 37 | }; 38 | 39 | expect(() => throwStartupFailedError({ id, stageId, log })).toThrowErrorMatchingSnapshot(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/compose/commands/up/validateStageUp/__tests__/validateStageUp.spec.ts: -------------------------------------------------------------------------------- 1 | import { CONTAINER_STATUS } from '@createContainer'; 2 | import { createRandomContainer } from '@randomContainer'; 3 | import { validateStageUp } from '../index'; 4 | 5 | describe('validateStageUp nothing failed', () => { 6 | const a = createRandomContainer({ status: CONTAINER_STATUS.done }); 7 | const b = createRandomContainer({ status: CONTAINER_STATUS.done }); 8 | const c = createRandomContainer({ status: CONTAINER_STATUS.done }); 9 | 10 | const containerStatuses = { 11 | [a.id]: a.$status.getState(), 12 | [b.id]: b.$status.getState(), 13 | [c.id]: c.$status.getState(), 14 | }; 15 | 16 | test('required = nil', () => { 17 | const res = validateStageUp({ containerStatuses, required: undefined }); 18 | expect(res.ok).toBe(true); 19 | }); 20 | 21 | test('required = all', () => { 22 | const res = validateStageUp({ containerStatuses, required: 'all' }); 23 | expect(res.ok).toBe(true); 24 | }); 25 | 26 | test('required = single', () => { 27 | const res = validateStageUp({ containerStatuses, required: [a] }); 28 | expect(res.ok).toBe(true); 29 | }); 30 | 31 | test('required = group', () => { 32 | const res = validateStageUp({ containerStatuses, required: [a, [b, c]] }); 33 | expect(res.ok).toBe(true); 34 | }); 35 | }); 36 | 37 | describe('validateStageUp failed one of', () => { 38 | const a = createRandomContainer({ status: CONTAINER_STATUS.done }); 39 | const f = createRandomContainer({ status: CONTAINER_STATUS.fail }); 40 | const c = createRandomContainer({ status: CONTAINER_STATUS.done }); 41 | 42 | const containerStatuses = { 43 | [a.id]: a.$status.getState(), 44 | [f.id]: f.$status.getState(), 45 | [c.id]: c.$status.getState(), 46 | }; 47 | 48 | test('required = nil', () => { 49 | const res = validateStageUp({ containerStatuses, required: undefined }); 50 | expect(res.ok).toBe(true); 51 | }); 52 | 53 | test('required = all', () => { 54 | const res = validateStageUp({ containerStatuses, required: 'all' }); 55 | 56 | expect(res.ok).toBe(false); 57 | // @ts-expect-error its ok 58 | expect(res.id).toStrictEqual([f.id]); 59 | }); 60 | 61 | test('required = single', () => { 62 | { 63 | const res = validateStageUp({ containerStatuses, required: [a] }); 64 | expect(res.ok).toBe(true); 65 | } 66 | 67 | { 68 | const res = validateStageUp({ containerStatuses, required: [f] }); 69 | expect(res.ok).toBe(false); 70 | } 71 | }); 72 | 73 | test('required = group', () => { 74 | { 75 | const res = validateStageUp({ containerStatuses, required: [f, [a, c]] }); 76 | expect(res.ok).toBe(false); 77 | } 78 | 79 | { 80 | const res = validateStageUp({ containerStatuses, required: [a, [f, c]] }); 81 | expect(res.ok).toBe(true); 82 | } 83 | 84 | { 85 | const res = validateStageUp({ containerStatuses, required: [a, [f]] }); 86 | expect(res.ok).toBe(false); 87 | } 88 | }); 89 | }); 90 | 91 | describe('off status except failed', () => { 92 | const a = createRandomContainer({ status: CONTAINER_STATUS.done }); 93 | const off = createRandomContainer({ status: CONTAINER_STATUS.off }); 94 | const c = createRandomContainer({ status: CONTAINER_STATUS.done }); 95 | 96 | const containerStatuses = { 97 | [a.id]: a.$status.getState(), 98 | [off.id]: off.$status.getState(), 99 | [c.id]: c.$status.getState(), 100 | }; 101 | 102 | test('required = all', () => { 103 | const res = validateStageUp({ containerStatuses, required: 'all' }); 104 | 105 | expect(res.ok).toBe(false); 106 | // @ts-expect-error its ok 107 | expect(res.id).toStrictEqual([off.id]); 108 | }); 109 | 110 | test('required = single', () => { 111 | { 112 | const res = validateStageUp({ containerStatuses, required: [a] }); 113 | expect(res.ok).toBe(true); 114 | } 115 | 116 | { 117 | const res = validateStageUp({ containerStatuses, required: [off] }); 118 | expect(res.ok).toBe(false); 119 | } 120 | }); 121 | 122 | test('required = group', () => { 123 | { 124 | const res = validateStageUp({ containerStatuses, required: [off, [a, c]] }); 125 | expect(res.ok).toBe(false); 126 | } 127 | 128 | { 129 | const res = validateStageUp({ containerStatuses, required: [a, [off, c]] }); 130 | expect(res.ok).toBe(true); 131 | } 132 | 133 | { 134 | const res = validateStageUp({ containerStatuses, required: [a, [off]] }); 135 | expect(res.ok).toBe(false); 136 | } 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/compose/commands/up/validateStageUp/index.ts: -------------------------------------------------------------------------------- 1 | import { CONTAINER_STATUS, type AnyContainer, type ContainerId } from '@createContainer'; 2 | import { isNil, type NonEmptyTuple } from '@shared'; 3 | import { statusIs, type createStageUpFn } from '../createStageUpFn'; 4 | 5 | type Params = { 6 | required: (AnyContainer | NonEmptyTuple)[] | 'all' | undefined; 7 | } & Pick>>, 'containerStatuses'>; 8 | 9 | const getGroupStatus = (group: AnyContainer[]) => { 10 | const statuses = group.map((x) => x.$status.getState()); 11 | const everyFail = statuses.every(statusIs.fail); 12 | 13 | if (everyFail) { 14 | return CONTAINER_STATUS.fail; 15 | } 16 | 17 | return statuses.every(statusIs.off) ? CONTAINER_STATUS.off : CONTAINER_STATUS.pending; 18 | }; 19 | 20 | type Result = { ok: true } | { ok: false; id: ContainerId[] }; 21 | 22 | const SUCCESS: Result = { ok: true }; 23 | 24 | const validateStageUp = (params: Params): Result => { 25 | if (isNil(params.required)) { 26 | return SUCCESS; 27 | } 28 | 29 | if (params.required === 'all') { 30 | const id = Object.entries(params.containerStatuses).find(([_, status]) => statusIs.failedOrOff(status))?.[0]; 31 | 32 | return isNil(id) ? SUCCESS : { ok: false, id: [id] }; 33 | } 34 | 35 | let result: ContainerId | ContainerId[] | undefined; 36 | 37 | for (let i = 0; i < params.required.length; i++) { 38 | const x = params.required[i] as (typeof params.required)[number]; 39 | const isContainer = 'id' in x; 40 | 41 | const status = isContainer ? x.$status.getState() : getGroupStatus(x); 42 | 43 | if (statusIs.failedOrOff(status)) { 44 | result = isContainer ? x.id : x.map((c) => c.id); 45 | break; 46 | } 47 | } 48 | 49 | return isNil(result) ? SUCCESS : { ok: false, id: typeof result === 'string' ? [result] : result }; 50 | }; 51 | 52 | export { throwStartupFailedError } from './startupFailedError'; 53 | export { validateStageUp }; 54 | -------------------------------------------------------------------------------- /src/compose/commands/up/validateStageUp/startupFailedError.ts: -------------------------------------------------------------------------------- 1 | import type { ContainerId } from '@createContainer'; 2 | import { LIBRARY_NAME, type Stage } from '@shared'; 3 | 4 | type Params = { 5 | id: ContainerId | ContainerId[]; 6 | stageId: Stage['id']; 7 | log: Record>; 8 | }; 9 | 10 | const throwStartupFailedError = ({ id, stageId, log }: Params) => { 11 | throw new Error( 12 | `${LIBRARY_NAME} Application startup failed.` + 13 | '\n' + 14 | `Required container(s) "${id}" did not up in stage "${stageId}".` + 15 | '\n\n' + 16 | 'Startup Log:' + 17 | '\n' + 18 | `${JSON.stringify(log, null, 2) 19 | .replace(/[\{\},"]/g, '') 20 | .replace(/\n\s*\n/g, '\n\n')}` + 21 | '\n' + 22 | 'Recommendations:' + 23 | '\n' + 24 | `- Verify if the container(s) "${id}" are truly required.` + 25 | '\n' + 26 | `- If not, consider removing them from the required list in "up.required".` + 27 | '\n' + 28 | `- Ensure all dependencies for the container(s) are correct and their logic works as expected.`, 29 | ); 30 | }; 31 | 32 | export { throwStartupFailedError }; 33 | -------------------------------------------------------------------------------- /src/compose/index.ts: -------------------------------------------------------------------------------- 1 | import { type ContainerId } from '@createContainer'; 2 | import { prepareStages, type StageTuples } from '@prepareStages'; 3 | import { type graph } from './commands/graph'; 4 | import { up } from './commands/up'; 5 | 6 | type UpFn = typeof up; 7 | type GraphFn = typeof graph; 8 | 9 | type Params = { 10 | stages: StageTuples; 11 | required?: Parameters[0]['required']; 12 | }; 13 | 14 | const compose = async (params: Params) => { 15 | const stages = prepareStages({ stageTuples: params.stages, visitedContainerIds: new Set() }); 16 | 17 | return { 18 | up: (config?: Parameters[1]) => up({ stages, required: params.required }, config), 19 | /* v8 ignore start */ 20 | diff: async () => { 21 | (await import('./commands/diff')).diff({ expected: params.stages, received: stages }); 22 | }, 23 | graph: async (config?: Parameters[1]) => 24 | (await import('./commands/graph')).graph({ stages }, { view: config?.view ?? 'containers' }), 25 | /* v8 ignore end */ 26 | }; 27 | }; 28 | 29 | export { compose }; 30 | -------------------------------------------------------------------------------- /src/compose/prepareStages/__tests__/get-containers-to-boot.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { getContainersToBoot } from '../getContainersToBoot'; 3 | 4 | describe('getContainersToBoot exhaustive manual tests', () => { 5 | test('handles containers with no dependencies', () => { 6 | const a = createRandomContainer(); 7 | const b = createRandomContainer(); 8 | 9 | const stageContainers = [a, b]; 10 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 11 | 12 | expect(result.containersToBoot).toEqual([ 13 | expect.objectContaining({ id: a.id, optionalDependencies: [] }), 14 | expect.objectContaining({ id: b.id, optionalDependencies: [] }), 15 | ]); 16 | expect(result.skippedContainers).toEqual({}); 17 | }); 18 | 19 | test('manual resolves strict dependencies correctly', () => { 20 | const a = createRandomContainer(); 21 | const b = createRandomContainer({ dependencies: [a] }); 22 | 23 | const stageContainers = [a, b]; 24 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 25 | 26 | expect(result.containersToBoot).toEqual([ 27 | expect.objectContaining({ id: a.id, optionalDependencies: [] }), 28 | expect.objectContaining({ id: b.id, optionalDependencies: [] }), 29 | ]); 30 | expect(result.skippedContainers).toEqual({}); 31 | }); 32 | 33 | test('auto resolves strict dependencies correctly', () => { 34 | const a = createRandomContainer(); 35 | const b = createRandomContainer({ dependencies: [a] }); 36 | 37 | const stageContainers = [b]; 38 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 39 | 40 | expect(result.containersToBoot).toEqual([ 41 | expect.objectContaining({ id: b.id, optionalDependencies: [] }), 42 | expect.objectContaining({ id: a.id, optionalDependencies: [] }), 43 | ]); 44 | expect(result.skippedContainers).toEqual({}); 45 | }); 46 | 47 | test('handles optional dependencies correctly | one', () => { 48 | const a = createRandomContainer(); 49 | const b = createRandomContainer({ optionalDependencies: [a] }); 50 | 51 | const stageContainers = [b]; 52 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 53 | 54 | expect(result.containersToBoot).toEqual([expect.objectContaining({ id: b.id, optionalDependencies: [] })]); 55 | expect(result.skippedContainers).toEqual({ 56 | [b.id]: [a.id], 57 | }); 58 | }); 59 | test('handles optional dependencies correctly | two', () => { 60 | const a = createRandomContainer(); 61 | const x = createRandomContainer(); 62 | const b = createRandomContainer({ optionalDependencies: [a, x] }); 63 | 64 | const stageContainers = [b]; 65 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 66 | 67 | expect(result.containersToBoot).toEqual([expect.objectContaining({ id: b.id, optionalDependencies: [] })]); 68 | expect(result.skippedContainers).toEqual({ 69 | [b.id]: [a.id, x.id], 70 | }); 71 | }); 72 | 73 | test('handles optional dependencies correctly | two | one not skipped', () => { 74 | const a = createRandomContainer(); 75 | const x = createRandomContainer(); 76 | const b = createRandomContainer({ optionalDependencies: [a, x] }); 77 | 78 | const stageContainers = [b, a]; 79 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 80 | 81 | expect(result.containersToBoot).toEqual([ 82 | expect.objectContaining({ id: b.id, optionalDependencies: [a] }), 83 | expect.objectContaining({ id: a.id, optionalDependencies: [] }), 84 | ]); 85 | expect(result.skippedContainers).toEqual({ 86 | [b.id]: [x.id], 87 | }); 88 | }); 89 | 90 | test('resolves transitive dependencies up to depth 3', () => { 91 | const a = createRandomContainer(); 92 | const b = createRandomContainer({ dependencies: [a] }); 93 | const c = createRandomContainer({ dependencies: [b] }); 94 | const d = createRandomContainer({ dependencies: [c] }); 95 | 96 | const stageContainers = [a, b, c, d]; 97 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 98 | 99 | expect(result.containersToBoot).toEqual([ 100 | expect.objectContaining({ id: a.id, optionalDependencies: [] }), 101 | expect.objectContaining({ id: b.id, optionalDependencies: [] }), 102 | expect.objectContaining({ id: c.id, optionalDependencies: [] }), 103 | expect.objectContaining({ id: d.id, optionalDependencies: [] }), 104 | ]); 105 | expect(result.skippedContainers).toEqual({}); 106 | }); 107 | 108 | test('skips optional dependencies in transitive chains | one', () => { 109 | const a = createRandomContainer(); 110 | const b = createRandomContainer({ optionalDependencies: [a] }); 111 | const c = createRandomContainer({ optionalDependencies: [b] }); 112 | const d = createRandomContainer({ optionalDependencies: [c] }); 113 | 114 | const stageContainers = [d]; 115 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 116 | 117 | expect(result.containersToBoot).toEqual([expect.objectContaining({ id: d.id, optionalDependencies: [] })]); 118 | expect(result.skippedContainers).toEqual({ 119 | [d.id]: [c.id], 120 | }); 121 | }); 122 | 123 | test('skips optional dependencies in transitive chains | two', () => { 124 | const a = createRandomContainer(); 125 | const b = createRandomContainer({ optionalDependencies: [a] }); 126 | const c = createRandomContainer({ optionalDependencies: [b] }); 127 | const d = createRandomContainer({ optionalDependencies: [c] }); 128 | 129 | const stageContainers = [d, c]; 130 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 131 | 132 | expect(result.containersToBoot).toEqual([ 133 | expect.objectContaining({ id: d.id, optionalDependencies: [c] }), 134 | expect.objectContaining({ id: c.id, optionalDependencies: [] }), 135 | ]); 136 | expect(result.skippedContainers).toEqual({ 137 | [c.id]: [b.id], 138 | }); 139 | }); 140 | 141 | test('handles mixed strict and optional dependencies', () => { 142 | const a = createRandomContainer(); 143 | const b = createRandomContainer({ dependencies: [a] }); 144 | const c = createRandomContainer({ dependencies: [b], optionalDependencies: [a] }); 145 | const d = createRandomContainer({ optionalDependencies: [c] }); 146 | 147 | const stageContainers = [a, b, c, d]; 148 | const result = getContainersToBoot({ stageContainers, visitedContainerIds: new Set() }); 149 | 150 | expect(result.containersToBoot).toEqual([ 151 | expect.objectContaining({ id: a.id, optionalDependencies: [] }), 152 | expect.objectContaining({ id: b.id, optionalDependencies: [] }), 153 | expect.objectContaining({ id: c.id, optionalDependencies: [a] }), 154 | expect.objectContaining({ id: d.id, optionalDependencies: [c] }), 155 | ]); 156 | expect(result.skippedContainers).toEqual({}); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/compose/prepareStages/__tests__/prepare-stages.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { prepareStages } from '../index'; 3 | 4 | describe('prepareStages', () => { 5 | test('single stage with strict and optional dependencies', () => { 6 | const x = createRandomContainer(); 7 | const a = createRandomContainer(); 8 | const b = createRandomContainer({ dependencies: [a], optionalDependencies: [x] }); 9 | 10 | const result = prepareStages({ visitedContainerIds: new Set(), stageTuples: [['_', [b]]] }); 11 | 12 | expect(result[0]?.containersToBoot).toStrictEqual([b, a]); 13 | expect(result[0]?.skippedContainers).toStrictEqual({ [b.id]: [x.id] }); 14 | }); 15 | 16 | test('multiple stages with no dependencies', () => { 17 | const a = createRandomContainer(); 18 | const b = createRandomContainer(); 19 | 20 | const result = prepareStages({ visitedContainerIds: new Set(), stageTuples: [['_', [a, b]]] }); 21 | 22 | expect(result[0]?.containersToBoot).toStrictEqual([a, b]); 23 | expect(result[0]?.skippedContainers).toStrictEqual({}); 24 | }); 25 | 26 | test('no containers to boot', () => { 27 | const result = prepareStages({ visitedContainerIds: new Set(), stageTuples: [] }); 28 | 29 | expect(result).toStrictEqual([]); 30 | }); 31 | 32 | test('multiple stages with dependencies', () => { 33 | const a = createRandomContainer(); 34 | const b = createRandomContainer({ dependencies: [a] }); 35 | const c = createRandomContainer({ dependencies: [b] }); 36 | 37 | const result = prepareStages({ 38 | visitedContainerIds: new Set(), 39 | stageTuples: [ 40 | ['x', [a]], 41 | ['y', [b]], 42 | ['z', [c]], 43 | ], 44 | }); 45 | 46 | expect(result[0]?.containersToBoot).toStrictEqual([a]); 47 | expect(result[1]?.containersToBoot).toStrictEqual([b]); 48 | expect(result[2]?.containersToBoot).toStrictEqual([c]); 49 | expect(result.every((stage) => stage.skippedContainers)).toStrictEqual(true); 50 | }); 51 | 52 | test('containers with shared dependencies', () => { 53 | const shared = createRandomContainer(); 54 | const a = createRandomContainer({ dependencies: [shared] }); 55 | const b = createRandomContainer({ dependencies: [shared] }); 56 | 57 | const result = prepareStages({ visitedContainerIds: new Set(), stageTuples: [['_', [a, b]]] }); 58 | 59 | expect(result[0]?.containersToBoot).toStrictEqual([a, b, shared]); 60 | expect(result[0]?.skippedContainers).toStrictEqual({}); 61 | }); 62 | 63 | test('invalid configuration', () => { 64 | const a = createRandomContainer({ id: 'a' }); 65 | const b = createRandomContainer({ dependencies: [a] }); 66 | const c = createRandomContainer({ dependencies: [b] }); 67 | 68 | expect(() => 69 | prepareStages({ 70 | visitedContainerIds: new Set(), 71 | stageTuples: [ 72 | ['x', [b]], 73 | ['y', [a]], 74 | ['z', [c]], 75 | ], 76 | }), 77 | ).toThrowErrorMatchingInlineSnapshot(` 78 | [Error: [app-compose] Container with ID "a" is already included in a previous stage (up to stage "y"). 79 | 80 | This indicates an issue in the stage definitions provided to the compose function. 81 | 82 | Suggested actions: 83 | - Remove the container from the "y" stage in the compose configuration. 84 | - Use the graph fn to verify container dependencies and resolve potential conflicts.] 85 | `); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/compose/prepareStages/__tests__/traverse-containers.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { traverseContainers } from '../getContainersToBoot'; 3 | 4 | describe('traverseContainers', () => { 5 | const containerA = createRandomContainer(); 6 | const containerB = createRandomContainer({ dependencies: [containerA] }); 7 | const containerC = createRandomContainer({ dependencies: [containerB], optionalDependencies: [containerA] }); 8 | const containerD = createRandomContainer({ dependencies: [containerC] }); 9 | 10 | test('should return an empty list if an empty array of containers is passed', () => { 11 | const result = traverseContainers([]); 12 | 13 | expect(result.strictDependencies).toEqual(new Set([])); 14 | }); 15 | 16 | test('should return the container itself if it has no dependencies or optionalDependencies', () => { 17 | const result = traverseContainers([containerA]); 18 | 19 | expect(result.strictDependencies).toEqual(new Set([])); 20 | }); 21 | 22 | test('should resolve only strict dependencies by default', () => { 23 | const result = traverseContainers([containerD]); 24 | 25 | expect(Array.from(result.strictDependencies)).toEqual(expect.arrayContaining([containerA, containerB, containerC])); 26 | expect(result.strictDependencies.size).toBe(3); 27 | }); 28 | 29 | test('should handle cyclic dependencies without getting stuck in a loop', () => { 30 | containerA.dependencies = [containerD]; // Create a cycle 31 | const result = traverseContainers([containerD]); 32 | 33 | expect(Array.from(result.strictDependencies)).toEqual( 34 | expect.arrayContaining([containerA, containerB, containerC, containerD]), 35 | ); 36 | expect(result.strictDependencies).toHaveLength(4); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/compose/prepareStages/__tests__/uniq-container-id.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { prepareStages } from '../index'; 3 | 4 | describe('container.id is uniq', () => { 5 | test('happy', () => { 6 | expect(() => 7 | prepareStages({ 8 | stageTuples: [ 9 | ['x', [createRandomContainer()]], 10 | ['y', [createRandomContainer()]], 11 | ], 12 | visitedContainerIds: new Set(), 13 | }), 14 | ).not.toThrowError(); 15 | }); 16 | 17 | test('unhappy', async () => { 18 | const id = '~'; 19 | 20 | expect(() => 21 | prepareStages({ 22 | stageTuples: [['x', [createRandomContainer({ id }), createRandomContainer({ id })]]], 23 | visitedContainerIds: new Set(), 24 | }), 25 | ).toThrowError(`[app-compose] Duplicate container ID found: ${id}`); 26 | }); 27 | 28 | test('unhappy 2', async () => { 29 | const id = '~'; 30 | const a = createRandomContainer({ id }); 31 | const b = createRandomContainer({ id }); 32 | 33 | expect(() => 34 | prepareStages({ 35 | stageTuples: [['x', [a, b]]], 36 | visitedContainerIds: new Set(), 37 | }), 38 | ).toThrowError(`[app-compose] Duplicate container ID found: ${id}`); 39 | }); 40 | 41 | test('unhappy with stages', async () => { 42 | const id = '~'; 43 | 44 | expect(() => 45 | prepareStages({ 46 | stageTuples: [ 47 | ['x', [createRandomContainer({ id })]], 48 | ['y', [createRandomContainer({ id })]], 49 | ], 50 | visitedContainerIds: new Set(), 51 | }), 52 | ).toThrowErrorMatchingInlineSnapshot(` 53 | [Error: [app-compose] Container with ID "~" is already included in a previous stage (up to stage "y"). 54 | 55 | This indicates an issue in the stage definitions provided to the compose function. 56 | 57 | Suggested actions: 58 | - Remove the container from the "y" stage in the compose configuration. 59 | - Use the graph fn to verify container dependencies and resolve potential conflicts.] 60 | `); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/compose/prepareStages/__tests__/uniq-stage-id.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { prepareStages } from '../index'; 3 | 4 | describe('container.id is uniq', () => { 5 | test('happy', () => { 6 | expect(() => 7 | prepareStages({ 8 | stageTuples: [ 9 | ['x', [createRandomContainer()]], 10 | ['y', [createRandomContainer()]], 11 | ], 12 | visitedContainerIds: new Set(), 13 | }), 14 | ).not.toThrowError(); 15 | }); 16 | 17 | test('unhappy with stages', async () => { 18 | expect(() => 19 | prepareStages({ 20 | stageTuples: [ 21 | ['x', [createRandomContainer()]], 22 | ['x', [createRandomContainer()]], 23 | ], 24 | visitedContainerIds: new Set(), 25 | }), 26 | ).toThrowErrorMatchingInlineSnapshot(` 27 | [Error: [app-compose] Duplicate stage id detected: "x". 28 | 29 | Each stage id must be unique. Please ensure that the stage "x" appears only once in the configuration.] 30 | `); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/compose/prepareStages/getContainersToBoot.ts: -------------------------------------------------------------------------------- 1 | import { type AnyContainer, type ContainerId } from '@createContainer'; 2 | import { type SkippedContainers } from '@shared'; 3 | import { partitionOptionalDeps } from './partitionOptionalDeps'; 4 | import { traverseContainers } from './traverseContainers'; 5 | import type { VisitedContainerIds } from './types'; 6 | 7 | type Params = { 8 | stageContainers: AnyContainer[]; 9 | visitedContainerIds: VisitedContainerIds; 10 | }; 11 | 12 | const prepare = ({ stageContainers, visitedContainerIds }: Params) => { 13 | const { strictDependencies } = traverseContainers(stageContainers); 14 | const containerIdsToBoot = new Set(); 15 | const containersToBoot = new Set(); 16 | 17 | for (const container of [...stageContainers, ...strictDependencies]) { 18 | if (!visitedContainerIds.has(container.id)) { 19 | containersToBoot.add(container); 20 | containerIdsToBoot.add(container.id); 21 | } 22 | } 23 | 24 | return { containerIdsToBoot, containersToBoot }; 25 | }; 26 | 27 | const getContainersToBoot = ({ stageContainers, visitedContainerIds }: Params) => { 28 | const { containerIdsToBoot, containersToBoot } = prepare({ stageContainers, visitedContainerIds }); 29 | const skippedContainers: SkippedContainers = {}; 30 | 31 | for (const container of containersToBoot) { 32 | const { included, skipped } = partitionOptionalDeps({ container, containerIdsToBoot, visitedContainerIds }); 33 | 34 | // otherwise, unstarted optional dependencies will prevent the application from starting 35 | container.optionalDependencies = included; 36 | 37 | if (skipped.length) { 38 | skippedContainers[container.id] = skipped; 39 | } 40 | } 41 | 42 | return { 43 | containersToBoot: Array.from(containersToBoot), 44 | skippedContainers, 45 | }; 46 | }; 47 | 48 | export { getContainersToBoot, traverseContainers }; 49 | -------------------------------------------------------------------------------- /src/compose/prepareStages/index.ts: -------------------------------------------------------------------------------- 1 | import { type SkippedContainers, type Stage } from '@shared'; 2 | import { getContainersToBoot } from './getContainersToBoot'; 3 | import type { VisitedContainerIds } from './types'; 4 | import { validate, type StageTuples } from './validators'; 5 | 6 | type Params = { 7 | stageTuples: StageTuples; 8 | visitedContainerIds: VisitedContainerIds; 9 | }; 10 | 11 | type Result = (Stage & { skippedContainers: SkippedContainers })[]; 12 | 13 | const prepareStages = ({ visitedContainerIds, stageTuples }: Params) => { 14 | validate.stageIds(stageTuples); 15 | 16 | return stageTuples.reduce((acc, [stageId, stageContainers]) => { 17 | stageContainers.forEach((container) => validate.alreadyProcessed({ visitedContainerIds, stageId, container })); 18 | 19 | const { containersToBoot, skippedContainers } = getContainersToBoot({ 20 | visitedContainerIds, 21 | stageContainers, 22 | }); 23 | 24 | containersToBoot.forEach((container) => validate.containerId({ container, visitedContainerIds })); 25 | 26 | acc.push({ id: stageId, containersToBoot, skippedContainers }); 27 | 28 | return acc; 29 | }, []); 30 | }; 31 | 32 | export { prepareStages, type StageTuples }; 33 | -------------------------------------------------------------------------------- /src/compose/prepareStages/partitionOptionalDeps.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer, ContainerId } from '@createContainer'; 2 | import type { VisitedContainerIds } from './types'; 3 | 4 | type Params = { 5 | container: AnyContainer; 6 | containerIdsToBoot: Set; 7 | visitedContainerIds: VisitedContainerIds; 8 | }; 9 | 10 | type Result = { 11 | included: AnyContainer[]; 12 | skipped: ContainerId[]; 13 | }; 14 | 15 | const partitionOptionalDeps = ({ container, containerIdsToBoot, visitedContainerIds }: Params): Result => { 16 | const result: Result = { included: [], skipped: [] }; 17 | const optionalDependencies = container.optionalDependencies; 18 | 19 | if (!optionalDependencies) { 20 | return result; 21 | } 22 | 23 | for (const dep of optionalDependencies) { 24 | if (containerIdsToBoot.has(dep.id) || visitedContainerIds.has(dep.id)) { 25 | result.included.push(dep); 26 | } else { 27 | result.skipped.push(dep.id); 28 | } 29 | } 30 | 31 | return result; 32 | }; 33 | 34 | export { partitionOptionalDeps }; 35 | -------------------------------------------------------------------------------- /src/compose/prepareStages/traverseContainers.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer } from '@createContainer'; 2 | 3 | const traverseContainers = (containers: AnyContainer[]) => { 4 | const strict = new Set(); 5 | const visited = new Set(); 6 | const stack = [...containers]; 7 | 8 | while (stack.length > 0) { 9 | const container = stack.pop()!; 10 | if (visited.has(container)) continue; 11 | visited.add(container); 12 | 13 | container.dependencies?.forEach((dep) => { 14 | stack.push(dep); 15 | strict.add(dep); 16 | }); 17 | } 18 | 19 | return { strictDependencies: strict }; 20 | }; 21 | 22 | export { traverseContainers }; 23 | -------------------------------------------------------------------------------- /src/compose/prepareStages/types.ts: -------------------------------------------------------------------------------- 1 | import type { ContainerId } from '@createContainer'; 2 | 3 | type VisitedContainerIds = Set; 4 | 5 | export type { VisitedContainerIds }; 6 | -------------------------------------------------------------------------------- /src/compose/prepareStages/validators.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer } from '@createContainer'; 2 | import { LIBRARY_NAME, type NonEmptyTuple, type Stage } from '@shared'; 3 | import type { VisitedContainerIds } from './types'; 4 | 5 | type StageTuples = [Stage['id'], NonEmptyTuple][]; 6 | 7 | const ERROR = { 8 | DUPLICATE_STAGE_ID: (sid: string) => 9 | `${LIBRARY_NAME} Duplicate stage id detected: "${sid}".` + 10 | '\n\n' + 11 | `Each stage id must be unique. Please ensure that the stage "${sid}" appears only once in the configuration.`, 12 | DUPLICATE_CONTAINER_ID: (cid: string) => `${LIBRARY_NAME} Duplicate container ID found: ${cid}`, 13 | ALREADY_PROCESSED: (cid: string, sid: string) => 14 | `${LIBRARY_NAME} Container with ID "${cid}" is already included in a previous stage (up to stage "${sid}").` + 15 | '\n\n' + 16 | `This indicates an issue in the stage definitions provided to the compose function.` + 17 | '\n\n' + 18 | `Suggested actions:` + 19 | '\n' + 20 | ` - Remove the container from the "${sid}" stage in the compose configuration.` + 21 | '\n' + 22 | ` - Use the graph fn to verify container dependencies and resolve potential conflicts.`, 23 | }; 24 | 25 | type CheckAlreadyProcessedParams = { 26 | container: AnyContainer; 27 | visitedContainerIds: VisitedContainerIds; 28 | stageId: Stage['id']; 29 | }; 30 | 31 | const validateAlreadyProcessed = ({ visitedContainerIds, container, stageId }: CheckAlreadyProcessedParams) => { 32 | if (visitedContainerIds.has(container.id)) { 33 | throw new Error(ERROR.ALREADY_PROCESSED(container.id, stageId)); 34 | } 35 | }; 36 | 37 | const validateStageIds = (stageTuples: StageTuples) => { 38 | const ids = new Set(); 39 | 40 | for (const [id] of stageTuples) { 41 | if (ids.has(id)) { 42 | throw new Error(ERROR.DUPLICATE_STAGE_ID(id)); 43 | } 44 | 45 | ids.add(id); 46 | } 47 | }; 48 | 49 | type ValidateContainerIdParams = { 50 | container: AnyContainer; 51 | visitedContainerIds: VisitedContainerIds; 52 | }; 53 | 54 | const validateContainerId = ({ container, visitedContainerIds }: ValidateContainerIdParams) => { 55 | if (visitedContainerIds.has(container.id)) { 56 | throw new Error(ERROR.DUPLICATE_CONTAINER_ID(container.id)); 57 | } 58 | 59 | visitedContainerIds.add(container.id); 60 | }; 61 | 62 | const validate = { 63 | alreadyProcessed: validateAlreadyProcessed, 64 | stageIds: validateStageIds, 65 | containerId: validateContainerId, 66 | }; 67 | 68 | export { validate, type StageTuples }; 69 | -------------------------------------------------------------------------------- /src/createContainer/__tests__/deps.spec-d.ts: -------------------------------------------------------------------------------- 1 | import { createContainer } from '../index'; 2 | import type { ExtractEnableFn, ExtractstartFn } from './types'; 3 | 4 | const __ = { 5 | a: createContainer({ 6 | id: 'a', 7 | domain: '_', 8 | start: () => ({ api: { t: () => true } }), 9 | }), 10 | b: createContainer({ 11 | id: 'b', 12 | domain: '_', 13 | start: () => ({ api: { f: () => false } }), 14 | }), 15 | c: createContainer({ 16 | id: 'd', 17 | domain: '_', 18 | start: () => ({ api: { nil: null } }), 19 | }), 20 | d: createContainer({ 21 | id: 'c', 22 | domain: '_', 23 | start: () => ({ api: { __: undefined } }), 24 | }), 25 | withEmptyAPI: createContainer({ 26 | id: 'emptyApi', 27 | domain: '_', 28 | start: () => ({ api: {} }), 29 | }), 30 | }; 31 | 32 | describe('void | void', () => { 33 | test('basic', () => { 34 | type Container = typeof createContainer<'_', '_', {}>; 35 | 36 | { 37 | type startFn = ExtractstartFn; 38 | 39 | expectTypeOf>().toMatchTypeOf<{}>(); 40 | } 41 | 42 | { 43 | type EnableFn = (() => Promise | boolean) | undefined; 44 | 45 | expectTypeOf>>().toEqualTypeOf<[]>(); 46 | expectTypeOf().toMatchTypeOf>(); 47 | } 48 | }); 49 | }); 50 | 51 | describe('dep | void', () => { 52 | test('one | void', () => { 53 | type Container = typeof createContainer<'_', '_', {}, [typeof __.a]>; 54 | 55 | type API = { [__.a.id]: { t: () => true } }; 56 | 57 | { 58 | type startFn = ExtractstartFn; 59 | 60 | expectTypeOf().toMatchTypeOf[0]>(); 61 | } 62 | { 63 | type EnableFn = ((d: API, _: { [__.a.id]: boolean }) => Promise | boolean) | undefined; 64 | 65 | expectTypeOf().toMatchTypeOf>(); 66 | } 67 | }); 68 | test('multiple | void', () => { 69 | type Container = typeof createContainer< 70 | '_', 71 | '_', 72 | {}, 73 | [typeof __.a, typeof __.b, typeof __.c, typeof __.withEmptyAPI] 74 | >; 75 | 76 | type API = { [__.a.id]: { t: () => true }; [__.b.id]: { f: () => false }; [__.c.id]: { nil: null } }; 77 | 78 | { 79 | type startFn = ExtractstartFn; 80 | 81 | expectTypeOf().toMatchTypeOf[0]>(); 82 | } 83 | { 84 | type EnableFn = ((d: API) => Promise | boolean) | undefined; 85 | 86 | expectTypeOf().toMatchTypeOf>(); 87 | } 88 | }); 89 | }); 90 | 91 | describe('deps | optDeps', () => { 92 | test('one | one', () => { 93 | type Container = typeof createContainer<'_', '_', {}, [typeof __.a], [typeof __.b]>; 94 | 95 | type Deps = { [__.a.id]: { t: () => true } }; 96 | type OptDeps = { [__.b.id]?: { f: () => false } }; 97 | type API = Deps & OptDeps; 98 | 99 | { 100 | type startFn = ExtractstartFn; 101 | 102 | expectTypeOf().toEqualTypeOf[0]>(); 103 | } 104 | { 105 | type EnableFn = ((_: API) => Promise | boolean) | undefined; 106 | 107 | expectTypeOf().toMatchTypeOf>(); 108 | } 109 | }); 110 | 111 | test('one | multiple', () => { 112 | type Container = typeof createContainer< 113 | '_', 114 | '_', 115 | {}, 116 | [typeof __.a], 117 | [typeof __.b, typeof __.c, typeof __.withEmptyAPI] 118 | >; 119 | 120 | type Deps = { [__.a.id]: { t: () => true } }; 121 | type OptDeps = { [__.b.id]?: { f: () => false }; [__.c.id]?: { nil: null } }; 122 | type API = Deps & OptDeps; 123 | 124 | { 125 | type startFn = ExtractstartFn; 126 | 127 | expectTypeOf().toEqualTypeOf[0]>(); 128 | } 129 | 130 | { 131 | type EnableFn = ((_: API) => Promise | boolean) | undefined; 132 | 133 | expectTypeOf().toMatchTypeOf>(); 134 | } 135 | }); 136 | 137 | test('multiple | multiple', () => { 138 | type Container = typeof createContainer< 139 | '_', 140 | '_', 141 | {}, 142 | [typeof __.a, typeof __.b, typeof __.withEmptyAPI], 143 | [typeof __.c, typeof __.d] 144 | >; 145 | 146 | type Deps = { [__.a.id]: { t: () => true }; [__.b.id]: { f: () => false } }; 147 | type OptDeps = { [__.c.id]?: { nil: null }; [__.d.id]?: { __: undefined } }; 148 | type API = Deps & OptDeps; 149 | 150 | { 151 | type startFn = ExtractstartFn; 152 | 153 | expectTypeOf().toEqualTypeOf[0]>(); 154 | } 155 | 156 | { 157 | type EnableFn = ((_: API) => Promise | boolean) | undefined; 158 | 159 | expectTypeOf().toMatchTypeOf>(); 160 | } 161 | }); 162 | }); 163 | 164 | describe('void | optDeps', () => { 165 | test('void | one', () => { 166 | type Container = typeof createContainer<'_', '_', {}, void, [typeof __.a]>; 167 | 168 | type API = { [__.a.id]?: { t: () => true } }; 169 | 170 | { 171 | type startFn = ExtractstartFn; 172 | 173 | expectTypeOf().toMatchTypeOf[0]>(); 174 | } 175 | { 176 | type EnableFn = ((_: API) => Promise | boolean) | undefined; 177 | 178 | expectTypeOf().toMatchTypeOf>(); 179 | } 180 | }); 181 | test('void | multiple', () => { 182 | type Container = typeof createContainer<'_', '_', {}, void, [typeof __.a, typeof __.b, typeof __.c]>; 183 | 184 | type API = { 185 | [__.a.id]?: { t: () => true }; 186 | [__.b.id]?: { f: () => false }; 187 | [__.c.id]?: { nil: null }; 188 | }; 189 | 190 | { 191 | type startFn = ExtractstartFn; 192 | 193 | expectTypeOf().toMatchTypeOf[0]>(); 194 | } 195 | { 196 | type EnableFn = ((_: API) => Promise | boolean) | undefined; 197 | 198 | expectTypeOf().toMatchTypeOf>(); 199 | } 200 | }); 201 | }); 202 | 203 | describe('edge cases', () => { 204 | test('with deps empty list', () => { 205 | type Container = typeof createContainer< 206 | // 207 | '', 208 | '_', 209 | {}, 210 | // @ts-expect-error 211 | // '[]' does not satisfy the constraint 'void | NonEmptyList' 212 | [] 213 | >; 214 | }); 215 | 216 | test('with optDeps empty list', () => { 217 | type ValidContainer = typeof createContainer<'', '_', {}, [typeof __.a]>; 218 | 219 | type Container = typeof createContainer< 220 | // 221 | '', 222 | '_', 223 | {}, 224 | [typeof __.a], 225 | // @ts-expect-error 226 | // '[]' does not satisfy the constraint 'void | NonEmptyList' 227 | [] 228 | >; 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /src/createContainer/__tests__/domain.spec-d.ts: -------------------------------------------------------------------------------- 1 | import { createContainer } from '../index'; 2 | 3 | test('createContainer.domain', () => { 4 | test('happy', () => { 5 | type Container = typeof createContainer<'_', 'myDomain', {}, void, void>; 6 | 7 | expectTypeOf['domain']>().toEqualTypeOf<'myDomain'>(); 8 | }); 9 | 10 | test('unhappy domain', () => { 11 | { 12 | test('unhappy', () => { 13 | createContainer({ 14 | id: 'id', 15 | // @ts-expect-error container.id cannot be an empty string 16 | domain: '', 17 | start: () => ({ api: {} }), 18 | }); 19 | }); 20 | } 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/createContainer/__tests__/id.spec-d.ts: -------------------------------------------------------------------------------- 1 | import { createContainer } from '../index'; 2 | 3 | describe('container.id not empty string', () => { 4 | test('happy', () => { 5 | createContainer({ 6 | id: 'a', 7 | domain: '_', 8 | start: () => ({ api: {} }), 9 | }); 10 | }); 11 | 12 | test('unhappy', () => { 13 | createContainer({ 14 | // @ts-expect-error container.id cannot be an empty string 15 | id: '', 16 | start: () => ({ api: {} }), 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/createContainer/__tests__/start.spec-d.ts: -------------------------------------------------------------------------------- 1 | import { createContainer } from '../index'; 2 | import type { AnyFn } from './types'; 3 | 4 | describe('start fn', () => { 5 | describe('api is obj', () => { 6 | test('happy', () => { 7 | typeof createContainer<'_', '_', {}>; 8 | typeof createContainer<'_', '_', null>; 9 | }); 10 | 11 | test('unhappy', () => { 12 | // @ts-expect-error 13 | typeof createContainer<'_', string>; 14 | // @ts-expect-error 15 | typeof createContainer<'_', number>; 16 | // @ts-expect-error 17 | typeof createContainer<'_', bigint>; 18 | // @ts-expect-error 19 | typeof createContainer<'_', boolean>; 20 | // @ts-expect-error 21 | typeof createContainer<'_', undefined>; 22 | // @ts-expect-error 23 | typeof createContainer<'_', Symbol>; 24 | // @ts-expect-error 25 | typeof createContainer<'_', []>; 26 | // @ts-expect-error 27 | typeof createContainer<'_', AnyFn>; 28 | // @ts-expect-error 29 | typeof createContainer<'_', Date>; 30 | // @ts-expect-error 31 | typeof createContainer<'_', Map>; 32 | // @ts-expect-error 33 | typeof createContainer<'_', Set>; 34 | // @ts-expect-error 35 | typeof createContainer<'_', WeakMap>; 36 | // @ts-expect-error 37 | typeof createContainer<'_', WeakSet>; 38 | // @ts-expect-error 39 | typeof createContainer<'_', RegExp>; 40 | }); 41 | }); 42 | 43 | test('return type', () => { 44 | type StartResult = ReturnType>['start']>; 45 | type ExpectedResult = { api: { __: null } } | Promise<{ api: { __: null } }>; 46 | 47 | expectTypeOf().toEqualTypeOf(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/createContainer/__tests__/types.ts: -------------------------------------------------------------------------------- 1 | type AnyFn = (...args: any) => any; 2 | 3 | type ExtractContainerParams = Parameters[0]; 4 | type ExtractstartFn = ExtractContainerParams['start']; 5 | type ExtractEnableFn = ExtractContainerParams['enable']; 6 | 7 | export type { AnyFn, ExtractEnableFn, ExtractstartFn }; 8 | -------------------------------------------------------------------------------- /src/createContainer/__tests__/validate.spec.ts: -------------------------------------------------------------------------------- 1 | import { createRandomContainer } from '@randomContainer'; 2 | import { randomUUID } from 'node:crypto'; 3 | 4 | describe('container validation rules', () => { 5 | test('invalid container id', () => { 6 | expect(() => createRandomContainer({ id: '' })).toThrowError('Container ID cannot be an empty string.'); 7 | }); 8 | 9 | test('invalid container domain', () => { 10 | expect(() => createRandomContainer({ domain: '' })).toThrowError('Container Domain cannot be an empty string.'); 11 | }); 12 | 13 | test('correct deps', () => { 14 | const a = createRandomContainer(); 15 | const bId = randomUUID(); 16 | 17 | expect(() => createRandomContainer({ id: bId, dependencies: [a], optionalDependencies: [a] })) 18 | .toThrowErrorMatchingInlineSnapshot(` 19 | [Error: Dependency conflict detected in container "${bId}": 20 | The following dependencies are listed as both required and optional: [${a.id}]. 21 | 22 | Each dependency should be listed only once, as either required or optional.] 23 | `); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/createContainer/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'effector'; 2 | import { 3 | CONTAINER_STATUS, 4 | type AnyAPI, 5 | type AnyContainer, 6 | type AnyDeps, 7 | type ContainerStatus, 8 | type EnableResult, 9 | type StartResult, 10 | } from './types'; 11 | import { validate, type ContainerDomainNameEmptyStringError, type ContainerIdEmptyStringError } from './validate'; 12 | 13 | type ExtractDeps = { 14 | [K in D[number] as Awaited>['api'] extends Record ? never : K['id']]: Awaited< 15 | ReturnType 16 | >['api']; 17 | }; 18 | 19 | type ExtractEnabled = { 20 | [K in D[number] as K['id']]: boolean; 21 | }; 22 | 23 | type DependenciesConfig = 24 | [Deps] extends [void] ? 25 | [OptionalDeps] extends [void] ? 26 | {} 27 | : { 28 | optionalDependencies: Exclude; 29 | } 30 | : [OptionalDeps] extends [void] ? 31 | { 32 | dependencies: Exclude; 33 | } 34 | : { 35 | dependencies: Exclude; 36 | optionalDependencies: Exclude; 37 | }; 38 | 39 | type Params< 40 | Id extends string, 41 | Domain extends string, 42 | API extends AnyAPI, 43 | Deps extends AnyDeps = void, 44 | OptionalDeps extends AnyDeps = void, 45 | > = 46 | '' extends Id ? ContainerIdEmptyStringError 47 | : '' extends Domain ? ContainerDomainNameEmptyStringError 48 | : DependenciesConfig & { 49 | id: Id; 50 | domain: Domain; 51 | start: ( 52 | api: ExtractDeps> & Partial>>, 53 | enabled: ExtractEnabled> & ExtractEnabled>, 54 | ) => StartResult; 55 | enable?: ( 56 | api: ExtractDeps> & Partial>>, 57 | enabled: ExtractEnabled> & ExtractEnabled>, 58 | ) => EnableResult; 59 | }; 60 | 61 | const createContainer = < 62 | Id extends string, 63 | Domain extends string, 64 | API extends AnyAPI, 65 | Deps extends AnyDeps = void, 66 | OptionalDeps extends AnyDeps = void, 67 | >( 68 | __params: Params, 69 | ) => { 70 | const params = validate(__params); 71 | const $status = createStore(CONTAINER_STATUS.idle); 72 | 73 | return { 74 | ...params, 75 | $status, 76 | }; 77 | }; 78 | 79 | export type { ContainerDomain, ContainerId } from './types'; 80 | export { CONTAINER_STATUS, createContainer, type AnyContainer, type ContainerStatus }; 81 | -------------------------------------------------------------------------------- /src/createContainer/types.ts: -------------------------------------------------------------------------------- 1 | import { type NonEmptyTuple } from '@shared'; 2 | import { type StoreWritable } from 'effector'; 3 | 4 | type AnyObject = Record; 5 | type ValueOf = T[keyof T]; 6 | 7 | const CONTAINER_STATUS = { 8 | idle: 'idle', 9 | pending: 'pending', 10 | done: 'done', 11 | fail: 'fail', 12 | off: 'off', 13 | } as const; 14 | 15 | type ContainerStatus = ValueOf; 16 | type StartResult = Promise<{ api: T }> | { api: T }; 17 | type EnableResult = Promise | boolean; 18 | 19 | type AnyAPI = AnyObject | null; 20 | type AnyStartFn = (...x: any) => StartResult; 21 | 22 | type AnyContainer = { 23 | id: string; 24 | domain: string; 25 | $status: StoreWritable; 26 | start: AnyStartFn; 27 | dependencies?: AnyContainer[]; 28 | optionalDependencies?: AnyContainer[]; 29 | enable?: (..._: any) => EnableResult; 30 | }; 31 | 32 | type AnyDeps = NonEmptyTuple | void; 33 | type ContainerId = AnyContainer['id']; 34 | type ContainerDomain = AnyContainer['domain']; 35 | 36 | export { CONTAINER_STATUS }; 37 | export type { AnyAPI, AnyContainer, AnyDeps, ContainerDomain, ContainerId, ContainerStatus, EnableResult, StartResult }; 38 | -------------------------------------------------------------------------------- /src/createContainer/validate.ts: -------------------------------------------------------------------------------- 1 | import { isNil } from '@shared'; 2 | import { type AnyContainer } from './types'; 3 | 4 | type ValidateParams = Pick; 5 | 6 | const ERROR = { 7 | CONTAINER_ID_EMPTY_STRING: 'Container ID cannot be an empty string.', 8 | CONTAINER_DOMAIN_NAME_EMPTY_STRING: 'Container Domain cannot be an empty string.', 9 | depsIntersection: (intersection: string[], containerId: ValidateParams['id']) => 10 | `Dependency conflict detected in container "${containerId}":` + 11 | '\n' + 12 | `The following dependencies are listed as both required and optional: [${intersection.join(', ')}].` + 13 | '\n\n' + 14 | `Each dependency should be listed only once, as either required or optional.`, 15 | } as const; 16 | 17 | type ContainerIdEmptyStringError = ValidateParams & { id: never; error: typeof ERROR.CONTAINER_ID_EMPTY_STRING }; 18 | type ContainerDomainNameEmptyStringError = ValidateParams & { 19 | domain: never; 20 | error: typeof ERROR.CONTAINER_DOMAIN_NAME_EMPTY_STRING; 21 | }; 22 | 23 | const validateContainerId = (x: ValidateParams) => { 24 | if (isNil(x.id) || x.id.length === 0) { 25 | throw new Error(ERROR.CONTAINER_ID_EMPTY_STRING); 26 | } 27 | }; 28 | 29 | const validateDomainName = (x: ValidateParams) => { 30 | if (isNil(x.domain) || x.domain.length === 0) { 31 | throw new Error(ERROR.CONTAINER_DOMAIN_NAME_EMPTY_STRING); 32 | } 33 | }; 34 | 35 | const validateDepsIntersection = (params: ValidateParams) => { 36 | if (isNil(params.dependencies) || isNil(params.optionalDependencies)) { 37 | return; 38 | } 39 | 40 | const depIds = new Set(params.dependencies.map((dep) => dep.id)); 41 | const optDepsIds = new Set(params.optionalDependencies.map((dep) => dep.id)); 42 | 43 | const intersection = depIds.intersection(optDepsIds); 44 | 45 | if (intersection.size) { 46 | throw new Error(ERROR.depsIntersection(Array.from(intersection), params.id)); 47 | } 48 | }; 49 | 50 | type InferValidParams = Exclude; 51 | 52 | const validate =

(params: P) => { 53 | validateContainerId(params); 54 | validateDomainName(params); 55 | validateDepsIntersection(params); 56 | 57 | return params as InferValidParams

; 58 | }; 59 | 60 | export { validate, type ContainerDomainNameEmptyStringError, type ContainerIdEmptyStringError }; 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { compose } from './compose'; 2 | export { createContainer } from './createContainer'; 3 | -------------------------------------------------------------------------------- /src/shared/colors.ts: -------------------------------------------------------------------------------- 1 | /* v8 ignore start */ 2 | 3 | const isNode = typeof process !== 'undefined' && process.versions && process.versions.node; 4 | 5 | const colors = { 6 | bgGreen: (x: string) => (isNode ? `\x1b[42m${x}\x1b[49m` : x), 7 | magenta: (x: string) => (isNode ? `\x1b[35m${x}\x1b[39m` : x), 8 | yellow: (x: string) => (isNode ? `\x1b[33m${x}\x1b[39m` : x), 9 | }; 10 | 11 | export { colors }; 12 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | import type { AnyContainer, ContainerId } from '@createContainer'; 2 | 3 | const LIBRARY_NAME = '[app-compose]'; 4 | type NonEmptyTuple = [T, ...T[]]; 5 | 6 | type StageId = string; 7 | type Stage = { 8 | id: StageId; 9 | containersToBoot: AnyContainer[]; 10 | }; 11 | // container | skipped dependencies of the container. eg: { deposit: [notifications] } 12 | type SkippedContainers = Record; 13 | 14 | export { colors } from './colors'; 15 | export { isNil } from './isNil'; 16 | export { pick } from './pick'; 17 | export { LIBRARY_NAME, type NonEmptyTuple, type SkippedContainers, type Stage }; 18 | -------------------------------------------------------------------------------- /src/shared/isNil.ts: -------------------------------------------------------------------------------- 1 | /* v8 ignore start */ 2 | 3 | export const isNil = (x: T | undefined | null): x is undefined | null => x === null || x === undefined; 4 | -------------------------------------------------------------------------------- /src/shared/pick.ts: -------------------------------------------------------------------------------- 1 | /* v8 ignore start */ 2 | 3 | type Result = { 4 | [P in K]: T[P]; 5 | }; 6 | 7 | const pick = (obj: T, keys: K[]): Result => { 8 | const result = {} as Result; 9 | 10 | for (const key of keys) { 11 | if (key in obj) { 12 | result[key] = obj[key]; 13 | } 14 | } 15 | 16 | return result; 17 | }; 18 | 19 | export { pick }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "strict": true, 5 | "noImplicitAny": true, 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "lib": ["esnext"], 9 | "resolveJsonModule": true, 10 | "importHelpers": true, 11 | "moduleResolution": "Node", 12 | "module": "esnext", 13 | "isolatedModules": true, 14 | "skipLibCheck": true, 15 | "removeComments": false, 16 | "forceConsistentCasingInFileNames": true, 17 | "noEmit": true, 18 | "noUncheckedIndexedAccess": true, 19 | "declaration": true, 20 | "strictNullChecks": true, 21 | "types": ["vitest/globals"], 22 | "verbatimModuleSyntax": true, 23 | "paths": { 24 | "@randomContainer": ["./src/compose/__fixtures__/createRandomContainer"], 25 | "@createContainer": ["./src/createContainer"], 26 | "@shared": ["./src/shared"], 27 | "@prepareStages": ["./src/compose/prepareStages"] 28 | } 29 | }, 30 | "exclude": ["website", "dist"] 31 | } 32 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from 'vite-tsconfig-paths'; 2 | import { defaultExclude, defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [tsconfigPaths()], 6 | test: { 7 | setupFiles: './vitest.setup.ts', 8 | environment: 'node', 9 | typecheck: { 10 | enabled: true, 11 | ignoreSourceErrors: true, 12 | include: ['./src/**/__tests__/**/*.spec-d.ts'], 13 | exclude: defaultExclude, 14 | }, 15 | include: ['./src/**/__tests__/**/*.spec.ts'], 16 | exclude: defaultExclude, 17 | globals: true, 18 | reporters: 'dot', 19 | coverage: { 20 | extension: ['.ts'], 21 | all: true, 22 | include: ['src/**/*'], 23 | exclude: [...defaultExclude, '**/__fixtures__/**', '**/__tests__/**', './src/index.ts'], 24 | reporter: 'text', 25 | provider: 'v8', 26 | thresholds: { 27 | 100: true, 28 | }, 29 | }, 30 | css: false, 31 | watch: false, 32 | pool: 'threads', 33 | }, 34 | resolve: { 35 | mainFields: ['module'], 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | beforeEach(() => { 4 | vi.spyOn(console, 'error').mockImplementation(() => {}); 5 | vi.spyOn(console, 'log').mockImplementation(() => {}); 6 | vi.spyOn(console, 'group').mockImplementation(() => {}); 7 | }); 8 | 9 | afterEach(() => { 10 | vi.restoreAllMocks(); 11 | }); 12 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Starlight Starter Kit: Basics 2 | 3 | [![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build) 4 | 5 | ``` 6 | npm create astro@latest -- --template starlight 7 | ``` 8 | 9 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/starlight/tree/main/examples/basics) 10 | [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/starlight/tree/main/examples/basics) 11 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/withastro/starlight&create_from_path=examples/basics) 12 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fwithastro%2Fstarlight%2Ftree%2Fmain%2Fexamples%2Fbasics&project-name=my-starlight-docs&repository-name=my-starlight-docs) 13 | 14 | > 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun! 15 | 16 | ## 🚀 Project Structure 17 | 18 | Inside of your Astro + Starlight project, you'll see the following folders and files: 19 | 20 | ``` 21 | . 22 | ├── public/ 23 | ├── src/ 24 | │ ├── assets/ 25 | │ ├── content/ 26 | │ │ ├── docs/ 27 | │ │ └── config.ts 28 | │ └── env.d.ts 29 | ├── astro.config.mjs 30 | ├── package.json 31 | └── tsconfig.json 32 | ``` 33 | 34 | Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name. 35 | 36 | Images can be added to `src/assets/` and embedded in Markdown with a relative link. 37 | 38 | Static assets, like favicons, can be placed in the `public/` directory. 39 | 40 | ## 🧞 Commands 41 | 42 | All commands are run from the root of the project, from a terminal: 43 | 44 | | Command | Action | 45 | | :------------------------ | :----------------------------------------------- | 46 | | `npm install` | Installs dependencies | 47 | | `npm run dev` | Starts local dev server at `localhost:4321` | 48 | | `npm run build` | Build your production site to `./dist/` | 49 | | `npm run preview` | Preview your build locally, before deploying | 50 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | 51 | | `npm run astro -- --help` | Get help using the Astro CLI | 52 | 53 | ## 👀 Want to learn more? 54 | 55 | Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat). 56 | -------------------------------------------------------------------------------- /website/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import starlight from '@astrojs/starlight'; 2 | import { defineConfig } from 'astro/config'; 3 | 4 | export default defineConfig({ 5 | site: 'https://grlt-hub.github.io', 6 | base: '/app-compose', 7 | integrations: [ 8 | starlight({ 9 | title: 'app-compose', 10 | description: 'App Compose - Compose modules into apps', 11 | social: { 12 | discord: 'https://discord.gg/ajv8eHzm', 13 | github: 'https://github.com/grlt-hub/app-compose', 14 | telegram: 'https://t.me/grlt_hub_app_compose', 15 | }, 16 | sidebar: [ 17 | { label: 'Introduction', link: '/' }, 18 | { 19 | label: 'Tutorials', 20 | autogenerate: { directory: 'tutorials' }, 21 | }, 22 | { 23 | label: 'How-to Guides', 24 | autogenerate: { directory: 'how-to-guides' }, 25 | }, 26 | { 27 | label: 'Reference', 28 | autogenerate: { directory: 'reference' }, 29 | }, 30 | ], 31 | tableOfContents: { 32 | maxHeadingLevel: 4, 33 | }, 34 | lastUpdated: true, 35 | }), 36 | ], 37 | }); 38 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/starlight": "^0.31.1", 14 | "astro": "^5.1.9", 15 | "sharp": "^0.33.5" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /website/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 36 | 45 | 52 | 57 | 63 | 67 | 71 | 75 | 78 | 81 | 85 | 89 | 95 | 101 | 103 | 107 | 109 | 111 | 114 | 117 | 121 | 124 | 128 | 138 | 143 | 145 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /website/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsSchema } from '@astrojs/starlight/schema'; 3 | 4 | export const collections = { 5 | docs: defineCollection({ schema: docsSchema() }), 6 | }; 7 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/debug.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Debug 3 | sidebar: 4 | order: 6 5 | --- 6 | 7 | In this section, you will learn how to debug container behavior in `app-compose`. 8 | 9 | You will see how to: 10 | 11 | - Track the container startup process to understand what’s happening. 12 | - Identify why more or fewer containers started than expected. 13 | 14 | This will help you quickly find and fix issues in your app. 15 | 16 | ## Container Startup Process 17 | 18 | ```ts {50} 19 | const hireChef = () => ({ 20 | hasBreak: false, 21 | makePizza: (_: { ingredients: string[] }) => 'pizza', 22 | }); 23 | const orderIngredients = () => ['dough', 'sauce', 'cheese']; 24 | 25 | const chef = createContainer({ 26 | id: 'John Doe', 27 | domain: 'italian-chef', 28 | start: async () => { 29 | const hiredChef = await hireChef(); 30 | 31 | return { api: hiredChef }; 32 | }, 33 | }); 34 | 35 | const ingredients = createContainer({ 36 | id: 'ingredients', 37 | domain: 'shop', 38 | dependencies: [chef], 39 | enable: (api) => api['John Doe'].hasBreak === false, 40 | start: async () => { 41 | const orderedIngredients = await orderIngredients(); 42 | 43 | return { api: { orderedIngredients } }; 44 | }, 45 | }); 46 | 47 | const pizza = createContainer({ 48 | id: 'pizza', 49 | domain: 'dish', 50 | dependencies: [chef, ingredients], 51 | start: (api) => { 52 | const pepperoniPizza = api['John Doe'].makePizza({ 53 | ingredients: api.ingredients.orderedIngredients, 54 | }); 55 | 56 | return { api: { pizza: pepperoniPizza } }; 57 | }, 58 | }); 59 | 60 | const cmd = await compose({ 61 | stages: [ 62 | ['prepare', [chef, ingredients]], 63 | ['cooking', [pizza]], 64 | ], 65 | required: 'all', 66 | }); 67 | 68 | await cmd.up({ debug: true }); 69 | ``` 70 | 71 | When you run this code, you should see the following output in the console: 72 | 73 | ```sh 74 | >> prepare 75 | • John Doe = idle 76 | • ingredients = idle 77 | >> prepare 78 | • John Doe = pending 79 | • ingredients = idle 80 | >> prepare 81 | • John Doe = done 82 | • ingredients = idle 83 | >> prepare 84 | • John Doe = done 85 | • ingredients = pending 86 | >> prepare 87 | • John Doe = done 88 | • ingredients = done 89 | >> cooking 90 | • pizza = idle 91 | >> cooking 92 | • pizza = pending 93 | >> cooking 94 | • pizza = done 95 | ``` 96 | 97 |
98 | Try it 99 | 100 | ## Understanding Why a Container Was Started or Skipped 101 | 102 | For this, you can use the diff command, which shows which containers were started, skipped, or added automatically. 103 | 104 | ```ts {58} 105 | const hireChef = () => ({ 106 | hasBreak: false, 107 | makePizza: (_: { ingredients: string[] }) => 'pizza', 108 | }); 109 | const orderIngredients = () => ['dough', 'sauce', 'cheese']; 110 | 111 | const chef = createContainer({ 112 | id: 'John Doe', 113 | domain: 'italian-chef', 114 | start: async () => { 115 | const hiredChef = await hireChef(); 116 | 117 | return { api: hiredChef }; 118 | }, 119 | }); 120 | 121 | const ingredients = createContainer({ 122 | id: 'ingredients', 123 | domain: 'shop', 124 | 125 | dependencies: [chef], 126 | enable: (api) => api['John Doe'].hasBreak === false, 127 | start: async () => { 128 | const orderedIngredients = await orderIngredients(); 129 | 130 | return { api: { orderedIngredients } }; 131 | }, 132 | }); 133 | 134 | const olives = createContainer({ 135 | id: 'olives', 136 | domain: 'ingredients', 137 | start: () => ({ api: { value: 'olives' } }), 138 | }); 139 | 140 | const pizza = createContainer({ 141 | id: 'pizza', 142 | domain: 'dish', 143 | dependencies: [chef, ingredients], 144 | optionalDependencies: [olives], 145 | start: (api) => { 146 | const pepperoniPizza = api['John Doe'].makePizza({ 147 | ingredients: api.ingredients.orderedIngredients, 148 | }); 149 | 150 | return { api: { pizza: pepperoniPizza } }; 151 | }, 152 | }); 153 | 154 | const cmd = await compose({ 155 | stages: [ 156 | ['prepare', [ingredients]], 157 | ['cooking', [pizza]], 158 | ], 159 | required: 'all', 160 | }); 161 | 162 | await cmd.diff(); 163 | ``` 164 | 165 | When you run this code, you should see the following highlighted output in the console: 166 | 167 | ```sh 168 | [app-compose] | diff command 169 | All skipped containers are optional. If they are expected to work, 170 | please include them in the list when calling `compose` function 171 | 172 | Stages: 173 | - prepare: 174 | expected: [ ingredients ] 175 | received: [ ingredients, John Doe ] 176 | - cooking: 177 | expected: [ pizza ] 178 | received: [ pizza ] 179 | skipped: 180 | - olives: [pizza] 181 | ``` 182 | 183 |
184 | Try it 185 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/dynamically-load.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dynamically load code for a container 3 | sidebar: 4 | order: 8 5 | --- 6 | 7 | In this section, you will learn how to dynamically load code for a container in `app-compose`.
8 | This is useful when you want to load a container only when it’s needed, which can improve app performance. 9 | 10 | ### Example 11 | 12 | ```ts title="featureModuleContainer.ts" {5,8} 13 | const featureModuleContainer = createContainer({ 14 | id: 'featureModule', 15 | domain: 'features', 16 | start: async () => { 17 | const { FeatureComponent } = await import('./FeatureComponent'); 18 | return { api: { ui: FeatureComponent } }; 19 | }, 20 | enable: () => process.env.FEATURE_MODULE_ENABLED === 'true', 21 | }); 22 | ``` 23 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/handle-asynchronous-operations.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Handle Asynchronous Operations 3 | sidebar: 4 | order: 1 5 | --- 6 | 7 | In this section, you will learn how to work with asynchronous operations in `app-compose`. 8 | 9 | Both `enable` and `start` can be async functions, which is useful when you need to perform operations like fetching data or checking system readiness. 10 | 11 | ## Example 12 | 13 | ```tsx {14-16} 14 | const isChefAvailable = async () => { 15 | await new Promise((resolve) => setTimeout(resolve, 1000)); 16 | return true; 17 | }; 18 | 19 | const sharpenKnives = async () => { 20 | await new Promise((resolve) => setTimeout(resolve, 2000)); 21 | return { knives: ['little', 'big'] }; 22 | }; 23 | 24 | const chef = createContainer({ 25 | id: 'John Doe', 26 | domain: 'italian-chef', 27 | enable: isChefAvailable, 28 | start: async () => { 29 | const data = await sharpenKnives(); 30 | return { api: data }; 31 | }, 32 | }); 33 | ``` 34 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/handle-container-failures.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Handle container failures 3 | sidebar: 4 | order: 5 5 | --- 6 | 7 | In this section, you will learn how to handle container failures in `app-compose`. 8 | 9 | When a container fails to start, you can detect this and take action. 10 | 11 | ### Example 12 | 13 | ```ts {21} 14 | const unstableService = createContainer({ 15 | id: 'unstable-service', 16 | domain: 'backend', 17 | start: () => { 18 | throw new Error('Service failed to start'); 19 | }, 20 | }); 21 | 22 | const myFeature = createContainer({ 23 | id: 'feature', 24 | domain: 'frontend', 25 | dependencies: [unstableService], 26 | start: () => ({ api: {} }), 27 | }); 28 | 29 | const { up } = await compose({ 30 | stages: [['stage-name', [myFeature]]], 31 | }); 32 | 33 | await up({ 34 | onContainerFail: ({ container, error, stageId }) => { 35 | // for example, sending the error 36 | // to an error tracking service. 37 | console.log({ 38 | stageId, 39 | container, 40 | msg: error.message, 41 | }); 42 | }, 43 | }); 44 | ``` 45 | 46 | When you run this code, you should see the following output in the console: 47 | 48 | ```json 49 | { 50 | "stageId": "stage-name", 51 | "container": { 52 | "id": "unstable-service", 53 | "domain": "backend" 54 | }, 55 | "msg": "Service failed to start" 56 | } 57 | ``` 58 | 59 |
60 | Try it 61 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/share-data-between-containers.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Share Data Between Containers 3 | sidebar: 4 | order: 2 5 | --- 6 | 7 | In this section, you will learn how to share data between containers in `app-compose`. 8 | 9 | Both enable and start can receive data from other containers. This is useful when one container depends on information from another. 10 | 11 | ## Example 12 | 13 | ```tsx {11,13} 14 | const ingredients = createContainer({ 15 | id: 'pizza-base', 16 | domain: 'kitchen', 17 | start: () => ({ api: { available: ['dough', 'sauce', 'cheese'] } }), 18 | }); 19 | 20 | const chef = createContainer({ 21 | id: 'John Doe', 22 | domain: 'italian-chef', 23 | dependencies: [ingredients], 24 | enable: (api) => api.ingredients.available.includes('cheese'), 25 | start: (api) => { 26 | console.log(`Making pizza with: ${api.ingredients.available.join(', ')}`); 27 | 28 | return { api: { dish: 'pizza' } }; 29 | }, 30 | }); 31 | ``` 32 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/stages-required.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Define which containers are critical for app startup 3 | sidebar: 4 | order: 7 5 | --- 6 | 7 | In this section, you will learn how to define which containers are critical for your app to start successfully.
8 | You can use the `required` option in `compose.up` to control the startup process and handle critical parts of your app effectively. 9 | 10 | ## Values 11 | 12 | - `required: undefined`: The app will start even if some containers do not work. This is the **default** value. 13 | - `required: 'all'`: All containers must start. If one container fails, up will stop and reject an error. 14 | - `required: [container1, container2]`: Only the listed containers must start successfully. If one of them fails, up will reject. 15 | - `required: [container1, [container2, container3]`: Nested lists mean that at least one container from the group must start.
16 | In this example, `container1` and either `container2` or `container3` must start successfully. 17 | 18 | ### Example 19 | 20 | ```tsx {6} 21 | const cmd = await compose({ 22 | stages: [ 23 | ['prepare', [chef, ingredients]], 24 | ['cooking', [pizza]], 25 | ], 26 | required: 'all', 27 | }); 28 | ``` 29 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/use-with-react.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Use with React 3 | sidebar: 4 | order: 3 5 | --- 6 | 7 | This page shows how to integrate `compose.up` with React for dynamic rendering of components managed by containers. 8 | 9 | ## Example 10 | 11 | ```tsx 12 | const Logo = () => React logo; 13 | 14 | const logoContainer = createContainer({ 15 | id: 'logo', 16 | domain: 'assets', 17 | start: () => ({ api: { ui: Logo } }), 18 | enable: () => true, 19 | }); 20 | 21 | const layoutContainer = createContainer({ 22 | id: 'layout', 23 | domain: 'layouts', 24 | optionalDependencies: [logoContainer], 25 | start: (apis) => { 26 | const Layout = () => (apis.logo ? : no logo); 27 | 28 | createRoot(document.getElementById('root')!).render( 29 | 30 |

Hello

31 | 32 | , 33 | ); 34 | 35 | return { api: { ui: Layout } }; 36 | }, 37 | }); 38 | 39 | const { up } = await compose({ 40 | stages: [['app', [logoContainer, layoutContainer]]], 41 | }); 42 | 43 | await up(); 44 | ``` 45 | 46 |
47 | Try it 48 | 49 | ## Tip 50 | 51 | This method works well for simple pages with few components. But if your app has many components inside each other, you might need more flexibility. 52 | 53 | For this, you can use slots. React doesn’t have slots by default, but you can use them with the [@grlt-hub/react-slots](https://github.com/grlt-hub/react-slots) package. 54 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/use-with.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Use with... 3 | sidebar: 4 | order: 4 5 | --- 6 | 7 | Unfortunately, examples are not available for all libraries. 8 | 9 | If you have successfully used `app-compose` with any of these frameworks, please help the project by [contributing](https://github.com/grlt-hub/app-compose) documentation. 10 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/visualize-the-system.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Visualize the System 3 | sidebar: 4 | order: 9 5 | --- 6 | 7 | In this section, you will learn how to visualize the system of containers in `app-compose`.
8 | Visualization helps you see how containers are connected, their dependencies, and their current statuses. 9 | 10 | You can use the `graph` command to generate a visual representation of your container structure. This makes it easier to understand complex systems and debug issues. 11 | 12 | ## Example 13 | 14 | ```ts {14} 15 | import { createContainer, compose } from '@grlt-hub/app-compose'; 16 | 17 | const start = () => ({ api: null }); 18 | 19 | const a = createContainer({ id: 'a', domain: 'a', start }); 20 | const b = createContainer({ id: 'b', domain: 'b', dependencies: [a], start }); 21 | const c = createContainer({ id: 'c', domain: 'c', optionalDependencies: [b], start }); 22 | const d = createContainer({ id: 'd', domain: 'd', dependencies: [c], optionalDependencies: [b], start }); 23 | 24 | const cmd = await compose({ 25 | stages: [['_', [a, b, c, d]]], 26 | }); 27 | 28 | const { graph, dependsOn, requiredBy } = await cmd.graph(); 29 | 30 | console.log(JSON.stringify(graph, undefined, 2)); 31 | ``` 32 | 33 | The `cmd.graph` command returns an object with: 34 | 35 | - `graph` — the visual representation of the system. 36 | - `dependsOn`(Container[]) — shows which containers the specified container depends on. 37 | - `requiredBy`(Container[]) — shows which containers require the specified container to work. 38 | 39 | The output provides a detailed breakdown of each container: 40 | 41 | - **Direct Dependencies**: 42 | - **Strict**: Lists containers that are `strict` dependencies of the current container. 43 | - **Optional**: Lists containers that are `optional` dependencies of the current container. 44 | - **Transitive Dependencies**: 45 | - **Strict**: Lists containers that are `strict` dependencies, inherited through a chain of dependencies. 46 | - **Optional**: Lists containers that are `optional` dependencies, inherited through a chain of dependencies. 47 | - **Transitive Dependency Paths**: 48 | - Each transitive dependency includes a path that describes how the dependency is reached, which is helpful for tracing and debugging. 49 | 50 | ```json 51 | { 52 | "a": { 53 | "domain": "a", 54 | "dependencies": [], 55 | "optionalDependencies": [], 56 | "transitive": { 57 | "dependencies": [], 58 | "optionalDependencies": [] 59 | } 60 | }, 61 | "b": { 62 | "domain": "b", 63 | "dependencies": ["a"], 64 | "optionalDependencies": [], 65 | "transitive": { 66 | "dependencies": [], 67 | "optionalDependencies": [] 68 | } 69 | }, 70 | "c": { 71 | "domain": "c", 72 | "dependencies": [], 73 | "optionalDependencies": ["b"], 74 | "transitive": { 75 | "dependencies": [], 76 | "optionalDependencies": [ 77 | { 78 | "id": "a", 79 | "path": "c -> b -> a" 80 | } 81 | ] 82 | } 83 | }, 84 | "d": { 85 | "domain": "d", 86 | "dependencies": ["c"], 87 | "optionalDependencies": ["b"], 88 | "transitive": { 89 | "dependencies": [], 90 | "optionalDependencies": [ 91 | { 92 | "id": "a", 93 | "path": "d -> b -> a" 94 | } 95 | ] 96 | } 97 | } 98 | } 99 | ``` 100 | 101 |
102 | Try it 103 | 104 | ## Grouping by Domain 105 | 106 | If you want to collapse the view to show only domains, you can pass an option: 107 | 108 | ```ts 109 | const { graph, dependsOn, requiredBy } = await cmd.graph({ view: 'domains' }); 110 | ``` 111 | 112 | ## When to Use `compose.graph` 113 | 114 | - **Debugging**: Use the graph output to identify and resolve potential issues, such as missing or transitive (hidden) dependencies. 115 | - **Optimizing Architecture**: Analyze the dependency structure to refactor and optimize your application's architecture. 116 | - **Documenting Dependencies**: Generate a visual representation to document the architecture for your team. 117 | -------------------------------------------------------------------------------- /website/src/content/docs/how-to-guides/with-feature-toggle.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Work with feature toggles 3 | sidebar: 4 | order: 0 5 | --- 6 | 7 | In this section, you will learn how to use feature toggles with `app-compose`.
8 | A feature toggle is a way to turn features on or off without changing the code. 9 | 10 | With `app-compose`, you can control when a container starts based on the feature toggle.
11 | You will see how to use the enable option to start or skip containers depending on the toggle. 12 | 13 | This helps you manage features easily, test new things, and turn features on or off when needed. 14 | 15 | ## Example 16 | 17 | ```tsx {1,7} 18 | const isChefAvailable = () => false; 19 | 20 | const chef = createContainer({ 21 | id: 'John Doe', 22 | domain: 'italian-chef', 23 | start: () => ({ api: null }), 24 | enable: isChefAvailable, 25 | }); 26 | ``` 27 | -------------------------------------------------------------------------------- /website/src/content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: Compose modules into apps. 4 | --- 5 | 6 | ## What is it? 7 | 8 | `app-compose` is a library for module-based applications. 9 | It helps developers easily connect different parts of an application — features, entities, services, and so on — so they work together as a single system. 10 | 11 | With `app-compose`, you can: 12 | 13 | - Simplify the management of complex dependencies. 14 | - Control the order in which modules run. 15 | - Intuitively enable or disable parts of the application. 16 | - Clearly visualize the parts of the application and their connections. 17 | 18 | Instead of manually managing the chaos of modules, `app-compose` turns them into a well-organized and scalable application. 19 | 20 | ## Cooking Up Your Application 21 | 22 | An application is like a dish: a collection of features, entities, and services. But by themselves, they don’t make an application. 23 | To bring everything to life, you need to combine them properly: at the right time, in the right order, and without anything extra. 24 | One misstep, and instead of a pizza, you might end up with a cake. 25 | 26 | If you’re unsure how to connect modules into a single system, [app-compose](https://grlt-hub.github.io/app-compose/) can simplify the process for you. 27 | 28 | ### Example 29 | 30 | ```ts 31 | import { createContainer, compose } from '@grlt-hub/app-compose'; 32 | 33 | // Imagine we are cooking a dish in our restaurant kitchen. 34 | // There are three steps: 35 | // 1. hire the chef 36 | // 2. order the ingredients, 37 | // 3. and cook the pizza. 38 | 39 | // First: prepare the "chef" 40 | // it’s like hiring the chef to start cooking. 41 | const chef = createContainer({ 42 | // The name of our chef. 43 | id: 'John Doe', 44 | // This chef specializes in Italian cuisine. 45 | domain: 'italian-chef', 46 | start: async () => { 47 | // For example, we are hiring a chef. 48 | const hiredChef = await hireChef(); 49 | 50 | // We return our chef. 51 | return { api: hiredChef }; 52 | }, 53 | }); 54 | 55 | // Second: if the chef is hired, 56 | // we need to order the ingredients. 57 | const ingredients = createContainer({ 58 | id: 'ingredients', 59 | domain: 'shop', 60 | // The ingredients ordering depends on the chef. 61 | dependencies: [chef], 62 | // If the chef is on break, 63 | // we can't proceed with the order. 64 | enable: (api) => api['John Doe'].hasBreak === false, 65 | start: async (api) => { 66 | // We order the ingredients. 67 | const orderedIngredients = await orderIngredients(); 68 | 69 | // We return the ordered ingredients. 70 | return { api: { orderedIngredients } }; 71 | }, 72 | }); 73 | 74 | // Third: we make the pizza. 75 | const pizza = createContainer({ 76 | id: 'pizza', 77 | domain: 'dish', 78 | dependencies: [chef, ingredients], 79 | start: (api) => { 80 | // The chef uses the ingredients 81 | // to make the pizza. 82 | const pepperoniPizza = api['John Doe'].makePizza({ 83 | ingredients: api.ingredients.orderedIngredients, 84 | }); 85 | 86 | // The pizza is ready! 87 | return { api: pepperoniPizza }; 88 | }, 89 | }); 90 | 91 | // Now the stages: we split the process into steps. 92 | // 1: "prepare" — hiring the chef and ordering the ingredients. 93 | // 2: "cooking" — making the pizza. 94 | const cmd = await compose({ 95 | stages: [ 96 | ['prepare', [chef, ingredients]], 97 | ['cooking', [pizza]], 98 | ], 99 | // We require everything to be ready. 100 | required: 'all', 101 | }); 102 | 103 | // The cooking process has started! 104 | await cmd.up(); 105 | ``` 106 | 107 | #### Example Status Flow 108 | 109 | Here’s how the statuses change during the cooking process: 110 | 111 | 1. **Initial state**: 112 | 113 | - `chef: 'idle', ingredients: 'idle'` — Everything is waiting. 114 | - `chef: 'pending', ingredients: 'idle'` — The chef is on the way to the kitchen. 115 | 116 | 2. **If the chef is ready to work**: 117 | 118 | - `chef: 'done', ingredients: 'pending'` — Ordering the ingredients. 119 | - `chef: 'done', ingredients: 'done', pizza: 'idle'` — All ingredients have been delivered. 120 | - `chef: 'done', ingredients: 'done', pizza: 'pending'` — Starting to make the pizza. 121 | - `chef: 'done', ingredients: 'done', pizza: 'done'` — The pizza is ready! 122 | 123 | 3. **If the chef is here, but taking a break**: 124 | 125 | - `chef: 'done', ingredients: 'off', pizza: 'off'` — Cooking is canceled. 126 | 127 | ## Strengths of the Library 128 | 129 | - Automatically resolves dependencies, removing the need to manually specify all containers. 130 | - Simplifies working with feature-toggles by eliminating excessive `if/else` logic for disabled functionality. 131 | - Allows you to define which parts of the application to run and in what order, prioritizing more important and less important dependencies. 132 | - Offers the ability to visualize the system composed of containers effectively (including transitive dependencies and their paths). 133 | - Provides a simple and intuitive developer experience (DX). 134 | - Ensures high performance, suitable for scalable applications. 135 | - Includes debugging tools to facilitate the development process. 136 | - Covered by 100% tests, including type tests. 137 | 138 | ## What app-compose is NOT 139 | 140 | - It does not tell you how to build a module. You choose how your modules work. app-compose only helps you put them together in one app. 141 | - It does not manage data or state. If you need state (like Effector or Redux), you add it inside your modules. app-compose only starts them. 142 | -------------------------------------------------------------------------------- /website/src/content/docs/reference/changelog.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | sidebar: 4 | order: 4 5 | --- 6 | 7 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 8 | and this project adheres to [Semantic Versioning](http://semver.org). 9 | 10 | ## 2.0.2 11 | 12 | ### Changed 13 | 14 | - reduce bundle size 15 | 16 | ## 2.0.1 17 | 18 | ### Fixed 19 | 20 | - `graph` return value 21 | 22 | ## 2.0.0 23 | 24 | Vault 25 | 26 | ### Added 27 | 28 | - support for managing container execution order using `stages`. 29 | - `diff` command to display which containers were automatically added and which ones were skipped. 30 | - `domain` a required parameter in `createContainer`. 31 | - `view: domain` to visualize the entire application graph at the domain level. 32 | - `graph.dependsOn` command to quickly identify which containers a given container depends on, including transitive dependencies. 33 | - `graph.requiredBy` command to quickly identify which containers depend on a given container, including transitive dependencies. 34 | - the second argument of the `start` and `enable` functions contains information about whether the parent containers have been resolved. 35 | 36 | ### Changed 37 | 38 | - all parent container APIs are wrapped into the first parameter in `start` and `enable` functions. 39 | - automatic resolving of strict dependencies is enabled by default. 40 | - renamed `dependsOn` to `dependencies`. 41 | - renamed `optionalDependsOn` to `optionalDependencies`. 42 | - renamed `onFail` to `onContainerFail`. 43 | - the message format for the `debug: true` option. 44 | 45 | ## 1.4.0 46 | 47 | ### Added 48 | 49 | - `onFail` option to `compose.up` config for handling container failures, allowing centralized error tracking and custom recovery actions. 50 | [How to](https://grlt-hub.github.io/app-compose/how-to-guides/handle-container-failures/) 51 | 52 | ## 1.3.0 53 | 54 | ### Added 55 | 56 | ```ts 57 | autoResolveDeps?: { 58 | strict: true; 59 | optional?: boolean; 60 | }; 61 | ``` 62 | 63 | in the `compose.up` and `compose.graph` configs. `autoResolveDeps` allows automatic resolution of container dependencies (both strict and optional) without the need to manually pass them to `compose.up` and `compose.graph`. 64 | 65 | ## 1.2.0 66 | 67 | ### Added 68 | 69 | - `apis: boolean` in the `compose.up` config for accessing your containers' APIs after execution. 70 | 71 | ## 1.1.0 72 | 73 | ### Added 74 | 75 | - [the ability to visualize](https://grlt-hub.github.io/app-compose/how-to-guides/visualize-the-system/) the system composed of containers effectively (including transitive dependencies and their paths) 76 | 77 | ## 1.0.0 78 | 79 | ### Added 80 | 81 | - `createContainer` fn 82 | - `compose.up` fn 83 | -------------------------------------------------------------------------------- /website/src/content/docs/reference/create-container.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: createContainer 3 | sidebar: 4 | order: 1 5 | --- 6 | 7 | `createContainer` is a function that creates and returns a container to manage modules in the application. Containers specify dependencies, define initialization conditions, and control the order of module startup. 8 | 9 | ## Syntax 10 | 11 | ```ts 12 | createContainer(config: ContainerConfig): Container 13 | ``` 14 | 15 | ## Parameters 16 | 17 | - _config_: `ContainerConfig` — configuration object for the container 18 | 19 | - **id** (`string`) - Required. Unique identifier of the container. 20 | - **domain** (`string`) - Required. A category for a container. 21 | - **dependencies** (`Container[]`) - Optional. List of strict dependencies. These containers must be in the `done` status for the current container to transition to `pending`. 22 | - **optionalDependencies** (`Container[]`) - Optional. List of optional dependencies. These containers can be in the `done`, `fail`, or `off` status for the current container to activate. 23 | - **start** (`(api?: DepsApi & OptionalDepsApi, enabled: {keyof api: boolean}) => { api: {} }`) - Required. Initialization function of the container, invoked upon activation. 24 | - Accepts: 25 | - api (optional) — an object with APIs from required and optional dependencies. 26 | - enabled (optional) — an object showing which dependencies are enabled (true) or not (false). 27 | - Returns: an object with an api property, which contains data or methods that other containers can use. 28 | - **enable** (`() => boolean | Promise`) - Optional. Function that determines if the container will be activated. Returns `true` to activate the container, and `false` to set the container to `off`. 29 | - Accepts: 30 | - api (optional) — an object with APIs from required and optional dependencies. 31 | - enabled (optional) — an object showing which dependencies are enabled (true) or not (false). 32 | - Returns: `boolean | Promise` 33 | 34 | ## Example 35 | 36 | ```ts 37 | const userContainer = createContainer({ 38 | id: 'user', 39 | domain: 'users', 40 | dependencies: ['auth'], 41 | optionalDependencies: ['settings'], 42 | start: () => { 43 | return { api: { getUser: () => ({ id: 1, name: 'John Doe' }) } }; 44 | }, 45 | enable: () => true, 46 | }); 47 | ``` 48 | -------------------------------------------------------------------------------- /website/src/content/docs/reference/glossary.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Glossary 3 | sidebar: 4 | order: 0 5 | --- 6 | 7 | Below you will find short definitions of important `app-compose` terms. Understanding these terms will help you set up containers, manage dependencies, and control the flow of your application. 8 | 9 | ## Container 10 | 11 | A container is a main part of your app. It is like a building block for your app. 12 | 13 | ### id 14 | 15 | An ID is a unique name for the containe 16 | 17 | ### domain 18 | 19 | A domain is a category for a container. It helps you put containers in logical groups (for example, ingredients or dish). 20 | 21 | ### dependencies 22 | 23 | Dependencies are containers that must start before this container can run.
24 | They are required—if one is missing, your container will not start. 25 | 26 | ### optionalDependencies 27 | 28 | optionalDependencies are containers that the container can use if they are available.
29 | They are not required, so your container can still run without them. 30 | 31 | ### api 32 | 33 | The object returned by container start. It can have data or methods that other containers use.
34 | An object like `{ api: {...} | null }` 35 | 36 | ### start 37 | 38 | The start function runs when the container begins.
39 | It can use the APIs and statuses of container dependencies. 40 | 41 | ### enable 42 | 43 | enable is a function that decides if the container can start.
44 | It can use the APIs and statuses of container dependencies. 45 | 46 | ## Compose 47 | 48 | compose is a function that collects all containers and prepares them to run.
49 | It returns an object with methods like up for starting the containers. 50 | 51 | ### stages 52 | 53 | Stages are steps for starting containers in priority.
54 | Each stage has a name and a list of containers.
55 | The containers in the first stage start first, then the next stage, and so on. 56 | 57 | ### up 58 | 59 | A command that starts all containers in the correct order. 60 | 61 | ### graph 62 | 63 | A command that displays how containers connect to each other. 64 | 65 | ### diff 66 | 67 | A command that shows which containers started and which were skipped or added automatically. 68 | -------------------------------------------------------------------------------- /website/src/content/docs/tutorials/basic-usage.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Usage 3 | sidebar: 4 | order: 1 5 | --- 6 | 7 | In this section, we will create a simple container using `createContainer` and run it with `compose.up`. This example will show how to start using the library and understand its basic ideas. 8 | 9 | The container we create will have an identifier, domain, and a start function that runs when the container starts. We will also see how `compose.up` connects containers and controls their execution. 10 | 11 | ## Analogy from Cooking 12 | 13 | Imagine you are in a kitchen. You have a chef, ingredients, and a pot. Each of them is a container. 14 | 15 | A chef alone can’t cook without ingredients. Ingredients won’t turn into a meal without a pot. 16 | 17 | Containers don’t do much on their own, but when they work together, you get a finished meal. `compose.up` is the one that organizes the process, makes sure everything happens in the right order, and starts each part at the right time. 18 | 19 | ## Example 20 | 21 | ```ts 22 | import { createContainer, compose } from '@grlt-hub/app-compose'; 23 | 24 | const hireChef = () => null; 25 | 26 | const chef = createContainer({ 27 | // The name of our chef. 28 | id: 'John Doe', 29 | // This chef specializes in Italian cuisine. 30 | domain: 'italian-chef', 31 | start: async () => { 32 | // For example, we are hiring a chef. 33 | const hiredChef = await hireChef(); 34 | 35 | console.log('The chef is already on the way!'); 36 | 37 | // Every start function should return an object like this: 38 | // { api: object | null } 39 | return { api: hiredChef }; 40 | }, 41 | }); 42 | 43 | const { up } = await compose({ 44 | stages: [['prepare', [chef]]], 45 | }); 46 | ``` 47 | 48 | When you run this code, you should see the following output in the console: 49 | 50 | ```sh 51 | > npm start 52 | The chef is already on the way! 53 | ``` 54 | 55 |
56 | Try it 57 | 58 | ## What’s Next? 59 | 60 | In this example, we created a simple container that works alone. But in real applications, containers often depend on other containers to do their job. 61 | 62 | Next, we will learn about `dependencies` and `optionalDependencies`. You’ll see how to connect containers and build flexible applications where different parts work together. 63 | -------------------------------------------------------------------------------- /website/src/content/docs/tutorials/dependencies.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dependencies 3 | sidebar: 4 | order: 2 5 | --- 6 | 7 | In real applications, containers often need to work together. Some containers depend on others to perform their tasks. For this, we use `dependencies` and `optionalDependencies`. 8 | 9 | - **dependencies** are containers that must be available for another container to start. If such a dependency is missing or not working, the container won’t start. 10 | 11 | - **optionalDependencies** are containers that can be used if available, but their absence won’t prevent the container from starting. 12 | 13 | In this section, we’ll show how to connect containers using `dependencies` and `optionalDependencies`. You’ll see how one container can use data or functionality from another. 14 | 15 | ## Analogy from Cooking 16 | 17 | Imagine you are making a pizza. 18 | 19 | - You need dough, sauce, and cheese — without them, you can’t make a pizza. These are your required dependencies (`dependencies`). 20 | - But olives are an optional dependency (`optionalDependencies`). They add extra flavor, but if you don’t have them, you’ll still have a delicious pizza. 21 | 22 | It’s the same with containers: some things are needed to make them work, and others just make them better. 23 | 24 | ## Example 25 | 26 | ```ts 27 | import { compose, createContainer } from '@grlt-hub/app-compose'; 28 | 29 | const dough = createContainer({ 30 | id: 'dough', 31 | domain: 'ingredients', 32 | start: () => ({ api: { value: 'dough' } }), 33 | }); 34 | 35 | const sauce = createContainer({ 36 | id: 'sauce', 37 | domain: 'ingredients', 38 | start: () => ({ api: { value: 'sauce' } }), 39 | }); 40 | 41 | const cheese = createContainer({ 42 | id: 'cheese', 43 | domain: 'ingredients', 44 | start: () => ({ api: { value: 'cheese' } }), 45 | }); 46 | 47 | const olives = createContainer({ 48 | id: 'olives', 49 | domain: 'ingredients', 50 | start: () => ({ api: { value: 'olives' } }), 51 | }); 52 | 53 | const pizza = createContainer({ 54 | id: 'pizza', 55 | domain: 'dish', 56 | 57 | // Required dependencies: pizza cannot be made without dough, sauce, and cheese 58 | dependencies: [dough, sauce, cheese], 59 | 60 | // Optional dependency: olives, but pizza can be made without them 61 | optionalDependencies: [olives], 62 | 63 | // Start function: combines all available ingredients into a pizza 64 | start: (api) => { 65 | const ingredients = Object.values(api).map((x) => x.value); 66 | 67 | console.log(`${ingredients.join(' + ')} = pizza`); 68 | 69 | return { api: { data: 'pizza' } }; 70 | }, 71 | }); 72 | 73 | const { up } = await compose({ 74 | stages: [['prepare', [pizza]]], 75 | }); 76 | 77 | up(); 78 | ``` 79 | 80 | When you run this code, you should see the following output in the console: 81 | 82 | ```sh 83 | > npm start 84 | dough + sauce + cheese = pizza 85 | ``` 86 | 87 |
88 | Try it 89 | 90 | ## Why Olives Are Missing? 91 | 92 | In the last example, olives are not in the pizza because they are optional. We did not add them, so the pizza has no olives. 93 | 94 | But why did dough, sauce, and cheese work even though we didn’t add them directly?
95 | That’s because they are required dependencies. When a container depends on others, `compose.up` _automatically includes required dependencies_, even if they are not specified. 96 | 97 | Now let’s see how to include olives. 98 | 99 | ## Including Olives 100 | 101 | To include olives, you just need to add them to `compose.up`. 102 | 103 | ```diff 104 | const { up } = await compose({ 105 | - stages: [['prepare', [pizza]]], 106 | + stages: [['prepare', [pizza, olives]]], 107 | }); 108 | ``` 109 | 110 | When you run this code, you should see the following output in the console: 111 | 112 | ```sh 113 | > npm start 114 | olives + dough + sauce + cheese = pizza 115 | ``` 116 | 117 |
118 | Try it 119 | 120 | ## What’s Next? 121 | 122 | So far, we’ve learned how to connect containers using dependencies and optional dependencies. This makes sure containers start when they need other containers. 123 | 124 | But sometimes this is not enough.
125 | Imagine you’re making a pizza. You have dough and sauce, and you usually add cheese. But today, you look in the fridge and see — there is no cheese. 126 | 127 | This is where `enable` helps. It lets you choose if a container should start, like checking if you have cheese before making the pizza. 128 | -------------------------------------------------------------------------------- /website/src/content/docs/tutorials/enable.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Enable 3 | sidebar: 4 | order: 3 5 | --- 6 | 7 | In this article, you’ll learn how to control when containers start using the `enable` option. While dependencies ensure that containers start when the required parts are ready, sometimes that’s not enough. 8 | 9 | The `enable` option allows you to set custom conditions that decide if a container should start. This is useful when you need more control, like starting a container only when certain data is available or a specific state is active. 10 | 11 | You’ll also see how `enable` can be used with **feature toggles** to turn features on or off in your app based on specific conditions. 12 | 13 | ## Analogy from Cooking 14 | 15 | Imagine you have dough and sauce, and you usually add cheese. But before you start making a pizza, you check if you have cheese. 16 | 17 | If there’s no cheese, you don’t start. 18 | 19 | This is how `enable` works. It helps you decide if you should start, even when everything else is ready. 20 | 21 | ## Example 22 | 23 | ```tsx 24 | import { compose, createContainer } from '@grlt-hub/app-compose'; 25 | 26 | const hasCheese = () => false; 27 | 28 | const dough = createContainer({ 29 | id: 'dough', 30 | domain: 'ingredients', 31 | start: () => ({ api: { value: 'dough' } }), 32 | }); 33 | 34 | const sauce = createContainer({ 35 | id: 'sauce', 36 | domain: 'ingredients', 37 | start: () => ({ api: { value: 'sauce' } }), 38 | }); 39 | 40 | const cheese = createContainer({ 41 | id: 'cheese', 42 | domain: 'ingredients', 43 | start: () => ({ api: { value: 'cheese' } }), 44 | // The container will start 45 | // only if hasCheese() returns true 46 | enable: hasCheese, 47 | }); 48 | 49 | const pizza = createContainer({ 50 | id: 'pizza', 51 | domain: 'dish', 52 | dependencies: [dough, sauce, cheese], 53 | start: () => { 54 | return { api: { data: 'pizza' } }; 55 | }, 56 | }); 57 | 58 | const { up } = await compose({ 59 | stages: [['prepare', [pizza]]], 60 | }); 61 | 62 | const result = await up(); 63 | 64 | console.log(JSON.stringify(result, undefined, 2)); 65 | ``` 66 | 67 | When you run this code, you should see the following output in the console: 68 | 69 | ```json 70 | { 71 | "allDone": false, 72 | "stages": { 73 | "prepare": { 74 | "allDone": false, 75 | "containerStatuses": { 76 | // because one of its required dependencies (cheese) is OFF 77 | "pizza": "off", 78 | 79 | "dough": "done", 80 | "sauce": "done", 81 | 82 | // because the enable condition returned false 83 | "cheese": "off" 84 | } 85 | } 86 | } 87 | } 88 | ``` 89 | 90 | The `enable` function should always return a boolean. If it’s not provided, it returns **true** by default. 91 | 92 | Try modifying `hasCheese` and see what happens. 93 | 94 |
95 | Try it 96 | 97 | ## What’s Next? 98 | 99 | We’ve learned how to control when containers start using enable. This helps start containers only when needed. 100 | 101 | But what if you want to control the priority of starting containers? 102 | Sometimes, one container should start before another, even if they are not connected. 103 | 104 | This is where `stages` help. They let you group containers and choose the order they start. 105 | -------------------------------------------------------------------------------- /website/src/content/docs/tutorials/getting-started.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | sidebar: 4 | order: 0 5 | --- 6 | 7 | import { Tabs, TabItem } from '@astrojs/starlight/components'; 8 | 9 | ## Installation 10 | 11 | Note: Effector version 23 is a peerDependency of the library. 12 | 13 | 14 | 15 | ```sh [npm] 16 | npm i effector @grlt-hub/app-compose 17 | # or 18 | npm i @grlt-hub/app-compose 19 | ``` 20 | 21 | 22 | ```sh [yarn] 23 | yarn add effector @grlt-hub/app-compose 24 | # or 25 | yarn add @grlt-hub/app-compose 26 | ``` 27 | 28 | 29 | ```sh [pnpm] 30 | pnpm add effector @grlt-hub/app-compose 31 | # or 32 | pnpm add @grlt-hub/app-compose 33 | ``` 34 | 35 | 36 | -------------------------------------------------------------------------------- /website/src/content/docs/tutorials/stages.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Stages 3 | sidebar: 4 | order: 4 5 | --- 6 | 7 | In the previous examples, we’ve already seen `stages`. Now it’s time to explain what they are and how they work. 8 | 9 | Stages are useful when you need to **control** the priority of starting containers.
10 | In real applications, some things are more important, and some are less important. 11 | 12 | It’s a good idea to **load and start important things first**, and then start the less important ones.
13 | This helps your app start in the right priority and feel better for the user. 14 | 15 | Each stage has a name and a list of containers to start. The stages run in order, so you can decide which containers start first and which start next. 16 | 17 | ## Analogy from Cooking 18 | 19 | Imagine you’re preparing a meal. You need to make **the main course** and **dessert**. 20 | 21 | It’s important to start with the main course because it takes more time to cook and people are usually hungry for it first.
22 | Once the main course is ready, you can start making the dessert. 23 | 24 | ## Example 25 | 26 | ```tsx 27 | import { compose, createContainer } from '@grlt-hub/app-compose'; 28 | 29 | const mainCource = createContainer({ 30 | id: 'pizza', 31 | domain: 'dish', 32 | start: () => { 33 | console.log('pizza is ready'); 34 | 35 | return { api: { value: 'pizza' } }; 36 | }, 37 | }); 38 | 39 | const dessert = createContainer({ 40 | id: 'dessert', 41 | domain: 'dish', 42 | start: () => { 43 | console.log('dessert is ready'); 44 | 45 | return { api: { value: 'dessert' } }; 46 | }, 47 | }); 48 | 49 | const { up } = await compose({ 50 | stages: [ 51 | // This stage runs first. 52 | // The main course (pizza) will start here. 53 | ['first', [mainCource]], 54 | // This stage runs after the first. 55 | // The dessert will start only when the first stage is done. 56 | ['then', [dessert]], 57 | ], 58 | }); 59 | 60 | up(); 61 | ``` 62 | 63 | When you run this code, you should see the following output in the console: 64 | 65 | ```sh 66 | > npm start 67 | pizza is ready 68 | dessert is ready 69 | ``` 70 | 71 |
72 | Try it 73 | -------------------------------------------------------------------------------- /website/src/content/docs/tutorials/summary.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Summary 3 | --- 4 | 5 | We have learned the basics of `app-compose`: 6 | 7 | - Containers and how to create them. 8 | - Dependencies and optional dependencies to connect containers. 9 | - `enable` to control when a container should start. 10 | - Stages to manage the order of container startup. 11 | 12 | With these tools, you can build flexible and organized applications. Next, you can explore advanced features and best practices to make your project even stronger. 13 | -------------------------------------------------------------------------------- /website/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } 4 | --------------------------------------------------------------------------------