├── .eslintrc.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md └── workflows │ ├── cicd.yaml │ ├── docs.yaml │ └── release.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── examples └── async │ ├── app.ts │ ├── package-lock.json │ └── package.json ├── jest.config.js ├── lib ├── core │ ├── encoder.ts │ ├── encoders │ │ ├── base64.ts │ │ └── json.ts │ ├── errors.ts │ ├── logger.ts │ ├── loggers │ │ └── logger.ts │ ├── options.ts │ ├── promises │ │ ├── promises.ts │ │ └── types.ts │ ├── retry.ts │ ├── schedules │ │ ├── schedules.ts │ │ └── types.ts │ ├── storage.ts │ ├── storages │ │ ├── memory.ts │ │ └── withTimeout.ts │ ├── store.ts │ ├── stores │ │ ├── local.ts │ │ └── remote.ts │ └── utils.ts ├── index.ts └── resonate.ts ├── package-lock.json ├── package.json ├── test ├── async.test.ts ├── auth.test.ts ├── combinators.test.ts ├── detached.test.ts ├── durable.test.ts ├── errorHandling.test.ts ├── options.test.ts ├── promiseTransitions.test.ts ├── promises.test.ts ├── resonate.test.ts ├── schedules.test.ts ├── sleep.test.ts ├── userResources.test.ts ├── utils.test.ts └── versions.test.ts ├── tsconfig.build.json ├── tsconfig.json └── typedoc.json /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - "eslint:recommended" 3 | - "plugin:@typescript-eslint/recommended" 4 | - "plugin:import/recommended" 5 | - "plugin:import/typescript" 6 | 7 | parser: "@typescript-eslint/parser" 8 | 9 | plugins: 10 | - "@typescript-eslint" 11 | 12 | env: 13 | node: true 14 | 15 | rules: 16 | "import/order": 17 | - error 18 | - alphabetize: { order: "asc" } 19 | 20 | "@typescript-eslint/no-explicit-any": 21 | - off 22 | 23 | "@typescript-eslint/no-unused-vars": 24 | - error 25 | - vars: all 26 | args: none 27 | 28 | require-yield: off 29 | 30 | parserOptions: 31 | project: "./tsconfig.json" 32 | 33 | ignorePatterns: 34 | - "/*.ts" # ignore root level .ts files 35 | - "/*.js" # ignore root level .js files 36 | - dist/ 37 | - docs/ 38 | - examples/ 39 | - coverage/ 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug or unexpected behavior with resonate-sdk-ts 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | ## Expected Behavior 10 | 11 | ## Actual Behavior 12 | 13 | ## To Reproduce 14 | 15 | 1. 16 | 2. 17 | 3. 18 | 19 | ## Specifications 20 | 21 | - Version: 22 | - Platform: 23 | 24 | ## Additional context 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: [] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for resonate-sdk-ts 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | ## Describe the problem you are facing 10 | 11 | ## Describe the solution you'd like 12 | 13 | ## Alternatives you've considered 14 | 15 | ## Additional context 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Summary 4 | 5 | 6 | 7 | ## Changes 8 | 9 | 10 | 11 | ## Related Issues 12 | 13 | 14 | 15 | ## Testing 16 | 17 | 18 | 19 | ## Screenshots (if applicable) 20 | 21 | 22 | 23 | ## Checklist 24 | 25 | 26 | 27 | - [ ] I have tested my changes thoroughly. 28 | - [ ] My code follows the project's coding standards. 29 | - [ ] I have updated the documentation (if applicable). 30 | - [ ] I have added relevant comments to the code. 31 | - [ ] I have resolved any merge conflicts. 32 | 33 | ## Reviewers 34 | 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yaml: -------------------------------------------------------------------------------- 1 | name: CICD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Set up Node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: "20" 23 | registry-url: "https://registry.npmjs.org" 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: 1.21 29 | 30 | - name: Checkout resonate-sdk-ts repository 31 | uses: actions/checkout@v4 32 | 33 | - name: Install dependencies 34 | run: npm install 35 | 36 | - name: Run linter 37 | run: npm run lint 38 | 39 | - name: Run prettier 40 | run: npm run prettier 41 | 42 | - name: Checkout resonate repository 43 | uses: actions/checkout@v4 44 | with: 45 | repository: resonatehq/resonate 46 | path: resonate 47 | 48 | - name: Build resonate 49 | run: go build -o resonate 50 | working-directory: resonate 51 | 52 | - name: Start resonate server 53 | run: ./resonate serve & 54 | working-directory: resonate 55 | 56 | - name: Run tests 57 | env: 58 | RESONATE_STORE_URL: http://localhost:8001 59 | run: npm test -- --verbose 60 | 61 | - name: Upload coverage report to Codecov 62 | uses: codecov/codecov-action@v4 63 | with: 64 | token: ${{ secrets.CODECOV_TOKEN }} 65 | 66 | build: 67 | needs: test 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Set up Node 71 | uses: actions/setup-node@v4 72 | with: 73 | node-version: "20" 74 | registry-url: "https://registry.npmjs.org" 75 | 76 | - name: Checkout resonate-sdk-ts repository 77 | uses: actions/checkout@v4 78 | 79 | - name: Install dependencies 80 | run: npm install 81 | 82 | - name: Build 83 | run: npm run build 84 | 85 | - name: Publish 86 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} 87 | env: 88 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 89 | run: | 90 | # Use 0.0.0 so as to not conflict with a newer version 91 | # Use the commit sha as a unique identifier 92 | npm version 0.0.0-SNAPSHOT-$GITHUB_SHA --git-tag-version false 93 | 94 | # Publish to the main tag 95 | npm publish --provenance --access public --tag main 96 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | id-token: write 11 | pages: write 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | - name: Set up Node 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: "20" 24 | registry-url: "https://registry.npmjs.org" 25 | - name: Install dependencies 26 | run: npm install 27 | - name: Build docs 28 | run: npm run docs 29 | - name: Archive artifact # from: https://github.com/actions/upload-pages-artifact 30 | shell: sh 31 | run: | 32 | echo ::group::Archive artifact 33 | tar \ 34 | --dereference --hard-dereference \ 35 | --directory docs \ 36 | -cvf "$RUNNER_TEMP/artifact.tar" \ 37 | --exclude=.git \ 38 | --exclude=.github \ 39 | . 40 | echo ::endgroup:: 41 | - name: Upload artifact 42 | id: upload-artifact 43 | uses: actions/upload-artifact@v4 44 | with: 45 | name: github-pages 46 | path: ${{ runner.temp }}/artifact.tar 47 | if-no-files-found: error 48 | 49 | deploy: 50 | runs-on: ubuntu-latest 51 | needs: build 52 | 53 | environment: 54 | name: github-pages 55 | url: ${{ steps.deployment.outputs.page_url }} 56 | 57 | steps: 58 | - id: deployment 59 | name: Deploy to GitHub Pages 60 | uses: actions/deploy-pages@v4 61 | with: 62 | artifact_name: github-pages 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | permissions: 9 | contents: read 10 | id-token: write 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Set up Node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: "20" 20 | registry-url: "https://registry.npmjs.org" 21 | 22 | - name: Checkout repo 23 | uses: actions/checkout@v4 24 | 25 | - name: Verify tag matches package.json version 26 | run: | 27 | RELEASE_VERSION=${{ github.ref_name }} 28 | PACKAGE_VERSION=$(npm pkg get version | sed 's/"//g') 29 | 30 | if [ "$RELEASE_VERSION" != "v$PACKAGE_VERSION" ]; then 31 | echo "Error: GitHub release tag ($RELEASE_VERSION) does not match package.json version ($PACKAGE_VERSION)" 32 | exit 1 33 | fi 34 | 35 | - name: Install dependencies 36 | run: npm install 37 | 38 | - name: Build 39 | run: npm run build 40 | 41 | - name: Publish 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | run: | 45 | npm publish --provenance --access public --tag latest 46 | 47 | verify: 48 | needs: publish 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Set up Node 52 | uses: actions/setup-node@v4 53 | with: 54 | node-version: "20" 55 | registry-url: "https://registry.npmjs.org" 56 | 57 | - name: Checkout repo 58 | uses: actions/checkout@v4 59 | 60 | - name: Install ts-node 61 | run: npm install -g ts-node 62 | 63 | - name: Verify package 64 | run: | 65 | npm install @resonatehq/sdk@${{ github.ref_name }} 66 | ts-node app.ts 67 | working-directory: examples/async 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | out/ 4 | dist/ 5 | docs/ 6 | coverage/ 7 | .next/ 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /*.ts 2 | /*.js 3 | build 4 | dist 5 | docs 6 | coverage 7 | examples 8 | README.md 9 | package.json 10 | package-lock.json 11 | tsconfig.json 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "example/web", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/example/web/index.ts", 13 | "preLaunchTask": "tsc: build - example/web/tsconfig.json", 14 | "outFiles": ["${workspaceFolder}/example/web/out/**/*.js"] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | Welcome to the Resonate project! We appreciate your interest in helping us build reliable 4 | and scalable distributed systems. To get started, follow these simple steps: 5 | 6 | ## Initial setup 7 | 8 | We follow the fork and branch workflow. There will be three Git repositories involved: 9 | 10 | 1. _upstream_ - the [resonate-sdk-ts](https://github.com/resonatehq/resonate-sdk-ts) repository on GitHub. 11 | 2. _origin_ - your GitHub fork of `upstream`. 12 | 3. _local_ - your local clone of `origin`. 13 | 14 | These steps are only needed once and not for subsequent changes you might want to make: 15 | 16 | 1. Fork the `resonate-sdk-ts` repository on GitHub to create `origin`. 17 | Visit [resonate-sdk-ts](https://github.com/resonatehq/resonate-sdk-ts) GitHub repository and click the `Fork` button. 18 | 19 | 2. Make a `local` clone of your fork. 20 | 21 | ```shell 22 | git clone git@github.com:/resonate-sdk-ts.git 23 | ``` 24 | 25 | 3. Add a remote pointing from `local` to `upstream`. 26 | 27 | ```shell 28 | cd resonate 29 | git remote add upstream git@github.com:resonatehq/resonate-sdk-ts.git 30 | ``` 31 | 32 | 4. Double check the two remotes are referencing the expected url. 33 | 34 | ```shell 35 | git remote get-url origin # git@github.com:/resonate-sdk-ts.git 36 | git remote get-url upstream # git@github.com:resonatehq/resonate-sdk-ts.git 37 | ``` 38 | 39 | ## Development workflow 40 | 41 |

42 | 43 |

44 | 45 | Here is a outline of the steps needed to make changes to the resonate 46 | project. 47 | 48 | 1. Make a local branch in your clone and pull any recent changes into it. 49 | 50 | ```shell 51 | git switch -c awesome_branch 52 | git pull upstream main 53 | ``` 54 | 55 | 2. Make changes and commit to local branch. 56 | 57 | ```shell 58 | git add . 59 | git commit -m "dead simple" 60 | ``` 61 | 62 | 3. Pull any changes that may have been made in the upstream repository 63 | main branch. 64 | 65 | ```shell 66 | git pull --rebase upstream main # may result in merge conflicts 67 | ``` 68 | 69 | 4. Push your branch to the corresponding branch in your fork. 70 | 71 | ```shell 72 | git push origin awesome_branch 73 | ``` 74 | 75 | 5. Select the branch you are working on in the drop-down menu of branches in 76 | your fork. Then hit the `Compare and pull request` button. 77 | 78 | 6. Once your pull request has been reviewed and approved by a maintainer, select 79 | the `Squash and merge` option. Edit the commit message as appropriate for the 80 | squashed commit. 81 | 82 | 7. Delete the branch from `origin`: 83 | 84 | ``` 85 | git push origin --delete awesome_branch 86 | ``` 87 | 88 | 8. Delete the branch from `local`: 89 | 90 | ``` 91 | git switch main 92 | git branch -D awesome_branch 93 | ``` 94 | 95 | ## What to contribute to? 96 | 97 | Here are some areas where your contributions would be valuable: 98 | 99 | - Bug fixes for existing packages. 100 | - Refactoring efforts to improve code quality. 101 | - Enhancements to our testing and reliability efforts. 102 | 103 | Thank you for your contributions and support in building a better Resonate! 🚀 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Resonate TypeScript SDK

6 | 7 |
8 | 9 | [![cicd](https://github.com/resonatehq/resonate-sdk-ts/actions/workflows/cicd.yaml/badge.svg)](https://github.com/resonatehq/resonate-sdk-ts/actions/workflows/cicd.yaml) 10 | [![codecov](https://codecov.io/gh/resonatehq/resonate-sdk-ts/branch/main/graph/badge.svg)](https://codecov.io/gh/resonatehq/resonate-sdk-ts) 11 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 12 | 13 |
14 | 15 | ## About this component 16 | 17 | The Resonate TypeScript SDK enables you to build reliable and scalable applications when paired with at least one [Resonate Server](https://github.com/resonatehq/resonate). 18 | 19 | - [Contribute to the Resonate Python SDK](./CONTRIBUTING.md) 20 | - [License](./LICENSE) 21 | 22 | ## Directory 23 | 24 | - [Get started with Resonate](https://docs.resonatehq.io/get-started) 25 | - [Try an example application](https://github.com/resonatehq-examples) 26 | - [Join the community](https://resonatehq.io/discord) 27 | - [Subscribe to Resonate HQ](https://journal.resonatehq.io/subscribe) 28 | - [Follow on Twitter / X](https://twitter.com/resonatehqio) 29 | - [Follow on LinkedIn](https://www.linkedin.com/company/resonatehqio) 30 | - [Subscribe on YouTube](https://www.youtube.com/@resonatehqio) 31 | 32 | ## Distributed Async Await 33 | 34 | Resonate implements the Distributed Async Await specification — [Learn more](https://www.distributed-async-await.io/) 35 | 36 | ## Why Resonate 37 | 38 | **Why Resonate?** 39 | 40 | Because developing distributed applications should be a delightful experience — [Learn more](https://docs.resonatehq.io/evaluate/why-resonate) 41 | 42 | ## Available SDKs 43 | 44 | Add reliablity and scalability to the language you love. 45 | 46 | | Language | Source Code | Package | Developer docs | 47 | | :-----------------------------------------------------------------------------------------------------------------: | --------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------- | 48 | | py sdk | https://github.com/resonatehq/resonate-sdk-py | [pypi](https://pypi.org/project/resonate-sdk/) | [docs](https://docs.resonatehq.io/develop/python) | 49 | | ts sdk | https://github.com/resonatehq/resonate-sdk-ts | [npm](https://www.npmjs.com/package/@resonatehq/sdk) | [docs](https://docs.resonatehq.io/develop/typescript) | 50 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Notes - Version 0.3.2 2 | 3 | ## Overview 4 | 5 | This is test. 6 | 7 | ## New Features 8 | 9 | - test 10 | 11 | ## Enhancements 12 | 13 | - test 14 | 15 | ## Bug Fixes 16 | 17 | - test 18 | 19 | ## Known Issues 20 | 21 | - test 22 | -------------------------------------------------------------------------------- /examples/async/app.ts: -------------------------------------------------------------------------------- 1 | import { Resonate, Context, exponential } from "@resonatehq/sdk"; 2 | 3 | const resonate = new Resonate({ 4 | timeout: 1000, 5 | retryPolicy: exponential( 6 | 100, // initial delay (in ms) 7 | 2, // backoff factor 8 | Infinity, // max attempts 9 | 60000, // max delay (in ms, 1 minute) 10 | ), 11 | }); 12 | 13 | resonate.register("app", (ctx: Context) => { 14 | return "Hello World"; 15 | }); 16 | 17 | async function main() { 18 | await resonate.run("app", "app.1"); 19 | } 20 | 21 | main(); 22 | -------------------------------------------------------------------------------- /examples/async/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "resonate-example-async", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "resonate-example-async", 9 | "version": "1.0.0", 10 | "license": "Apache-2.0", 11 | "dependencies": { 12 | "@resonatehq/sdk": "file:../.." 13 | } 14 | }, 15 | "../..": { 16 | "name": "@resonatehq/sdk", 17 | "version": "0.6.3", 18 | "license": "Apache-2.0", 19 | "dependencies": { 20 | "cron-parser": "^4.9.0" 21 | }, 22 | "devDependencies": { 23 | "@jest/globals": "^29.7.0", 24 | "@types/node": "^20.11.19", 25 | "@typescript-eslint/eslint-plugin": "^6.10.0", 26 | "eslint": "^8.53.0", 27 | "eslint-config-prettier": "^9.0.0", 28 | "eslint-config-standard-with-typescript": "^39.1.1", 29 | "eslint-import-resolver-typescript": "^3.6.1", 30 | "eslint-plugin-import": "^2.29.1", 31 | "eslint-plugin-n": "^16.3.1", 32 | "eslint-plugin-promise": "^6.1.1", 33 | "prettier": "^3.2.5", 34 | "ts-jest": "^29.1.1", 35 | "ts-node": "^10.9.2", 36 | "typedoc": "^0.25.7", 37 | "typedoc-material-theme": "^1.0.2", 38 | "typescript": "^5.2.2" 39 | }, 40 | "engines": { 41 | "node": ">= 18" 42 | } 43 | }, 44 | "node_modules/@resonatehq/sdk": { 45 | "resolved": "../..", 46 | "link": true 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/async/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "resonate-example-async", 3 | "version": "1.0.0", 4 | "description": "Resonate async example", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/resonatehq/resonate-sdk-ts.git" 9 | }, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "Resonate Developers", 14 | "license": "Apache-2.0", 15 | "dependencies": { 16 | "@resonatehq/sdk": "file:../.." 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | coverageDirectory: "coverage", 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | testPathIgnorePatterns: ["dist/"], 7 | }; 8 | -------------------------------------------------------------------------------- /lib/core/encoder.ts: -------------------------------------------------------------------------------- 1 | export interface IEncoder { 2 | encode(data: I): O; 3 | decode(data: O): I; 4 | } 5 | -------------------------------------------------------------------------------- /lib/core/encoders/base64.ts: -------------------------------------------------------------------------------- 1 | import { IEncoder } from "../encoder"; 2 | 3 | export class Base64Encoder implements IEncoder { 4 | encode(data: string): string { 5 | return btoa(data); 6 | } 7 | 8 | decode(data: string): string { 9 | return atob(data); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/core/encoders/json.ts: -------------------------------------------------------------------------------- 1 | import { IEncoder } from "../encoder"; 2 | import { ResonateError } from "../errors"; 3 | 4 | export class JSONEncoder implements IEncoder { 5 | encode(data: unknown): string | undefined { 6 | // note about undefined: 7 | // undefined is not json serializable, so immediately return undefined 8 | if (data === undefined) { 9 | return undefined; 10 | } 11 | 12 | return JSON.stringify(data, (_, value) => { 13 | if (value === Infinity) { 14 | return "Infinity"; 15 | } 16 | 17 | if (value === -Infinity) { 18 | return "-Infinity"; 19 | } 20 | 21 | if (value instanceof AggregateError) { 22 | return { 23 | __type: "aggregate_error", 24 | message: value.message, 25 | stack: value.stack, 26 | name: value.name, 27 | errors: value.errors, 28 | }; 29 | } 30 | 31 | if (value instanceof ResonateError) { 32 | return { 33 | __type: "resonate_error", 34 | message: value.message, 35 | stack: value.stack, 36 | name: value.name, 37 | code: value.code, 38 | cause: value.cause, 39 | retriable: value.retriable, 40 | }; 41 | } 42 | 43 | if (value instanceof Error) { 44 | return { 45 | __type: "error", 46 | message: value.message, 47 | stack: value.stack, 48 | name: value.name, 49 | }; 50 | } 51 | 52 | return value; 53 | }); 54 | } 55 | 56 | decode(data: string | undefined): unknown { 57 | // note about undefined: 58 | // undefined causes JSON.parse to throw, so immediately return undefined 59 | if (data === undefined) { 60 | return undefined; 61 | } 62 | 63 | return JSON.parse(data, (_, value) => { 64 | if (value === "Infinity") { 65 | return Infinity; 66 | } 67 | 68 | if (value === "-Infinity") { 69 | return Infinity; 70 | } 71 | 72 | if (value?.__type === "aggregate_error") { 73 | return Object.assign(new AggregateError(value.errors, value.message), value); 74 | } 75 | 76 | if (value?.__type === "resonate_error") { 77 | return Object.assign(new ResonateError(value.message, value.code, value.cause, value.retriable), value); 78 | } 79 | 80 | if (value?.__type === "error") { 81 | return Object.assign(new Error(value.message), value); 82 | } 83 | 84 | return value; 85 | }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/core/errors.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCodes { 2 | // unknown 3 | UNKNOWN = 0, 4 | FETCH = 1, 5 | 6 | // canceled 7 | CANCELED = 10, 8 | 9 | // timedout 10 | TIMEDOUT = 20, 11 | 12 | // ABORT 13 | ABORT = 30, 14 | 15 | // store 16 | STORE = 40, 17 | STORE_UNAUTHORIZED = 41, 18 | STORE_PAYLOAD = 42, 19 | STORE_FORBIDDEN = 43, 20 | STORE_NOT_FOUND = 44, 21 | STORE_ALREADY_EXISTS = 45, 22 | STORE_INVALID_STATE = 46, 23 | STORE_ENCODER = 47, 24 | 25 | // error in user function 26 | USER = 60, 27 | } 28 | 29 | export class ResonateError extends Error { 30 | constructor( 31 | message: string, 32 | public readonly code: ErrorCodes, 33 | public readonly cause?: any, 34 | public readonly retriable: boolean = false, 35 | ) { 36 | super(message); 37 | } 38 | 39 | public static fromError(e: unknown): ResonateError { 40 | return e instanceof ResonateError ? e : new ResonateError("Unknown error", ErrorCodes.UNKNOWN, e, true); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/core/logger.ts: -------------------------------------------------------------------------------- 1 | export type LogLevel = "debug" | "info" | "warn" | "error"; 2 | 3 | export interface ILogger { 4 | level: LogLevel; 5 | debug(...args: any[]): void; 6 | info(...args: any[]): void; 7 | warn(...args: any[]): void; 8 | error(...args: any[]): void; 9 | table(...args: any[]): void; 10 | } 11 | -------------------------------------------------------------------------------- /lib/core/loggers/logger.ts: -------------------------------------------------------------------------------- 1 | import { ILogger, LogLevel } from "../logger"; 2 | 3 | export class Logger implements ILogger { 4 | public level: LogLevel; 5 | private _level: number; 6 | 7 | constructor(level?: LogLevel) { 8 | this.level = level ?? (process.env.LOG_LEVEL as LogLevel) ?? "info"; 9 | 10 | switch (this.level) { 11 | case "debug": 12 | this._level = 0; 13 | break; 14 | case "info": 15 | this._level = 1; 16 | break; 17 | case "warn": 18 | this._level = 2; 19 | break; 20 | case "error": 21 | this._level = 3; 22 | break; 23 | } 24 | } 25 | 26 | debug(...args: any[]): void { 27 | this.log(0, args); 28 | } 29 | 30 | info(...args: any[]): void { 31 | this.log(1, args); 32 | } 33 | 34 | warn(...args: any[]): void { 35 | this.log(2, args); 36 | } 37 | 38 | error(...args: any[]): void { 39 | this.log(3, args); 40 | } 41 | 42 | table(...args: any[]): void { 43 | if (this._level <= 0) { 44 | console.table(args); 45 | } 46 | } 47 | 48 | private log(level: number, args: any[]): void { 49 | if (this._level <= level) { 50 | console.log(...args); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/core/options.ts: -------------------------------------------------------------------------------- 1 | import { IEncoder } from "./encoder"; 2 | import { ILogger } from "./logger"; 3 | import { RetryPolicy } from "./retry"; 4 | import { IStore } from "./store"; 5 | 6 | /** 7 | * Resonate configuration options. 8 | */ 9 | export type ResonateOptions = { 10 | /** 11 | * Store authentication options. 12 | */ 13 | auth: AuthOptions; 14 | 15 | /** 16 | * An encoder instance used for encoding and decoding values 17 | * returned (or thrown) by registered functions. If not provided, 18 | * a default JSON encoder will be used. 19 | */ 20 | encoder: IEncoder; 21 | 22 | /** 23 | * The frequency in ms to heartbeat locks. 24 | */ 25 | heartbeat: number; 26 | 27 | /** 28 | * A process id that can be used to uniquely identify this Resonate 29 | * instance. If not provided a default value will be generated. 30 | */ 31 | pid: string; 32 | 33 | /** 34 | * The frequency in ms to poll the promise store for remote 35 | * promises. 36 | */ 37 | pollFrequency: number; 38 | 39 | /** 40 | * A logger instance, if not provided a default logger will be 41 | * used. 42 | */ 43 | logger: ILogger; 44 | 45 | /** 46 | * A retry policy, defaults to exponential backoff. 47 | */ 48 | retryPolicy: RetryPolicy; 49 | 50 | /** 51 | * Tags to add to all durable promises. 52 | */ 53 | tags: Record; 54 | 55 | /** 56 | * A store instance, if provided will take predence over the 57 | * default store. 58 | */ 59 | store: IStore; 60 | 61 | /** 62 | * The default promise timeout in ms, used for every function 63 | * executed by calling run. Defaults to 1000. 64 | */ 65 | timeout: number; 66 | 67 | /** 68 | * The remote promise store url. If not provided, an in-memory 69 | * promise store will be used. 70 | */ 71 | url: string; 72 | }; 73 | 74 | /** 75 | * Resonate function invocation options. 76 | */ 77 | export type Options = { 78 | __resonate: true; 79 | 80 | /** 81 | * Persist the result to durable storage. 82 | */ 83 | durable: boolean; 84 | 85 | /** 86 | * A function that calculates the id for this execution 87 | * defaults to a random funciton. 88 | */ 89 | eidFn: (id: string) => string; 90 | 91 | /** 92 | * Overrides the default encoder. 93 | */ 94 | encoder: IEncoder; 95 | 96 | /** 97 | * Overrides the default funciton to calculate the idempotency key. 98 | * defaults to a variation fnv-1a the hash funciton. 99 | */ 100 | idempotencyKeyFn: (id: string) => string; 101 | 102 | /** 103 | * Acquire a lock for the execution. 104 | */ 105 | shouldLock: boolean | undefined; 106 | 107 | /** 108 | * Overrides the default polling frequency. 109 | */ 110 | pollFrequency: number; 111 | 112 | /** 113 | * Overrides the default retry policy. 114 | */ 115 | retryPolicy: RetryPolicy; 116 | 117 | /** 118 | * Additional tags to add to the durable promise. 119 | */ 120 | tags: Record; 121 | 122 | /** 123 | * Overrides the default timeout. 124 | */ 125 | timeout: number; 126 | 127 | /** 128 | * The function version to execute. Only applicable on calls to 129 | * resonate.run. 130 | */ 131 | version: number; 132 | }; 133 | 134 | export type PartialOptions = Partial & { __resonate: true }; 135 | 136 | /* 137 | * Construct options. 138 | * 139 | * @param opts A partial {@link Options} object. 140 | * @returns PartialOptions. 141 | */ 142 | export function options(opts: Partial = {}): PartialOptions { 143 | return { ...opts, __resonate: true }; 144 | } 145 | 146 | /** 147 | * A subset of configuration options for overriding when invocating a top level function. 148 | */ 149 | export type InvocationOverrides = Partial< 150 | Pick 151 | > & { __resonate: true }; 152 | 153 | export function isOptions(o: unknown): o is PartialOptions { 154 | return typeof o === "object" && o !== null && (o as PartialOptions).__resonate === true; 155 | } 156 | 157 | export type StoreOptions = { 158 | /** 159 | * The store authentication options. 160 | */ 161 | auth: AuthOptions; 162 | 163 | /** 164 | * The store encoder, defaults to a base64 encoder. 165 | */ 166 | encoder: IEncoder; 167 | 168 | /** 169 | * The frequency in ms to heartbeat locks. 170 | */ 171 | heartbeat: number; 172 | 173 | /** 174 | * A logger instance, if not provided a default logger will be 175 | * used. 176 | */ 177 | logger: ILogger; 178 | 179 | /** 180 | * A process id that can be used to uniquely identify this Resonate 181 | * instance. If not provided a default value will be generated. 182 | */ 183 | pid: string; 184 | 185 | /** 186 | * Number of retries to attempt before throwing an error. If not 187 | * provided, a default value will be used. 188 | */ 189 | retries: number; 190 | }; 191 | 192 | export type AuthOptions = { 193 | /** 194 | * Basic auth credentials. 195 | */ 196 | basic: { 197 | password: string; 198 | username: string; 199 | }; 200 | }; 201 | -------------------------------------------------------------------------------- /lib/core/promises/promises.ts: -------------------------------------------------------------------------------- 1 | import { IEncoder } from "../encoder"; 2 | import { ErrorCodes, ResonateError } from "../errors"; 3 | import { IPromiseStore } from "../store"; 4 | import { DurablePromiseRecord } from "./types"; 5 | 6 | /** 7 | * Durable Promise create options. 8 | */ 9 | export type CreateOptions = { 10 | /** 11 | * Durable Promise idempotency key. 12 | */ 13 | idempotencyKey: string | undefined; 14 | 15 | /** 16 | * Durable Promise headers. 17 | */ 18 | headers: Record; 19 | 20 | /** 21 | * Durable Promise param, will be encoded with the provided encoder. 22 | */ 23 | param: unknown; 24 | 25 | /** 26 | * Durable Promise tags. 27 | */ 28 | tags: Record; 29 | 30 | /** 31 | * Create the Durable Promise in strict mode. 32 | */ 33 | strict: boolean; 34 | }; 35 | 36 | /** 37 | * Durable Promise complete options. 38 | */ 39 | export type CompleteOptions = { 40 | /** 41 | * Durable Promise idempotency key. 42 | */ 43 | idempotencyKey: string | undefined; 44 | 45 | /** 46 | * Durable Promise headers. 47 | */ 48 | headers: Record; 49 | 50 | /** 51 | * Create the Durable Promise in strict mode. 52 | */ 53 | strict: boolean; 54 | }; 55 | 56 | export class DurablePromise { 57 | private readonly completed: Promise>; 58 | private complete!: (value: DurablePromise) => void; 59 | private interval: NodeJS.Timeout | undefined; 60 | 61 | /** 62 | * Creates a Durable Promise instance. This is provided as a lower level API, used by the Resonate class internally. 63 | * 64 | * @constructor 65 | * @param store - A reference to a promise store. 66 | * @param encoder - An encoder instance used for encode and decode promise data. 67 | * @param promise - The raw Durable Promise. 68 | */ 69 | constructor( 70 | private store: IPromiseStore, 71 | private encoder: IEncoder, 72 | private promise: DurablePromiseRecord, 73 | ) { 74 | this.completed = new Promise((resolve) => { 75 | this.complete = resolve; 76 | }); 77 | } 78 | 79 | /** 80 | * The Durable Promise id. 81 | */ 82 | get id() { 83 | return this.promise.id; 84 | } 85 | 86 | /** 87 | * The Durable Promise create idempotency key. 88 | */ 89 | get idempotencyKeyForCreate() { 90 | return this.promise.idempotencyKeyForCreate; 91 | } 92 | 93 | /** 94 | * The Durable Promise complete idempotency key. 95 | */ 96 | get idempotencyKeyForComplete() { 97 | return this.promise.idempotencyKeyForComplete; 98 | } 99 | 100 | /** 101 | * The Durable Promise created on time. 102 | */ 103 | get createdOn() { 104 | return this.promise.createdOn; 105 | } 106 | 107 | /** 108 | * The Durable Promise timeout time. 109 | */ 110 | get timeout() { 111 | return this.promise.timeout; 112 | } 113 | 114 | /** 115 | * Returns true when the Durable Promise is pending. 116 | */ 117 | get pending() { 118 | return this.promise.state === "PENDING"; 119 | } 120 | 121 | /** 122 | * Returns true when the Durable Promise is resolved. 123 | */ 124 | get resolved() { 125 | return this.promise.state === "RESOLVED"; 126 | } 127 | 128 | /** 129 | * Returns true when the Durable Promise is rejected. 130 | */ 131 | get rejected() { 132 | return this.promise.state === "REJECTED"; 133 | } 134 | 135 | /** 136 | * Returns true when the Durable Promise is canceled. 137 | */ 138 | get canceled() { 139 | return this.promise.state === "REJECTED_CANCELED"; 140 | } 141 | 142 | /** 143 | * Returns true when the Durable Promise is timedout. 144 | */ 145 | get timedout() { 146 | return this.promise.state === "REJECTED_TIMEDOUT"; 147 | } 148 | 149 | /** 150 | * Returns the decoded promise param data. 151 | */ 152 | param() { 153 | return this.encoder.decode(this.promise.param.data); 154 | } 155 | 156 | /** 157 | * Returns the decoded promise value data. 158 | */ 159 | value() { 160 | if (!this.resolved) { 161 | throw new Error("Promise is not resolved"); 162 | } 163 | 164 | return this.encoder.decode(this.promise.value.data) as T; 165 | } 166 | 167 | /** 168 | * Returns the decoded promise value data as an error. 169 | */ 170 | error() { 171 | if (this.rejected) { 172 | return this.encoder.decode(this.promise.value.data); 173 | } else if (this.canceled) { 174 | return new ResonateError( 175 | "Resonate function canceled", 176 | ErrorCodes.CANCELED, 177 | this.encoder.decode(this.promise.value.data), 178 | ); 179 | } else if (this.timedout) { 180 | return new ResonateError( 181 | `Resonate function timedout at ${new Date(this.promise.timeout).toISOString()}`, 182 | ErrorCodes.TIMEDOUT, 183 | ); 184 | } else { 185 | throw new Error("Promise is not rejected, canceled, or timedout"); 186 | } 187 | } 188 | 189 | /** 190 | * Creates a Durable Promise. 191 | * @param store - A reference to a promise store. 192 | * @param encoder - An encoder instance used for encode and decode promise data. 193 | * @param id - The Durable Promise id. 194 | * @param timeout - The Durable Promise timeout in milliseconds. 195 | * @param opts - A partial Durable Promise create options. 196 | * @returns A Durable Promise instance. 197 | */ 198 | static async create( 199 | store: IPromiseStore, 200 | encoder: IEncoder, 201 | id: string, 202 | timeout: number, 203 | opts: Partial = {}, 204 | ): Promise> { 205 | const storedPromise = await store.create( 206 | id, 207 | opts.idempotencyKey, 208 | opts.strict ?? false, 209 | opts.headers, 210 | encoder.encode(opts.param), 211 | timeout, 212 | opts.tags, 213 | ); 214 | return new DurablePromise(store, encoder, storedPromise); 215 | } 216 | 217 | /** 218 | * Resolves a Durable Promise. 219 | * @param store - A reference to a promise store. 220 | * @param encoder - An encoder instance used for encode and decode promise data. 221 | * @param id - The Durable Promise id. 222 | * @param value - The Durable Promise value, will be encoded with the provided encoder. 223 | * @param opts - A partial Durable Promise create options. 224 | * @returns A Durable Promise instance. 225 | */ 226 | static async resolve( 227 | store: IPromiseStore, 228 | encoder: IEncoder, 229 | id: string, 230 | value: T, 231 | opts: Partial = {}, 232 | ) { 233 | return new DurablePromise( 234 | store, 235 | encoder, 236 | await store.resolve(id, opts.idempotencyKey, opts.strict ?? false, opts.headers, encoder.encode(value)), 237 | ); 238 | } 239 | 240 | /** 241 | * Rejects a Durable Promise. 242 | * @param store - A reference to a promise store. 243 | * @param encoder - An encoder instance used for encode and decode promise data. 244 | * @param id - The Durable Promise id. 245 | * @param error - The Durable Promise error value, will be encoded with the provided encoder. 246 | * @param opts - A partial Durable Promise create options. 247 | * @returns A Durable Promise instance. 248 | */ 249 | static async reject( 250 | store: IPromiseStore, 251 | encoder: IEncoder, 252 | id: string, 253 | error: any, 254 | opts: Partial = {}, 255 | ) { 256 | return new DurablePromise( 257 | store, 258 | encoder, 259 | await store.reject(id, opts.idempotencyKey, opts.strict ?? false, opts.headers, encoder.encode(error)), 260 | ); 261 | } 262 | 263 | /** 264 | * Cancels a Durable Promise. 265 | * @param store - A reference to a promise store. 266 | * @param encoder - An encoder instance used for encode and decode promise data. 267 | * @param id - The Durable Promise id. 268 | * @param error - The Durable Promise error value, will be encoded with the provided encoder. 269 | * @param opts - A partial Durable Promise create options. 270 | * @returns A Durable Promise instance. 271 | */ 272 | static async cancel( 273 | store: IPromiseStore, 274 | encoder: IEncoder, 275 | id: string, 276 | error: any, 277 | opts: Partial = {}, 278 | ) { 279 | return new DurablePromise( 280 | store, 281 | encoder, 282 | await store.cancel(id, opts.idempotencyKey, opts.strict ?? false, opts.headers, encoder.encode(error)), 283 | ); 284 | } 285 | 286 | /** 287 | * Gets a Durable Promise. 288 | * @param store - A reference to a promise store. 289 | * @param encoder - An encoder instance used for encode and decode promise data. 290 | * @param id - The Durable Promise id. 291 | * @returns A Durable Promise instance. 292 | */ 293 | static async get(store: IPromiseStore, encoder: IEncoder, id: string) { 294 | return new DurablePromise(store, encoder, await store.get(id)); 295 | } 296 | 297 | /** 298 | * Search for Durable Promises. 299 | * @param store - A reference to a promise store. 300 | * @param encoder - An encoder instance used for encode and decode promise data. 301 | * @param id - An id to match against Durable Promise ids, can include wilcards. 302 | * @param state - A state to search for, can be one of {pending, resolved, rejected}, matches all states if undefined. 303 | * @param tags - Tags to search against. 304 | * @param limit - The maximum number of Durable Promises to return per page. 305 | * @returns An async generator that yields Durable Promise instances. 306 | */ 307 | static async *search( 308 | store: IPromiseStore, 309 | encoder: IEncoder, 310 | id: string, 311 | state?: string, 312 | tags?: Record, 313 | limit?: number, 314 | ): AsyncGenerator[], void> { 315 | for await (const promises of store.search(id, state, tags, limit)) { 316 | yield promises.map((p) => new DurablePromise(store, encoder, p)); 317 | } 318 | } 319 | 320 | /** 321 | * Resolves the Durable Promise. 322 | * @param value - The Durable Promise value, will be encoded with the provided encoder. 323 | * @param opts - A partial Durable Promise create options. 324 | * @returns this instance. 325 | */ 326 | async resolve(value: T, opts: Partial = {}) { 327 | this.promise = !this.pending 328 | ? this.promise 329 | : await this.store.resolve( 330 | this.id, 331 | opts.idempotencyKey, 332 | opts.strict ?? false, 333 | opts.headers, 334 | this.encoder.encode(value), 335 | ); 336 | 337 | if (!this.pending) { 338 | this.complete(this); 339 | } 340 | 341 | return this; 342 | } 343 | 344 | /** 345 | * Rejects the Durable Promise. 346 | * @param error - The Durable Promise error value, will be encoded with the provided encoder. 347 | * @param opts - A partial Durable Promise create options. 348 | * @returns this instance. 349 | */ 350 | async reject(error: any, opts: Partial = {}) { 351 | this.promise = !this.pending 352 | ? this.promise 353 | : await this.store.reject( 354 | this.id, 355 | opts.idempotencyKey, 356 | opts.strict ?? false, 357 | opts.headers, 358 | this.encoder.encode(error), 359 | ); 360 | 361 | if (!this.pending) { 362 | this.complete(this); 363 | } 364 | 365 | return this; 366 | } 367 | 368 | /** 369 | * Cancels the Durable Promise. 370 | * @param error - The Durable Promise error value, will be encoded with the provided encoder. 371 | * @param opts - A partial Durable Promise create options. 372 | * @returns this instance. 373 | */ 374 | async cancel(error: any, opts: Partial = {}) { 375 | this.promise = !this.pending 376 | ? this.promise 377 | : await this.store.cancel( 378 | this.id, 379 | opts.idempotencyKey, 380 | opts.strict ?? false, 381 | opts.headers, 382 | this.encoder.encode(error), 383 | ); 384 | 385 | if (!this.pending) { 386 | this.complete(this); 387 | } 388 | 389 | return this; 390 | } 391 | 392 | /** 393 | * Polls the Durable Promise store to sychronize the state, stops when the promise is complete. 394 | * @param timeout - The time at which to stop polling if the promise is still pending. 395 | * @param frequency - The frequency in ms to poll. 396 | * @returns A Promise that resolves when the Durable Promise is complete. 397 | */ 398 | async sync(timeout: number = Infinity, frequency: number = 5000): Promise { 399 | if (!this.pending) return; 400 | 401 | // reset polling interval 402 | clearInterval(this.interval); 403 | this.interval = setInterval(() => this.poll(), frequency); 404 | 405 | // poll immediately for now 406 | // we can revisit when we implement a cache subsystem 407 | await this.poll(); 408 | 409 | // set timeout promise 410 | let timeoutId: NodeJS.Timeout | undefined; 411 | const timeoutPromise = 412 | timeout === Infinity 413 | ? new Promise(() => {}) // wait forever 414 | : new Promise((resolve) => (timeoutId = setTimeout(resolve, timeout))); 415 | 416 | // await either: 417 | // - completion of the promise 418 | // - timeout 419 | await Promise.race([this.completed, timeoutPromise]); 420 | 421 | // clear polling interval 422 | clearInterval(this.interval); 423 | 424 | // clear timeout 425 | clearTimeout(timeoutId); 426 | 427 | // throw error if timeout occcured 428 | if (this.pending) { 429 | throw new Error("Timeout occured while waiting for promise to complete"); 430 | } 431 | } 432 | 433 | /** 434 | * Polls the Durable Promise store, and returns the value when the Durable Promise is complete. 435 | * @param timeout - The time at which to stop polling if the promise is still pending. 436 | * @param frequency - The frequency in ms to poll. 437 | * @returns The promise value, or throws an error. 438 | */ 439 | async wait(timeout: number = Infinity, frequency: number = 5000): Promise { 440 | await this.sync(timeout, frequency); 441 | 442 | if (this.resolved) { 443 | return this.value(); 444 | } else { 445 | throw this.error(); 446 | } 447 | } 448 | 449 | private async poll() { 450 | try { 451 | this.promise = await this.store.get(this.id); 452 | 453 | if (!this.pending) { 454 | this.complete(this); 455 | } 456 | } catch (e) { 457 | // TODO: log 458 | } 459 | } 460 | } 461 | -------------------------------------------------------------------------------- /lib/core/promises/types.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { IEncoder } from "../encoder"; 3 | import { ErrorCodes, ResonateError } from "../errors"; 4 | 5 | export type DurablePromiseRecord = { 6 | state: "PENDING" | "RESOLVED" | "REJECTED" | "REJECTED_CANCELED" | "REJECTED_TIMEDOUT"; 7 | id: string; 8 | timeout: number; 9 | param: { 10 | headers: Record | undefined; 11 | data: string | undefined; 12 | }; 13 | value: { 14 | headers: Record | undefined; 15 | data: string | undefined; 16 | }; 17 | createdOn: number; 18 | completedOn: number | undefined; 19 | idempotencyKeyForCreate: string | undefined; 20 | idempotencyKeyForComplete: string | undefined; 21 | tags: Record | undefined; 22 | }; 23 | 24 | // This is an unsound type guard, we should be more strict in what we call a DurablePromise 25 | export function isDurablePromiseRecord(p: unknown): p is DurablePromiseRecord { 26 | return ( 27 | p !== null && 28 | typeof p === "object" && 29 | "state" in p && 30 | typeof p.state === "string" && 31 | ["PENDING", "RESOLVED", "REJECTED", "REJECTED_CANCELED", "REJECTED_TIMEDOUT"].includes(p.state) 32 | ); 33 | } 34 | 35 | export function isPendingPromise(p: DurablePromiseRecord): boolean { 36 | return p.state === "PENDING"; 37 | } 38 | 39 | export function isResolvedPromise(p: DurablePromiseRecord): boolean { 40 | return p.state === "RESOLVED"; 41 | } 42 | 43 | export function isRejectedPromise(p: DurablePromiseRecord): boolean { 44 | return p.state === "REJECTED"; 45 | } 46 | 47 | export function isCanceledPromise(p: DurablePromiseRecord): boolean { 48 | return p.state === "REJECTED_CANCELED"; 49 | } 50 | 51 | export function isTimedoutPromise(p: DurablePromiseRecord): boolean { 52 | return p.state === "REJECTED_TIMEDOUT"; 53 | } 54 | 55 | export function isCompletedPromise(p: DurablePromiseRecord): boolean { 56 | return ["RESOLVED", "REJECTED", "REJECTED_CANCELED", "REJECTED_TIMEDOUT"].includes(p.state); 57 | } 58 | 59 | /** 60 | * Handles a completed durable promise and returns its result. 61 | * 62 | * @param p - The DurablePromiseRecord to handle. 63 | * @param encoder - An IEncoder instance used to decode the promise's data. 64 | * @returns The decoded result of the promise if resolved, or null if pending. 65 | * @throws {ResonateError} If the promise was rejected, canceled, or timed out. 66 | * 67 | * @remarks 68 | * Users must handle the null return case, which could indicate either: 69 | * 1. The promise is still pending, or 70 | * 2. The promise completed with a null value. 71 | * It's important to distinguish between these cases in the calling code if necessary. 72 | */ 73 | export function handleCompletedPromise(p: DurablePromiseRecord, encoder: IEncoder): R { 74 | assert(p.state !== "PENDING", "Promise was pending when trying to handle its completion"); 75 | switch (p.state) { 76 | case "RESOLVED": 77 | return encoder.decode(p.value.data) as R; 78 | case "REJECTED": 79 | throw encoder.decode(p.value.data); 80 | case "REJECTED_CANCELED": 81 | throw new ResonateError("Resonate function canceled", ErrorCodes.CANCELED, encoder.decode(p.value.data)); 82 | case "REJECTED_TIMEDOUT": 83 | throw new ResonateError( 84 | `Resonate function timedout at ${new Date(p.timeout).toISOString()}`, 85 | ErrorCodes.TIMEDOUT, 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/core/retry.ts: -------------------------------------------------------------------------------- 1 | export type RetryPolicy = Exponential | Linear | Never; 2 | 3 | export type Exponential = { 4 | kind: "exponential"; 5 | initialDelayMs: number; 6 | backoffFactor: number; 7 | maxAttempts: number; 8 | maxDelayMs: number; 9 | }; 10 | 11 | export type Linear = { 12 | kind: "linear"; 13 | delayMs: number; 14 | maxAttempts: number; 15 | }; 16 | 17 | export type Never = { 18 | kind: "never"; 19 | }; 20 | 21 | export function isRetryPolicy(value: unknown): value is RetryPolicy { 22 | // Check if the value is an object 23 | if (typeof value !== "object" || value === null) { 24 | return false; 25 | } 26 | 27 | // Check if the object has a 'kind' property and if its value is a valid kind string 28 | const kindValue = (value as RetryPolicy).kind; 29 | if (kindValue !== "exponential" && kindValue !== "linear" && kindValue !== "never") { 30 | return false; 31 | } 32 | 33 | // Check if the object matches the corresponding type based on the 'kind' value 34 | switch (kindValue) { 35 | case "exponential": 36 | return ( 37 | "initialDelayMs" in value && 38 | "backoffFactor" in value && 39 | "maxAttempts" in value && 40 | "maxDelayMs" in value && 41 | typeof (value as Exponential).initialDelayMs === "number" && 42 | typeof (value as Exponential).backoffFactor === "number" && 43 | typeof (value as Exponential).maxAttempts === "number" && 44 | typeof (value as Exponential).maxDelayMs === "number" 45 | ); 46 | case "linear": 47 | return ( 48 | "delayMs" in value && 49 | "maxAttempts" in value && 50 | typeof (value as Linear).delayMs === "number" && 51 | typeof (value as Linear).maxAttempts === "number" 52 | ); 53 | case "never": 54 | return true; // No additional properties to check for 'never' type 55 | default: 56 | return false; // unreachable 57 | } 58 | } 59 | export function exponential( 60 | initialDelayMs: number = 100, 61 | backoffFactor: number = 2, 62 | maxAttempts: number = Infinity, 63 | maxDelayMs: number = 60000, 64 | ): Exponential { 65 | return { 66 | kind: "exponential", 67 | initialDelayMs, 68 | backoffFactor, 69 | maxAttempts, 70 | maxDelayMs, 71 | }; 72 | } 73 | 74 | export function linear(delayMs: number = 1000, maxAttempts: number = Infinity): Linear { 75 | return { 76 | kind: "linear", 77 | delayMs, 78 | maxAttempts, 79 | }; 80 | } 81 | 82 | export function never(): Never { 83 | return { kind: "never" }; 84 | } 85 | 86 | /** 87 | * Returns an iterable iterator that yields delay values for each retry attempt, 88 | * based on the specified retry policy. 89 | * The iterator stops yielding delay values when either the timeout is reached or the maximum 90 | * number of attempts is exceeded. 91 | * 92 | * @param ctx - The context object containing the retry policy, attempt number, and timeout. 93 | * @returns An iterable iterator that yields delay values for each retry attempt. 94 | * 95 | */ 96 | export function retryIterator( 97 | ctx: T, 98 | ): IterableIterator { 99 | const { initialDelay, backoffFactor, maxAttempts, maxDelay } = retryDefaults(ctx.retryPolicy); 100 | 101 | const __next = (itCtx: { attempt: number; timeout: number }): { done: boolean; delay?: number } => { 102 | // attempt 0: 0ms delay 103 | // attampt n: {initial * factor^(attempt-1)}ms delay (or max delay) 104 | const delay = Math.min( 105 | Math.min(itCtx.attempt, 1) * initialDelay * Math.pow(backoffFactor, itCtx.attempt - 1), 106 | maxDelay, 107 | ); 108 | 109 | if (Date.now() + delay >= itCtx.timeout || itCtx.attempt >= maxAttempts) { 110 | return { done: true }; 111 | } 112 | 113 | return { 114 | done: false, 115 | delay: delay, 116 | }; 117 | }; 118 | 119 | return { 120 | next() { 121 | const { done, delay } = __next(ctx); 122 | return { done, value: delay || 0 }; 123 | }, 124 | [Symbol.iterator]() { 125 | return this; 126 | }, 127 | }; 128 | } 129 | 130 | export async function runWithRetry( 131 | func: () => Promise, 132 | onRetry: () => Promise, 133 | retryPolicy: RetryPolicy, 134 | timeout: number, 135 | ) { 136 | let error; 137 | 138 | const ctx = { attempt: 0, retryPolicy, timeout }; 139 | // invoke the function according to the retry policy 140 | for (const delay of retryIterator(ctx)) { 141 | await new Promise((resolve) => setTimeout(resolve, delay)); 142 | 143 | if (ctx.attempt > 0) { 144 | await onRetry(); 145 | } 146 | 147 | try { 148 | return await func(); 149 | } catch (e) { 150 | error = e; 151 | // bump the attempt count 152 | ctx.attempt++; 153 | } 154 | } 155 | 156 | // if all attempts fail throw the last error 157 | throw error; 158 | } 159 | 160 | function retryDefaults(retryPolicy: RetryPolicy): { 161 | initialDelay: number; 162 | backoffFactor: number; 163 | maxAttempts: number; 164 | maxDelay: number; 165 | } { 166 | switch (retryPolicy.kind) { 167 | case "exponential": 168 | return { 169 | initialDelay: retryPolicy.initialDelayMs, 170 | backoffFactor: retryPolicy.backoffFactor, 171 | maxAttempts: retryPolicy.maxAttempts, 172 | maxDelay: retryPolicy.maxDelayMs, 173 | }; 174 | case "linear": 175 | return { 176 | initialDelay: retryPolicy.delayMs, 177 | backoffFactor: 1, 178 | maxAttempts: retryPolicy.maxAttempts, 179 | maxDelay: retryPolicy.delayMs, 180 | }; 181 | case "never": 182 | return { 183 | initialDelay: 0, 184 | backoffFactor: 0, 185 | maxAttempts: 1, 186 | maxDelay: 0, 187 | }; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /lib/core/schedules/schedules.ts: -------------------------------------------------------------------------------- 1 | import { IEncoder } from "../encoder"; 2 | import { IScheduleStore } from "../store"; 3 | import { Schedule as ScheduleRecord } from "./types"; 4 | 5 | export type Options = { 6 | description: string | undefined; 7 | idempotencyKey: string | undefined; 8 | tags: Record | undefined; 9 | promiseHeaders: Record; 10 | promiseParam: unknown; 11 | promiseTags: Record | undefined; 12 | }; 13 | 14 | export class Schedule { 15 | constructor( 16 | private store: IScheduleStore, 17 | private encoder: IEncoder, 18 | public schedule: ScheduleRecord, 19 | ) {} 20 | 21 | static async create( 22 | store: IScheduleStore, 23 | encoder: IEncoder, 24 | id: string, 25 | cron: string, 26 | promiseId: string, 27 | promiseTimeout: number, 28 | opts: Partial = {}, 29 | ): Promise { 30 | return new Schedule( 31 | store, 32 | encoder, 33 | await store.create( 34 | id, 35 | opts.idempotencyKey, 36 | opts.description, 37 | cron, 38 | opts.tags, 39 | promiseId, 40 | promiseTimeout, 41 | opts.promiseHeaders, 42 | encoder.encode(opts.promiseParam), 43 | opts.promiseTags, 44 | ), 45 | ); 46 | } 47 | 48 | static async get(store: IScheduleStore, encoder: IEncoder, id: string) { 49 | return new Schedule(store, encoder, await store.get(id)); 50 | } 51 | 52 | static async *search( 53 | store: IScheduleStore, 54 | encoder: IEncoder, 55 | id: string, 56 | tags?: Record, 57 | limit?: number, 58 | ): AsyncGenerator { 59 | for await (const schedules of store.search(id, tags, limit)) { 60 | yield schedules.map((s) => new Schedule(store, encoder, s)); 61 | } 62 | } 63 | 64 | async delete() { 65 | await this.store.delete(this.schedule.id); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/core/schedules/types.ts: -------------------------------------------------------------------------------- 1 | export type Schedule = { 2 | id: string; 3 | description: string | undefined; 4 | cron: string; 5 | tags: Record | undefined; 6 | promiseId: string; 7 | promiseTimeout: number; 8 | promiseParam: { 9 | data: string | undefined; 10 | headers: Record | undefined; 11 | }; 12 | promiseTags: Record | undefined; 13 | lastRunTime: number | undefined; 14 | nextRunTime: number; 15 | idempotencyKey: string | undefined; 16 | createdOn: number; 17 | }; 18 | 19 | export function isSchedule(s: unknown): s is Schedule { 20 | return s !== null && typeof s === "object" && "id" in s; 21 | } 22 | -------------------------------------------------------------------------------- /lib/core/storage.ts: -------------------------------------------------------------------------------- 1 | export interface IStorage { 2 | rmw(id: string, func: (item: T | undefined) => X): Promise; 3 | rmd(id: string, func: (item: T) => boolean): Promise; 4 | all(): AsyncGenerator; 5 | } 6 | -------------------------------------------------------------------------------- /lib/core/storages/memory.ts: -------------------------------------------------------------------------------- 1 | import { IStorage } from "../storage"; 2 | 3 | export class MemoryStorage implements IStorage { 4 | private items: Record = {}; 5 | 6 | // read-modify-write 7 | async rmw(id: string, func: (item: T | undefined) => X): Promise { 8 | const item = func(this.items[id]); 9 | if (item) { 10 | this.items[id] = item; 11 | } 12 | 13 | return item; 14 | } 15 | 16 | // read-modify-delete 17 | async rmd(id: string, func: (item: T) => boolean): Promise { 18 | const item = this.items[id]; 19 | let result = false; 20 | 21 | if (item && func(item)) { 22 | delete this.items[id]; 23 | result = true; 24 | } 25 | return result; 26 | } 27 | 28 | async *all(): AsyncGenerator { 29 | yield Object.values(this.items); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/core/storages/withTimeout.ts: -------------------------------------------------------------------------------- 1 | import { DurablePromiseRecord, isPendingPromise } from "../promises/types"; 2 | import { IStorage } from "../storage"; 3 | import { MemoryStorage } from "./memory"; 4 | 5 | export class WithTimeout implements IStorage { 6 | constructor(private storage: IStorage = new MemoryStorage()) {} 7 | 8 | rmw( 9 | id: string, 10 | func: (item: DurablePromiseRecord | undefined) => T, 11 | ): Promise { 12 | return this.storage.rmw(id, (p) => func(p ? timeout(p) : undefined)); 13 | } 14 | 15 | rmd(id: string, func: (item: DurablePromiseRecord) => boolean): Promise { 16 | return this.storage.rmd(id, (p) => func(timeout(p))); 17 | } 18 | 19 | async *all(): AsyncGenerator { 20 | for await (const promises of this.storage.all()) { 21 | yield promises.map(timeout); 22 | } 23 | } 24 | } 25 | 26 | function timeout(promise: T): DurablePromiseRecord { 27 | if (isPendingPromise(promise) && Date.now() >= promise.timeout) { 28 | return { 29 | state: promise.tags?.["resonate:timeout"] === "true" ? "RESOLVED" : "REJECTED_TIMEDOUT", 30 | id: promise.id, 31 | timeout: promise.timeout, 32 | param: promise.param, 33 | value: { 34 | headers: undefined, 35 | data: undefined, 36 | }, 37 | createdOn: promise.createdOn, 38 | completedOn: promise.timeout, 39 | idempotencyKeyForCreate: promise.idempotencyKeyForCreate, 40 | idempotencyKeyForComplete: undefined, 41 | tags: promise.tags, 42 | }; 43 | } 44 | 45 | return promise; 46 | } 47 | -------------------------------------------------------------------------------- /lib/core/store.ts: -------------------------------------------------------------------------------- 1 | import { DurablePromiseRecord } from "./promises/types"; 2 | 3 | import { Schedule } from "./schedules/types"; 4 | 5 | /** 6 | * Store Interface 7 | */ 8 | export interface IStore { 9 | readonly promises: IPromiseStore; 10 | readonly schedules: IScheduleStore; 11 | readonly locks: ILockStore; 12 | } 13 | 14 | /** 15 | * Promise Store API 16 | */ 17 | export interface IPromiseStore { 18 | /** 19 | * Creates a new durable promise 20 | * 21 | * @param id Unique identifier for the promise. 22 | * @param ikey Idempotency key associated with the create operation. 23 | * @param strict If true, deduplicates only if the promise is pending. 24 | * @param headers Key value pairs associated with the data. 25 | * @param data Encoded data of type string. 26 | * @param timeout Time (in milliseconds) after which the promise is considered expired. 27 | * @param tags Key value pairs associated with the promise. 28 | * @returns A durable promise that is pending, canceled, resolved, or rejected. 29 | */ 30 | create( 31 | id: string, 32 | ikey: string | undefined, 33 | strict: boolean, 34 | headers: Record | undefined, 35 | data: string | undefined, 36 | timeout: number, 37 | tags: Record | undefined, 38 | ): Promise; 39 | 40 | /** 41 | * Cancels a new promise. 42 | * 43 | * @param id Unique identifier for the promise. 44 | * @param ikey Idempotency key associated with the create operation. 45 | * @param strict If true, deduplicates only if the promise is canceled. 46 | * @param headers Key value pairs associated with the data. 47 | * @param data Encoded data of type string. 48 | * @returns A durable promise that is canceled, resolved, or rejected. 49 | */ 50 | cancel( 51 | id: string, 52 | ikey: string | undefined, 53 | strict: boolean, 54 | headers: Record | undefined, 55 | data: string | undefined, 56 | ): Promise; 57 | 58 | /** 59 | * Resolves a promise. 60 | * 61 | * @param id Unique identifier for the promise to be resolved. 62 | * @param ikey Idempotency key associated with the resolve promise. 63 | * @param strict If true, deduplicates only if the promise is resolved. 64 | * @param headers Key value pairs associated with the data. 65 | * @param data Encoded data of type string. 66 | * @returns A durable promise that is canceled, resolved, or rejected. 67 | */ 68 | resolve( 69 | id: string, 70 | ikey: string | undefined, 71 | strict: boolean, 72 | headers: Record | undefined, 73 | data: string | undefined, 74 | ): Promise; 75 | 76 | /** 77 | * Rejects a promise 78 | * 79 | * @param id Unique identifier for the promise to be rejected. 80 | * @param ikey Integration key associated with the promise. 81 | * @param strict If true, deduplicates only if the promise is rejected. 82 | * @param headers Key value pairs associated with the data. 83 | * @param data Encoded data of type string. 84 | * @returns A durable promise that is canceled, resolved, or rejected. 85 | */ 86 | reject( 87 | id: string, 88 | ikey: string | undefined, 89 | strict: boolean, 90 | headers: Record | undefined, 91 | data: string | undefined, 92 | ): Promise; 93 | 94 | /** 95 | * Retrieves a promise based on its id. 96 | * 97 | * @param id Unique identifier for the promise to be retrieved. 98 | * @returns A durable promise that is pending, canceled, resolved, or rejected. 99 | */ 100 | get(id: string): Promise; 101 | 102 | /** 103 | * Search for promises. 104 | * 105 | * @param id Ids to match, can include wildcards. 106 | * @param state State to match. 107 | * @param tags Tags to match. 108 | * @param limit Maximum number of promises to return. 109 | * @returns A list of Durable Promises. 110 | */ 111 | search( 112 | id: string, 113 | state: string | undefined, 114 | tags: Record | undefined, 115 | limit?: number, 116 | ): AsyncGenerator; 117 | } 118 | 119 | /** 120 | * Schedule Store API 121 | */ 122 | export interface IScheduleStore { 123 | /** 124 | * Creates a new schedule. 125 | * 126 | * @param id Unique identifier for the schedule. 127 | * @param ikey Idempotency key associated with the create operation. 128 | * @param description Description of the schedule. 129 | * @param cron CRON expression defining the schedule's execution time. 130 | * @param tags Key-value pairs associated with the schedule. 131 | * @param promiseId Unique identifier for the associated promise. 132 | * @param promiseTimeout Timeout for the associated promise in milliseconds. 133 | * @param promiseHeaders Headers associated with the promise data. 134 | * @param promiseData Encoded data for the promise of type string. 135 | * @param promiseTags Key-value pairs associated with the promise. 136 | * @returns A Promise resolving to the created schedule. 137 | */ 138 | create( 139 | id: string, 140 | ikey: string | undefined, 141 | description: string | undefined, 142 | cron: string, 143 | tags: Record | undefined, 144 | promiseId: string, 145 | promiseTimeout: number, 146 | promiseHeaders: Record | undefined, 147 | promiseData: string | undefined, 148 | promiseTags: Record | undefined, 149 | ): Promise; 150 | 151 | /** 152 | * Retrieves a schedule based on its id. 153 | * 154 | * @param id Unique identifier for the promise to be retrieved. 155 | * @returns A promise schedule that is pending, canceled, resolved, or rejected. 156 | */ 157 | get(id: string): Promise; 158 | 159 | /** 160 | * Deletes a schedule based on its id. 161 | * @param id Unique identifier for the promise to be deleted. 162 | * @returns A promise schedule that is pending, canceled, resolved, or rejected. 163 | */ 164 | delete(id: string): Promise; 165 | 166 | /** 167 | * Search for schedules. 168 | * 169 | * @param id Ids to match, can include wildcards. 170 | * @param tags Tags to match. 171 | * @returns A list of promise schedules. 172 | */ 173 | search(id: string, tags: Record | undefined, limit?: number): AsyncGenerator; 174 | } 175 | 176 | /** 177 | * Lock Store API 178 | */ 179 | export interface ILockStore { 180 | /** 181 | * Try to acquire a lock. 182 | * 183 | * @param id Id of lock. 184 | * @param eid Execution id of lock. 185 | * @param expiry Time in ms before lock will expire. 186 | * @returns A boolean indicating whether or not the lock was acquired. 187 | */ 188 | tryAcquire(id: string, eid: string, expiry?: number): Promise; 189 | 190 | /** 191 | * Release a lock. 192 | * 193 | * @param id Id of lock. 194 | * @param eid Execution id of lock. 195 | */ 196 | release(id: string, eid: string): Promise; 197 | } 198 | -------------------------------------------------------------------------------- /lib/core/stores/local.ts: -------------------------------------------------------------------------------- 1 | import * as cronParser from "cron-parser"; 2 | import { ErrorCodes, ResonateError } from "../errors"; 3 | import { ILogger } from "../logger"; 4 | import { Logger } from "../loggers/logger"; 5 | import { StoreOptions } from "../options"; 6 | import { 7 | DurablePromiseRecord, 8 | isPendingPromise, 9 | isResolvedPromise, 10 | isRejectedPromise, 11 | isCanceledPromise, 12 | isTimedoutPromise, 13 | } from "../promises/types"; 14 | import { Schedule } from "../schedules/types"; 15 | import { IStorage } from "../storage"; 16 | import { MemoryStorage } from "../storages/memory"; 17 | import { WithTimeout } from "../storages/withTimeout"; 18 | import { IStore, IPromiseStore, IScheduleStore, ILockStore } from "../store"; 19 | 20 | export class LocalStore implements IStore { 21 | public promises: LocalPromiseStore; 22 | public schedules: LocalScheduleStore; 23 | public locks: LocalLockStore; 24 | 25 | public readonly logger: ILogger; 26 | 27 | private toSchedule: Schedule[] = []; 28 | private next: number | undefined = undefined; 29 | 30 | constructor( 31 | opts: Partial = {}, 32 | promiseStorage: IStorage = new WithTimeout(new MemoryStorage()), 33 | scheduleStorage: IStorage = new MemoryStorage(), 34 | lockStorage: IStorage<{ id: string; eid: string }> = new MemoryStorage<{ id: string; eid: string }>(), 35 | ) { 36 | this.promises = new LocalPromiseStore(this, promiseStorage); 37 | this.schedules = new LocalScheduleStore(this, scheduleStorage); 38 | this.locks = new LocalLockStore(this, lockStorage); 39 | 40 | this.logger = opts.logger ?? new Logger(); 41 | 42 | this.init(); 43 | } 44 | 45 | // handler the schedule store can call 46 | addSchedule(schedule: Schedule) { 47 | this.toSchedule = this.toSchedule.filter((s) => s.id != schedule.id).concat(schedule); 48 | this.setSchedule(); 49 | } 50 | 51 | // handler the schedule store can call 52 | deleteSchedule(id: string) { 53 | this.toSchedule = this.toSchedule.filter((s) => s.id != id); 54 | this.setSchedule(); 55 | } 56 | 57 | private async init() { 58 | for await (const schedules of this.schedules.search("*")) { 59 | this.toSchedule = this.toSchedule.concat(schedules); 60 | } 61 | 62 | this.setSchedule(); 63 | } 64 | 65 | private setSchedule() { 66 | // clear timeout 67 | clearTimeout(this.next); 68 | 69 | // sort array in ascending order by nextRunTime 70 | this.toSchedule.sort((a, b) => a.nextRunTime - b.nextRunTime); 71 | 72 | if (this.toSchedule.length > 0) { 73 | // set new timeout to schedule promise 74 | // + converts to number 75 | this.next = +setTimeout(() => this.schedulePromise(), this.toSchedule[0].nextRunTime - Date.now()); 76 | } 77 | } 78 | 79 | private schedulePromise() { 80 | this.next = undefined; 81 | const schedule = this.toSchedule.shift(); 82 | 83 | if (schedule) { 84 | const id = this.generatePromiseId(schedule); 85 | 86 | // create promise 87 | try { 88 | this.promises.create( 89 | id, 90 | id, 91 | false, 92 | schedule.promiseParam?.headers, 93 | schedule.promiseParam?.data, 94 | Date.now() + schedule.promiseTimeout, 95 | { ...schedule.promiseTags, "resonate:schedule": schedule.id, "resonate:invocation": "true" }, 96 | ); 97 | } catch (error) { 98 | this.logger.warn("error creating scheduled promise", error); 99 | } 100 | 101 | // update schedule 102 | try { 103 | this.schedules.update(schedule.id, schedule.nextRunTime); 104 | } catch (error) { 105 | this.logger.warn("error updating schedule", error); 106 | } 107 | } 108 | } 109 | 110 | private generatePromiseId(schedule: Schedule): string { 111 | return schedule.promiseId 112 | .replace("{{.id}}", schedule.id) 113 | .replace("{{.timestamp}}", schedule.nextRunTime.toString()); 114 | } 115 | } 116 | 117 | export class LocalPromiseStore implements IPromiseStore { 118 | constructor( 119 | private store: LocalStore, 120 | private storage: IStorage, 121 | ) {} 122 | 123 | async create( 124 | id: string, 125 | ikey: string | undefined, 126 | strict: boolean, 127 | headers: Record | undefined, 128 | data: string | undefined, 129 | timeout: number, 130 | tags: Record | undefined, 131 | ): Promise { 132 | return this.storage.rmw(id, (promise) => { 133 | if (!promise) { 134 | return { 135 | state: "PENDING", 136 | id: id, 137 | timeout: timeout, 138 | param: { 139 | headers: headers, 140 | data: data, 141 | }, 142 | value: { 143 | headers: undefined, 144 | data: undefined, 145 | }, 146 | createdOn: Date.now(), 147 | completedOn: undefined, 148 | idempotencyKeyForCreate: ikey, 149 | idempotencyKeyForComplete: undefined, 150 | tags: tags, 151 | }; 152 | } 153 | 154 | if (strict && !isPendingPromise(promise)) { 155 | throw new ResonateError("Forbidden request: Durable promise previously created", ErrorCodes.STORE_FORBIDDEN); 156 | } 157 | 158 | if (promise.idempotencyKeyForCreate === undefined || ikey !== promise.idempotencyKeyForCreate) { 159 | throw new ResonateError("Forbidden request: Missing idempotency key for create", ErrorCodes.STORE_FORBIDDEN); 160 | } 161 | 162 | return promise; 163 | }); 164 | } 165 | 166 | async resolve( 167 | id: string, 168 | ikey: string | undefined, 169 | strict: boolean, 170 | headers: Record | undefined, 171 | data: string | undefined, 172 | ): Promise { 173 | return this.storage.rmw(id, (promise) => { 174 | if (!promise) { 175 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND); 176 | } 177 | 178 | if (isPendingPromise(promise)) { 179 | return { 180 | state: "RESOLVED", 181 | id: promise.id, 182 | timeout: promise.timeout, 183 | param: promise.param, 184 | value: { 185 | headers: headers, 186 | data: data, 187 | }, 188 | createdOn: promise.createdOn, 189 | completedOn: Date.now(), 190 | idempotencyKeyForCreate: promise.idempotencyKeyForCreate, 191 | idempotencyKeyForComplete: ikey, 192 | tags: promise.tags, 193 | }; 194 | } 195 | 196 | if (strict && !isResolvedPromise(promise)) { 197 | throw new ResonateError("Forbidden request", ErrorCodes.STORE_FORBIDDEN); 198 | } 199 | 200 | if ( 201 | !isTimedoutPromise(promise) && 202 | (promise.idempotencyKeyForComplete === undefined || ikey !== promise.idempotencyKeyForComplete) 203 | ) { 204 | throw new ResonateError("Forbidden request", ErrorCodes.STORE_FORBIDDEN); 205 | } 206 | 207 | return promise; 208 | }); 209 | } 210 | 211 | async reject( 212 | id: string, 213 | ikey: string | undefined, 214 | strict: boolean, 215 | headers: Record | undefined, 216 | data: string | undefined, 217 | ): Promise { 218 | return this.storage.rmw(id, (promise) => { 219 | if (!promise) { 220 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND); 221 | } 222 | 223 | if (isPendingPromise(promise)) { 224 | return { 225 | state: "REJECTED", 226 | id: promise.id, 227 | timeout: promise.timeout, 228 | param: promise.param, 229 | value: { 230 | headers: headers, 231 | data: data, 232 | }, 233 | createdOn: promise.createdOn, 234 | completedOn: Date.now(), 235 | idempotencyKeyForCreate: promise.idempotencyKeyForCreate, 236 | idempotencyKeyForComplete: ikey, 237 | tags: promise.tags, 238 | }; 239 | } 240 | 241 | if (strict && !isRejectedPromise(promise)) { 242 | throw new ResonateError("Forbidden request", ErrorCodes.STORE_FORBIDDEN); 243 | } 244 | 245 | if ( 246 | !isTimedoutPromise(promise) && 247 | (promise.idempotencyKeyForComplete === undefined || ikey !== promise.idempotencyKeyForComplete) 248 | ) { 249 | throw new ResonateError("Forbidden request", ErrorCodes.STORE_FORBIDDEN); 250 | } 251 | 252 | return promise; 253 | }); 254 | } 255 | 256 | async cancel( 257 | id: string, 258 | ikey: string | undefined, 259 | strict: boolean, 260 | headers: Record | undefined, 261 | data: string | undefined, 262 | ): Promise { 263 | return this.storage.rmw(id, (promise) => { 264 | if (!promise) { 265 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND); 266 | } 267 | 268 | if (isPendingPromise(promise)) { 269 | return { 270 | state: "REJECTED_CANCELED", 271 | id: promise.id, 272 | timeout: promise.timeout, 273 | param: promise.param, 274 | value: { 275 | headers: headers, 276 | data: data, 277 | }, 278 | createdOn: promise.createdOn, 279 | completedOn: Date.now(), 280 | idempotencyKeyForCreate: promise.idempotencyKeyForCreate, 281 | idempotencyKeyForComplete: ikey, 282 | tags: promise.tags, 283 | }; 284 | } 285 | 286 | if (strict && !isCanceledPromise(promise)) { 287 | throw new ResonateError("Forbidden request", ErrorCodes.STORE_FORBIDDEN); 288 | } 289 | 290 | if ( 291 | !isTimedoutPromise(promise) && 292 | (promise.idempotencyKeyForComplete === undefined || ikey !== promise.idempotencyKeyForComplete) 293 | ) { 294 | throw new ResonateError("Forbidden request", ErrorCodes.STORE_FORBIDDEN); 295 | } 296 | 297 | return promise; 298 | }); 299 | } 300 | 301 | async get(id: string): Promise { 302 | const promise = await this.storage.rmw(id, (p) => p); 303 | 304 | if (!promise) { 305 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND); 306 | } 307 | 308 | return promise; 309 | } 310 | 311 | async *search( 312 | id: string, 313 | state?: string, 314 | tags?: Record, 315 | limit?: number, 316 | ): AsyncGenerator { 317 | // filter the promises returned from all storage 318 | const regex = new RegExp(id.replaceAll("*", ".*")); 319 | const states = searchStates(state); 320 | const tagEntries = Object.entries(tags ?? {}); 321 | 322 | for await (const promises of this.storage.all()) { 323 | yield promises 324 | .filter((p) => states.includes(p.state)) 325 | .filter((p) => regex.test(p.id)) 326 | .filter((p) => tagEntries.every(([k, v]) => p.tags?.[k] == v)); 327 | } 328 | } 329 | } 330 | 331 | export class LocalScheduleStore implements IScheduleStore { 332 | constructor( 333 | private store: LocalStore, 334 | private storage: IStorage, 335 | ) {} 336 | 337 | async create( 338 | id: string, 339 | ikey: string | undefined, 340 | description: string | undefined, 341 | cron: string, 342 | tags: Record | undefined, 343 | promiseId: string, 344 | promiseTimeout: number, 345 | promiseHeaders: Record, 346 | promiseData: string | undefined, 347 | promiseTags: Record | undefined, 348 | ): Promise { 349 | const schedule = await this.storage.rmw(id, (schedule) => { 350 | if (schedule) { 351 | if (schedule.idempotencyKey === undefined || ikey != schedule.idempotencyKey) { 352 | throw new ResonateError("Already exists", ErrorCodes.STORE_ALREADY_EXISTS); 353 | } 354 | return schedule; 355 | } 356 | 357 | const createdOn = Date.now(); 358 | 359 | let nextRunTime: number; 360 | try { 361 | nextRunTime = this.nextRunTime(cron, createdOn); 362 | } catch (error) { 363 | throw ResonateError.fromError(error); 364 | } 365 | 366 | return { 367 | id, 368 | description, 369 | cron, 370 | tags, 371 | promiseId, 372 | promiseTimeout, 373 | promiseParam: { 374 | headers: promiseHeaders, 375 | data: promiseData, 376 | }, 377 | promiseTags, 378 | lastRunTime: undefined, 379 | nextRunTime: nextRunTime, 380 | idempotencyKey: ikey, 381 | createdOn: createdOn, 382 | }; 383 | }); 384 | 385 | if (this.store) { 386 | this.store.addSchedule(schedule); 387 | } 388 | 389 | return schedule; 390 | } 391 | 392 | async update(id: string, lastRunTime: number): Promise { 393 | const schedule = await this.storage.rmw(id, (schedule) => { 394 | if (!schedule) { 395 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND); 396 | } 397 | 398 | // update iff not already updated 399 | if (schedule.nextRunTime === lastRunTime) { 400 | let nextRunTime: number; 401 | try { 402 | nextRunTime = this.nextRunTime(schedule.cron, lastRunTime); 403 | } catch (error) { 404 | throw ResonateError.fromError(error); 405 | } 406 | 407 | schedule.lastRunTime = lastRunTime; 408 | schedule.nextRunTime = nextRunTime; 409 | } 410 | 411 | return schedule; 412 | }); 413 | 414 | if (this.store) { 415 | this.store.addSchedule(schedule); 416 | } 417 | 418 | return schedule; 419 | } 420 | 421 | async delete(id: string): Promise { 422 | const result = await this.storage.rmd(id, () => true); 423 | if (!result) { 424 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND); 425 | } 426 | 427 | if (this.store) { 428 | this.store.deleteSchedule(id); 429 | } 430 | } 431 | 432 | async get(id: string): Promise { 433 | const schedule = await this.storage.rmw(id, (s) => s); 434 | 435 | if (!schedule) { 436 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND); 437 | } 438 | 439 | return schedule; 440 | } 441 | 442 | async *search(id: string, tags?: Record, limit?: number): AsyncGenerator { 443 | // filter the schedules returned from storage 444 | const regex = new RegExp(id.replaceAll("*", ".*")); 445 | const tagEntries = Object.entries(tags ?? {}); 446 | 447 | for await (const schedules of this.storage.all()) { 448 | yield schedules.filter((s) => regex.test(s.id)).filter((s) => tagEntries.every(([k, v]) => s.tags?.[k] == v)); 449 | } 450 | } 451 | 452 | private nextRunTime(cron: string, lastRunTime: number): number { 453 | return cronParser 454 | .parseExpression(cron, { currentDate: new Date(lastRunTime) }) 455 | .next() 456 | .getTime(); 457 | } 458 | } 459 | 460 | export class LocalLockStore implements ILockStore { 461 | constructor( 462 | private store: LocalStore, 463 | private storage: IStorage<{ id: string; eid: string }>, 464 | ) {} 465 | 466 | async tryAcquire(id: string, eid: string): Promise { 467 | const lock = await this.storage.rmw(id, (lock) => { 468 | if (!lock || lock.eid === eid) { 469 | return { 470 | id, 471 | eid, 472 | }; 473 | } 474 | 475 | return lock; 476 | }); 477 | 478 | if (lock.eid !== eid) { 479 | throw new ResonateError("Forbidden request", ErrorCodes.STORE_FORBIDDEN); 480 | } 481 | 482 | return true; 483 | } 484 | 485 | async release(id: string, eid: string): Promise { 486 | const result = await this.storage.rmd(id, (lock) => lock.eid === eid); 487 | if (!result) { 488 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND); 489 | } 490 | 491 | return true; 492 | } 493 | } 494 | 495 | // Utils 496 | 497 | function searchStates(state: string | undefined): string[] { 498 | if (state?.toLowerCase() == "pending") { 499 | return ["PENDING"]; 500 | } else if (state?.toLowerCase() == "resolved") { 501 | return ["RESOLVED"]; 502 | } else if (state?.toLowerCase() == "rejected") { 503 | return ["REJECTED", "REJECTED_CANCELED", "REJECTED_TIMEDOUT"]; 504 | } else { 505 | return ["PENDING", "RESOLVED", "REJECTED", "REJECTED_CANCELED", "REJECTED_TIMEDOUT"]; 506 | } 507 | } 508 | -------------------------------------------------------------------------------- /lib/core/stores/remote.ts: -------------------------------------------------------------------------------- 1 | import { IEncoder } from "../encoder"; 2 | import { Base64Encoder } from "../encoders/base64"; 3 | import { ErrorCodes, ResonateError } from "../errors"; 4 | import { ILogger } from "../logger"; 5 | import { Logger } from "../loggers/logger"; 6 | import { StoreOptions } from "../options"; 7 | import { DurablePromiseRecord, isDurablePromiseRecord, isCompletedPromise } from "../promises/types"; 8 | import { Schedule, isSchedule } from "../schedules/types"; 9 | import { IStore, IPromiseStore, IScheduleStore, ILockStore } from "../store"; 10 | import * as utils from "../utils"; 11 | 12 | export class RemoteStore implements IStore { 13 | private readonly headers: Record = { 14 | Accept: "application/json", 15 | "Content-Type": "application/json", 16 | }; 17 | 18 | public readonly promises: RemotePromiseStore; 19 | public readonly schedules: RemoteScheduleStore; 20 | public readonly locks: RemoteLockStore; 21 | 22 | public readonly encoder: IEncoder; 23 | public readonly heartbeat: number; 24 | public readonly logger: ILogger; 25 | public readonly pid: string; 26 | public readonly retries: number; 27 | 28 | constructor( 29 | public readonly url: string, 30 | opts: Partial = {}, 31 | ) { 32 | // store components 33 | this.promises = new RemotePromiseStore(this); 34 | this.schedules = new RemoteScheduleStore(this); 35 | this.locks = new RemoteLockStore(this); 36 | 37 | // store options 38 | this.encoder = opts.encoder ?? new Base64Encoder(); 39 | this.logger = opts.logger ?? new Logger(); 40 | this.heartbeat = opts.heartbeat ?? 15000; 41 | this.pid = opts.pid ?? utils.randomId(); 42 | this.retries = opts.retries ?? 2; 43 | 44 | // auth 45 | if (opts.auth?.basic) { 46 | this.headers["Authorization"] = `Basic ${btoa(`${opts.auth.basic.username}:${opts.auth.basic.password}`)}`; 47 | } 48 | } 49 | 50 | async call(path: string, guard: (b: unknown) => b is T, options: RequestInit): Promise { 51 | let error: unknown; 52 | 53 | // add auth headers 54 | options.headers = { ...this.headers, ...options.headers }; 55 | 56 | for (let i = 0; i < this.retries + 1; i++) { 57 | try { 58 | this.logger.debug("store:req", { 59 | url: this.url, 60 | method: options.method, 61 | headers: options.headers, 62 | body: options.body, 63 | }); 64 | 65 | let r!: Response; 66 | try { 67 | r = await fetch(`${this.url}/${path}`, options); 68 | } catch (err) { 69 | // We need to differentiate between errors in this fetch and possible fetching 70 | // errors in the user code, that is why we wrap the fetch error in a resonate 71 | // error. 72 | // Some fetch errors are caused by malformed urls or wrong use of headers, 73 | // thoses shouldn't be retriable, but fetch throws a `TypeError` for those 74 | // aswell as errors when the server is unreachble which is a retriable error. 75 | // Since it is hard to identify which error was thrown, we mark all of them as retriable. 76 | throw new ResonateError("Fetch Error", ErrorCodes.FETCH, err, true); 77 | } 78 | 79 | const body: unknown = r.status !== 204 ? await r.json() : undefined; 80 | 81 | this.logger.debug("store:res", { 82 | status: r.status, 83 | body: body, 84 | }); 85 | 86 | if (!r.ok) { 87 | switch (r.status) { 88 | case 400: 89 | throw new ResonateError("Invalid request", ErrorCodes.STORE_PAYLOAD, body); 90 | case 401: 91 | throw new ResonateError("Unauthorized request", ErrorCodes.STORE_UNAUTHORIZED, body); 92 | case 403: 93 | throw new ResonateError("Forbidden request", ErrorCodes.STORE_FORBIDDEN, body); 94 | case 404: 95 | throw new ResonateError("Not found", ErrorCodes.STORE_NOT_FOUND, body); 96 | case 409: 97 | throw new ResonateError("Already exists", ErrorCodes.STORE_ALREADY_EXISTS, body); 98 | default: 99 | throw new ResonateError("Server error", ErrorCodes.STORE, body, true); 100 | } 101 | } 102 | 103 | if (!guard(body)) { 104 | throw new ResonateError("Invalid response", ErrorCodes.STORE_PAYLOAD, body); 105 | } 106 | 107 | return body; 108 | } catch (e: unknown) { 109 | if (e instanceof ResonateError && !e.retriable) { 110 | throw e; 111 | } else { 112 | error = e; 113 | } 114 | } 115 | } 116 | 117 | throw ResonateError.fromError(error); 118 | } 119 | } 120 | 121 | export class RemotePromiseStore implements IPromiseStore { 122 | constructor(private store: RemoteStore) {} 123 | 124 | async create( 125 | id: string, 126 | ikey: string | undefined, 127 | strict: boolean, 128 | headers: Record | undefined, 129 | data: string | undefined, 130 | timeout: number, 131 | tags: Record | undefined, 132 | ): Promise { 133 | const reqHeaders: Record = { 134 | Strict: JSON.stringify(strict), 135 | }; 136 | 137 | if (ikey !== undefined) { 138 | reqHeaders["Idempotency-Key"] = ikey; 139 | } 140 | 141 | const promise = await this.store.call("promises", isDurablePromiseRecord, { 142 | method: "POST", 143 | headers: reqHeaders, 144 | body: JSON.stringify({ 145 | id: id, 146 | param: { 147 | headers: headers, 148 | data: data ? encode(data, this.store.encoder) : undefined, 149 | }, 150 | timeout: timeout, 151 | tags: tags, 152 | }), 153 | }); 154 | 155 | return decode(promise, this.store.encoder); 156 | } 157 | 158 | async cancel( 159 | id: string, 160 | ikey: string | undefined, 161 | strict: boolean, 162 | headers: Record | undefined, 163 | data: string | undefined, 164 | ): Promise { 165 | const reqHeaders: Record = { 166 | Strict: JSON.stringify(strict), 167 | }; 168 | 169 | if (ikey !== undefined) { 170 | reqHeaders["Idempotency-Key"] = ikey; 171 | } 172 | 173 | const promise = await this.store.call(`promises/${id}`, isDurablePromiseRecord, { 174 | method: "PATCH", 175 | headers: reqHeaders, 176 | body: JSON.stringify({ 177 | state: "REJECTED_CANCELED", 178 | value: { 179 | headers: headers, 180 | data: data ? encode(data, this.store.encoder) : undefined, 181 | }, 182 | }), 183 | }); 184 | 185 | if (!isCompletedPromise(promise)) { 186 | throw new ResonateError("Invalid response", ErrorCodes.STORE_PAYLOAD, promise); 187 | } 188 | 189 | return decode(promise, this.store.encoder); 190 | } 191 | 192 | async resolve( 193 | id: string, 194 | ikey: string | undefined, 195 | strict: boolean, 196 | headers: Record | undefined, 197 | data: string | undefined, 198 | ): Promise { 199 | const reqHeaders: Record = { 200 | Strict: JSON.stringify(strict), 201 | }; 202 | 203 | if (ikey !== undefined) { 204 | reqHeaders["Idempotency-Key"] = ikey; 205 | } 206 | 207 | const promise = await this.store.call(`promises/${id}`, isDurablePromiseRecord, { 208 | method: "PATCH", 209 | headers: reqHeaders, 210 | body: JSON.stringify({ 211 | state: "RESOLVED", 212 | value: { 213 | headers: headers, 214 | data: data ? encode(data, this.store.encoder) : undefined, 215 | }, 216 | }), 217 | }); 218 | 219 | if (!isCompletedPromise(promise)) { 220 | throw new ResonateError("Invalid response", ErrorCodes.STORE_PAYLOAD, promise); 221 | } 222 | 223 | return decode(promise, this.store.encoder); 224 | } 225 | 226 | async reject( 227 | id: string, 228 | ikey: string | undefined, 229 | strict: boolean, 230 | headers: Record | undefined, 231 | data: string | undefined, 232 | ): Promise { 233 | const reqHeaders: Record = { 234 | Strict: JSON.stringify(strict), 235 | }; 236 | 237 | if (ikey !== undefined) { 238 | reqHeaders["Idempotency-Key"] = ikey; 239 | } 240 | 241 | const promise = await this.store.call(`promises/${id}`, isDurablePromiseRecord, { 242 | method: "PATCH", 243 | headers: reqHeaders, 244 | body: JSON.stringify({ 245 | state: "REJECTED", 246 | value: { 247 | headers: headers, 248 | data: data ? encode(data, this.store.encoder) : undefined, 249 | }, 250 | }), 251 | }); 252 | 253 | if (!isCompletedPromise(promise)) { 254 | throw new ResonateError("Invalid response", ErrorCodes.STORE_PAYLOAD, promise); 255 | } 256 | 257 | return decode(promise, this.store.encoder); 258 | } 259 | 260 | async get(id: string): Promise { 261 | const promise = await this.store.call(`promises/${id}`, isDurablePromiseRecord, { 262 | method: "GET", 263 | }); 264 | 265 | return decode(promise, this.store.encoder); 266 | } 267 | 268 | async *search( 269 | id: string, 270 | state: string | undefined, 271 | tags: Record | undefined, 272 | limit: number | undefined, 273 | ): AsyncGenerator { 274 | let cursor: string | null | undefined = undefined; 275 | 276 | while (cursor !== null) { 277 | const params = new URLSearchParams({ id }); 278 | 279 | if (state !== undefined) { 280 | params.append("state", state); 281 | } 282 | 283 | for (const [k, v] of Object.entries(tags ?? {})) { 284 | params.append(`tags[${k}]`, v); 285 | } 286 | 287 | if (limit !== undefined) { 288 | params.append("limit", limit.toString()); 289 | } 290 | 291 | if (cursor !== undefined) { 292 | params.append("cursor", cursor); 293 | } 294 | 295 | const res = await this.store.call(`promises?${params.toString()}`, isSearchPromiseResult, { 296 | method: "GET", 297 | }); 298 | 299 | cursor = res.cursor; 300 | yield res.promises.map((p) => decode(p, this.store.encoder)); 301 | } 302 | } 303 | } 304 | 305 | export class RemoteScheduleStore implements IScheduleStore { 306 | constructor(private store: RemoteStore) {} 307 | 308 | async create( 309 | id: string, 310 | ikey: string | undefined, 311 | description: string | undefined, 312 | cron: string, 313 | tags: Record | undefined, 314 | promiseId: string, 315 | promiseTimeout: number, 316 | promiseHeaders: Record | undefined, 317 | promiseData: string | undefined, 318 | promiseTags: Record | undefined, 319 | ): Promise { 320 | const reqHeaders: Record = {}; 321 | 322 | if (ikey !== undefined) { 323 | reqHeaders["Idempotency-Key"] = ikey; 324 | } 325 | 326 | const schedule = this.store.call(`schedules`, isSchedule, { 327 | method: "POST", 328 | headers: reqHeaders, 329 | body: JSON.stringify({ 330 | id, 331 | description, 332 | cron, 333 | tags, 334 | promiseId, 335 | promiseTimeout, 336 | promiseParam: { 337 | headers: promiseHeaders, 338 | data: promiseData ? encode(promiseData, this.store.encoder) : undefined, 339 | }, 340 | promiseTags, 341 | }), 342 | }); 343 | 344 | return schedule; 345 | } 346 | 347 | async get(id: string): Promise { 348 | const schedule = this.store.call(`schedules/${id}`, isSchedule, { 349 | method: "GET", 350 | }); 351 | 352 | return schedule; 353 | } 354 | 355 | async delete(id: string): Promise { 356 | await this.store.call(`schedules/${id}`, (b: unknown): b is any => true, { 357 | method: "DELETE", 358 | }); 359 | } 360 | 361 | async *search( 362 | id: string, 363 | tags: Record | undefined, 364 | limit: number | undefined, 365 | ): AsyncGenerator { 366 | let cursor: string | null | undefined = undefined; 367 | 368 | while (cursor !== null) { 369 | const params = new URLSearchParams({ id }); 370 | 371 | for (const [k, v] of Object.entries(tags ?? {})) { 372 | params.append(`tags[${k}]`, v); 373 | } 374 | 375 | if (limit !== undefined) { 376 | params.append("limit", limit.toString()); 377 | } 378 | 379 | if (cursor !== undefined) { 380 | params.append("cursor", cursor); 381 | } 382 | 383 | const res = await this.store.call(`schedules?${params.toString()}`, isSearchSchedulesResult, { 384 | method: "GET", 385 | }); 386 | 387 | cursor = res.cursor; 388 | yield res.schedules; 389 | } 390 | } 391 | } 392 | 393 | export class RemoteLockStore implements ILockStore { 394 | private heartbeatInterval: number | null = null; 395 | private locksHeld: number = 0; 396 | 397 | constructor(private store: RemoteStore) {} 398 | 399 | async tryAcquire( 400 | resourceId: string, 401 | executionId: string, 402 | expiry: number = this.store.heartbeat * 4, 403 | ): Promise { 404 | // lock expiry cannot be less than heartbeat frequency 405 | expiry = Math.max(expiry, this.store.heartbeat); 406 | 407 | await this.store.call(`locks/acquire`, (b: unknown): b is any => true, { 408 | method: "POST", 409 | body: JSON.stringify({ 410 | resourceId: resourceId, 411 | processId: this.store.pid, 412 | executionId: executionId, 413 | expiryInMilliseconds: expiry, 414 | }), 415 | }); 416 | 417 | // increment the number of locks held 418 | this.locksHeld++; 419 | 420 | // lazily start the heartbeat 421 | this.startHeartbeat(); 422 | 423 | return true; 424 | } 425 | 426 | async release(resourceId: string, executionId: string): Promise { 427 | await this.store.call(`locks/release`, (b: unknown): b is void => b === undefined, { 428 | method: "POST", 429 | body: JSON.stringify({ 430 | resourceId, 431 | executionId, 432 | }), 433 | }); 434 | 435 | // decrement the number of locks held 436 | this.locksHeld = Math.max(this.locksHeld - 1, 0); 437 | 438 | if (this.locksHeld === 0) { 439 | this.stopHeartbeat(); 440 | } 441 | 442 | return true; 443 | } 444 | 445 | private startHeartbeat(): void { 446 | if (this.heartbeatInterval === null) { 447 | // the + converts to a number 448 | this.heartbeatInterval = +setInterval(() => this.heartbeat(), this.store.heartbeat); 449 | } 450 | } 451 | 452 | private stopHeartbeat(): void { 453 | if (this.heartbeatInterval !== null) { 454 | clearInterval(this.heartbeatInterval); 455 | this.heartbeatInterval = null; 456 | } 457 | } 458 | 459 | private async heartbeat() { 460 | const res = await this.store.call<{ locksAffected: number }>( 461 | `locks/heartbeat`, 462 | (b: unknown): b is { locksAffected: number } => 463 | typeof b === "object" && b !== null && "locksAffected" in b && typeof b.locksAffected === "number", 464 | { 465 | method: "POST", 466 | body: JSON.stringify({ 467 | processId: this.store.pid, 468 | }), 469 | }, 470 | ); 471 | 472 | // set the number of locks held 473 | this.locksHeld = res.locksAffected; 474 | 475 | if (this.locksHeld === 0) { 476 | this.stopHeartbeat(); 477 | } 478 | } 479 | } 480 | 481 | // Utils 482 | 483 | function encode(value: string, encoder: IEncoder): string { 484 | try { 485 | return encoder.encode(value); 486 | } catch (e: unknown) { 487 | throw new ResonateError("Encoder error", ErrorCodes.STORE_ENCODER, e); 488 | } 489 | } 490 | 491 | function decode

(promise: P, encoder: IEncoder): P { 492 | try { 493 | if (promise.param?.data) { 494 | promise.param.data = encoder.decode(promise.param.data); 495 | } 496 | 497 | if (promise.value?.data) { 498 | promise.value.data = encoder.decode(promise.value.data); 499 | } 500 | 501 | return promise; 502 | } catch (e: unknown) { 503 | throw new ResonateError("Decoder error", ErrorCodes.STORE_ENCODER, e); 504 | } 505 | } 506 | 507 | // Type guards 508 | 509 | function isSearchPromiseResult(obj: unknown): obj is { cursor: string; promises: DurablePromiseRecord[] } { 510 | return ( 511 | typeof obj === "object" && 512 | obj !== null && 513 | "cursor" in obj && 514 | obj.cursor !== undefined && 515 | (obj.cursor === null || typeof obj.cursor === "string") && 516 | "promises" in obj && 517 | obj.promises !== undefined && 518 | Array.isArray(obj.promises) && 519 | obj.promises.every(isDurablePromiseRecord) 520 | ); 521 | } 522 | 523 | function isSearchSchedulesResult(obj: unknown): obj is { cursor: string; schedules: Schedule[] } { 524 | return ( 525 | typeof obj === "object" && 526 | obj !== null && 527 | "cursor" in obj && 528 | obj.cursor !== undefined && 529 | (obj.cursor === null || typeof obj.cursor === "string") && 530 | "schedules" in obj && 531 | obj.schedules !== undefined && 532 | Array.isArray(obj.schedules) && 533 | obj.schedules.every(isSchedule) 534 | ); 535 | } 536 | -------------------------------------------------------------------------------- /lib/core/utils.ts: -------------------------------------------------------------------------------- 1 | import { Options, isOptions } from "./options"; 2 | 3 | export function randomId(): string { 4 | return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(16); 5 | } 6 | 7 | export function hash(s: string): string { 8 | let h = 0; 9 | for (let i = 0; i < s.length; i++) { 10 | h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; 11 | } 12 | 13 | // Generate fixed length hexadecimal hash 14 | const hashString = (Math.abs(h) >>> 0).toString(16); // Convert to unsigned int and then to hexadecimal 15 | const maxLength = 8; 16 | return "0".repeat(Math.max(0, maxLength - hashString.length)) + hashString; 17 | } 18 | 19 | export function split(argsWithOpts: any[]): { args: any[]; opts: Partial } { 20 | const possibleOpts = argsWithOpts.at(-1); 21 | return isOptions(possibleOpts) 22 | ? { args: argsWithOpts.slice(0, -1), opts: possibleOpts } 23 | : { args: argsWithOpts, opts: {} }; 24 | } 25 | 26 | /** 27 | * Merges two objects, preferring values from the first object when both are defined. 28 | * If a property is undefined in the first object, the value from the second object is used. 29 | * 30 | * @template T - Type of the first object 31 | * @template U - Type of the second object 32 | * @param {T} obj1 - The first object to merge 33 | * @param {U} obj2 - The second object to merge 34 | * @returns {T & U} A new object containing all properties from both input objects 35 | * 36 | * @example 37 | * const obj1 = { a: 1, b: undefined }; 38 | * const obj2 = { b: 2, c: 3 }; 39 | * const result = mergeObjects(obj1, obj2); 40 | * // result is { a: 1, b: 2, c: 3 } 41 | * 42 | * @remarks 43 | * - Properties from obj1 take precedence over obj2 when both are defined. 44 | * - The function creates a new object and does not modify the input objects. 45 | * - Nested objects and arrays are not deeply merged, only their references are copied. 46 | */ 47 | export function mergeObjects(obj1: T, obj2: U): T & U { 48 | return Object.entries({ ...obj1, ...obj2 }).reduce((acc, [key, value]) => { 49 | acc[key as keyof (T & U)] = ( 50 | obj1[key as keyof T] !== undefined ? obj1[key as keyof T] : obj2[key as keyof U] 51 | ) as any; 52 | return acc; 53 | }, {} as any); 54 | } 55 | 56 | /** 57 | * Creates a promise that resolves after a specified delay. 58 | * 59 | * @param ms - The delay in milliseconds. 60 | * @returns A promise that resolves after the specified delay. 61 | * 62 | * @example 63 | * // Basic usage 64 | * await sleep(1000); // Pauses execution for 1 second 65 | * 66 | * @example 67 | * // Using in an async function 68 | * async function example() { 69 | * console.log('Start'); 70 | * await sleep(2000); 71 | * console.log('2 seconds later'); 72 | * } 73 | * 74 | * @example 75 | * // Using with .then() 76 | * sleep(3000).then(() => console.log('3 seconds have passed')); 77 | */ 78 | export async function sleep(ms: number): Promise { 79 | if (ms < 0) { 80 | throw new Error("ms should be a positive integer"); 81 | } 82 | return new Promise((resolve) => setTimeout(resolve, ms)); 83 | } 84 | 85 | /** 86 | * Determines the current state of a Promise. 87 | * 88 | * @param p - The Promise whose state is to be determined. 89 | * 90 | * @returns {Promise<'pending' | 'resolved' | 'rejected'>} A Promise that resolves to a string 91 | * representing the state of the input Promise: 92 | * - 'pending': The input Promise has not settled yet. 93 | * - 'resolved': The input Promise has resolved successfully. 94 | * - 'rejected': The input Promise has been rejected. 95 | * 96 | * @throws {TypeError} If the input is not a Promise. 97 | * 98 | * @example 99 | * const myPromise = new Promise(resolve => setTimeout(() => resolve('done'), 1000)); 100 | * 101 | * // Check state immediately 102 | * promiseState(myPromise).then(state => console.log(state)); // Logs: 'pending' 103 | * 104 | * // Check state after 2 seconds 105 | * setTimeout(() => { 106 | * promiseState(myPromise).then(state => console.log(state)); // Logs: 'resolved' 107 | * }, 2000); 108 | * 109 | * @remarks 110 | * This function uses `Promise.race()` internally to determine the state of the input Promise. 111 | * It does not affect the execution or result of the input Promise in any way. 112 | * 113 | * Note that the state of a Promise can change from 'pending' to either 'resolved' or 'rejected', 114 | * but once it's settled (either 'resolved' or 'rejected'), it cannot change again. 115 | */ 116 | export function promiseState(p: Promise): Promise<"pending" | "resolved" | "rejected"> { 117 | const t = {}; 118 | return Promise.race([p, t]).then( 119 | (v) => (v === t ? "pending" : "resolved"), // Resolved branch 120 | () => "rejected", // Rejected branch 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /lib/index.ts: -------------------------------------------------------------------------------- 1 | // invok 2 | export * from "./resonate"; 3 | 4 | // errors 5 | export * from "./core/errors"; 6 | 7 | // options 8 | export * from "./core/options"; 9 | 10 | // promises 11 | export * as promises from "./core/promises/promises"; 12 | 13 | // schedules 14 | export * as schedules from "./core/schedules/schedules"; 15 | 16 | // retry policies 17 | export * from "./core/retry"; 18 | 19 | // interfaces 20 | export * from "./core/encoder"; 21 | export * from "./core/logger"; 22 | export * from "./core/storage"; 23 | export * from "./core/store"; 24 | 25 | // implementations 26 | export * from "./core/encoders/base64"; 27 | export * from "./core/encoders/json"; 28 | export * from "./core/loggers/logger"; 29 | export * from "./core/storages/memory"; 30 | export * from "./core/storages/withTimeout"; 31 | export * from "./core/stores/local"; 32 | export * from "./core/stores/remote"; 33 | 34 | // utils 35 | export * as utils from "./core/utils"; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@resonatehq/sdk", 3 | "version": "0.6.3", 4 | "description": "TypeScript SDK for Resonate", 5 | "author": "Resonate Developers", 6 | "license": "Apache-2.0", 7 | "type": "commonjs", 8 | "main": "dist/index.js", 9 | "types": "dist/index.d.ts", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/resonatehq/resonate-sdk-ts.git" 13 | }, 14 | "publishConfig": { 15 | "provenance": true 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "scripts": { 21 | "test": "jest --config jest.config.js", 22 | "build": "tsc --build tsconfig.build.json", 23 | "lint": "eslint .", 24 | "lint:fix": "eslint . --fix", 25 | "prettier": "prettier . --check", 26 | "prettier:fix": "prettier . --write", 27 | "docs": "npx typedoc lib/index.ts --githubPages", 28 | "debug": "node --inspect-brk ./node_modules/.bin/jest -i" 29 | }, 30 | "dependencies": { 31 | "cron-parser": "^4.9.0" 32 | }, 33 | "devDependencies": { 34 | "@jest/globals": "^29.7.0", 35 | "@types/node": "^20.11.19", 36 | "@typescript-eslint/eslint-plugin": "^6.10.0", 37 | "eslint": "^8.53.0", 38 | "eslint-config-prettier": "^9.0.0", 39 | "eslint-config-standard-with-typescript": "^39.1.1", 40 | "eslint-import-resolver-typescript": "^3.6.1", 41 | "eslint-plugin-import": "^2.29.1", 42 | "eslint-plugin-n": "^16.3.1", 43 | "eslint-plugin-promise": "^6.1.1", 44 | "prettier": "^3.2.5", 45 | "ts-jest": "^29.1.1", 46 | "ts-node": "^10.9.2", 47 | "typedoc": "^0.25.7", 48 | "typedoc-material-theme": "^1.0.2", 49 | "typescript": "^5.2.2" 50 | }, 51 | "engines": { 52 | "node": ">= 18" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/async.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from "@jest/globals"; 2 | import { options } from "../lib/core/options"; 3 | import * as retry from "../lib/core/retry"; 4 | import * as utils from "../lib/core/utils"; 5 | import { Resonate, Context } from "../lib/resonate"; 6 | 7 | jest.setTimeout(10000); 8 | 9 | describe("Functions: async", () => { 10 | async function run(ctx: Context, func: any) { 11 | return await ctx.run(func); 12 | } 13 | 14 | function ordinarySuccess() { 15 | return "foo"; 16 | } 17 | 18 | function ordinaryFailure() { 19 | throw new Error("foo"); 20 | } 21 | 22 | async function ordinarySuccessAsync() { 23 | return "foo"; 24 | } 25 | 26 | async function ordinaryFailureAsync() { 27 | throw new Error("foo"); 28 | } 29 | 30 | async function deferredSuccess(ctx: Context) { 31 | return await ctx.run("success", options({ pollFrequency: 0 })); 32 | } 33 | 34 | async function deferredFailure(ctx: Context) { 35 | return await ctx.run("failure", options({ pollFrequency: 0 })); 36 | } 37 | 38 | test("success", async () => { 39 | const resonate = new Resonate({ 40 | timeout: 1000, 41 | retryPolicy: retry.exponential( 42 | 100, // initial delay (in ms) 43 | 2, // backoff factor 44 | Infinity, // max attempts 45 | 60000, // max delay (in ms, 1 minute) 46 | ), 47 | }); 48 | 49 | resonate.register("run", run); 50 | resonate.register("success", ordinarySuccess); 51 | resonate.register("successAsync", ordinarySuccessAsync); 52 | 53 | // pre-resolve deferred 54 | const deferred = await resonate.promises.create("success", Date.now() + 1000, { 55 | idempotencyKey: utils.hash("success"), 56 | }); 57 | deferred.resolve("foo"); 58 | 59 | const results: string[] = [ 60 | ordinarySuccess(), 61 | await ordinarySuccessAsync(), 62 | await resonate.run("run", "run.a", ordinarySuccess), 63 | await resonate.run("run", "run.b", ordinarySuccessAsync), 64 | await resonate.run("run", "run.c", deferredSuccess), 65 | await resonate.run("success", "run.d"), 66 | await resonate.run("successAsync", "run.e"), 67 | ]; 68 | 69 | expect(results.every((r) => r === "foo")).toBe(true); 70 | }); 71 | 72 | test("failure", async () => { 73 | const resonate = new Resonate({ 74 | timeout: 1000, 75 | retryPolicy: retry.linear(0, 3), 76 | }); 77 | 78 | resonate.register("run", run); 79 | resonate.register("failure", ordinaryFailure); 80 | resonate.register("failureAsync", ordinaryFailureAsync); 81 | 82 | // pre-reject deferred 83 | const deferred = await resonate.promises.create("failure", Date.now() + 1000, { 84 | idempotencyKey: utils.hash("failure"), 85 | }); 86 | deferred.reject(new Error("foo")); 87 | 88 | const functions = [ 89 | () => ordinaryFailureAsync(), 90 | () => resonate.run("run", "run.a", ordinaryFailure), 91 | () => resonate.run("run", "run.b", ordinaryFailureAsync), 92 | () => resonate.run("run", "run.c", deferredFailure), 93 | () => resonate.run("failure", "run.d"), 94 | () => resonate.run("failureAsync", "run.e"), 95 | ]; 96 | 97 | for (const f of functions) { 98 | await expect(f()).rejects.toThrow("foo"); 99 | } 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /test/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest, beforeEach } from "@jest/globals"; 2 | import { RemoteStore } from "../lib"; 3 | 4 | jest.setTimeout(10000); 5 | 6 | describe("Auth", () => { 7 | // mock fetch 8 | // return a 500 so that an error is thrown (and ignored) 9 | const fetchMock = jest.fn(async () => new Response(null, { status: 500 })); 10 | global.fetch = fetchMock; 11 | 12 | const store = new RemoteStore("http://localhost:8080", { 13 | auth: { basic: { username: "foo", password: "bar" } }, 14 | retries: 0, 15 | }); 16 | 17 | // prettier-ignore 18 | const funcs = [ 19 | // promises 20 | { name: "promises.get", func: () => store.promises.get("") }, 21 | { name: "promises.create", func: () => store.promises.create("", undefined, false, undefined, undefined, 0, undefined) }, 22 | { name: "promises.cancel", func: () => store.promises.cancel("", undefined, false, undefined, undefined) }, 23 | { name: "promises.resolve", func: () => store.promises.resolve("", undefined, false, undefined, undefined) }, 24 | { name: "promises.reject", func: () => store.promises.reject("", undefined, false, undefined, undefined) }, 25 | { name: "promises.search", func: () => store.promises.search("", undefined, undefined, undefined).next() }, 26 | 27 | // schedules 28 | { name: "schedules.get", func: () => store.schedules.get("") }, 29 | { name: "schedules.create", func: () => store.schedules.create("", undefined, undefined, "", undefined, "", 0, undefined, undefined, undefined) }, 30 | { name: "schedules.delete", func: () => store.schedules.delete("") }, 31 | { name: "schedules.search", func: () => store.schedules.search("", undefined, undefined).next() }, 32 | 33 | // locks 34 | { name: "locks.tryAcquire", func: () => store.locks.tryAcquire("", "") }, 35 | { name: "locks.release", func: () => store.locks.release("", "") }, 36 | ]; 37 | 38 | beforeEach(() => { 39 | fetchMock.mockClear(); 40 | }); 41 | 42 | describe("basic auth", () => { 43 | for (const { name, func } of funcs) { 44 | test(name, async () => { 45 | await func().catch(() => {}); 46 | 47 | expect(fetch).toHaveBeenCalledTimes(1); 48 | expect(fetch).toHaveBeenCalledWith( 49 | expect.any(String), 50 | expect.objectContaining({ 51 | headers: expect.objectContaining({ 52 | Authorization: `Basic Zm9vOmJhcg==`, 53 | }), 54 | }), 55 | ); 56 | }); 57 | } 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/combinators.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from "@jest/globals"; 2 | import * as retry from "../lib/core/retry"; 3 | import { Resonate, Context } from "../lib/resonate"; 4 | 5 | jest.setTimeout(10000); 6 | 7 | async function throwOrReturn(v: any) { 8 | if (v instanceof Error) { 9 | throw v; 10 | } 11 | 12 | return v; 13 | } 14 | 15 | describe("Combinators", () => { 16 | const resonate = new Resonate({ 17 | retryPolicy: retry.never(), 18 | }); 19 | 20 | describe("all", () => { 21 | resonate.register("all", (c: Context, v: any[]) => c.all(v.map((v) => c.run(() => throwOrReturn(v))))); 22 | 23 | for (const { name, args } of [ 24 | { name: "empty", args: [] }, 25 | { name: "numbers", args: [1, 2, 3] }, 26 | { name: "strings", args: ["a", "b", "c"] }, 27 | { name: "mixed", args: [1, "b", {}] }, 28 | ]) { 29 | test(`resolved: ${name}`, async () => { 30 | const r = await resonate.run("all", `all.resolved.${name}`, args); 31 | expect(r).toEqual(await Promise.all(args.map(throwOrReturn))); 32 | expect(r).toEqual(args); 33 | }); 34 | } 35 | 36 | for (const { name, args } of [ 37 | { name: "first", args: [new Error("1"), 2, 3] }, 38 | { name: "middle", args: [1, new Error("2"), 3] }, 39 | { name: "last", args: [1, 2, new Error("3")] }, 40 | ]) { 41 | test(`rejected: ${name}`, async () => { 42 | const r = resonate.run("all", `all.rejected.${name}`, args); 43 | const e = await Promise.all(args.map(throwOrReturn)).catch((e) => e); 44 | expect(r).rejects.toThrow(e); 45 | }); 46 | } 47 | }); 48 | 49 | describe("any", () => { 50 | resonate.register("any", (c: Context, v: any[]) => c.any(v.map((v) => c.run(() => throwOrReturn(v))))); 51 | 52 | for (const { name, args } of [ 53 | { name: "first", args: [new Error("1"), 2, 3] }, 54 | { name: "middle", args: [1, new Error("2"), 3] }, 55 | { name: "last", args: [1, 2, new Error("3")] }, 56 | ]) { 57 | test(`resolved: ${name}`, async () => { 58 | const r = await resonate.run("any", `any.resolved.${name}`, args); 59 | expect(r).toEqual(await Promise.any(args.map(throwOrReturn))); 60 | expect(r).toEqual(args.find((v) => !(v instanceof Error))); 61 | }); 62 | } 63 | 64 | for (const { name, args } of [ 65 | { name: "empty", args: [] }, 66 | { name: "one", args: [new Error("1")] }, 67 | { name: "two", args: [new Error("1"), new Error("2")] }, 68 | ]) { 69 | test(`rejected: ${name}`, async () => { 70 | const r = resonate.run("any", `any.rejected.${name}`, args); 71 | const e = await Promise.any(args.map(throwOrReturn)).catch((e) => e); 72 | expect(r).rejects.toThrow(e); 73 | expect(r).rejects.toBeInstanceOf(AggregateError); 74 | }); 75 | } 76 | }); 77 | 78 | describe("race", () => { 79 | resonate.register("race", (c: Context, v: any[]) => c.race(v.map((v) => c.run(() => throwOrReturn(v))))); 80 | 81 | for (const { name, args } of [ 82 | { name: "one", args: [1] }, 83 | { name: "two", args: [1, new Error("2")] }, 84 | { name: "three", args: [1, 2, new Error("3")] }, 85 | ]) { 86 | test(`resolved: ${name}`, async () => { 87 | const r = await resonate.run("race", `race.resolved.${name}`, args); 88 | expect(r).toEqual(await Promise.race(args.map(throwOrReturn))); 89 | expect(r).toEqual(args[0]); 90 | }); 91 | } 92 | 93 | for (const { name, args } of [ 94 | { name: "one", args: [new Error("1")] }, 95 | { name: "two", args: [new Error("1"), 2] }, 96 | { name: "three", args: [new Error("1"), 2, 3] }, 97 | ]) { 98 | test(`rejected: ${name}`, async () => { 99 | const r = resonate.run("race", `race.rejected.${name}`, args); 100 | const e = await Promise.race(args.map(throwOrReturn)).catch((e) => e); 101 | expect(r).rejects.toThrow(e); 102 | expect(r).rejects.toThrow(args[0]); 103 | }); 104 | } 105 | }); 106 | 107 | describe("allSettled", () => { 108 | resonate.register("allSettled", (c: Context, v: any[]) => 109 | c.allSettled(v.map((v) => c.run(() => throwOrReturn(v)))), 110 | ); 111 | 112 | for (const { name, args } of [ 113 | { name: "empty", args: [] }, 114 | { name: "one", args: [1] }, 115 | { name: "two", args: [1, new Error("2")] }, 116 | { name: "three", args: [1, 2, new Error("3")] }, 117 | ]) { 118 | test(`resolved: ${name}`, async () => { 119 | const r = await resonate.run("allSettled", `allSettled.resolved.${name}`, args); 120 | expect(r).toEqual(await Promise.allSettled(args.map(throwOrReturn))); 121 | expect(r).toEqual( 122 | args.map((a) => 123 | a instanceof Error 124 | ? { 125 | status: "rejected", 126 | reason: a, 127 | } 128 | : { 129 | status: "fulfilled", 130 | value: a, 131 | }, 132 | ), 133 | ); 134 | }); 135 | } 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /test/detached.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "@jest/globals"; 2 | import { options } from "../lib/core/options"; 3 | import { never } from "../lib/core/retry"; 4 | import { sleep } from "../lib/core/utils"; 5 | import { Resonate, Context } from "../lib/resonate"; 6 | 7 | describe("Detached tests", () => { 8 | test("A detached function does not get implicitly awaited at the end of a context", async () => { 9 | const resonate = new Resonate({ 10 | // url: "http://127.0.0.1:8001", 11 | }); 12 | 13 | const arr = []; 14 | resonate.register("detached", async (ctx: Context) => { 15 | const handle = await ctx.invokeLocal(async () => { 16 | await sleep(15); 17 | arr.push(4); 18 | }); 19 | await handle.result(); 20 | }); 21 | 22 | resonate.register("foo", async (ctx: Context) => { 23 | await ctx.invokeLocal( 24 | async (ctx: Context) => { 25 | await sleep(10); 26 | arr.push(2); 27 | }, 28 | options({ retryPolicy: never() }), 29 | ); 30 | 31 | await ctx.detached("detached", "d.1"); 32 | 33 | arr.push(1); 34 | }); 35 | 36 | const tophandle = await resonate.invokeLocal("foo", "foo.0"); 37 | await tophandle.result(); 38 | arr.push(3); 39 | 40 | // Since we are not awaiting the detached invocation we should not see its effect over arr 41 | expect(arr).toEqual([1, 2, 3]); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/durable.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, jest, test } from "@jest/globals"; 2 | import { options } from "../lib/core/options"; 3 | import { never } from "../lib/core/retry"; 4 | import { Resonate, Context } from "../lib/resonate"; 5 | 6 | jest.setTimeout(10000); 7 | 8 | describe("Durability tests", () => { 9 | const resonate = new Resonate(); 10 | const createSpy = jest.spyOn(resonate.store.promises, "create"); 11 | const resolveSpy = jest.spyOn(resonate.store.promises, "resolve"); 12 | const rejectSpy = jest.spyOn(resonate.store.promises, "reject"); 13 | 14 | resonate.register("default", async (ctx: Context, val: string) => { 15 | return await ctx.run(() => val); 16 | }); 17 | 18 | resonate.register( 19 | "durable", 20 | async (ctx: Context, val: string) => { 21 | return await ctx.run(() => val, options({ durable: true })); 22 | }, 23 | { durable: false }, 24 | ); 25 | 26 | resonate.register( 27 | "fails-durable", 28 | async (ctx: Context, val: string) => { 29 | await ctx.run( 30 | (ctx: Context) => { 31 | throw new Error(val); 32 | }, 33 | options({ durable: true, retryPolicy: never() }), 34 | ); 35 | }, 36 | { durable: true, retryPolicy: never() }, 37 | ); 38 | 39 | resonate.register( 40 | "volatile", 41 | async (ctx: Context, val: string) => { 42 | return await ctx.run(() => val, options({ durable: false })); 43 | }, 44 | { durable: false }, 45 | ); 46 | 47 | resonate.register( 48 | "fails-volatile", 49 | async (ctx: Context, val: string) => { 50 | return await ctx.run( 51 | () => { 52 | throw new Error(val); 53 | }, 54 | options({ durable: false, retryPolicy: never() }), 55 | ); 56 | }, 57 | { durable: false, retryPolicy: never() }, 58 | ); 59 | 60 | beforeEach(() => { 61 | createSpy.mockClear(); 62 | resolveSpy.mockClear(); 63 | rejectSpy.mockClear(); 64 | }); 65 | 66 | test("default should be durable", async () => { 67 | // Test the function executes correctly 68 | expect(await resonate.run("default", "default.a", "a")).toBe("a"); 69 | expect(await resonate.run("default", "default.a", "b")).toBe("a"); 70 | expect(await resonate.run("default", "default.b", "b")).toBe("b"); 71 | 72 | // Test the store was called 73 | // TODO(avillega): test that it has been called more than once, since the top level is always durable 74 | expect(createSpy).toHaveBeenCalled(); 75 | expect(resolveSpy).toHaveBeenCalled(); 76 | }); 77 | 78 | test("durable should be durable", async () => { 79 | expect(await resonate.run("durable", "durable.a", "a")).toBe("a"); 80 | expect(await resonate.run("durable", "durable.a", "b")).toBe("a"); 81 | expect(await resonate.run("durable", "durable.b", "b")).toBe("b"); 82 | await expect(resonate.run("fails-durable", "fails-durable.a", "a")).rejects.toThrow("a"); 83 | 84 | // Test the store was called 85 | // TODO(avillega): test that it has been called more than once, since the top level is always durable 86 | expect(createSpy).toHaveBeenCalled(); 87 | expect(resolveSpy).toHaveBeenCalled(); 88 | expect(rejectSpy).toHaveBeenCalled(); 89 | }); 90 | 91 | test("volatile should not be durable", async () => { 92 | // Top level functions are always durable so we check that the spy has been called once per top level invocation 93 | 94 | expect(await resonate.run("volatile", "volatile.a", "a")).toBe("a"); 95 | expect(createSpy).toHaveBeenCalledTimes(1); 96 | expect(resolveSpy).toHaveBeenCalledTimes(1); 97 | 98 | // Even non-durable results are locally cached and we don't need to call the store again 99 | expect(await resonate.run("volatile", "volatile.a", "b")).toBe("a"); 100 | expect(createSpy).toHaveBeenCalledTimes(1); 101 | expect(resolveSpy).toHaveBeenCalledTimes(1); 102 | 103 | expect(await resonate.run("volatile", "volatile.b", "b")).toBe("b"); 104 | expect(createSpy).toHaveBeenCalledTimes(2); 105 | expect(resolveSpy).toHaveBeenCalledTimes(2); 106 | 107 | // We throw in the internal function, we still only call the store for the rejection of the top level 108 | await expect(resonate.run("fails-volatile", "fails-volatile.a", "a")).rejects.toThrow("a"); 109 | expect(createSpy).toHaveBeenCalledTimes(3); 110 | expect(rejectSpy).toHaveBeenCalledTimes(1); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/errorHandling.test.ts: -------------------------------------------------------------------------------- 1 | import { fail } from "assert"; 2 | import { describe, test, expect, jest } from "@jest/globals"; 3 | import { ErrorCodes, never, options, ResonateError } from "../lib"; 4 | import { sleep } from "../lib/core/utils"; 5 | import { Context, Resonate } from "../lib/resonate"; 6 | 7 | jest.setTimeout(10000); 8 | 9 | describe("Errors", () => { 10 | test("Errors in user functions propagate back to top level", async () => { 11 | const resonate = new Resonate(); 12 | 13 | resonate.register( 14 | "err", 15 | async (ctx: Context, val: string) => { 16 | await ctx.run(async (ctx: Context) => { 17 | await ctx.run(async (ctx: Context) => { 18 | await ctx.run(async (ctx: Context) => { 19 | throw new Error(val); 20 | }); 21 | throw new Error("should not reach this point"); 22 | }); 23 | }); 24 | }, 25 | options({ retryPolicy: never() }), 26 | ); 27 | 28 | const errToReturn = "This is the error"; 29 | const handle = await resonate.invokeLocal("err", "err.0", errToReturn); 30 | 31 | await expect(handle.result()).rejects.toThrow(errToReturn); 32 | const durablePromiseRecord = await resonate.store.promises.get(handle.invocationId); 33 | expect(durablePromiseRecord.state).toBe("REJECTED"); 34 | }); 35 | 36 | test("Unrecoverable errors deep in the stack causes top level to abort", async () => { 37 | const resonate = new Resonate(); 38 | 39 | resonate.register( 40 | "err", 41 | async (ctx: Context, val: string) => { 42 | await ctx.run(async (ctx: Context) => { 43 | await ctx.run(async (ctx: Context) => { 44 | // Make the create fail from this point forward 45 | jest 46 | .spyOn(resonate.store.promises, "create") 47 | .mockRejectedValue(new ResonateError("Fetch Error", ErrorCodes.FETCH, "mock", true)); 48 | await ctx.run(async (ctx: Context) => { 49 | return "Should not reach this point"; 50 | }); 51 | }); 52 | }); 53 | }, 54 | options({ retryPolicy: never() }), 55 | ); 56 | // TODO(avillega): Should it not retry when there was an unrecoverable error? 57 | 58 | const handle = await resonate.invokeLocal("err", "err.0", "nil"); 59 | try { 60 | await handle.result(); 61 | } catch (err) { 62 | if (err instanceof ResonateError) { 63 | expect(err.code).toBe(ErrorCodes.ABORT); 64 | } else { 65 | fail("Error should be a Resonate Error"); 66 | } 67 | } 68 | 69 | // The local promise must be rejected after aborting, but the durablePromise must be pending 70 | await expect(handle.state()).resolves.toBe("rejected"); 71 | const durablePromiseRecord = await resonate.store.promises.get(handle.invocationId); 72 | expect(durablePromiseRecord.state).toBe("PENDING"); 73 | }); 74 | 75 | test("Unrecoverable errors deep in the stack causes top level to abort even when the user catch the error", async () => { 76 | const resonate = new Resonate(); 77 | 78 | resonate.register( 79 | "err", 80 | async (ctx: Context, val: string) => { 81 | await ctx.run(async (ctx: Context) => { 82 | try { 83 | await ctx.run(async (ctx: Context) => { 84 | // Make the create fail from this point forward 85 | jest 86 | .spyOn(resonate.store.promises, "create") 87 | .mockRejectedValue(new ResonateError("Fetch Error", ErrorCodes.FETCH, "mock", true)); 88 | await ctx.run(async (ctx: Context) => { 89 | return "Should not reach this point"; 90 | }); 91 | }); 92 | } catch (err) { 93 | // ignore the error 94 | } 95 | }); 96 | }, 97 | options({ retryPolicy: never() }), 98 | ); 99 | // TODO(avillega): Should it not retry when there was an unrecoverable error? 100 | 101 | const handle = await resonate.invokeLocal("err", "err.0", "nil"); 102 | try { 103 | await handle.result(); 104 | } catch (err) { 105 | if (err instanceof ResonateError) { 106 | expect(err.code).toBe(ErrorCodes.ABORT); 107 | } else { 108 | fail("Error should be a Resonate Error"); 109 | } 110 | } 111 | 112 | // The local promise must be rejected after aborting, but the durablePromise must be pending 113 | await expect(handle.state()).resolves.toBe("rejected"); 114 | const durablePromiseRecord = await resonate.store.promises.get(handle.invocationId); 115 | expect(durablePromiseRecord.state).toBe("PENDING"); 116 | }); 117 | 118 | test("Unrecoverable errors in the top level cause to abort", async () => { 119 | const resonate = new Resonate(); 120 | 121 | resonate.register( 122 | "err", 123 | async (ctx: Context, val: string) => { 124 | await ctx.run((ctx: Context) => "all good here"); 125 | 126 | await ctx.run(async (ctx: Context) => { 127 | return "all good here too"; 128 | }); 129 | 130 | jest 131 | .spyOn(resonate.store.promises, "resolve") 132 | .mockRejectedValue(new ResonateError("Fetch Error", ErrorCodes.FETCH, "mock", true)); 133 | }, 134 | options({ retryPolicy: never() }), 135 | ); 136 | 137 | const handle = await resonate.invokeLocal("err", "err.0", "nil"); 138 | try { 139 | await handle.result(); 140 | } catch (err) { 141 | if (err instanceof ResonateError) { 142 | expect(err.code).toBe(ErrorCodes.ABORT); 143 | } else { 144 | fail("Error should be a Resonate Error"); 145 | } 146 | } 147 | 148 | // The local promise must be rejected after aborting, but the durablePromise must be pending 149 | await expect(handle.state()).resolves.toBe("rejected"); 150 | const durablePromiseRecord = await resonate.store.promises.get(handle.invocationId); 151 | expect(durablePromiseRecord.state).toBe("PENDING"); 152 | }); 153 | 154 | test("Unrecoverable errors in the top level cause to abort even when ignored by the user", async () => { 155 | const resonate = new Resonate(); 156 | 157 | resonate.register( 158 | "err", 159 | async (ctx: Context, val: string) => { 160 | try { 161 | await ctx.run((ctx: Context) => "all good here"); 162 | 163 | await ctx.run(async (ctx: Context) => { 164 | return "all good here too"; 165 | }); 166 | 167 | jest 168 | .spyOn(resonate.store.promises, "resolve") 169 | .mockRejectedValue(new ResonateError("Fetch Error", ErrorCodes.FETCH, "mock", true)); 170 | } catch { 171 | // ignore error 172 | } 173 | }, 174 | options({ retryPolicy: never() }), 175 | ); 176 | 177 | const handle = await resonate.invokeLocal("err", "err.0", "nil"); 178 | try { 179 | await handle.result(); 180 | } catch (err) { 181 | if (err instanceof ResonateError) { 182 | expect(err.code).toBe(ErrorCodes.ABORT); 183 | } else { 184 | fail("Error should be a Resonate Error"); 185 | } 186 | } 187 | 188 | // The local promise must be rejected after aborting, but the durablePromise must be pending 189 | await expect(handle.state()).resolves.toBe("rejected"); 190 | const durablePromiseRecord = await resonate.store.promises.get(handle.invocationId); 191 | expect(durablePromiseRecord.state).toBe("PENDING"); 192 | }); 193 | 194 | test("Timeout errors should propagate to top level execution", async () => { 195 | const resonate = new Resonate(); 196 | 197 | resonate.register( 198 | "err", 199 | async (ctx: Context, val: string) => { 200 | await ctx.run((ctx: Context) => "all good here"); 201 | await ctx.run( 202 | async (ctx: Context) => { 203 | await sleep(12); 204 | return "all good here too"; 205 | }, 206 | options({ timeout: 10 }), 207 | ); 208 | }, 209 | options({ retryPolicy: never() }), 210 | ); 211 | 212 | const handle = await resonate.invokeLocal("err", "err.0", "nil"); 213 | try { 214 | await handle.result(); 215 | } catch (err) { 216 | if (err instanceof ResonateError) { 217 | expect(err.code).toBe(ErrorCodes.TIMEDOUT); 218 | } else { 219 | fail("Error should be a Resonate Error"); 220 | } 221 | } 222 | 223 | await expect(handle.state()).resolves.toBe("rejected"); 224 | const durablePromiseRecord = await resonate.store.promises.get(handle.invocationId); 225 | expect(durablePromiseRecord.state).toBe("REJECTED"); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /test/options.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from "@jest/globals"; 2 | import { Options, options } from "../lib/core/options"; 3 | import * as retry from "../lib/core/retry"; 4 | import * as a from "../lib/resonate"; 5 | 6 | jest.setTimeout(10000); 7 | 8 | async function aTest(ctx: a.Context, opts: Partial = {}) { 9 | return [ 10 | ctx.invocationData.opts, 11 | ...(await ctx.run( 12 | async (ctx: a.Context) => [ctx.invocationData.opts, await ctx.run((ctx: a.Context) => ctx.invocationData.opts)], 13 | options(opts), 14 | )), 15 | ]; 16 | } 17 | 18 | describe("Options", () => { 19 | const resonateOpts = { 20 | pollFrequency: 1000, 21 | retryPolicy: retry.exponential(), 22 | tags: { a: "a", b: "b", c: "c" }, 23 | timeout: 1000, 24 | }; 25 | 26 | const overrides: Partial = { 27 | durable: false, 28 | eidFn: () => "eid", 29 | idempotencyKeyFn: (_: string) => "idempotencyKey", 30 | shouldLock: false, 31 | pollFrequency: 2000, 32 | retryPolicy: retry.linear(), 33 | tags: { c: "x", d: "d", e: "e" }, 34 | timeout: 2000, 35 | version: 2, 36 | }; 37 | // Note: eidFn, encoder and idempotencyKeyFn are not serializable, and are note checked in the tests 38 | 39 | // Note: we are disabling durable for all tests here 40 | // so that value returned from the run is not serialized. 41 | 42 | const resonate = new a.Resonate(resonateOpts); 43 | resonate.register("test.1", aTest, { durable: false }); 44 | resonate.register("test.1", aTest, { durable: false, version: 2 }); 45 | resonate.register("test.2", aTest, overrides); 46 | 47 | test("resonate default options propagate down", async () => { 48 | const [top, middle, bottom] = await resonate.run<[Options, Options, Options]>( 49 | "test.1", 50 | `test.1.1`, 51 | options({ version: 1 }), 52 | ); 53 | 54 | // Most options defaults are set when created a resonate instance 55 | for (const opts of [top, middle, bottom]) { 56 | expect(opts.durable).toBe(false); 57 | expect(opts.pollFrequency).toBe(resonateOpts.pollFrequency); 58 | expect(opts.retryPolicy).toEqual(resonateOpts.retryPolicy); 59 | expect(opts.timeout).toBe(resonateOpts.timeout); 60 | expect(opts.version).toBe(1); 61 | } 62 | 63 | expect(top.shouldLock).toBe(false); 64 | expect(middle.shouldLock).toBe(false); 65 | expect(bottom.shouldLock).toBe(false); 66 | 67 | expect(top.tags).toEqual({ ...resonateOpts.tags, "resonate:invocation": "true" }); 68 | expect(middle.tags).toEqual(resonateOpts.tags); 69 | expect(bottom.tags).toEqual(resonateOpts.tags); 70 | }); 71 | 72 | test("registered options propagate down", async () => { 73 | const a = await resonate.run<[Options, Options, Options]>("test.2", `test.2.1`); 74 | 75 | const [top, middle, bottom] = a; 76 | for (const opts of [top, middle, bottom]) { 77 | expect(opts.durable).toBe(overrides.durable); 78 | expect(opts.shouldLock).toBe(overrides.shouldLock); 79 | expect(opts.pollFrequency).toBe(overrides.pollFrequency); 80 | expect(opts.retryPolicy).toEqual(overrides.retryPolicy); 81 | expect(opts.timeout).toBe(overrides.timeout); 82 | expect(opts.version).toBe(overrides.version); 83 | } 84 | 85 | expect(top.tags).toEqual({ ...resonateOpts.tags, ...overrides.tags, "resonate:invocation": "true" }); 86 | expect(middle.tags).toEqual({ ...resonateOpts.tags, ...overrides.tags }); 87 | expect(bottom.tags).toEqual({ ...resonateOpts.tags, ...overrides.tags }); 88 | }); 89 | 90 | test("options passed to resonate run affect top level only", async () => { 91 | const [top, ...bottom] = await resonate.run<[Options, Options, Options]>("test.1", `test.1.2`, options(overrides)); 92 | 93 | // Note: only some options are valid at the top level 94 | // this is because we would lose this information on the recovery path. 95 | 96 | // top level options 97 | expect(top.durable).toBe(false); 98 | expect(top.shouldLock).toBe(false); 99 | expect(top.pollFrequency).toBe(resonateOpts.pollFrequency); 100 | expect(top.retryPolicy).toEqual(overrides.retryPolicy); 101 | expect(top.tags).toEqual({ ...resonateOpts.tags, ...overrides.tags, "resonate:invocation": "true" }); 102 | expect(top.timeout).toBe(overrides.timeout); 103 | expect(top.version).toBe(overrides.version); 104 | 105 | // bottom level options 106 | for (const opts of bottom) { 107 | expect(opts.durable).toBe(false); 108 | expect(opts.shouldLock).toBe(false); 109 | expect(opts.pollFrequency).toBe(resonateOpts.pollFrequency); 110 | expect(opts.retryPolicy).toEqual(resonateOpts.retryPolicy); 111 | expect(opts.tags).toEqual(resonateOpts.tags); 112 | expect(opts.timeout).toBe(resonateOpts.timeout); 113 | expect(opts.version).toBe(overrides.version); 114 | } 115 | }); 116 | 117 | test("options passed to context run affect current level only", async () => { 118 | const [top, middle, bottom] = await resonate.run<[Options, Options, Options]>( 119 | "test.1", 120 | `test.1.3`, 121 | overrides, 122 | options({ version: 1 }), 123 | ); 124 | 125 | // middle options (overriden) 126 | expect(middle.durable).toBe(overrides.durable); 127 | expect(middle.shouldLock).toBe(overrides.shouldLock); 128 | expect(middle.pollFrequency).toBe(overrides.pollFrequency); 129 | expect(middle.retryPolicy).toEqual(overrides.retryPolicy); 130 | expect(middle.tags).toEqual({ ...resonateOpts.tags, ...overrides.tags }); 131 | expect(middle.timeout).toBe(overrides.timeout); 132 | 133 | // top and bottom options 134 | for (const opts of [top, bottom]) { 135 | expect(opts.durable).toBe(false); 136 | expect(opts.pollFrequency).toBe(resonateOpts.pollFrequency); 137 | expect(opts.retryPolicy).toEqual(resonateOpts.retryPolicy); 138 | expect(opts.timeout).toBe(resonateOpts.timeout); 139 | expect(opts.shouldLock).toBe(false); 140 | } 141 | 142 | expect(top.version).toBe(1); 143 | expect(middle.version).toBeDefined(); 144 | expect(bottom.version).toBeDefined(); 145 | 146 | expect(top.tags).toEqual({ ...resonateOpts.tags, "resonate:invocation": "true" }); 147 | expect(bottom.tags).toEqual(resonateOpts.tags); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /test/promises.test.ts: -------------------------------------------------------------------------------- 1 | import { jest, describe, test, expect } from "@jest/globals"; 2 | 3 | import { IStore } from "../lib/core/store"; 4 | import { LocalStore } from "../lib/core/stores/local"; 5 | import { RemoteStore } from "../lib/core/stores/remote"; 6 | 7 | jest.setTimeout(10000); 8 | 9 | describe("Store: Promise", () => { 10 | const stores: IStore[] = [new LocalStore()]; 11 | 12 | if (process.env.RESONATE_STORE_URL) { 13 | stores.push(new RemoteStore(process.env.RESONATE_STORE_URL)); 14 | } 15 | 16 | for (const store of stores.map((s) => s.promises)) { 17 | describe(store.constructor.name, () => { 18 | test("Promise Store: Get promise that exists", async () => { 19 | const promiseId = "existing-promise"; 20 | const createdPromise = await store.create( 21 | promiseId, 22 | undefined, 23 | false, 24 | undefined, 25 | undefined, 26 | Number.MAX_SAFE_INTEGER, 27 | undefined, 28 | ); 29 | 30 | const retrievedPromise = await store.get(promiseId); 31 | 32 | expect(retrievedPromise.id).toBe(createdPromise.id); 33 | }); 34 | 35 | test("Promise Store: Get promise that does not exist", async () => { 36 | const nonExistingPromiseId = "non-existing-promise"; 37 | 38 | await expect(store.get(nonExistingPromiseId)).rejects.toThrowError("Not found"); 39 | }); 40 | 41 | test("Promise Store: Search by id", async () => { 42 | const promiseId = "search-by-id-promise"; 43 | await store.create(promiseId, undefined, false, undefined, undefined, Number.MAX_SAFE_INTEGER, undefined); 44 | 45 | const promises = []; 46 | for await (const results of store.search(promiseId, undefined, undefined, undefined)) { 47 | promises.push(...results); 48 | } 49 | 50 | expect(promises.length).toBe(1); 51 | expect(promises[0].id).toBe(promiseId); 52 | }); 53 | 54 | test("Promise Store: Search by id with wildcard(s)", async () => { 55 | const promiseIdPrefix = "search-by-id-prefix"; 56 | const wildcardSearch = `${promiseIdPrefix}-*`; 57 | 58 | for (let i = 1; i <= 3; i++) { 59 | await store.create( 60 | `${promiseIdPrefix}-${i}`, 61 | undefined, 62 | false, 63 | undefined, 64 | undefined, 65 | Number.MAX_SAFE_INTEGER, 66 | undefined, 67 | ); 68 | } 69 | 70 | const promises = []; 71 | for await (const results of store.search(wildcardSearch, undefined, undefined, undefined)) { 72 | promises.push(...results); 73 | } 74 | 75 | expect(promises.length).toBe(3); 76 | }); 77 | 78 | test("Promise Store: Search by state", async () => { 79 | const promiseIdPrefix = "search-by-state"; 80 | await store.create( 81 | `${promiseIdPrefix}-pending`, 82 | undefined, 83 | true, 84 | undefined, 85 | undefined, 86 | Number.MAX_SAFE_INTEGER, 87 | undefined, 88 | ); 89 | await store.create( 90 | `${promiseIdPrefix}-resolved`, 91 | undefined, 92 | true, 93 | undefined, 94 | undefined, 95 | Number.MAX_SAFE_INTEGER, 96 | undefined, 97 | ); 98 | await store.create( 99 | `${promiseIdPrefix}-rejected`, 100 | undefined, 101 | true, 102 | undefined, 103 | undefined, 104 | Number.MAX_SAFE_INTEGER, 105 | undefined, 106 | ); 107 | await store.create( 108 | `${promiseIdPrefix}-canceled`, 109 | undefined, 110 | true, 111 | undefined, 112 | undefined, 113 | Number.MAX_SAFE_INTEGER, 114 | undefined, 115 | ); 116 | await store.create(`${promiseIdPrefix}-timedout`, undefined, true, undefined, undefined, 0, undefined); 117 | 118 | await store.resolve(`${promiseIdPrefix}-resolved`, undefined, true, undefined, undefined); 119 | await store.reject(`${promiseIdPrefix}-rejected`, undefined, true, undefined, undefined); 120 | await store.cancel(`${promiseIdPrefix}-canceled`, undefined, true, undefined, undefined); 121 | 122 | // pending 123 | const pendingPromises = []; 124 | for await (const results of store.search(`${promiseIdPrefix}-*`, "pending", undefined)) { 125 | pendingPromises.push(...results); 126 | } 127 | 128 | expect(pendingPromises.length).toBe(1); 129 | expect(pendingPromises[0].id).toBe(`${promiseIdPrefix}-pending`); 130 | expect(pendingPromises[0].state).toBe("PENDING"); 131 | 132 | // resolved 133 | const resolvedPromises = []; 134 | for await (const results of store.search(`${promiseIdPrefix}-*`, "resolved", undefined)) { 135 | resolvedPromises.push(...results); 136 | } 137 | 138 | expect(resolvedPromises.length).toBe(1); 139 | expect(resolvedPromises[0].id).toBe(`${promiseIdPrefix}-resolved`); 140 | expect(resolvedPromises[0].state).toBe("RESOLVED"); 141 | 142 | // rejected 143 | const rejectedPromises = []; 144 | for await (const results of store.search(`${promiseIdPrefix}-*`, "rejected", undefined)) { 145 | rejectedPromises.push(...results); 146 | } 147 | 148 | const ids = rejectedPromises.map((p) => p.id); 149 | const states = rejectedPromises.map((p) => p.state); 150 | 151 | expect(rejectedPromises.length).toBe(3); 152 | expect(ids).toContain(`${promiseIdPrefix}-rejected`); 153 | expect(ids).toContain(`${promiseIdPrefix}-canceled`); 154 | expect(ids).toContain(`${promiseIdPrefix}-timedout`); 155 | expect(states).toContain("REJECTED"); 156 | expect(states).toContain("REJECTED_CANCELED"); 157 | expect(states).toContain("REJECTED_TIMEDOUT"); 158 | }); 159 | 160 | test("Promise Store: Search by tags", async () => { 161 | const promiseId = "search-by-tags-promise"; 162 | const tags = { category: "search testing", priority: "high" }; 163 | await store.create(promiseId, undefined, false, undefined, undefined, Number.MAX_SAFE_INTEGER, tags); 164 | 165 | const promises = []; 166 | for await (const results of store.search(promiseId, undefined, tags, undefined)) { 167 | promises.push(...results); 168 | } 169 | 170 | expect(promises.length).toBe(1); 171 | expect(promises[0].id).toBe(promiseId); 172 | expect(promises[0].tags).toEqual(tags); 173 | }); 174 | }); 175 | } 176 | }); 177 | -------------------------------------------------------------------------------- /test/resonate.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from "@jest/globals"; 2 | import { options } from "../lib/core/options"; 3 | import { Context, Resonate } from "../lib/resonate"; 4 | 5 | jest.setTimeout(10000); 6 | 7 | describe("Resonate", () => { 8 | test("Register without options", async () => { 9 | const resonate = new Resonate(); 10 | 11 | resonate.register("foo", () => "foo.1"); 12 | resonate.register("foo", () => "foo.2", { version: 2 }); 13 | resonate.register("bar", () => "bar.1", { version: 1 }); 14 | resonate.register("bar", () => "bar.2", { version: 2 }); 15 | 16 | expect(await resonate.run("foo", "foo.0")).toBe("foo.2"); 17 | expect(await resonate.run("foo", "foo.1", options({ version: 1 }))).toBe("foo.1"); 18 | expect(await resonate.run("foo", "foo.2", options({ version: 2 }))).toBe("foo.2"); 19 | 20 | expect(await resonate.run("bar", "bar.0")).toBe("bar.2"); 21 | expect(await resonate.run("bar", "bar.1", options({ version: 1 }))).toBe("bar.1"); 22 | expect(await resonate.run("bar", "bar.2", options({ version: 2 }))).toBe("bar.2"); 23 | }); 24 | 25 | test("Register with options", async () => { 26 | const resonate = new Resonate(); 27 | 28 | resonate.register("foo", () => "foo.1", options({ timeout: 1000 })); 29 | resonate.register("foo", () => "foo.2", { version: 2 }); 30 | resonate.register("bar", () => "bar.1", { version: 1 }); 31 | resonate.register("bar", () => "bar.2", { version: 2 }); 32 | 33 | expect(await resonate.run("foo", "foo.0")).toBe("foo.2"); 34 | expect(await resonate.run("foo", "foo.1", options({ version: 1 }))).toBe("foo.1"); 35 | expect(await resonate.run("foo", "foo.2", options({ version: 2 }))).toBe("foo.2"); 36 | 37 | expect(await resonate.run("bar", "bar.0")).toBe("bar.2"); 38 | expect(await resonate.run("bar", "bar.1", options({ version: 1 }))).toBe("bar.1"); 39 | expect(await resonate.run("bar", "bar.2", options({ version: 2 }))).toBe("bar.2"); 40 | }); 41 | 42 | test("Register throws error", () => { 43 | const resonate = new Resonate(); 44 | resonate.register("foo", () => {}); 45 | resonate.register("foo", () => {}, { version: 2 }); 46 | 47 | expect(() => resonate.register("foo", () => {})).toThrow("Function foo version 1 already registered"); 48 | expect(() => resonate.register("foo", () => {}, { version: 1 })).toThrow( 49 | "Function foo version 1 already registered", 50 | ); 51 | expect(() => resonate.register("foo", () => {}, { version: 2 })).toThrow( 52 | "Function foo version 2 already registered", 53 | ); 54 | }); 55 | 56 | test("Register module", async () => { 57 | const resonate = new Resonate(); 58 | 59 | resonate.registerModule({ 60 | foo() { 61 | return "foo"; 62 | }, 63 | bar() { 64 | return "bar"; 65 | }, 66 | }); 67 | 68 | expect(await resonate.run("foo", "foo.0")).toBe("foo"); 69 | expect(await resonate.run("bar", "bar.0")).toBe("bar"); 70 | }); 71 | 72 | test("Schedule", async () => { 73 | const resonate = new Resonate(); 74 | 75 | const fooPromise = new Promise((resolve) => { 76 | resonate.schedule("foo", "* * * * * *", () => resolve("foo")); 77 | }); 78 | 79 | const barPromise = new Promise((resolve) => { 80 | resonate.schedule("bar", "* * * * * *", (c: Context, v: string) => resolve(v), "bar"); 81 | }); 82 | 83 | const bazPromise = new Promise((resolve) => { 84 | resonate.register("baz", () => resolve("baz")); 85 | resonate.schedule("baz", "* * * * * *", "baz"); 86 | }); 87 | 88 | const quxPromise = new Promise((resolve) => { 89 | resonate.register("qux", () => resolve("qux"), { version: 1 }); 90 | resonate.schedule("qux", "* * * * * *", "qux", options({ version: 1 })); 91 | }); 92 | 93 | const foo = await resonate.schedules.get("foo"); 94 | const bar = await resonate.schedules.get("bar"); 95 | const baz = await resonate.schedules.get("baz"); 96 | const qux = await resonate.schedules.get("qux"); 97 | 98 | resonate.start(1000); // no delay for tests 99 | 100 | expect(await fooPromise).toBe("foo"); 101 | expect(await barPromise).toBe("bar"); 102 | expect(await bazPromise).toBe("baz"); 103 | expect(await quxPromise).toBe("qux"); 104 | 105 | resonate.stop(); 106 | 107 | // delete the schedules in order to stop the local 108 | // store interval that creates promises 109 | await foo.delete(); 110 | await bar.delete(); 111 | await baz.delete(); 112 | await qux.delete(); 113 | }); 114 | 115 | test("Schedule throws error", async () => { 116 | const resonate = new Resonate(); 117 | 118 | expect(resonate.schedule("foo", "", "foo")).rejects.toThrow("Function foo version 0 not registered"); 119 | expect(resonate.schedule("foo", "", "foo", options({ version: 1 }))).rejects.toThrow( 120 | "Function foo version 1 not registered", 121 | ); 122 | expect(resonate.schedule("foo", "", "foo", options({ version: 2 }))).rejects.toThrow( 123 | "Function foo version 2 not registered", 124 | ); 125 | 126 | resonate.register("bar", () => {}); 127 | expect(resonate.schedule("bar", "", () => {})).rejects.toThrow("Function bar version 1 already registered"); 128 | 129 | const baz = await resonate.schedule("baz", "", () => {}); 130 | expect(resonate.schedule("baz", "", () => {})).rejects.toThrow("Function baz version 1 already registered"); 131 | 132 | resonate.register("qux", () => {}); 133 | expect(resonate.schedule("qux", "x", "qux")).rejects.toThrow(); 134 | expect(resonate.schedule("qux", "* * * * * * *", "qux")).rejects.toThrow(); 135 | 136 | // delete the schedules in order to stop the local 137 | // store interval that creates promises 138 | await baz.delete(); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /test/schedules.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from "@jest/globals"; 2 | import { Schedule } from "../lib/core/schedules/types"; 3 | import { IStore } from "../lib/core/store"; 4 | import { LocalStore } from "../lib/core/stores/local"; 5 | import { RemoteStore } from "../lib/core/stores/remote"; 6 | 7 | jest.setTimeout(10000); 8 | 9 | describe("Store: Schedules", () => { 10 | const stores: IStore[] = [new LocalStore()]; 11 | 12 | if (process.env.RESONATE_STORE_URL) { 13 | stores.push(new RemoteStore(process.env.RESONATE_STORE_URL)); 14 | } 15 | 16 | for (const store of stores.map((s) => s.schedules)) { 17 | describe(store.constructor.name, () => { 18 | test("Schedule Store: Create schedule", async () => { 19 | const scheduleId = "new-schedule"; 20 | const cronExpression = "* * * * *"; // Every minute 21 | 22 | const createdSchedule = await store.create( 23 | scheduleId, 24 | scheduleId, 25 | undefined, 26 | cronExpression, 27 | undefined, 28 | "promise-1", 29 | 1, 30 | undefined, 31 | undefined, 32 | undefined, 33 | ); 34 | 35 | expect(createdSchedule.id).toBe(scheduleId); 36 | expect(createdSchedule.cron).toBe(cronExpression); 37 | 38 | // Clean up 39 | await store.delete(scheduleId); 40 | }); 41 | 42 | // this test needs to be discussed 43 | test("Schedule Store: Create schedule that exists with same idempotency key", async () => { 44 | const scheduleId = "existing-schedule"; 45 | const cronExpression = "* * * * *"; // Every minute 46 | 47 | // Create the initial schedule 48 | await store.create( 49 | scheduleId, 50 | scheduleId, 51 | undefined, 52 | cronExpression, 53 | undefined, 54 | "promise-1", 55 | 1, 56 | undefined, 57 | undefined, 58 | undefined, 59 | ); 60 | 61 | const schedule = await store.create( 62 | scheduleId, 63 | scheduleId, 64 | undefined, 65 | cronExpression, 66 | undefined, 67 | "promise-1", 68 | 1, 69 | undefined, 70 | undefined, 71 | undefined, 72 | ); 73 | 74 | expect(schedule.id).toBe(scheduleId); 75 | expect(schedule.cron).toBe(cronExpression); 76 | expect(schedule.idempotencyKey).toBe(scheduleId); 77 | 78 | // Clean up 79 | await store.delete(scheduleId); 80 | }); 81 | 82 | test("Schedule Store: Create schedule that exists with different idempotency key", async () => { 83 | const scheduleId = "existing-schedule"; 84 | const cronExpression = "* * * * *"; // Every minute 85 | 86 | // Create the initial schedule 87 | await store.create( 88 | scheduleId, 89 | scheduleId, 90 | undefined, 91 | cronExpression, 92 | undefined, 93 | "promise-1", 94 | 1, 95 | undefined, 96 | undefined, 97 | undefined, 98 | ); 99 | 100 | // Attempt to create a schedule with a different idempotency key, should throw error 101 | await expect( 102 | store.create( 103 | scheduleId, 104 | "new-idempotency-key", 105 | undefined, 106 | cronExpression, 107 | undefined, 108 | "promise-1", 109 | 1, 110 | undefined, 111 | undefined, 112 | undefined, 113 | ), 114 | ).rejects.toThrowError("Already exists"); 115 | 116 | // Clean up 117 | await store.delete(scheduleId); 118 | }); 119 | 120 | test("Schedule Store: Get schedule that exists", async () => { 121 | const scheduleId = "existing-schedule"; 122 | const cronExpression = "* * * * *"; // Every minute 123 | 124 | // Create the schedule 125 | await store.create( 126 | scheduleId, 127 | scheduleId, 128 | undefined, 129 | cronExpression, 130 | undefined, 131 | "promise-1", 132 | 1, 133 | undefined, 134 | undefined, 135 | undefined, 136 | ); 137 | 138 | // Get the existing schedule 139 | const existingSchedule = await store.get(scheduleId); 140 | expect(existingSchedule.id).toBe(scheduleId); 141 | 142 | // Clean up 143 | await store.delete(scheduleId); 144 | }); 145 | 146 | test("Schedule Store: Get schedule that does not exist", async () => { 147 | const nonExistingScheduleId = "non-existing-schedule-id"; 148 | 149 | // Attempt to get a schedule that does not exist, should throw NOT_FOUND error 150 | await expect(store.get(nonExistingScheduleId)).rejects.toThrowError("Not found"); 151 | }); 152 | 153 | test("Schedule Store: Delete schedule that exists", async () => { 154 | const scheduleId = "schedule-to-delete"; 155 | 156 | // Create the schedule first 157 | await store.create( 158 | scheduleId, 159 | scheduleId, 160 | undefined, 161 | "* * * * *", 162 | undefined, 163 | "promise-1", 164 | 1, 165 | undefined, 166 | undefined, 167 | undefined, 168 | ); 169 | 170 | // Attempt to delete the schedule 171 | const isDeleted = await store.delete(scheduleId); 172 | expect(isDeleted).toBeUndefined(); 173 | 174 | // Attempt to get the deleted schedule, should throw NOT_FOUND error 175 | await expect(store.get(scheduleId)).rejects.toThrowError("Not found"); 176 | }); 177 | 178 | test("Schedule Store: Delete schedule that does not exist", async () => { 179 | const nonExistingScheduleId = "non-existing-schedule-id"; 180 | 181 | // Attempt to delete a schedule that does not exist, should throw NOT_FOUND error 182 | await expect(store.delete(nonExistingScheduleId)).rejects.toThrowError("Not found"); 183 | }); 184 | 185 | test("Schedule Store: Search by id", async () => { 186 | const scheduleId = "search-by-id-schedule"; 187 | 188 | // Create the schedule 189 | await store.create( 190 | scheduleId, 191 | scheduleId, 192 | undefined, 193 | "* * * * *", 194 | undefined, 195 | "promise-1", 196 | 1, 197 | undefined, 198 | undefined, 199 | undefined, 200 | ); 201 | 202 | // Search for the schedule by id 203 | let schedules: Schedule[] = []; 204 | for await (const searchResults of store.search(scheduleId, undefined, undefined)) { 205 | schedules = schedules.concat(searchResults); 206 | } 207 | expect(schedules.length).toBe(1); 208 | expect(schedules[0].id).toBe(scheduleId); 209 | 210 | // Clean up 211 | await store.delete(scheduleId); 212 | }); 213 | 214 | test("Schedule Store: Search by id with wildcard(s)", async () => { 215 | const scheduleIdPrefix = "should-match"; 216 | const wildcardSearch = `${scheduleIdPrefix}*`; 217 | 218 | const scheduleIds = [ 219 | "should-match-1", 220 | "should-match-2", 221 | "should-match-3", 222 | "should-not-match-1", 223 | "should-not-match-2", 224 | "should-not-match-3", 225 | ]; 226 | 227 | for (const i in scheduleIds) { 228 | await store.create( 229 | scheduleIds[i], 230 | scheduleIds[i], 231 | "Search by ID Prefix Schedule", 232 | "* * * * *", 233 | undefined, 234 | `promise-${scheduleIds[i]}`, 235 | 1, 236 | undefined, 237 | undefined, 238 | undefined, 239 | ); 240 | } 241 | 242 | // Search for schedules by id prefix with wildcard 243 | let schedules: Schedule[] = []; 244 | for await (const searchResults of store.search(wildcardSearch, undefined, undefined)) { 245 | schedules = schedules.concat(searchResults); 246 | } 247 | expect(schedules.length).toBe(3); 248 | // assert ids 249 | expect(schedules.map((s) => s.id)).toEqual(expect.arrayContaining(scheduleIds.slice(0, 3))); 250 | 251 | // Clean up 252 | for (const i in scheduleIds) { 253 | await store.delete(scheduleIds[i]); 254 | } 255 | }); 256 | 257 | test("Schedule Store: Search by tags", async () => { 258 | const scheduleIds = ["search-by-tags-schedule", "should-not-match-1", "should-not-match-2"]; 259 | 260 | const scheduleId = "search-by-tags-schedule"; 261 | const searchtag = { category: "search testing", priority: "high" }; 262 | 263 | // Create the schedule with specific tags 264 | for (const i in scheduleIds) { 265 | let tags = { category: "search don't match", priority: "med" }; 266 | if (scheduleIds[i] === scheduleId) { 267 | tags = searchtag; 268 | } 269 | await store.create( 270 | scheduleIds[i], 271 | scheduleIds[i], 272 | undefined, 273 | "* * * * *", 274 | tags, 275 | `promise-${scheduleIds[i]}`, 276 | 1, 277 | undefined, 278 | undefined, 279 | undefined, 280 | ); 281 | } 282 | 283 | // Search for the schedule by tags 284 | let schedules: Schedule[] = []; 285 | for await (const searchResults of store.search(scheduleId, searchtag, undefined)) { 286 | schedules = schedules.concat(searchResults); 287 | } 288 | expect(schedules.length).toBe(1); 289 | 290 | // Clean up 291 | for (const i in scheduleIds) { 292 | await store.delete(scheduleIds[i]); 293 | } 294 | }); 295 | }); 296 | } 297 | }); 298 | -------------------------------------------------------------------------------- /test/sleep.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from "@jest/globals"; 2 | import { IStore } from "../lib/core/store"; 3 | import { LocalStore } from "../lib/core/stores/local"; 4 | import { RemoteStore } from "../lib/core/stores/remote"; 5 | import { Resonate, Context } from "../lib/resonate"; 6 | 7 | jest.setTimeout(10000); 8 | 9 | describe("Sleep", () => { 10 | const stores: IStore[] = [new LocalStore()]; 11 | 12 | if (process.env.RESONATE_STORE_URL) { 13 | stores.push(new RemoteStore(process.env.RESONATE_STORE_URL)); 14 | } 15 | 16 | for (const store of stores) { 17 | describe(store.constructor.name, () => { 18 | const resonate = new Resonate({ store }); 19 | const timestamp = Date.now(); 20 | const funcId = `sleep-${timestamp}`; 21 | resonate.register(funcId, async (ctx: Context, ms: number) => { 22 | const t1 = Date.now(); 23 | await ctx.sleep(ms); 24 | const t2 = Date.now(); 25 | 26 | return t2 - t1; 27 | }); 28 | 29 | for (const ms of [1, 10, 100, 500]) { 30 | test(`${ms}ms`, async () => { 31 | const t = await resonate.run(funcId, `${funcId}.${ms}`, ms); 32 | expect(t).toBeGreaterThanOrEqual(ms); 33 | expect(t).toBeLessThan(ms + 200); // Allow 200ms tolerance because of the server round trip 34 | }); 35 | } 36 | }); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /test/userResources.test.ts: -------------------------------------------------------------------------------- 1 | import { fail } from "assert"; 2 | import { describe, test, expect, jest } from "@jest/globals"; 3 | import { ErrorCodes, linear, never, options, ResonateError } from "../lib"; 4 | import { Context, Resonate } from "../lib/resonate"; 5 | 6 | jest.setTimeout(10000); 7 | describe("User Defined Resources", () => { 8 | test("Set a resource at the Resonate level and get the resource", async () => { 9 | const resonate = new Resonate(); 10 | 11 | const resource = { 12 | a: "a", 13 | b: "b", 14 | }; 15 | resonate.setResource("mock", resource); 16 | 17 | resonate.register("resource-fn", async (ctx: Context) => { 18 | expect(ctx.getResource("mock")).toBe(resource); 19 | await ctx.run(async (ctx: Context) => { 20 | expect(ctx.getResource("mock")).toBe(resource); 21 | }); 22 | }); 23 | 24 | resonate.register("resource-fn2", async (ctx: Context) => { 25 | expect(ctx.getResource("mock")).toBe(resource); 26 | await ctx.run(async (ctx: Context) => { 27 | expect(ctx.getResource("mock")).toBe(resource); 28 | }); 29 | }); 30 | 31 | await resonate.run("resource-fn", "resource.0"); 32 | await resonate.run("resource-fn2", "resource.1"); 33 | }); 34 | 35 | test("Set and get a resource", async () => { 36 | const resonate = new Resonate(); 37 | 38 | resonate.register("resource", async (ctx: Context, resourceVal: unknown) => { 39 | ctx.setResource("mock", resourceVal); 40 | const resource = ctx.getResource("mock"); 41 | expect(resource).toBe(resourceVal); 42 | }); 43 | 44 | const resourceVal = {}; 45 | await resonate.run("resource", "resource.0", resourceVal); 46 | }); 47 | 48 | test("Set a resource and get it deep in the context stack", async () => { 49 | const resonate = new Resonate(); 50 | 51 | resonate.register( 52 | "resource", 53 | async (ctx: Context, val: string) => { 54 | ctx.setResource("res", val); 55 | return await ctx.run(async (ctx: Context) => { 56 | return await ctx.run(async (ctx: Context) => { 57 | return await ctx.run(async (ctx: Context) => { 58 | return ctx.getResource("res"); 59 | }); 60 | }); 61 | }); 62 | }, 63 | options({ retryPolicy: never() }), 64 | ); 65 | 66 | const res = "resource"; 67 | const handle = await resonate.invokeLocal("resource", "resource.0", res); 68 | await expect(handle.result()).resolves.toBe(res); 69 | }); 70 | 71 | test("Finalizers are called in reverse definition order", async () => { 72 | const resonate = new Resonate(); 73 | 74 | const arr: number[] = []; 75 | resonate.register( 76 | "res", 77 | async (ctx: Context) => { 78 | ctx.setResource("4", 4, async () => { 79 | arr.push(4); 80 | }); 81 | 82 | ctx.setResource("3", 3, async () => { 83 | arr.push(3); 84 | }); 85 | 86 | ctx.setResource("2", 2, async () => { 87 | arr.push(2); 88 | }); 89 | 90 | ctx.setResource("1", 1, async () => { 91 | arr.push(1); 92 | }); 93 | }, 94 | options({ retryPolicy: never() }), 95 | ); 96 | 97 | const handle = await resonate.invokeLocal("res", "res.0"); 98 | await handle.result(); 99 | expect(arr).toEqual([1, 2, 3, 4]); 100 | }); 101 | 102 | test("Finalizers are called in the presence of errors", async () => { 103 | const resonate = new Resonate(); 104 | 105 | const arr: number[] = []; 106 | resonate.register( 107 | "res", 108 | async (ctx: Context) => { 109 | ctx.setResource("4", 4, async () => { 110 | arr.push(4); 111 | }); 112 | 113 | ctx.setResource("3", 3, async () => { 114 | arr.push(3); 115 | }); 116 | 117 | ctx.setResource("2", 2, async () => { 118 | arr.push(2); 119 | }); 120 | 121 | ctx.setResource("1", 1, async () => { 122 | arr.push(1); 123 | }); 124 | 125 | throw new Error("Some Error"); 126 | }, 127 | options({ retryPolicy: never() }), 128 | ); 129 | 130 | const handle = await resonate.invokeLocal("res", "res.0"); 131 | await expect(handle.result()).rejects.toThrow("Some Error"); 132 | expect(arr).toEqual([1, 2, 3, 4]); 133 | }); 134 | 135 | test("Finalizers are called in the presence of unrecoverable errors", async () => { 136 | const resonate = new Resonate(); 137 | 138 | const arr: number[] = []; 139 | resonate.register( 140 | "res", 141 | async (ctx: Context) => { 142 | ctx.setResource("4", 4, async () => { 143 | arr.push(4); 144 | }); 145 | 146 | ctx.setResource("3", 3, async () => { 147 | arr.push(3); 148 | }); 149 | 150 | ctx.setResource("2", 2, async () => { 151 | arr.push(2); 152 | }); 153 | 154 | ctx.setResource("1", 1, async () => { 155 | arr.push(1); 156 | }); 157 | 158 | jest 159 | .spyOn(resonate.store.promises, "resolve") 160 | .mockRejectedValue(new ResonateError("Fetch Error", ErrorCodes.FETCH, "mock", true)); 161 | }, 162 | options({ retryPolicy: never() }), 163 | ); 164 | 165 | const handle = await resonate.invokeLocal("res", "res.0"); 166 | const durablePromise = await resonate.store.promises.get(handle.invocationId); 167 | expect(durablePromise.state).toBe("PENDING"); 168 | try { 169 | await handle.result(); 170 | } catch (err) { 171 | if (err instanceof ResonateError) { 172 | expect(err.code).toBe(ErrorCodes.ABORT); 173 | } else { 174 | fail("expected Resonate Error"); 175 | } 176 | } 177 | expect(arr).toEqual([1, 2, 3, 4]); 178 | }); 179 | 180 | test("Error thrown is the user error and not the setResource Error", async () => { 181 | const resonate = new Resonate(); 182 | 183 | resonate.register( 184 | "res", 185 | async (ctx: Context) => { 186 | ctx.setResource("myResource", "resource"); 187 | throw new Error("Some Error"); 188 | }, 189 | options({ retryPolicy: linear(10, 3) }), 190 | ); 191 | 192 | const handle = await resonate.invokeLocal("res", "res.0"); 193 | try { 194 | await handle.result(); 195 | } catch (err) { 196 | expect(err instanceof Error).toBe(true); 197 | expect((err as Error).message).toEqual("Some Error"); 198 | } 199 | }); 200 | 201 | test("Trying to Overwrite a resource throws", async () => { 202 | const resonate = new Resonate(); 203 | 204 | resonate.register( 205 | "overwrite", 206 | async (ctx: Context) => { 207 | ctx.setResource("test", "original"); 208 | ctx.setResource("test", "overwritten"); 209 | return ctx.getResource("test"); 210 | }, 211 | options({ retryPolicy: never() }), 212 | ); 213 | 214 | const handle = await resonate.invokeLocal("overwrite", "overwrite.0"); 215 | await expect(handle.result()).rejects.toThrowError(Error); 216 | }); 217 | 218 | test("Accessing a non-existent resource", async () => { 219 | const resonate = new Resonate(); 220 | 221 | resonate.register("nonexistent", async (ctx: Context) => { 222 | return ctx.getResource("doesNotExist"); 223 | }); 224 | 225 | const handle = await resonate.invokeLocal("nonexistent", "nonexistent.0"); 226 | await expect(handle.result()).resolves.toBeUndefined(); 227 | }); 228 | 229 | test("Setting and getting resources of different types", async () => { 230 | const resonate = new Resonate(); 231 | 232 | resonate.register("multipleTypes", async (ctx: Context) => { 233 | ctx.setResource("string", "Hello"); 234 | ctx.setResource("number", 42); 235 | ctx.setResource("boolean", true); 236 | ctx.setResource("object", { key: "value" }); 237 | ctx.setResource("array", [1, 2, 3]); 238 | 239 | return { 240 | string: ctx.getResource("string"), 241 | number: ctx.getResource("number"), 242 | boolean: ctx.getResource("boolean"), 243 | object: ctx.getResource("object"), 244 | array: ctx.getResource("array"), 245 | }; 246 | }); 247 | 248 | const handle = await resonate.invokeLocal>("multipleTypes", "multipleTypes.0"); 249 | await expect(handle.result()).resolves.toEqual({ 250 | string: "Hello", 251 | number: 42, 252 | boolean: true, 253 | object: { key: "value" }, 254 | array: [1, 2, 3], 255 | }); 256 | }); 257 | 258 | test("Resource are correctly set across retries", async () => { 259 | const resonate = new Resonate(); 260 | let attempts = 0; 261 | 262 | resonate.register( 263 | "persistentResource", 264 | async (ctx: Context) => { 265 | attempts++; 266 | if (!ctx.getResource("persistent")) { 267 | ctx.setResource("persistent", "I persist"); 268 | } 269 | if (attempts < 3) { 270 | throw new Error("Retry me"); 271 | } 272 | return ctx.getResource("persistent"); 273 | }, 274 | options({ retryPolicy: linear(10, 3) }), 275 | ); 276 | 277 | const handle = await resonate.invokeLocal("persistentResource", "persistentResource.0"); 278 | await expect(handle.result()).resolves.toBe("I persist"); 279 | expect(attempts).toBe(3); 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from "@jest/globals"; 2 | import { mergeObjects, sleep, promiseState } from "../lib/core/utils"; 3 | 4 | jest.setTimeout(2000); 5 | 6 | describe("mergeObjects", () => { 7 | test("merges two objects with non-overlapping keys", () => { 8 | const obj1 = { a: 1, b: 2 }; 9 | const obj2 = { c: 3, d: 4 }; 10 | const result = mergeObjects(obj1, obj2); 11 | expect(result).toEqual({ a: 1, b: 2, c: 3, d: 4 }); 12 | }); 13 | 14 | test("prefers values from obj1 when keys overlap and neither is undefined", () => { 15 | const obj1 = { a: 1, b: 2 }; 16 | const obj2 = { b: 3, c: 4 }; 17 | const result = mergeObjects(obj1, obj2); 18 | expect(result).toEqual({ a: 1, b: 2, c: 4 }); 19 | }); 20 | 21 | test("uses obj2 value when obj1 value is undefined", () => { 22 | const obj1 = { a: 1, b: undefined as number | undefined }; 23 | const obj2 = { b: 2, c: 3 }; 24 | const result = mergeObjects(obj1, obj2); 25 | expect(result).toEqual({ a: 1, b: 2, c: 3 }); 26 | }); 27 | 28 | test("handles nested objects", () => { 29 | const obj1 = { a: { x: 1 }, b: 2 }; 30 | const obj2 = { a: { y: 2 }, c: 3 }; 31 | const result = mergeObjects(obj1, obj2); 32 | expect(result).toEqual({ a: { x: 1 }, b: 2, c: 3 }); 33 | }); 34 | 35 | test("handles arrays", () => { 36 | const obj1 = { a: [1, 2], b: 2 }; 37 | const obj2 = { a: [3, 4], c: 3 }; 38 | const result = mergeObjects(obj1, obj2); 39 | expect(result).toEqual({ a: [1, 2], b: 2, c: 3 }); 40 | }); 41 | 42 | test("handles empty objects", () => { 43 | const obj1 = {}; 44 | const obj2 = { a: 1 }; 45 | const result = mergeObjects(obj1, obj2); 46 | expect(result).toEqual({ a: 1 }); 47 | }); 48 | 49 | test("handles objects with null values", () => { 50 | const obj1 = { a: null, b: 2 }; 51 | const obj2 = { a: 1, c: null }; 52 | const result = mergeObjects(obj1, obj2); 53 | expect(result).toEqual({ a: null, b: 2, c: null }); 54 | }); 55 | }); 56 | 57 | describe("sleep function", () => { 58 | // Helper function to measure time 59 | const measureTime = async (fn: () => Promise): Promise => { 60 | const start = Date.now(); 61 | await fn(); 62 | return Date.now() - start; 63 | }; 64 | 65 | test("should resolve after specified milliseconds", async () => { 66 | const duration = 500; 67 | const elapsed = await measureTime(() => sleep(duration)); 68 | expect(elapsed).toBeGreaterThanOrEqual(duration - 50); // Allow 50ms tolerance because of event loop 69 | expect(elapsed).toBeLessThan(duration + 50); // Allow 50ms tolerance because of the event loop 70 | }); 71 | 72 | test("should resolve in order", async () => { 73 | const results: number[] = []; 74 | await Promise.all([ 75 | sleep(300).then(() => results.push(3)), 76 | sleep(100).then(() => results.push(1)), 77 | sleep(200).then(() => results.push(2)), 78 | ]); 79 | expect(results).toEqual([1, 2, 3]); 80 | }); 81 | 82 | test("should work with zero milliseconds", async () => { 83 | const start = Date.now(); 84 | await sleep(0); 85 | const elapsed = Date.now() - start; 86 | expect(elapsed).toBeLessThan(50); // Should resolve almost immediately 87 | }); 88 | 89 | test("should reject for negative milliseconds", async () => { 90 | await expect(sleep(-100)).rejects.toThrow(); 91 | }); 92 | }); 93 | 94 | describe("promiseState", () => { 95 | test('returns "pending" for a pending promise', async () => { 96 | const pendingPromise = new Promise(() => {}); 97 | const result = await promiseState(pendingPromise); 98 | expect(result).toBe("pending"); 99 | }); 100 | 101 | test('returns "fulfilled" for a resolved promise', async () => { 102 | const fulfilledPromise = Promise.resolve("success"); 103 | const result = await promiseState(fulfilledPromise); 104 | expect(result).toBe("resolved"); 105 | }); 106 | 107 | test('returns "rejected" for a rejected promise', async () => { 108 | const rejectedPromise = Promise.reject("error"); 109 | const result = await promiseState(rejectedPromise); 110 | expect(result).toBe("rejected"); 111 | }); 112 | 113 | test("handles promises that resolve after a delay", async () => { 114 | const delayedPromise = new Promise((resolve) => setTimeout(() => resolve("delayed success"), 100)); 115 | const immediatePendingResult = await promiseState(delayedPromise); 116 | expect(immediatePendingResult).toBe("pending"); 117 | 118 | await new Promise((resolve) => setTimeout(resolve, 150)); 119 | const laterFulfilledResult = await promiseState(delayedPromise); 120 | expect(laterFulfilledResult).toBe("resolved"); 121 | }); 122 | 123 | test("handles promises that reject after a delay", async () => { 124 | const delayedRejection = new Promise((_, reject) => setTimeout(() => reject("delayed error"), 100)); 125 | const immediatePendingResult = await promiseState(delayedRejection); 126 | expect(immediatePendingResult).toBe("pending"); 127 | 128 | await new Promise((resolve) => setTimeout(resolve, 150)); 129 | const laterRejectedResult = await promiseState(delayedRejection); 130 | expect(laterRejectedResult).toBe("rejected"); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /test/versions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, jest } from "@jest/globals"; 2 | import { options } from "../lib/core/options"; 3 | import { Resonate, Context } from "../lib/resonate"; 4 | 5 | jest.setTimeout(10000); 6 | 7 | describe("Functions: versions", () => { 8 | const resonate = new Resonate(); 9 | resonate.register("test", (ctx: Context) => ({ v: "v1", c: ctx.invocationData.opts.version })); 10 | resonate.register("test", (ctx: Context) => ({ v: "v2", c: ctx.invocationData.opts.version }), { version: 2 }); 11 | resonate.register("test", (ctx: Context) => ({ v: "v3", c: ctx.invocationData.opts.version }), { version: 3 }); 12 | 13 | test("should return v1", async () => { 14 | const result = await resonate.run("test", "a", options({ version: 1 })); 15 | expect(result).toMatchObject({ v: "v1", c: 1 }); 16 | }); 17 | 18 | test("should return v2", async () => { 19 | const result = await resonate.run("test", "b", options({ version: 2 })); 20 | expect(result).toMatchObject({ v: "v2", c: 2 }); 21 | }); 22 | 23 | test("should return v3", async () => { 24 | const r1 = await resonate.run("test", "c", options({ version: 3 })); 25 | expect(r1).toMatchObject({ v: "v3", c: 3 }); 26 | 27 | const r2 = await resonate.run("test", "d", options({ version: 0 })); 28 | expect(r2).toMatchObject({ v: "v3", c: 3 }); 29 | 30 | const r3 = await resonate.run("test", "e"); 31 | expect(r3).toMatchObject({ v: "v3", c: 3 }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["test/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "lib": ["esnext"], 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "outDir": "./dist", 10 | "strict": true, 11 | "allowJs": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "allowSyntheticDefaultImports": true 16 | }, 17 | "include": [ 18 | "lib/**/*", 19 | "test/**/*" 20 | ] 21 | } -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugin": ["typedoc-material-theme"], 3 | "themeColor": "#3f4965" 4 | } 5 | --------------------------------------------------------------------------------