diff --git a/patterns-use-cases/xstate/.eslintignore b/patterns-use-cases/xstate/.eslintignore new file mode 100644 index 00000000..f06235c4 --- /dev/null +++ b/patterns-use-cases/xstate/.eslintignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/patterns-use-cases/xstate/.eslintrc.json b/patterns-use-cases/xstate/.eslintrc.json new file mode 100644 index 00000000..77b1f4de --- /dev/null +++ b/patterns-use-cases/xstate/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "env": { + "browser": true, + "commonjs": true, + "es2021": true + }, + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], + "overrides": [], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest" + }, + "plugins": ["@typescript-eslint"], + "rules": {} +} diff --git a/patterns-use-cases/xstate/README.md b/patterns-use-cases/xstate/README.md new file mode 100644 index 00000000..7574e142 --- /dev/null +++ b/patterns-use-cases/xstate/README.md @@ -0,0 +1,46 @@ +# Deploying a XState state machine on Restate + +This example shows how to integrate Restate deeply with +[XState](https://stately.ai/docs/xstate). The code in [lib.ts](./lib.ts) and +[promise.ts](./promise.ts) converts an XState machine into two Restate +services: + +1. A keyed service, which stores the state of the state machine, keyed on an + identifier for this instance of the machine. This service is called with + every event that must be processed by the state machine. XState machines are + generally pure and are not async; side effects generally happen through + [Promise Actors](https://stately.ai/docs/promise-actors). As such, this + service should never block the machine, so other events can always be + processed. +2. An unkeyed service, which exists solely to execute Promise Actors and call + back to the state machine with their result. As this is an unkeyed service, + the Promise won't hold up any other events. This service doesn't need to be + called by you directly. + +Both services are set up and managed automatically by interpreting the state +machine definition, and should generally be deployed together, whether as a +Lambda or as a long-lived service. + +In `app.ts` you will see an example of an XState machine that uses cross-machine +communication, delays, and Promise actors, all running in Restate. However, +most XState machines should work out of the box; this is still experimental, so +we haven't tested everything yet! + +To try out this example: + +```bash +# start a local Restate instance +restate-server +# start the service +npm run example +# register the state machine service against restate +restate dep register http://localhost:9080 + +# create a state machine +curl http://localhost:8080/auth/myMachine/create +# watch the state +watch -n1 'curl -s http://localhost:8080/auth/myMachine/snapshot' +# kick off the machine +curl http://localhost:8080/auth/myMachine/send --json '{"event": {"type": "AUTH"}}' +# and watch the auth flow progress! +``` diff --git a/patterns-use-cases/xstate/package-lock.json b/patterns-use-cases/xstate/package-lock.json new file mode 100644 index 00000000..82cdf76a --- /dev/null +++ b/patterns-use-cases/xstate/package-lock.json @@ -0,0 +1,771 @@ +{ + "name": "@restatedev/examples-patterns-xstate", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@restatedev/examples-patterns-xstate", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@restatedev/restate-sdk": "^1.3.0", + "xstate": "^5.18.0" + }, + "devDependencies": { + "@types/node": "^20.12.7", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.0.2" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", + "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@restatedev/restate-sdk": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@restatedev/restate-sdk/-/restate-sdk-1.3.0.tgz", + "integrity": "sha512-tVbuBV+lQJ58xOImRCfIP5RHJ1uSsa8M7+GWtl6ofyHtuhHGpLJqNkh7jIWGIRnpjXsQp/nlhDld/1sJo3vBMg==", + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^1.8.0", + "@restatedev/restate-sdk-core": "^1.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@restatedev/restate-sdk-core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@restatedev/restate-sdk-core/-/restate-sdk-core-1.3.0.tgz", + "integrity": "sha512-CS2dLD7ppkQSmUSx8PrIOjpOduhMO8cXO2WtlBMXRcpk/wxdCXZtvVe0oQ06DrchX6J71mX1qC7DZvCBjDA0eA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.12.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.10.tgz", + "integrity": "sha512-Eem5pH9pmWBHoGAT8Dr5fdc5rYA+4NAovdM4EktRPVAAiJhmWWfQrA0cFhAbOsQdSfIHjAud6YdkbL69+zSKjw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz", + "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/ts-node-dev/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/tsconfig/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/xstate": { + "version": "5.18.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.18.0.tgz", + "integrity": "sha512-MKlq/jhyFBYm6Z9+P0k9nhMrHYTTg1ZGmhMw8tVe67oDq9nIlEf2/u/bY5kvUvqu4LTCiVl67hnfd92RMLRyVg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/patterns-use-cases/xstate/package.json b/patterns-use-cases/xstate/package.json new file mode 100644 index 00000000..56d1d58a --- /dev/null +++ b/patterns-use-cases/xstate/package.json @@ -0,0 +1,23 @@ +{ + "name": "@restatedev/examples-patterns-xstate", + "version": "0.0.1", + "description": "An example of an XState state machine on Restate", + "type": "commonjs", + "license": "MIT", + "author": "Restate developers", + "email": "code@restate.dev", + "scripts": { + "build": "tsc --noEmitOnError", + "example": "RESTATE_DEBUG_LOGGING=OFF ts-node-dev --transpile-only src/app.ts" + }, + "dependencies": { + "@restatedev/restate-sdk": "^1.3.0", + "xstate": "^5.18.0" + }, + "devDependencies": { + "@types/node": "^20.12.7", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.0.2" + } +} diff --git a/patterns-use-cases/xstate/src/app.ts b/patterns-use-cases/xstate/src/app.ts new file mode 100644 index 00000000..bbde7772 --- /dev/null +++ b/patterns-use-cases/xstate/src/app.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 - Restate Software, Inc., Restate GmbH + * + * This file is part of the Restate Examples for the Node.js/TypeScript SDK, + * which is released under the MIT license. + * + * You can find a copy of the license in the file LICENSE + * in the root directory of this repository or package or at + * https://github.com/restatedev/examples/blob/main/LICENSE + */ + +import * as restate from "@restatedev/restate-sdk"; + +import { createMachine, sendTo } from "xstate"; +import { bindXStateRouter } from "./lib"; +import { fromPromise } from "./promise"; + +const authServerMachine = createMachine( + { + id: "server", + initial: "waitingForCode", + states: { + waitingForCode: { + on: { + CODE: { + target: "process", + }, + }, + }, + process: { + invoke: { + id: "process", + src: "authorise", + onDone: { + actions: sendTo( + ({ self }) => self._parent!, + { type: "TOKEN" }, + { delay: 1000 } + ), + }, + }, + }, + }, + }, + { + actors: { + authorise: fromPromise( + () => new Promise((resolve) => setTimeout(resolve, 5000)) + ), + }, + } +); + +const authClientMachine = createMachine({ + id: "client", + initial: "idle", + states: { + idle: { + on: { + AUTH: { target: "authorizing" }, + }, + }, + authorizing: { + invoke: { + id: "auth-server", + src: authServerMachine, + }, + entry: sendTo("auth-server", ({ self }) => ({ + type: "CODE", + sender: self, + })), + on: { + TOKEN: { target: "authorized" }, + }, + }, + authorized: { + type: "final", + }, + }, +}); + +bindXStateRouter(restate.endpoint(), "auth", authClientMachine).listen(); diff --git a/patterns-use-cases/xstate/src/lib.ts b/patterns-use-cases/xstate/src/lib.ts new file mode 100644 index 00000000..6d4ce93e --- /dev/null +++ b/patterns-use-cases/xstate/src/lib.ts @@ -0,0 +1,480 @@ +import { + Actor, + ActorLogicFrom, + ActorOptions, + ActorSystem, + ActorSystemInfo, + AnyActorRef, + AnyEventObject, + AnyStateMachine, + createActor as createXActor, + EventFromLogic, + EventObject, + HomomorphicOmit, + InputFrom, + InspectionEvent, + InteropSubscribable, + Observer, + Snapshot, + Subscription, + toObserver, +} from "xstate"; +import * as restate from "@restatedev/restate-sdk"; +import { TerminalError } from "@restatedev/restate-sdk"; +import { promiseService } from "./promise"; + +export interface RestateActorSystem + extends ActorSystem { + _bookId: () => string; + _register: (sessionId: string, actorRef: AnyActorRef) => string; + _unregister: (actorRef: AnyActorRef) => void; + _sendInspectionEvent: ( + event: HomomorphicOmit + ) => void; + actor: (sessionId: string) => AnyActorRef | undefined; + _set: (key: K, actorRef: T["actors"][K]) => void; + _relay: ( + source: AnyActorRef | SerialisableActorRef | undefined, + target: AnyActorRef, + event: AnyEventObject + ) => void; + api: XStateApi>; + ctx: restate.ObjectContext; + systemName: string; +} + +export type SerialisableActorRef = { + id: string; + sessionId: string; + _parent?: SerialisableActorRef; +}; + +export const serialiseActorRef = ( + actorRef: AnyActorRef +): SerialisableActorRef => { + return { + id: actorRef.id, + sessionId: actorRef.sessionId, + _parent: + actorRef._parent === undefined + ? undefined + : serialiseActorRef(actorRef._parent), + }; +}; + +type SerialisableScheduledEvent = { + id: string; + event: EventObject; + startedAt: number; + delay: number; + source: SerialisableActorRef; + target: SerialisableActorRef; + uuid: string; +}; + +async function createSystem

( + ctx: restate.ObjectContext, + api: XStateApi>, + systemName: string +): Promise> { + const events = + (await ctx.get<{ [key: string]: SerialisableScheduledEvent }>("events")) ?? + {}; + const childrenByID = + (await ctx.get<{ [key: string]: SerialisableActorRef }>("children")) ?? {}; + + const children = new Map(); + const keyedActors = new Map(); + const reverseKeyedActors = new WeakMap(); + const observers = new Set | ((inspectionEvent: InspectionEvent) => void)>(); + + const scheduler = { + schedule( + _source: AnyActorRef, + _target: AnyActorRef, + event: EventObject, + delay: number, + id: string | undefined + ): void { + if (id === undefined) { + id = ctx.rand.random().toString(36).slice(2); + } + + const { source, target } = { + source: serialiseActorRef(_source), + target: serialiseActorRef(_target), + }; + + console.log( + "schedule from", + source.id, + "to", + target.id, + "with id", + id, + "and delay", + delay + ); + + const scheduledEvent: SerialisableScheduledEvent = { + source, + target, + event, + delay, + id, + startedAt: Date.now(), + uuid: ctx.rand.uuidv4(), + }; + const scheduledEventId = createScheduledEventId(source, id); + if (scheduledEventId in events) { + console.log( + "Ignoring duplicated schedule from", + source.id, + "to", + target.id + ); + return; + } + + events[scheduledEventId] = scheduledEvent; + + ctx + .objectSendClient(api.actor, systemName, { delay }) + .send({ scheduledEvent, source, target, event }); + ctx.set("events", events); + }, + cancel(source: AnyActorRef, id: string): void { + console.log("cancel schedule from", source.id, "with id", id); + + const scheduledEventId = createScheduledEventId(source, id); + + delete events[scheduledEventId]; + ctx.set("events", events); + }, + cancelAll(actorRef: AnyActorRef): void { + console.log("cancel all for", actorRef.id); + + for (const scheduledEventId in events) { + const scheduledEvent = events[scheduledEventId]; + if (scheduledEvent.source.sessionId === actorRef.sessionId) { + delete events[scheduledEventId]; + } + } + ctx.set("events", events); + }, + }; + + const system: RestateActorSystem = { + ctx, + api, + systemName, + + _bookId: () => ctx.rand.uuidv4(), + _register: (sessionId, actorRef) => { + if (actorRef.id in childrenByID) { + // rehydration case; ensure session ID maintains continuity + sessionId = childrenByID[actorRef.id].sessionId; + actorRef.sessionId = sessionId; + } else { + // new actor case + childrenByID[actorRef.id] = serialiseActorRef(actorRef); + ctx.set("children", childrenByID); + } + console.log("register", sessionId, actorRef.id); + children.set(sessionId, actorRef); + return sessionId; + }, + _unregister: (actorRef) => { + if (actorRef.id in childrenByID) { + // rehydration case; ensure session ID maintains continuity + actorRef.sessionId = childrenByID[actorRef.id].sessionId; + } + + children.delete(actorRef.sessionId); + delete childrenByID[actorRef.id]; + ctx.set("children", childrenByID); + const systemId = reverseKeyedActors.get(actorRef); + + if (systemId !== undefined) { + keyedActors.delete(systemId); + reverseKeyedActors.delete(actorRef); + } + }, + _sendInspectionEvent: (event) => { + const resolvedInspectionEvent: InspectionEvent = { + ...event, + rootId: "root", + }; + observers.forEach((observer) => { + if (typeof observer == "function") { + observer(resolvedInspectionEvent) + } else { + observer.next?.(resolvedInspectionEvent); + } + }); + }, + actor: (sessionId) => { + return children.get(sessionId); + }, + get: (systemId) => { + return keyedActors.get(systemId) as T["actors"][any]; + }, + _set: (systemId, actorRef) => { + const existing = keyedActors.get(systemId); + if (existing && existing !== actorRef) { + throw new Error( + `Actor with system ID '${systemId as string}' already exists.` + ); + } + + keyedActors.set(systemId, actorRef); + reverseKeyedActors.set(actorRef, systemId); + }, + inspect: (observer) => { + observers.add(observer); + return {unsubscribe: () => { + observers.delete(observer) + }} + }, + _relay: (source, target, event) => { + console.log( + "Relaying message from", + source?.id, + "to", + target.id, + ":", + event.type + ); + (target as any)._send(event); + }, + scheduler, + getSnapshot: () => { + return { + _scheduledEvents: {}, // unused + }; + }, + start: () => {}, + _logger: (...args) => ctx.console.log(...args), + _clock: { + setTimeout(fn, timeout) { + throw new Error("clock should be unused") + }, + clearTimeout(id) { + throw new Error("clock should be unused") + } + } + }; + + return system; +} + +interface FakeParent extends AnyActorRef { + _send: (event: EventFromLogic) => void; +} + +export async function createActor( + ctx: restate.ObjectContext, + api: XStateApi, + systemName: string, + logic: TLogic, + options?: ActorOptions +): Promise> { + const system = await createSystem(ctx, api, systemName); + const snapshot = (await ctx.get>("snapshot")) ?? undefined; + + const parent: FakeParent = { + id: "fakeRoot", + sessionId: "fakeRoot", + send: () => {}, + _send: () => {}, + start: () => {}, + getSnapshot: (): null => { + return null; + }, // TODO + getPersistedSnapshot: (): Snapshot => { + return { + status: "active", + output: undefined, + error: undefined, + }; + }, // TODO + stop: () => {}, // TODO + on: () => { return {unsubscribe: () => {}} }, // TODO + system, + src: "fakeRoot", + subscribe: (): Subscription => { + return { + unsubscribe() {}, + }; + }, + [Symbol.observable]: (): InteropSubscribable => { + return { + subscribe(): Subscription { + return { + unsubscribe() {}, + }; + }, + }; + }, + }; + + if (options?.inspect) { + // Always inspect at the system-level + system.inspect(toObserver(options.inspect)); + } + + return createXActor(logic, { + id: "root", + ...options, + parent, + snapshot, + } as any); +} + +const actorObject = ( + path: string, + logic: TLogic +) => { + const api = xStateApi(path); + + return restate.object({ + name: path, + handlers: { + create: async ( + ctx: restate.ObjectContext, + request?: { input?: InputFrom } + ): Promise> => { + const systemName = ctx.key; + + ctx.clear("snapshot"); + ctx.clear("events"); + ctx.clear("children"); + + const root = ( + await createActor(ctx, api, systemName, logic, { + input: request?.input, + }) + ).start(); + + ctx.set("snapshot", root.getPersistedSnapshot()); + + return root.getPersistedSnapshot(); + }, + send: async ( + ctx: restate.ObjectContext, + request?: { + scheduledEvent?: SerialisableScheduledEvent; + source?: SerialisableActorRef; + target?: SerialisableActorRef; + event: AnyEventObject; + } + ): Promise | undefined> => { + const systemName = ctx.key; + + if (!request) { + throw new TerminalError("Must provide a request"); + } + + if (request.scheduledEvent) { + const events = + (await ctx.get<{ [key: string]: SerialisableScheduledEvent }>( + "events" + )) ?? {}; + const scheduledEventId = createScheduledEventId( + request.scheduledEvent.source, + request.scheduledEvent.id + ); + if (!(scheduledEventId in events)) { + console.log( + "Received now cancelled event", + scheduledEventId, + "for target", + request.target + ); + return; + } + if (events[scheduledEventId].uuid !== request.scheduledEvent.uuid) { + console.log( + "Received now replaced event", + scheduledEventId, + "for target", + request.target + ); + return; + } + delete events[scheduledEventId]; + ctx.set("events", events); + } + + const root = (await createActor(ctx, api, systemName, logic)).start(); + + let actor; + if (request.target) { + actor = (root.system as RestateActorSystem).actor( + request.target.sessionId + ); + if (!actor) { + throw new TerminalError( + `Actor ${request.target.id} not found; it may have since stopped` + ); + } + } else { + actor = root; + } + + (root.system as RestateActorSystem)._relay( + request.source, + actor, + request.event + ); + + const nextSnapshot = root.getPersistedSnapshot(); + ctx.set("snapshot", nextSnapshot); + + return nextSnapshot; + }, + snapshot: async ( + ctx: restate.ObjectContext, + systemName: string + ): Promise> => { + const root = await createActor(ctx, api, systemName, logic); + + return root.getPersistedSnapshot(); + }, + }, + }) +}; + +export const bindXStateRouter = ( + server: restate.RestateEndpoint, + path: string, + logic: TLogic +): restate.RestateEndpoint => { + return server + .bind(actorObject(path, logic)) + .bind(promiseService(path, logic)) +}; + +export const xStateApi = ( + path: string +): XStateApi => { + const actor: ActorObject = { name: path }; + const promise: PromiseService = { + name: `${path}.promises`, + }; + return { actor, promise }; +}; + +type ActorObject = ReturnType>; +type PromiseService = ReturnType>; +type XStateApi = { + actor: ActorObject; + promise: PromiseService; +}; + +function createScheduledEventId( + actorRef: SerialisableActorRef, + id: string +): string { + return `${actorRef.sessionId}.${id}`; +} diff --git a/patterns-use-cases/xstate/src/promise.ts b/patterns-use-cases/xstate/src/promise.ts new file mode 100644 index 00000000..94a21199 --- /dev/null +++ b/patterns-use-cases/xstate/src/promise.ts @@ -0,0 +1,276 @@ +import { + ActorLogic, + ActorRefFrom, + AnyActorLogic, + AnyActorRef, + AnyStateMachine, + InvokeConfig, + NonReducibleUnknown, + Snapshot, +} from "xstate"; +import { AnyActorSystem } from "xstate/dist/declarations/src/system"; +import { + RestateActorSystem, + SerialisableActorRef, + serialiseActorRef, + xStateApi, +} from "./lib"; +import * as restate from "@restatedev/restate-sdk"; +import { TerminalError } from "@restatedev/restate-sdk"; + +export type PromiseSnapshot = Snapshot & { + input: TInput | undefined; + sent: boolean; +}; + +const RESTATE_PROMISE_SENT = "restate.promise.sent"; +const RESTATE_PROMISE_RESOLVE = "restate.promise.resolve"; +const RESTATE_PROMISE_REJECT = "restate.promise.reject"; +const XSTATE_STOP = "xstate.stop"; + +type PromiseCreator = ({ + input, + ctx, +}: { + input: TInput; + ctx: restate.Context; +}) => PromiseLike; + +export type PromiseActorLogic = ActorLogic< + PromiseSnapshot, + { type: string; [k: string]: unknown }, + TInput, // input + AnyActorSystem +> & { + sentinel: "restate.promise.actor"; + config: PromiseCreator; +}; + +export type PromiseActorRef = ActorRefFrom< + PromiseActorLogic +>; + +export function fromPromise

( + promiseCreator: PromiseCreator +): PromiseActorLogic { + const logic: PromiseActorLogic = { + sentinel: "restate.promise.actor", + config: promiseCreator, + transition: (state, event) => { + if (state.status !== "active") { + return state; + } + + switch (event.type) { + case RESTATE_PROMISE_SENT: { + return { + ...state, + sent: true, + }; + } + case RESTATE_PROMISE_RESOLVE: { + const resolvedValue = (event as any).data; + return { + ...state, + status: "done", + output: resolvedValue, + input: undefined, + }; + } + case RESTATE_PROMISE_REJECT: + return { + ...state, + status: "error", + error: (event as any).data, + input: undefined, + }; + case XSTATE_STOP: + return { + ...state, + status: "stopped", + input: undefined, + }; + default: + return state; + } + }, + start: (state, { self, system }) => { + if (state.status !== "active") { + return; + } + + if (state.sent) { + return; + } + + const rs = system as RestateActorSystem; + + rs.ctx.serviceSendClient(rs.api.promise).invoke({ + systemName: rs.systemName, + self: serialiseActorRef(self), + srcs: actorSrc(self), + input: state.input, + }); + + // note that we sent off the promise so we don't do it again + (system as any)._relay(self, self, { + type: RESTATE_PROMISE_SENT, + }); + }, + getInitialSnapshot: (_, input) => { + return { + status: "active", + output: undefined, + error: undefined, + input, + sent: false, + }; + }, + getPersistedSnapshot: (snapshot) => snapshot, + restoreSnapshot: (snapshot: any) => snapshot, + }; + + return logic; +} + +function actorSrc(actor?: AnyActorRef): string[] { + if (actor === undefined) { + return []; + } + if (typeof actor.src !== "string") { + return []; + } + return [actor.src, ...actorSrc(actor._parent)]; +} + +export const promiseService = ( + path: string, + logic: TLogic +) => { + const api = xStateApi(path); + + return restate.service({ + name: `${path}.promises`, + handlers: { + invoke: async ( + ctx: restate.Context, + { + systemName, + self, + srcs, + input, + }: { + systemName: string; + self: SerialisableActorRef; + srcs: string[]; + input: NonReducibleUnknown; + } + ) => { + console.log( + "run promise with srcs", + srcs, + "in system", + systemName, + "with input", + input + ); + + const [promiseSrc, ...machineSrcs] = srcs; + + let stateMachine: AnyStateMachine = logic; + for (const src of machineSrcs) { + let maybeSM; + try { + maybeSM = resolveReferencedActor(stateMachine, src); + } catch (e) { + throw new TerminalError( + `Failed to resolve promise actor ${src}: ${e}` + ); + } + if (maybeSM === undefined) { + throw new TerminalError( + `Couldn't find state machine actor with src ${src}` + ); + } + if ("implementations" in maybeSM) { + stateMachine = maybeSM as AnyStateMachine; + } else { + throw new TerminalError( + `Couldn't recognise machine actor with src ${src}` + ); + } + } + + let promiseActor: PromiseActorLogic | undefined; + let maybePA; + try { + maybePA = resolveReferencedActor(stateMachine, promiseSrc); + } catch (e) { + throw new TerminalError( + `Failed to resolve promise actor ${promiseSrc}: ${e}` + ); + } + if (maybePA === undefined) { + throw new TerminalError( + `Couldn't find promise actor with src ${promiseSrc}` + ); + } + if ( + "sentinel" in maybePA && + maybePA.sentinel === "restate.promise.actor" + ) { + promiseActor = maybePA as PromiseActorLogic; + } else { + throw new TerminalError( + `Couldn't recognise promise actor with src ${promiseSrc}` + ); + } + + const resolvedPromise = Promise.resolve( + promiseActor.config({ input, ctx }) + ); + + await resolvedPromise.then( + (response) => { + ctx.objectSendClient(api.actor, systemName).send({ + source: self, + target: self, + event: { + type: RESTATE_PROMISE_RESOLVE, + data: response, + }, + }); + }, + (errorData) => { + ctx.objectSendClient(api.actor, systemName).send({ + source: self, + target: self, + event: { + type: RESTATE_PROMISE_REJECT, + data: errorData, + }, + }); + } + ); + }, + }, + }) +}; + +export function resolveReferencedActor( + machine: AnyStateMachine, + src: string +): AnyActorLogic | undefined { + const match = src.match(/^xstate\.invoke\.(\d+)\.(.*)/)!; + if (!match) { + return machine.implementations.actors[src] as AnyActorLogic; + } + const [, indexStr, nodeId] = match; + const node = machine.getStateNodeById(nodeId); + const invokeConfig = node.config.invoke!; + return ( + Array.isArray(invokeConfig) + ? invokeConfig[indexStr as any] + : (invokeConfig as InvokeConfig) + )?.src; +} diff --git a/patterns-use-cases/xstate/tsconfig.json b/patterns-use-cases/xstate/tsconfig.json new file mode 100644 index 00000000..4f0702ad --- /dev/null +++ b/patterns-use-cases/xstate/tsconfig.json @@ -0,0 +1,109 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "esnext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs" /* Specify what module code is generated. */, + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, + "declarationMap": true /* Create sourcemaps for d.ts files. */, + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + "sourceMap": true /* Create source map files for emitted JavaScript files. */, + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + "skipDefaultLibCheck": true /* Skip type checking .d.ts files that are included with TypeScript. */, + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}