├── .editorconfig ├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── C5_final_logo_horiz.png └── signal_image.png ├── code-of-conduct.md ├── package-lock.json ├── package.json ├── rollup.config.ts ├── src ├── SignalGraphBuilder.ts ├── signal.ts ├── signalGraph.ts ├── signalGraphDefinition.ts ├── signalGraphDefinitionTransform.ts ├── toposort.ts └── util.ts ├── test ├── SignalGraphBuilder.test.ts ├── fixtures.ts ├── signal.test.ts ├── signalGraph.test.ts ├── toposort.test.ts └── tsconfig.json ├── tools ├── gh-pages-publish.ts └── semantic-release-prepare.ts ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Goals 2 | 3 | 4 | # Implementation 5 | 6 | 7 | # For discussion 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .vscode 7 | .idea 8 | dist 9 | compiled 10 | .awcache 11 | .rpt2_cache 12 | docs 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | branches: 3 | only: 4 | - master 5 | - /^greenkeeper/.*$/ 6 | cache: npm 7 | notifications: 8 | email: false 9 | node_js: 10 | - node 11 | script: 12 | - npm run test:prod && npm run build 13 | after_success: 14 | - npm run report-coverage 15 | - npm run semantic-release 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | We're really glad you're reading this, because we need volunteer developers to help this project come to fruition. 👏 2 | 3 | ## Instructions 4 | 5 | These steps will guide you through contributing to this project: 6 | 7 | - Fork the repo 8 | - Clone it and install dependencies 9 | 10 | git clone https://github.com/YOUR-USERNAME/typescript-library-starter 11 | npm install 12 | 13 | Keep in mind that after running `npm install` the git repo is reset. So a good way to cope with this is to have a copy of the folder to push the changes, and the other to try them. 14 | 15 | Make and commit your changes. Make sure the commands npm run build and npm run test:prod are working. 16 | 17 | Finally send a [GitHub Pull Request](https://github.com/alexjoverm/typescript-library-starter/compare?expand=1) with a clear list of what you've done (read more [about pull requests](https://help.github.com/articles/about-pull-requests/)). Make sure all of your commits are atomic (one feature per commit). 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 hannahhoward 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 2 | [![Greenkeeper badge](https://badges.greenkeeper.io/rxreact/signal.svg)](https://greenkeeper.io/) 3 | [![Build Status](https://travis-ci.org/rxreact/signal.svg?branch=master)](https://travis-ci.org/rxreact/signal) 4 | [![Coverage Status](https://coveralls.io/repos/github/rxreact/signal/badge.svg?branch=master)](https://coveralls.io/github/rxreact/signal?branch=master) 5 | 6 | Development Sponsored By: 7 | [![Carbon Five](./assets/C5_final_logo_horiz.png)](http://www.carbonfive.com) 8 | 9 | # Signal 10 | 11 | This package answers to one of the most difficult questions when writing applications with RxJS: how to I build a data model for my application with observables? 12 | 13 | Existing data modeling solutions use RxJS to *mimic* other more well-known solutions for state management -- i.e. *how would I build Redux with RxJS*? This approach often sacrifices the power and potential of observables without providing much benefit. At the same time, working with raw observables without any framework brings up a million pitfalls -- hot vs cold, when to subscribe, how to manage dependencies and test, etc. 14 | 15 | Signal is drawn from our experience as professional programmers at Carbon Five who use RxJS on a number of production projects, and is essentially captures our "best practices" we've developed over time for modelling data with Observables. 16 | 17 | ## Installation 18 | 19 | In your project: 20 | 21 | ``` 22 | npm install @rxreact/signal --save 23 | ``` 24 | 25 | or 26 | 27 | ``` 28 | yarn add @rxreact/signal 29 | ``` 30 | 31 | RxJS and React are peer dependencies and need to be installed seperately 32 | 33 | ## What is a Signal Graph? 34 | 35 | This tutorial assumes a basic knowledge of RxJS and functional reactive programming. You can start with: 36 | 37 | [The introduction to Reactive Programming you've been missing 38 | ](https://gist.github.com/staltz/868e7e9bc2a7b8c1f754) 39 | 40 | [Egghead Courses](https://egghead.io/courses/introduction-to-reactive-programming) 41 | 42 | Let's explore the idea of a Signal Graph by building something we use all the time: a login form. 43 | 44 | ![Signal Graph](./assets/signal_image.png) 45 | 46 | A login form will need an entry for your credentials and a way to submit. You probably want to display feedback from the server about incorrect logins, and maybe you also want to disable clicking submit while a login is in progress. 47 | 48 | So how do we model this with observables? We'll let's start with what we have - three streams of data from user inputs. We can track every thing the user types in each text field, and we can track clicking on a submit button. 49 | 50 | From there, we can probably come up with a stream of attempted logins by combining the username and password every time the user clicks the submit button. 51 | 52 | We can use those to kick off API calls to a server, which will eventually result a stream of responses. 53 | 54 | From there we can derive more things -- we can seperate successes and failures from the response stream, and we can derive whether a login is progress from the time between a login attempt and a login response. 55 | 56 | We can extract useful data from our success and failure streams -- the error messages returned from the server and maybe an auth token that comes back in a success login response. 57 | 58 | And finally later on we can use the auth token stream to trigger a fetch of a protected resource since the next screen after a login is usually to display some personalized data. 59 | 60 | We've built up a series of streams, starting from our `primary signals` which are our user inputs. The network of these connected observables is called a signal graph. The signals are the emissions from observables, and the graph is how they're all tied together. This is how we can architect programs with Observables. Our programs become a series of reactive data streams, starting with primary signals and extending to all the derivations based on those primary signals. Taken together, those form a signal graph. 61 | 62 | Signal graphs are a concept. Your observables together form a signal graph whether or not you use this library. However, using this library to directly define your signal graph will help you think about your design, and solve a number of potential pitfalls you're likely to encounter writing production code with observables. 63 | 64 | ## Usage 65 | 66 | In traditional RxJs, we'd define a series of signals manually: 67 | 68 | ```javascript 69 | const username$ = new Subject(); 70 | const password$ = new Subject(); 71 | const submitButton$ = new Subject(); 72 | 73 | const loginAttempts$ = submitButton$.pipe(withLatestFrom(username$, password$)); 74 | 75 | const loginResponses$ = loginAttempts$.pipe( 76 | mergeMap(([_, username, password]) => api.login( 77 | username, 78 | password 79 | )) 80 | ); 81 | ``` 82 | 83 | The first issue this presents is testing -- `loginResponse$` is dependent on several of the other signals and difficult to test. We can solve this by switch to factory functions: 84 | 85 | ```typescript 86 | const makeLoginAttempts = ( 87 | submitButton$: Observable, 88 | username$: Observable, 89 | password$: Observable 90 | ) => submitButton$.pipe(withLatestFrom(username$, password$)) 91 | 92 | const makeLoginResponses = (loginAttempts$: Observable<[void, string, string]>, api: API) => 93 | loginAttempts$.pipe(flatMap(([_, username, password]) => api.login({ username, password }))) 94 | ``` 95 | 96 | But now have to wire up all that DI manually in our regular code. 97 | 98 | This library provides a DSL for succinctly defining graphs: 99 | 100 | ```typescript 101 | 102 | type SignalsType = { 103 | username$: string 104 | password$: string 105 | submitButton$: void 106 | loginAttempts$: [void, string, string] 107 | loginResponses$: LoginResponse 108 | loginInProgress$: boolean 109 | loginSuccesses$: LoginSuccess 110 | loginFailures$: LoginFailure 111 | loginFailureMessage$: string 112 | authStatus$: AuthStatus 113 | } 114 | 115 | type Dependencies = { 116 | api: API 117 | } 118 | 119 | const signalGraph = new SignalGraphBuilder() 120 | .define(addPrimary('username$')) 121 | .define( 122 | addPrimary('password$'), 123 | addPrimary('submitButton$'), 124 | addDependency('api', api), 125 | addDerived('loginAttempts$', makeLoginAttempts, 'submitButton$', 'username$', 'password$'), 126 | addDerived('loginResponses$', makeLoginResponses, 'loginAttempts$', 'api'), 127 | addDerived('loginInProgress$', makeLoginInProgress, 'loginAttempts$', 'loginResponses$'), 128 | addDerived('loginSuccesses$', makeLoginSuccesses, 'loginResponses$'), 129 | addDerived('loginFailures$', makeLoginFailures, 'loginResponses$'), 130 | addDerived( 131 | 'loginFailureMessage$', 132 | makeLoginFailureMessage, 133 | 'loginAttempts$', 134 | 'loginFailures$' 135 | ), 136 | addDerived('authStatus$', makeAuthStatus, 'loginSuccesses$') 137 | ) 138 | .initializeWith({ 139 | loginInProgress$: false, 140 | loginFailureMessage$: '', 141 | username$: '', 142 | password$: '', 143 | authStatus$: { status: 'unauthorized' } 144 | }) 145 | .build() 146 | ``` 147 | 148 | We describe dependencies, provide factory functions, and the whole graph is built for us when we call `build`. We don't even have to define Subjects for our primary signals. It detects cyclic dependencies and missing signals. We can hydrate the graph with an initial state. For those following the subtleties of hot/cold observables, all observables in this graph have `shareReplay(1)` called on them, because this makes our graph behave like a predicatable state machine. In fact, other than by injecting outside dependencies, the graph is purely functional. 149 | 150 | 151 | ### Getting signals in an out of the graph 152 | 153 | Once your graph is built, you can extract any signal by calling: 154 | 155 | ```typescript 156 | signalGraph.output(`loginAttempts$`) 157 | ``` 158 | 159 | This will give you an observable that emits values for that node on the graph. 160 | 161 | If you want to put new data into one of the primary signals, call: 162 | 163 | ```typescript 164 | signalGraph.input(`username$`) 165 | ``` 166 | 167 | This will give you a Subject that you can call `next()` on to cause that signal to emit new values. 168 | 169 | If you use React for your UI, make sure to check out [@rxreact/signal-connect](https://github.com/rxreact/signal-connect) which makes connecting signal graphs to React components super easy! 170 | 171 | ### Connecting multiple graphs 172 | 173 | While it's possible to model an entire application as one signal graph, in practice that graph will get huge in a production application. So it's nice often to seperate graphs by different major domains or feature areas of your application. However, in this case you need to connect the graphs, and `@rxreact/signal` lets you do that. 174 | 175 | Let's say you had a second graph for a protected area of the application: 176 | 177 | ```typescript 178 | const protectedAreaGraph = new SignalGraphBuilder< 179 | { 180 | userToken$: string 181 | authStatus$: AuthStatus 182 | protected$: string 183 | }, 184 | { api: API } 185 | >() 186 | .define( 187 | addPrimary('authStatus$'), 188 | addDerived('userToken$', makeUserToken, 'authStatus$'), 189 | addDependency('api', api), 190 | addDerived('protected$', makeProtected, 'userToken$', 'api') 191 | ) 192 | .initializeWith({ 193 | protected$: '' 194 | }) 195 | .build() 196 | ``` 197 | 198 | Now you want to connect outputs of `authStatus$` from your authentication graph to inputs of `authStatus$` in your protected area graph. You can do this easily: 199 | 200 | ```typescript 201 | protectedAreaGraph.connect( 202 | 'authStatus$', 203 | signalGraph, 204 | 'authStatus$' 205 | ) 206 | ``` 207 | 208 | ### Typescript FTW 209 | 210 | While `@rxreact/signal` can be used with just javascript, if you use Typescript you get a number of added bonuses, in the form of super strong type checking. You can see from the examples above that `SignalGraphBuilder` takes two generic parameters that specify the types of your signals and the types of your external dependencies. Once you've done that, all of your definition code has very strong type checking. If you type a signal name wrong, you'll get an error. If the signal you reference as a dependency doesn't match the type expected by the factory function, you'll get an error. It becomes quite difficult to write a graph incorrectly! 211 | 212 | ### Caveat Emptor 213 | 214 | This libraries are still in very in development and the typings require Typescript 3.1. Feel free to experiment but beware production usage! 215 | 216 | Expect though that development will continue and this will be a production-grade library in the future! 217 | 218 | ## Enjoy! 219 | -------------------------------------------------------------------------------- /assets/C5_final_logo_horiz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxreact/signal/bb2089f8892dd6bb8c0bbd47e900d97dafc6c460/assets/C5_final_logo_horiz.png -------------------------------------------------------------------------------- /assets/signal_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rxreact/signal/bb2089f8892dd6bb8c0bbd47e900d97dafc6c460/assets/signal_image.png -------------------------------------------------------------------------------- /code-of-conduct.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at alexjovermorales@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rxreact/signal", 3 | "version": "0.0.0-development", 4 | "description": "", 5 | "keywords": [], 6 | "main": "dist/signal.umd.js", 7 | "module": "dist/signal.es5.js", 8 | "typings": "dist/types/signal.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "author": "hannahhoward ", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/rxreact/signal.git" 16 | }, 17 | "license": "MIT", 18 | "engines": { 19 | "node": ">=6.0.0" 20 | }, 21 | "scripts": { 22 | "lint": "tslint -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 23 | "prebuild": "rimraf dist", 24 | "build": "tsc --module commonjs && rollup -c rollup.config.ts", 25 | "docs": "typedoc --out docs --target es6 --theme minimal --mode file src", 26 | "start": "rollup -c rollup.config.ts -w", 27 | "test": "jest", 28 | "test:watch": "jest --watch", 29 | "test:prod": "npm run lint && npm run test -- --coverage --no-cache", 30 | "deploy-docs": "ts-node tools/gh-pages-publish", 31 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 32 | "commit": "git-cz", 33 | "semantic-release": "semantic-release", 34 | "semantic-release-prepare": "ts-node tools/semantic-release-prepare" 35 | }, 36 | "lint-staged": { 37 | "{src,test}/**/*.ts": [ 38 | "prettier --write --no-semi --single-quote", 39 | "git add" 40 | ] 41 | }, 42 | "config": { 43 | "commitizen": { 44 | "path": "node_modules/cz-conventional-changelog" 45 | }, 46 | "validate-commit-msg": { 47 | "types": "conventional-commit-types", 48 | "helpMessage": "Use \"npm run commit\" instead, we use conventional-changelog format :) (https://github.com/commitizen/cz-cli)" 49 | } 50 | }, 51 | "jest": { 52 | "transform": { 53 | ".(ts|tsx)": "ts-jest" 54 | }, 55 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 56 | "moduleFileExtensions": [ 57 | "ts", 58 | "tsx", 59 | "js" 60 | ], 61 | "coveragePathIgnorePatterns": [ 62 | "/node_modules/", 63 | "/test/" 64 | ], 65 | "coverageThreshold": { 66 | "global": { 67 | "branches": 90, 68 | "functions": 95, 69 | "lines": 95, 70 | "statements": 95 71 | } 72 | }, 73 | "collectCoverage": true, 74 | "mapCoverage": true 75 | }, 76 | "devDependencies": { 77 | "@types/jest": "^24.0.11", 78 | "@types/node": "^11.13.2", 79 | "colors": "^1.1.2", 80 | "commitizen": "^3.0.7", 81 | "coveralls": "^3.0.0", 82 | "cross-env": "^5.0.1", 83 | "cz-conventional-changelog": "^2.1.0", 84 | "husky": "^1.1.0", 85 | "jest": "^24.7.1", 86 | "lint-staged": "^8.1.5", 87 | "lodash.camelcase": "^4.3.0", 88 | "prettier": "^1.14.2", 89 | "prompt": "^1.0.0", 90 | "replace-in-file": "^3.0.0-beta.2", 91 | "rimraf": "^2.6.1", 92 | "rollup": "^0.67.0", 93 | "rollup-plugin-commonjs": "^9.0.0", 94 | "rollup-plugin-json": "^4.0.0", 95 | "rollup-plugin-node-resolve": "^4.2.1", 96 | "rollup-plugin-sourcemaps": "^0.4.2", 97 | "rollup-plugin-typescript2": "^0.20.1", 98 | "rxjs": "^6.3.2", 99 | "semantic-release": "^15.13.8", 100 | "ts-jest": "^24.0.1", 101 | "ts-node": "^8.0.3", 102 | "tslint": "^5.8.0", 103 | "tslint-config-prettier": "^1.1.0", 104 | "tslint-config-standard": "^8.0.1", 105 | "typedoc": "0.15.0-0", 106 | "typescript": "^3.1.1", 107 | "validate-commit-msg": "^2.12.2" 108 | }, 109 | "peerDependencies": { 110 | "rxjs": "^6.3.2" 111 | }, 112 | "husky": { 113 | "hooks": { 114 | "pre-commit": "lint-staged", 115 | "commit-msg": "validate-commit-msg" 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import sourceMaps from "rollup-plugin-sourcemaps"; 4 | import camelCase from "lodash.camelcase"; 5 | import typescript from "rollup-plugin-typescript2"; 6 | import json from "rollup-plugin-json"; 7 | 8 | const pkg = require("./package.json"); 9 | 10 | const libraryName = "signal"; 11 | 12 | export default { 13 | input: `src/${libraryName}.ts`, 14 | output: [ 15 | { file: pkg.main, name: camelCase(libraryName), format: "umd" }, 16 | { file: pkg.module, format: "es" } 17 | ], 18 | sourcemap: true, 19 | // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') 20 | external: ["rxjs", "rxjs/operators"], 21 | watch: { 22 | include: "src/**" 23 | }, 24 | plugins: [ 25 | // Allow json resolution 26 | json(), 27 | // Compile TypeScript files 28 | typescript({ useTsconfigDeclarationDir: true }), 29 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 30 | commonjs(), 31 | // Allow node_modules resolution, so you can use 'external' to control 32 | // which external modules to include in the bundle 33 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 34 | resolve(), 35 | 36 | // Resolve source maps to the original source 37 | sourceMaps() 38 | ] 39 | }; 40 | -------------------------------------------------------------------------------- /src/SignalGraphBuilder.ts: -------------------------------------------------------------------------------- 1 | import { SignalGraphDefinition, DerivableSignals, ObservableMap } from './signalGraphDefinition' 2 | import { SignalGraphDefinitionTransform } from './signalGraphDefinitionTransform' 3 | import { buildSignalGraph, BuildSignalGraphFn } from './signalGraph' 4 | 5 | type SignalTransforms = { 6 | [K in keyof T]: SignalGraphDefinitionTransform< 7 | S, 8 | Dep, 9 | T[K] extends [keyof S, keyof S] ? T[K] : never 10 | > 11 | } 12 | 13 | type FirstElements = { 14 | [K in keyof Exts]: Exts[K] extends [keyof S, keyof S] ? Exts[K][0] : never 15 | } 16 | type SecondElements = { 17 | [K in keyof Exts]: Exts[K] extends [keyof S, keyof S] ? Exts[K][1] : never 18 | } 19 | 20 | type ToupleUnion = L[number] 21 | export default class SignalGraphBuilder< 22 | S, 23 | Dep = {}, 24 | P extends keyof S = never, 25 | D extends keyof S = never 26 | > { 27 | constructor( 28 | private signalGraphDefinition: SignalGraphDefinition = { 29 | depedencies: {}, 30 | primaryKeys: [], 31 | derivableSignals: {} as DerivableSignals & Dep, D> 32 | }, 33 | private initialValues: Partial = {}, 34 | private buildSignalGraphFn: BuildSignalGraphFn = buildSignalGraph 35 | ) {} 36 | 37 | public define( 38 | ...transforms: SignalTransforms 39 | ): SignalGraphBuilder< 40 | S, 41 | Dep, 42 | P | ToupleUnion>, 43 | D | ToupleUnion> 44 | > { 45 | const newDefinition: SignalGraphDefinition< 46 | S, 47 | Dep, 48 | P | ToupleUnion>, 49 | D | ToupleUnion> 50 | > = transforms.reduce((definition, transform) => transform(definition), this 51 | .signalGraphDefinition as SignalGraphDefinition) 52 | return new SignalGraphBuilder(newDefinition, this.initialValues, this.buildSignalGraphFn) 53 | } 54 | 55 | public initializeWith(initialValues: Partial) { 56 | return new SignalGraphBuilder( 57 | this.signalGraphDefinition, 58 | initialValues, 59 | this.buildSignalGraphFn 60 | ) 61 | } 62 | 63 | public build() { 64 | return this.buildSignalGraphFn(this.signalGraphDefinition, this.initialValues) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/signal.ts: -------------------------------------------------------------------------------- 1 | export { SignalGraph, BuildSignalGraphFn } from './signalGraph' 2 | export { default as SignalGraphBuilder } from './SignalGraphBuilder' 3 | export { 4 | addDependency, 5 | addDerived, 6 | addPrimary, 7 | SignalGraphDefinitionTransform 8 | } from './signalGraphDefinitionTransform' 9 | export { Signals, SignalGraphDefinition } from './signalGraphDefinition' 10 | -------------------------------------------------------------------------------- /src/signalGraph.ts: -------------------------------------------------------------------------------- 1 | import toposort from './toposort' 2 | import { Observable, ReplaySubject, Subscription, Observer } from 'rxjs' 3 | import { 4 | SignalGraphDefinition, 5 | Signals, 6 | DerivableSignals, 7 | DependencyList, 8 | SubjectMap, 9 | ObservableMap 10 | } from './signalGraphDefinition' 11 | import { transformValues, assoc } from './util' 12 | import { shareReplay, startWith } from 'rxjs/operators' 13 | 14 | type MatchingKeys = { [P in keyof T]: T[P] extends V ? P : never }[keyof T] 15 | 16 | export interface SignalGraph { 17 | connect( 18 | key: K1, 19 | graph: SignalGraph, 20 | otherKey: MatchingKeys | MatchingKeys 21 | ): Subscription 22 | input(key: K1): SubjectMap[K1] 23 | 24 | output(key: K1): ObservableMap[K1] 25 | output(key: K2): ObservableMap[K2] 26 | output( 27 | key: K1 | K2 28 | ): ObservableMap[K1] | ObservableMap[K2] 29 | } 30 | 31 | type SignalDependencyMap = { [K in keyof S]: (keyof S)[] } 32 | 33 | const makeSignalDependencyMap = (derivableSignals: DerivableSignals) => 34 | transformValues, SignalDependencyMap>( 35 | signal => signal.dependencyList.filter((dep): dep is keyof S => dep !== undefined), 36 | derivableSignals 37 | ) 38 | 39 | const isPrimaryKey = ( 40 | primarySignals: ObservableMap>, 41 | key: any 42 | ): key is P => !!(key && (primarySignals as any)[key]) 43 | 44 | const isDependencyKey = (dependencies: Dep, key: any): key is keyof Dep => 45 | !!(key && (dependencies as any)[key]) 46 | 47 | const isDerivedKey = ( 48 | derivedKeys: DerivableSignals, 49 | key: any 50 | ): key is D => !!(key && (derivedKeys as any)[key]) 51 | 52 | const addDefaults = (source: SignalMaybeValue) => 53 | source.hasInitialValue 54 | ? source.signal.pipe( 55 | startWith(source.initialValue), 56 | shareReplay(1) 57 | ) 58 | : source.signal.pipe(shareReplay(1)) 59 | 60 | const makeInputs = ( 61 | signalGraphDefinition: SignalGraphDefinition 62 | ) => 63 | transformValues>>( 64 | _ => new ReplaySubject(1), 65 | signalGraphDefinition.primaryKeys 66 | ) 67 | 68 | type SignalWithValue = { 69 | hasInitialValue: true 70 | signal: Observable 71 | initialValue: T 72 | } 73 | 74 | type SignalWithoutValue = { 75 | hasInitialValue: false 76 | signal: Observable 77 | } 78 | 79 | type SignalMaybeValue = SignalWithValue | SignalWithoutValue 80 | type MappedWithValues = { [K in P]: SignalMaybeValue } 81 | 82 | const withInitialValues = ( 83 | signals: ObservableMap>, 84 | initialValues: Partial 85 | ): MappedWithValues => { 86 | return (Object.keys(signals) as P[]).reduce>( 87 | (acc, key) => 88 | assoc( 89 | key, 90 | initialValues.hasOwnProperty(key) 91 | ? { 92 | hasInitialValue: true, 93 | signal: signals[key], 94 | initialValue: initialValues[key] as S[P] 95 | } 96 | : { hasInitialValue: false, signal: signals[key] }, 97 | acc 98 | ), 99 | {} as MappedWithValues 100 | ) 101 | } 102 | 103 | const makePrimarySignals = ( 104 | inputs: SubjectMap>, 105 | initialValues: Partial 106 | ) => 107 | transformValues, ObservableMap>>( 108 | input => addDefaults(input), 109 | withInitialValues(inputs, initialValues) 110 | ) 111 | 112 | const makeSignalDependenciesFn = ( 113 | primarySignals: ObservableMap>, 114 | dependencies: Dep, 115 | derivedKeys: DerivableSignals, D> 116 | ) => ( 117 | derivedSignals: Partial>>, 118 | dependencyList: DependencyList> 119 | ) => 120 | dependencyList.map(dependencyName => { 121 | if (isPrimaryKey(primarySignals, dependencyName)) { 122 | return primarySignals[dependencyName] 123 | } 124 | if (isDependencyKey(dependencies, dependencyName)) { 125 | return dependencies[dependencyName] 126 | } 127 | if (isDerivedKey(derivedKeys, dependencyName) && derivedSignals[dependencyName]) { 128 | return derivedSignals[dependencyName] 129 | } 130 | if (dependencyName) { 131 | throw Error('Signal Dependency Not Found') 132 | } 133 | return undefined 134 | }) as Signals[keyof Signals][] 135 | 136 | type SignalDependenciesFn = ( 137 | derivedSignals: Partial>>, 138 | dependencyList: DependencyList> 139 | ) => (Signals[keyof Signals] | undefined)[] 140 | 141 | const makeDerivedSignals = ( 142 | derivableSignals: DerivableSignals, D>, 143 | initialValues: Partial, 144 | makeSignalDependencies: SignalDependenciesFn 145 | ) => { 146 | const signalDependencyMap = makeSignalDependencyMap(derivableSignals) 147 | const sortedSignals = toposort(signalDependencyMap) 148 | 149 | return sortedSignals 150 | .filter((signalName): signalName is D => isDerivedKey(derivableSignals, signalName)) 151 | .reduce( 152 | (acc, signalName) => { 153 | const derivableSignal = derivableSignals[signalName] 154 | const signalDependencies = makeSignalDependencies(acc, derivableSignal.dependencyList) 155 | const signal = derivableSignal.derivationFn(...signalDependencies) 156 | const signalMaybeValue: SignalMaybeValue = initialValues.hasOwnProperty(signalName) 157 | ? { hasInitialValue: true, signal, initialValue: initialValues[signalName] as S[D] } 158 | : { hasInitialValue: false, signal } 159 | return assoc(signalName, addDefaults(signalMaybeValue), acc) 160 | }, 161 | {} as ObservableMap> 162 | ) 163 | } 164 | 165 | export type BuildSignalGraphFn = ( 166 | signalGraphDefinition: SignalGraphDefinition, 167 | initialValues: Partial 168 | ) => SignalGraph, Pick> 169 | 170 | const signalGraph = < 171 | SignalsType, 172 | PrimarySignalsKeys extends keyof SignalsType, 173 | DerivedSignalsKeys extends keyof SignalsType 174 | >( 175 | inputs: SubjectMap>, 176 | primarySignals: ObservableMap>, 177 | derivedSignals: ObservableMap> 178 | ) => { 179 | function output( 180 | key: K1 181 | ): ObservableMap>[K1] 182 | function output( 183 | key: K2 184 | ): ObservableMap>[K2] 185 | function output(signal: K1 | K2) { 186 | return isPrimaryKey(primarySignals, signal) ? primarySignals[signal] : derivedSignals[signal] 187 | } 188 | return { 189 | output, 190 | connect: ( 191 | key: K1, 192 | graph: SignalGraph, 193 | otherKey: 194 | | MatchingKeys[K1], OP> 195 | | MatchingKeys[K1], OD> 196 | ): Subscription => { 197 | const input: Observer = inputs[key] 198 | return graph.output(otherKey as any).subscribe(input) 199 | }, 200 | input: (signal: K1) => inputs[signal] 201 | } 202 | } 203 | export const buildSignalGraph: BuildSignalGraphFn = < 204 | SignalsType, 205 | Dependencies, 206 | PrimarySignalsKeys extends keyof SignalsType, 207 | DerivedSignalsKeys extends keyof SignalsType 208 | >( 209 | signalGraphDefinition: SignalGraphDefinition< 210 | SignalsType, 211 | Dependencies, 212 | PrimarySignalsKeys, 213 | DerivedSignalsKeys 214 | >, 215 | initialValues: Partial 216 | ): SignalGraph, Pick> => { 217 | const inputs = makeInputs(signalGraphDefinition) 218 | const primarySignals = makePrimarySignals(inputs, initialValues) 219 | const dependencies = signalGraphDefinition.depedencies 220 | const makeSignalDependencies = makeSignalDependenciesFn( 221 | primarySignals, 222 | dependencies, 223 | signalGraphDefinition.derivableSignals 224 | ) 225 | const derivedSignals = makeDerivedSignals( 226 | signalGraphDefinition.derivableSignals, 227 | initialValues, 228 | makeSignalDependencies 229 | ) 230 | return signalGraph(inputs, primarySignals, derivedSignals) 231 | } 232 | -------------------------------------------------------------------------------- /src/signalGraphDefinition.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Subject } from 'rxjs' 2 | 3 | export type ObservableMap = { [P in keyof T]: Observable } 4 | export type SubjectMap = { [P in keyof T]: Subject } 5 | 6 | export type Signals = ObservableMap & Dependencies //#endregion 7 | 8 | export type DependencyList = [T?, T?, T?, T?, T?, T?, T?, T?, T?, T?] 9 | 10 | type Value = P extends keyof S ? S[P] : undefined 11 | 12 | export type SignalDerivation< 13 | S, 14 | P extends keyof S, 15 | T extends keyof S, 16 | K extends DependencyList 17 | > = ( 18 | ...args: [ 19 | Value, 20 | Value, 21 | Value, 22 | Value, 23 | Value, 24 | Value, 25 | Value, 26 | Value, 27 | Value, 28 | Value 29 | ] 30 | ) => S[P] 31 | 32 | export type InternalSignalDerivation = ( 33 | ...args: (S[keyof S] | undefined)[] 34 | ) => S[P] 35 | 36 | export type DerivableSignals = { 37 | [P in D]: { 38 | derivationFn: InternalSignalDerivation 39 | dependencyList: DependencyList 40 | } 41 | } 42 | 43 | export interface SignalGraphDefinition< 44 | SignalsType, 45 | Dependencies = {}, 46 | P extends keyof SignalsType = never, 47 | D extends keyof SignalsType = never 48 | > { 49 | depedencies: Partial 50 | primaryKeys: P[] 51 | derivableSignals: DerivableSignals & Dependencies, D> 52 | } 53 | -------------------------------------------------------------------------------- /src/signalGraphDefinitionTransform.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SignalGraphDefinition, 3 | Signals, 4 | DerivableSignals, 5 | DependencyList, 6 | SignalDerivation, 7 | InternalSignalDerivation 8 | } from './signalGraphDefinition' 9 | import { assoc } from './util' 10 | 11 | export type SignalGraphDefinitionTransform< 12 | SignalsType, 13 | Dependencies, 14 | EP extends [keyof SignalsType, keyof SignalsType] 15 | > =

( 16 | signalGraphDefinition: SignalGraphDefinition 17 | ) => SignalGraphDefinition 18 | 19 | /** 20 | * Injects a dependency into the Signal Graph that can be used by derived 21 | * signals 22 | * @param key The key of the dependency 23 | * @param dependency The dependency to be injected 24 | */ 25 | export const addDependency = < 26 | SignalsType, 27 | Dependencies, 28 | K extends Exclude 29 | >( 30 | key: K, 31 | dependency: Signals[K] 32 | ): SignalGraphDefinitionTransform => < 33 | P extends keyof SignalsType, 34 | D extends keyof SignalsType 35 | >( 36 | signalGraphDefinition: SignalGraphDefinition 37 | ) => ({ 38 | ...signalGraphDefinition, 39 | depedencies: assoc(key, dependency, signalGraphDefinition.depedencies) 40 | }) 41 | 42 | /** 43 | * Adds a primary signal into the Signal Graph, wich are the source streams 44 | * of the Signal Graph. 45 | * @param key The key for the primary signal 46 | */ 47 | export const addPrimary = ( 48 | key: K 49 | ): SignalGraphDefinitionTransform => < 50 | P extends keyof SignalsType, 51 | D extends keyof SignalsType 52 | >( 53 | signalGraphDefinition: SignalGraphDefinition 54 | ) => ({ 55 | ...signalGraphDefinition, 56 | primaryKeys: (signalGraphDefinition.primaryKeys as (P | K)[]).concat(key) 57 | }) 58 | 59 | /** 60 | * Adds a new derived signal into the Signal Graph. It uses other signals passed 61 | * by arguments to create a new derived signal. 62 | * @param key The key for the derivation 63 | * @param derivationFn The function that will create a derived stream from its 64 | * arguments 65 | * @param args The list of keys of other primary signals or derived streams 66 | * required for the derivation function 67 | */ 68 | export const addDerived = < 69 | SignalsType, 70 | Dependencies, 71 | K extends keyof SignalsType, 72 | DL extends DependencyList, K>> 73 | >( 74 | key: K, 75 | derivationFn: SignalDerivation< 76 | Signals, 77 | K, 78 | Exclude, K>, 79 | DL 80 | >, 81 | ...args: DL 82 | ): SignalGraphDefinitionTransform => < 83 | P extends keyof SignalsType, 84 | D extends keyof SignalsType 85 | >( 86 | signalGraphDefinition: SignalGraphDefinition 87 | ) => ({ 88 | ...signalGraphDefinition, 89 | derivableSignals: assoc, D | K>, K>( 90 | key, 91 | { 92 | derivationFn: derivationFn as InternalSignalDerivation, K>, 93 | dependencyList: args 94 | }, 95 | signalGraphDefinition.derivableSignals as DerivableSignals< 96 | Signals, 97 | K | D 98 | > 99 | ) 100 | }) 101 | -------------------------------------------------------------------------------- /src/toposort.ts: -------------------------------------------------------------------------------- 1 | type DepEdge = [T, T] 2 | 3 | type DependencyMap = { [k in T]?: T[] } 4 | 5 | const uniqueNodes = ( 6 | dependencyMap: DependencyMap 7 | ): Array => { 8 | const allSignals = Object.entries(dependencyMap).reduce>( 9 | (acc, [key, dependencies]) => acc.concat([key as T]).concat(dependencies || []), 10 | [] 11 | ) 12 | return Array.from(new Set(allSignals)) 13 | } 14 | 15 | const makeEdges = (dependencyMap: DependencyMap) => 16 | Object.entries(dependencyMap).reduce>>( 17 | (acc, [key, dependencies]) => 18 | acc.concat((dependencies || []).map((val): DepEdge => [val, key as T])), 19 | [] 20 | ) 21 | 22 | type IncomingCounts = { [k in T]: number } 23 | 24 | const makeIncomingCounts = ( 25 | edges: Array> 26 | ): IncomingCounts => 27 | edges.reduce>( 28 | (acc, edge) => 29 | Object.assign({}, acc, { [edge[0]]: acc[edge[0]] || 0, [edge[1]]: (acc[edge[1]] || 0) + 1 }), 30 | {} as IncomingCounts 31 | ) 32 | 33 | type OutgoingEdges = { [k in T]: Array } 34 | 35 | const makeOutgoingEdges = ( 36 | edges: Array> 37 | ): OutgoingEdges => 38 | edges.reduce>( 39 | (acc, edge) => 40 | Object.assign({}, acc, { 41 | [edge[0]]: [...(acc[edge[0]] || []), edge[1]], 42 | [edge[1]]: acc[edge[1]] || [] 43 | }), 44 | {} as OutgoingEdges 45 | ) 46 | 47 | interface ZeroDepNodesResult { 48 | zeroDepNodes: Array 49 | remainingNodeCounts: IncomingCounts 50 | } 51 | 52 | const removeZeroDepNodes = ( 53 | incomingCounts: IncomingCounts 54 | ): ZeroDepNodesResult => 55 | Object.entries(incomingCounts).reduce>( 56 | ({ zeroDepNodes, remainingNodeCounts }, [key, incomingCount]) => 57 | incomingCount === 0 58 | ? { zeroDepNodes: [...zeroDepNodes, key as T], remainingNodeCounts } 59 | : { 60 | zeroDepNodes, 61 | remainingNodeCounts: Object.assign({}, remainingNodeCounts, { 62 | [key]: incomingCount 63 | }) 64 | }, 65 | { zeroDepNodes: [], remainingNodeCounts: {} as IncomingCounts } 66 | ) 67 | 68 | const decrementIncomingCounts = ( 69 | incomingCounts: IncomingCounts, 70 | nodesToDecrement: Array 71 | ) => 72 | nodesToDecrement.reduce>( 73 | (acc, node) => Object.assign({}, acc, { [node]: acc[node] - 1 }), 74 | incomingCounts 75 | ) 76 | 77 | interface GraphTraverseState { 78 | alreadyTraversed: Array 79 | toTraverse: Array 80 | incomingCounts: IncomingCounts 81 | outgoingEdges: OutgoingEdges 82 | } 83 | 84 | const traverseGraph = ( 85 | graphTraverseState: GraphTraverseState 86 | ): Array => { 87 | if (graphTraverseState.toTraverse.length === 0) { 88 | return graphTraverseState.alreadyTraversed 89 | } else { 90 | const currentNode = graphTraverseState.toTraverse[0] 91 | const incomingCounts = decrementIncomingCounts( 92 | graphTraverseState.incomingCounts, 93 | graphTraverseState.outgoingEdges[currentNode] 94 | ) 95 | const { zeroDepNodes, remainingNodeCounts } = removeZeroDepNodes(incomingCounts) 96 | return traverseGraph({ 97 | alreadyTraversed: [...graphTraverseState.alreadyTraversed, currentNode], 98 | toTraverse: [...graphTraverseState.toTraverse.slice(1), ...zeroDepNodes], 99 | incomingCounts: remainingNodeCounts, 100 | outgoingEdges: graphTraverseState.outgoingEdges 101 | }) 102 | } 103 | } 104 | 105 | const toposort = (dependencyMap: DependencyMap) => { 106 | const nodes = uniqueNodes(dependencyMap) 107 | const edges = makeEdges(dependencyMap) 108 | const incomingCounts = makeIncomingCounts(edges) 109 | const outgoingEdges = makeOutgoingEdges(edges) 110 | const { zeroDepNodes, remainingNodeCounts } = removeZeroDepNodes(incomingCounts) 111 | const toposortedSignals = traverseGraph({ 112 | incomingCounts: remainingNodeCounts, 113 | outgoingEdges, 114 | toTraverse: zeroDepNodes, 115 | alreadyTraversed: [] 116 | }) 117 | if (toposortedSignals.length !== nodes.length) { 118 | throw Error('Circular Dependency Or Incomplete graph') 119 | } 120 | return toposortedSignals 121 | } 122 | 123 | export default toposort 124 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export const assoc = (k: P, v: S[P], existing: S): S => { 2 | return Object.assign({}, existing, { [k]: v }) 3 | } 4 | 5 | export function transformValues(transform: (x: keyof T) => T[keyof T], list: (keyof T)[]): T 6 | export function transformValues( 7 | transform: (x: T[keyof T & keyof U]) => U[keyof T & keyof U], 8 | list: T 9 | ): U 10 | export function transformValues( 11 | transform: (x: T[keyof T & keyof U]) => U[keyof T & keyof U], 12 | list: T 13 | ): U { 14 | return Array.isArray(list) 15 | ? list.reduce((acc, cur) => assoc(cur, transform(cur), acc), {} as U) 16 | : ((Object.keys(list) as (keyof T & keyof U)[]).reduce>( 17 | (acc, cur) => assoc(cur, transform(list[cur]), acc), 18 | {} 19 | ) as U) 20 | } 21 | -------------------------------------------------------------------------------- /test/SignalGraphBuilder.test.ts: -------------------------------------------------------------------------------- 1 | import { SignalGraphDefinition } from '../src/signalGraphDefinition' 2 | import { Observable, of, combineLatest } from 'rxjs' 3 | import { map } from 'rxjs/operators' 4 | import SignalGraphBuilder from '../src/SignalGraphBuilder' 5 | import { addPrimary, addDependency, addDerived } from '../src/signalGraphDefinitionTransform' 6 | import { SignalGraph } from '../src/SignalGraph' 7 | describe('SignalGraphBuilder', () => { 8 | type SignalsType = { 9 | x: string 10 | y: string 11 | z: string 12 | } 13 | 14 | type Dependencies = { 15 | dep: Observable 16 | } 17 | 18 | const startingDefinition: SignalGraphDefinition = { 19 | primaryKeys: [], 20 | depedencies: {}, 21 | derivableSignals: {} 22 | } 23 | const startingInitialValues = {} 24 | const buildSignalGraph = jest.fn().mockImplementation((...args) => args) 25 | 26 | describe('define', () => { 27 | const dep = of('sauce') 28 | const zDerivation = map((yVal: string) => 'Hello ' + yVal) 29 | const yDerivation = (x: Observable, dep: Observable) => 30 | combineLatest(x, dep).pipe(map(([xVal, depVal]) => xVal + ' ' + depVal)) 31 | const expectedSignalGraphDefinition = { 32 | primaryKeys: ['x'], 33 | depedencies: { 34 | dep 35 | }, 36 | derivableSignals: { 37 | z: { 38 | derivationFn: zDerivation, 39 | dependencyList: ['y'] 40 | }, 41 | y: { 42 | derivationFn: yDerivation, 43 | dependencyList: ['x', 'dep'] 44 | } 45 | } 46 | } 47 | it('applies all definition transforms', () => { 48 | const signalGraphBuilder = new SignalGraphBuilder( 49 | startingDefinition, 50 | startingInitialValues, 51 | buildSignalGraph 52 | ) 53 | // this is to verify that seperating primary and derived signal types is working 54 | const signalGraphTypeTest: SignalGraph< 55 | Pick, 56 | Pick 57 | > = signalGraphBuilder 58 | .define( 59 | addPrimary('x'), 60 | addDependency('dep', dep), 61 | addDerived('z', zDerivation, 'y'), 62 | addDerived('y', yDerivation, 'x', 'dep') 63 | ) 64 | .build() 65 | const signalGraph: any = signalGraphTypeTest 66 | expect(signalGraph[0]).toEqual(expectedSignalGraphDefinition) 67 | }) 68 | }) 69 | describe('initializeWith', () => { 70 | const expectedInitialValues = { 71 | x: 'abcd', 72 | z: 'defg' 73 | } 74 | it('passes initial values to the signal graph builder', () => { 75 | const signalGraphBuilder = new SignalGraphBuilder( 76 | startingDefinition, 77 | startingInitialValues, 78 | buildSignalGraph 79 | ) 80 | const signalGraph: any = signalGraphBuilder 81 | .initializeWith({ 82 | x: 'abcd', 83 | z: 'defg' 84 | }) 85 | .build() 86 | expect(signalGraph[1]).toEqual(expectedInitialValues) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/fixtures.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from 'rxjs' 2 | 3 | export const fakeUsers: User[] = [ 4 | { 5 | id: 1, 6 | name: 'Leanne Graham', 7 | username: 'Bret', 8 | email: 'Sincere@april.biz', 9 | address: { 10 | street: 'Kulas Light', 11 | suite: 'Apt. 556', 12 | city: 'Gwenborough', 13 | zipcode: '92998-3874', 14 | geo: { 15 | lat: '-37.3159', 16 | lng: '81.1496' 17 | } 18 | }, 19 | phone: '1-770-736-8031 x56442', 20 | website: 'hildegard.org', 21 | company: { 22 | name: 'Romaguera-Crona', 23 | catchPhrase: 'Multi-layered client-server neural-net', 24 | bs: 'harness real-time e-markets' 25 | } 26 | }, 27 | { 28 | id: 2, 29 | name: 'Ervin Howell', 30 | username: 'Antonette', 31 | email: 'Shanna@melissa.tv', 32 | address: { 33 | street: 'Victor Plains', 34 | suite: 'Suite 879', 35 | city: 'Wisokyburgh', 36 | zipcode: '90566-7771', 37 | geo: { 38 | lat: '-43.9509', 39 | lng: '-34.4618' 40 | } 41 | }, 42 | phone: '010-692-6593 x09125', 43 | website: 'anastasia.net', 44 | company: { 45 | name: 'Deckow-Crist', 46 | catchPhrase: 'Proactive didactic contingency', 47 | bs: 'synergize scalable supply-chains' 48 | } 49 | } 50 | ] 51 | 52 | export const makeFakePosts = (userId: Id): Post[] => [ 53 | { 54 | userId, 55 | id: 1, 56 | title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit', 57 | body: 58 | 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto' 59 | }, 60 | { 61 | userId, 62 | id: 2, 63 | title: 'qui est esse', 64 | body: 65 | 'est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla' 66 | } 67 | ] 68 | 69 | export const makeFakeComments = (postId: Id) => [ 70 | { 71 | postId, 72 | id: 1, 73 | name: 'id labore ex et quam laborum', 74 | email: 'Eliseo@gardner.biz', 75 | body: 76 | 'laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium' 77 | }, 78 | { 79 | postId, 80 | id: 2, 81 | name: 'quo vero reiciendis velit similique earum', 82 | email: 'Jayne_Kuhic@sydney.com', 83 | body: 84 | 'est natus enim nihil est dolore omnis voluptatem numquam\net omnis occaecati quod ullam at\nvoluptatem error expedita pariatur\nnihil sint nostrum voluptatem reiciendis et' 85 | } 86 | ] 87 | 88 | export interface API { 89 | getUsers(): Observable 90 | getPostsForUser(userId: Id): Observable 91 | getCommentsForPost(postId: Id): Observable 92 | } 93 | 94 | export const stubApi: API = { 95 | getUsers(): Observable { 96 | return of(fakeUsers) 97 | }, 98 | getPostsForUser(userId: Id): Observable { 99 | return of(makeFakePosts(userId)) 100 | }, 101 | getCommentsForPost(postId: Id): Observable { 102 | return of(makeFakeComments(postId)) 103 | } 104 | } 105 | 106 | export type Id = number 107 | export type Email = string 108 | export type Name = string 109 | export type Title = string 110 | 111 | export interface Geo { 112 | lat: string 113 | lng: string 114 | } 115 | 116 | export interface Address { 117 | street: string 118 | suite: string 119 | city: string 120 | zipcode: string 121 | geo: Geo 122 | } 123 | 124 | export interface Company { 125 | name: Name 126 | catchPhrase: string 127 | bs: string 128 | } 129 | 130 | export interface User { 131 | id: Id 132 | name: Name 133 | username: string 134 | email: Email 135 | address: Address 136 | phone: string 137 | website: string 138 | company: Company 139 | } 140 | 141 | export interface Post { 142 | userId: Id 143 | id: Id 144 | title: Title 145 | body: string 146 | } 147 | 148 | export interface PostComment { 149 | postId: Id 150 | id: Id 151 | name: Name 152 | email: Email 153 | body: string 154 | } 155 | -------------------------------------------------------------------------------- /test/signal.test.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest, zip } from 'rxjs' 2 | import { map, flatMap } from 'rxjs/operators' 3 | import { SignalGraphBuilder, addPrimary, addDependency, addDerived } from '../src/signal' 4 | import { 5 | User, 6 | Id, 7 | Post, 8 | API, 9 | stubApi, 10 | fakeUsers, 11 | makeFakePosts, 12 | makeFakeComments, 13 | PostComment 14 | } from './fixtures' 15 | 16 | const signalGraph = new SignalGraphBuilder< 17 | { 18 | activeUsers$: User[] 19 | selectUser$: Id 20 | selectedUser$: User | undefined 21 | activePosts$: Post[] 22 | activePostsWithComments$: Array<[Post, PostComment[]]> 23 | }, 24 | { 25 | api: API 26 | } 27 | >() 28 | .define( 29 | addPrimary('selectUser$'), 30 | addDependency('api', stubApi), 31 | addDerived('activeUsers$', api => api.getUsers(), 'api'), 32 | addDerived( 33 | 'selectedUser$', 34 | (activeUsers$, selectUser$) => 35 | combineLatest(activeUsers$, selectUser$).pipe( 36 | map(([users, selection]) => users.find(user => user.id === selection)) 37 | ), 38 | 'activeUsers$', 39 | 'selectUser$' 40 | ), 41 | addDerived( 42 | 'activePosts$', 43 | (api, selectUser$) => selectUser$.pipe(flatMap(id => api.getPostsForUser(id))), 44 | 'api', 45 | 'selectUser$' 46 | ), 47 | addDerived( 48 | 'activePostsWithComments$', 49 | (api, activePosts$) => 50 | activePosts$.pipe( 51 | flatMap(posts => { 52 | const postsWithComments = posts.map(post => { 53 | return api 54 | .getCommentsForPost(post.id) 55 | .pipe(map((comments): [Post, PostComment[]] => [post, comments])) 56 | }) 57 | return zip(...postsWithComments) 58 | }) 59 | ), 60 | 'api', 61 | 'activePosts$' 62 | ) 63 | ) 64 | .build() 65 | 66 | describe('a full signal graph', () => { 67 | describe('when given inputs', () => { 68 | signalGraph.input('selectUser$').next(1) 69 | it('produces the correct outputs', async () => { 70 | await new Promise(resolve => { 71 | signalGraph.output('activeUsers$').subscribe(activeUsers => { 72 | expect(activeUsers).toHaveLength(2) 73 | expect(activeUsers).toEqual(fakeUsers) 74 | resolve(true) 75 | }) 76 | }) 77 | await new Promise(resolve => { 78 | signalGraph.output('selectedUser$').subscribe(selectedUser => { 79 | expect(selectedUser).toEqual(fakeUsers[0]) 80 | resolve(true) 81 | }) 82 | }) 83 | 84 | await new Promise(resolve => { 85 | signalGraph.output('activePosts$').subscribe(activePosts => { 86 | expect(activePosts).toEqual(makeFakePosts(1)) 87 | resolve(true) 88 | }) 89 | }) 90 | 91 | await new Promise(resolve => { 92 | signalGraph.output('activePostsWithComments$').subscribe(activePostsWithComments => { 93 | expect(activePostsWithComments[0][0]).toEqual(makeFakePosts(1)[0]) 94 | expect(activePostsWithComments[0][1]).toEqual(makeFakeComments(1)) 95 | resolve(true) 96 | }) 97 | }) 98 | }) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /test/signalGraph.test.ts: -------------------------------------------------------------------------------- 1 | import { buildSignalGraph } from '../src/signalGraph' 2 | import { Observable, combineLatest, Subject, of } from 'rxjs' 3 | import { SignalGraphDefinition } from '../src/signalGraphDefinition' 4 | import { map, take, toArray } from 'rxjs/operators' 5 | 6 | type SignalsType = { 7 | x: string 8 | y: string 9 | z: string 10 | } 11 | 12 | type Dependencies = { 13 | dep: Observable 14 | } 15 | 16 | type PrimarySignalsKeys = 'x' 17 | type DerivedSignalsKeys = 'y' | 'z' 18 | 19 | describe('SignalGraph', () => { 20 | describe('given a complete graph', () => { 21 | const signalGraphDefinition: SignalGraphDefinition< 22 | SignalsType, 23 | Dependencies, 24 | PrimarySignalsKeys, 25 | DerivedSignalsKeys 26 | > = { 27 | primaryKeys: ['x'], 28 | depedencies: { 29 | dep: of('sauce') 30 | }, 31 | derivableSignals: { 32 | z: { 33 | derivationFn: y => (y ? y.pipe(map(yVal => 'Hello ' + yVal)) : new Observable()), 34 | dependencyList: ['y'] 35 | }, 36 | y: { 37 | derivationFn: (x, dep) => 38 | x && dep 39 | ? combineLatest(x, dep).pipe(map(([xVal, depVal]) => xVal + ' ' + depVal)) 40 | : new Observable(), 41 | dependencyList: ['x', 'dep'] 42 | } 43 | } 44 | } 45 | 46 | it('has primary signals', () => { 47 | const signalGraph = buildSignalGraph(signalGraphDefinition, {}) 48 | expect(signalGraph.output('x')).toBeInstanceOf(Observable) 49 | }) 50 | it('has derived signals', () => { 51 | const signalGraph = buildSignalGraph(signalGraphDefinition, {}) 52 | expect(signalGraph.output('y')).toBeInstanceOf(Observable) 53 | expect(signalGraph.output('z')).toBeInstanceOf(Observable) 54 | }) 55 | it('has derived signals that behave correctly', async () => { 56 | const signalGraph = buildSignalGraph(signalGraphDefinition, {}) 57 | await new Promise(resolve => { 58 | signalGraph.output('z').subscribe(result => { 59 | expect(result).toEqual('Hello apple sauce') 60 | resolve(true) 61 | }) 62 | signalGraph.input('x').next('apple') 63 | }) 64 | }) 65 | it("inputs hold their last input until they're subscribed to", async () => { 66 | const signalGraph = buildSignalGraph(signalGraphDefinition, {}) 67 | await new Promise(resolve => { 68 | signalGraph.input('x').next('apple') 69 | signalGraph.input('x').next('cheese') 70 | signalGraph.output('z').subscribe(result => { 71 | expect(result).toEqual('Hello cheese sauce') 72 | resolve(true) 73 | }) 74 | }) 75 | }) 76 | it('when subscriptions go to zero, only the last input value is passed when subscriptions happen again', async () => { 77 | const signalGraph = buildSignalGraph(signalGraphDefinition, {}) 78 | await new Promise(resolve => { 79 | signalGraph.input('x').next('apple') 80 | signalGraph.input('x').next('cheese') 81 | signalGraph 82 | .output('z') 83 | .pipe(take(1)) 84 | .subscribe(_ => null, _ => null, () => resolve(true)) 85 | }) 86 | await new Promise(resolve => { 87 | signalGraph.input('x').next('oranges') 88 | signalGraph.input('x').next('pepper') 89 | signalGraph.output('z').subscribe(result => { 90 | expect(result).toEqual('Hello pepper sauce') 91 | resolve(true) 92 | }) 93 | }) 94 | }) 95 | it('can initialize observables with initial values for primary signals', async () => { 96 | const signalGraph = buildSignalGraph(signalGraphDefinition, { 97 | x: 'orange' 98 | }) 99 | await new Promise(resolve => { 100 | signalGraph 101 | .output('z') 102 | .pipe( 103 | take(2), 104 | toArray() 105 | ) 106 | .subscribe(result => { 107 | expect(result).toEqual(['Hello orange sauce', 'Hello apple sauce']) 108 | resolve(true) 109 | }) 110 | signalGraph.input('x').next('apple') 111 | }) 112 | }) 113 | it('can initialize observables with initial values for derived signals', async () => { 114 | const signalGraph = buildSignalGraph(signalGraphDefinition, { 115 | y: 'cheesy tots' 116 | }) 117 | await new Promise(resolve => { 118 | signalGraph 119 | .output('z') 120 | .pipe( 121 | take(2), 122 | toArray() 123 | ) 124 | .subscribe(result => { 125 | expect(result).toEqual(['Hello cheesy tots', 'Hello apple sauce']) 126 | resolve(true) 127 | }) 128 | signalGraph.input('x').next('apple') 129 | }) 130 | }) 131 | it('can be connected to other graphs', async () => { 132 | const signalGraph = buildSignalGraph(signalGraphDefinition, {}) 133 | const otherDefinition: SignalGraphDefinition<{ o: string; p: string }, {}, 'o', 'p'> = { 134 | primaryKeys: ['o'], 135 | depedencies: {}, 136 | derivableSignals: { 137 | p: { 138 | derivationFn: o$ => (o$ ? o$.pipe(map(o => 'hot ' + o)) : new Observable()), 139 | dependencyList: ['o'] 140 | } 141 | } 142 | } 143 | const otherGraph = buildSignalGraph(otherDefinition, {}) 144 | signalGraph.connect( 145 | 'x', 146 | otherGraph, 147 | 'p' 148 | ) 149 | await new Promise(resolve => { 150 | signalGraph.output('z').subscribe(result => { 151 | expect(result).toEqual('Hello hot wonton sauce') 152 | resolve(true) 153 | }) 154 | otherGraph.input('o').next('wonton') 155 | }) 156 | }) 157 | }) 158 | describe('given a incorrectly defined graph', () => { 159 | const signalGraphDefinition: SignalGraphDefinition = { 160 | primaryKeys: [] as never[], 161 | depedencies: { 162 | dep: of('sauce') 163 | }, 164 | derivableSignals: { 165 | y: { 166 | derivationFn: (x, dep) => 167 | x && dep 168 | ? combineLatest(x, dep).pipe(map(([xVal, depVal]) => xVal + ' ' + depVal)) 169 | : new Observable(), 170 | dependencyList: ['x', 'dep'] 171 | } 172 | } 173 | } 174 | 175 | it('throws an exception when built', () => { 176 | expect(() => buildSignalGraph(signalGraphDefinition, {})).toThrowError( 177 | 'Signal Dependency Not Found' 178 | ) 179 | }) 180 | }) 181 | 182 | describe('given a graph with undefines in signal derivations', () => { 183 | const signalGraphDefinition: SignalGraphDefinition = { 184 | primaryKeys: [] as never[], 185 | depedencies: { 186 | dep: of('sauce') 187 | }, 188 | derivableSignals: { 189 | y: { 190 | derivationFn: (_, dep) => 191 | dep ? dep.pipe(map(depVal => 'Hello ' + depVal)) : new Observable(), 192 | dependencyList: [undefined, 'dep'] 193 | } 194 | } 195 | } 196 | 197 | const signalGraph = buildSignalGraph(signalGraphDefinition, {}) 198 | 199 | it('has signals', () => { 200 | expect(signalGraph.output('y')).toBeInstanceOf(Observable) 201 | }) 202 | it('has derived signals that behave correctly', async () => { 203 | await new Promise(resolve => { 204 | signalGraph.output('y').subscribe(result => { 205 | expect(result).toEqual('Hello sauce') 206 | resolve(true) 207 | }) 208 | }) 209 | }) 210 | }) 211 | 212 | describe('given an incomplete but well defined graph', () => { 213 | const signalGraphDefinition: SignalGraphDefinition = { 214 | primaryKeys: [] as never[], 215 | depedencies: { 216 | dep: of('sauce') 217 | }, 218 | derivableSignals: { 219 | y: { 220 | derivationFn: dep => 221 | dep ? dep.pipe(map(depVal => 'Hello ' + depVal)) : new Observable(), 222 | dependencyList: ['dep'] 223 | } 224 | } 225 | } 226 | 227 | const signalGraph = buildSignalGraph(signalGraphDefinition, {}) 228 | 229 | it('has signals', () => { 230 | expect(signalGraph.output('y')).toBeInstanceOf(Observable) 231 | }) 232 | it('has derived signals that behave correctly', async () => { 233 | await new Promise(resolve => { 234 | signalGraph.output('y').subscribe(result => { 235 | expect(result).toEqual('Hello sauce') 236 | resolve(true) 237 | }) 238 | }) 239 | }) 240 | }) 241 | }) 242 | -------------------------------------------------------------------------------- /test/toposort.test.ts: -------------------------------------------------------------------------------- 1 | import toposort from '../src/toposort' 2 | 3 | describe('toposort', () => { 4 | const depedencies = { 5 | ['lemon juice']: ['lemon'], 6 | filling: ['lemon juice', 'butter', 'apples', 'sugar', 'cinnamon', 'egg'], 7 | applePie: ['crust', 'filling'], 8 | crust: ['sugar', 'flour', 'salt', 'butter', 'egg'] 9 | } 10 | 11 | it('sorts depedencies in topological order', () => { 12 | const sortedDepedencies = toposort(depedencies) 13 | ;[ 14 | 'lemon', 15 | 'lemon juice', 16 | 'butter', 17 | 'apples', 18 | 'sugar', 19 | 'cinnamon', 20 | 'egg', 21 | 'flour', 22 | 'salt' 23 | ].forEach(item => { 24 | expect(sortedDepedencies.indexOf(item)).toBeLessThan(sortedDepedencies.indexOf('filling')) 25 | expect(sortedDepedencies.indexOf(item)).toBeLessThan(sortedDepedencies.indexOf('crust')) 26 | expect(sortedDepedencies.indexOf(item)).toBeLessThan(sortedDepedencies.indexOf('applePie')) 27 | }) 28 | expect(sortedDepedencies.indexOf('lemon')).toBeLessThan( 29 | sortedDepedencies.indexOf('lemon juice') 30 | ) 31 | expect(sortedDepedencies.indexOf('filling')).toBeLessThan(sortedDepedencies.indexOf('applePie')) 32 | expect(sortedDepedencies.indexOf('crust')).toBeLessThan(sortedDepedencies.indexOf('applePie')) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": [".", "../src"] 4 | } 5 | -------------------------------------------------------------------------------- /tools/gh-pages-publish.ts: -------------------------------------------------------------------------------- 1 | const { cd, exec, echo, touch } = require("shelljs") 2 | const { readFileSync } = require("fs") 3 | const url = require("url") 4 | 5 | let repoUrl 6 | let pkg = JSON.parse(readFileSync("package.json") as any) 7 | if (typeof pkg.repository === "object") { 8 | if (!pkg.repository.hasOwnProperty("url")) { 9 | throw new Error("URL does not exist in repository section") 10 | } 11 | repoUrl = pkg.repository.url 12 | } else { 13 | repoUrl = pkg.repository 14 | } 15 | 16 | let parsedUrl = url.parse(repoUrl) 17 | let repository = (parsedUrl.host || "") + (parsedUrl.path || "") 18 | let ghToken = process.env.GH_TOKEN 19 | 20 | echo("Deploying docs!!!") 21 | cd("docs") 22 | touch(".nojekyll") 23 | exec("git init") 24 | exec("git add .") 25 | exec('git config user.name "hannahhoward"') 26 | exec('git config user.email "hannah@hannahhoward.net"') 27 | exec('git commit -m "docs(docs): update gh-pages"') 28 | exec( 29 | `git push --force --quiet "https://${ghToken}@${repository}" master:gh-pages` 30 | ) 31 | echo("Docs deployed!!") 32 | -------------------------------------------------------------------------------- /tools/semantic-release-prepare.ts: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const { fork } = require("child_process") 3 | const colors = require("colors") 4 | 5 | const { readFileSync, writeFileSync } = require("fs") 6 | const pkg = JSON.parse( 7 | readFileSync(path.resolve(__dirname, "..", "package.json")) 8 | ) 9 | 10 | pkg.scripts.prepush = "npm run test:prod && npm run build" 11 | pkg.scripts.commitmsg = "validate-commit-msg" 12 | 13 | writeFileSync( 14 | path.resolve(__dirname, "..", "package.json"), 15 | JSON.stringify(pkg, null, 2) 16 | ) 17 | 18 | // Call husky to set up the hooks 19 | fork(path.resolve(__dirname, "..", "node_modules", "husky", "bin", "install")) 20 | 21 | console.log() 22 | console.log(colors.green("Done!!")) 23 | console.log() 24 | 25 | if (pkg.repository.url.trim()) { 26 | console.log(colors.cyan("Now run:")) 27 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 28 | console.log(colors.cyan(" semantic-release-cli setup")) 29 | console.log() 30 | console.log( 31 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 32 | ) 33 | console.log() 34 | console.log( 35 | colors.gray( 36 | 'Note: Make sure "repository.url" in your package.json is correct before' 37 | ) 38 | ) 39 | } else { 40 | console.log( 41 | colors.red( 42 | 'First you need to set the "repository.url" property in package.json' 43 | ) 44 | ) 45 | console.log(colors.cyan("Then run:")) 46 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 47 | console.log(colors.cyan(" semantic-release-cli setup")) 48 | console.log() 49 | console.log( 50 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 51 | ) 52 | } 53 | 54 | console.log() 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module":"es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "declarationDir": "dist/types", 14 | "outDir": "dist/lib", 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ] 18 | }, 19 | "include": [ 20 | "src" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-config-prettier" 5 | ] 6 | } --------------------------------------------------------------------------------