├── docs ├── eskv.png └── eskv.client.md ├── src ├── eskv │ ├── wwwroot │ │ ├── favicon.ico │ │ └── index.html │ ├── eskv.fsproj │ ├── AspNetExtensions.fs │ ├── Streams.fs │ └── Program.fs ├── eskv.ui │ ├── Shared.fs │ ├── package.json │ ├── style.scss │ ├── eskv.ui.fsproj │ ├── Style.fs │ └── App.fs └── eskv.client │ ├── eskv.client.fsproj │ ├── Hashing.fs │ └── eskv.fs ├── dev-server.cmd ├── dev-server.sh ├── Directory.Build.props ├── version.json ├── .config └── dotnet-tools.json ├── README.md ├── .github └── workflows │ └── dotnet.yml ├── eskv.sln ├── LICENSE.md └── .gitignore /docs/eskv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thinkbeforecoding/eskv/HEAD/docs/eskv.png -------------------------------------------------------------------------------- /src/eskv/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thinkbeforecoding/eskv/HEAD/src/eskv/wwwroot/favicon.ico -------------------------------------------------------------------------------- /dev-server.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | dotnet tool restore 4 | 5 | if not exist .\src\eskv.ui\node_modules ( call npm ci --prefix .\src\eskv.ui\ ) 6 | 7 | dotnet fable watch .\src\eskv.ui\eskv.ui.fsproj --run "call npm exec --prefix ./src/eskv.ui/ parcel -- ./src/eskv.ui/App.fs.js ./src/eskv.ui/style.scss" -------------------------------------------------------------------------------- /dev-server.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | dotnet tool restore 4 | 5 | if [ ! -d ./src/eskv.ui/node_modules ]; then npm ci --prefix ./src/eskv.ui/; fi 6 | 7 | dotnet fable watch ./src/eskv.ui/eskv.ui.fsproj --run npm exec --prefix ./src/eskv.ui/ parcel -- ./src/eskv.ui/App.fs.js ./src/eskv.ui/style.scss 8 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 3.4.255 6 | all 7 | 8 | 9 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", 3 | "version": "1.5-alpha", 4 | "publicReleaseRefSpec": [ 5 | "^refs/heads/main$", 6 | "^refs/heads/v\\d+(?:\\.\\d+)?(?:\\.\\d+)?$", 7 | "^refs/tags/v\\d+(?:\\.\\d+)?(?:\\.\\d+)?$" 8 | ], 9 | "cloudBuild": { 10 | "buildNumber": { 11 | "enabled": true 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/eskv/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | eskv 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "fable": { 6 | "version": "3.7.20", 7 | "commands": [ 8 | "fable" 9 | ] 10 | }, 11 | "femto": { 12 | "version": "0.16.0", 13 | "commands": [ 14 | "femto" 15 | ] 16 | }, 17 | "nbgv": { 18 | "version": "3.5.119", 19 | "commands": [ 20 | "nbgv" 21 | ] 22 | }, 23 | "fantomas": { 24 | "version": "5.1.0", 25 | "commands": [ 26 | "fantomas" 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/eskv.ui/Shared.fs: -------------------------------------------------------------------------------- 1 | module Shared 2 | 3 | type Container = string 4 | type Key = string 5 | type ETag = string 6 | 7 | 8 | type AllStreamData = 9 | { StreamId: string 10 | EventNumber: int 11 | EventType: string 12 | EventData: string } 13 | 14 | type ServerCmd = 15 | | KeyChanged of Container * Key * (string * ETag) 16 | | KeyDeleted of Container * Key 17 | | ContainerDeleted of Container 18 | | StreamUpdated of AllStreamData[] 19 | | StreamLoaded of AllStreamData[] 20 | | KeysLoaded of (Container * (Key * (string * ETag)) list) list 21 | -------------------------------------------------------------------------------- /src/eskv.ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kv.ui", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "author": "", 9 | "license": "ISC", 10 | "dependencies": { 11 | "@creativebulma/bulma-tooltip": "^1.2.0", 12 | "bulma": "^0.9.3", 13 | "bulma-tooltip": "^3.0.2", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "style.css": "^1.0.0" 17 | }, 18 | "devDependencies": { 19 | "@parcel/transformer-sass": "^2.4.0", 20 | "parcel": "^2.4.0", 21 | "sass": "^1.42.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/eskv.ui/style.scss: -------------------------------------------------------------------------------- 1 | // Colors 2 | 3 | $grey-darker: #282a2c; 4 | $white: #FAFBEF; 5 | $turquoise: #A1C3AF; 6 | $orange: #CF5B23; 7 | $white-bis: adjust-color($white, $lightness:-2%); 8 | 9 | $grey-lightest: #f3ebc6; 10 | $blue: #49639a; 11 | $red: #de281c; 12 | 13 | $black: #4b4805; 14 | 15 | @import "./node_modules/bulma/bulma.sass"; 16 | @import "./node_modules/@creativebulma/bulma-tooltip/src/sass/index.sass"; 17 | 18 | 19 | .key { 20 | padding-bottom: .5em; 21 | padding-left: .75em; 22 | padding-right: .75em; 23 | padding-top: .5em; 24 | } 25 | 26 | 27 | 28 | .editable:hover { 29 | cursor: pointer; 30 | border-color: $border; 31 | 32 | } 33 | 34 | table th { 35 | padding-left: 0.75em; 36 | padding-right: 0.75em; 37 | 38 | } 39 | 40 | 41 | 42 | .editable { 43 | border-color: $border; 44 | border-radius: 4px; 45 | border-color: transparent; 46 | padding-bottom: calc(.5em - 1px); 47 | padding-left: calc(.75em - 1px); 48 | padding-right: calc(.75em - 1px); 49 | padding-top: calc(.5em - 1px); 50 | border-style: solid; 51 | } 52 | 53 | 54 | .is-cell { 55 | padding-left: 0.75em; 56 | } 57 | 58 | .key-container { 59 | border-top: 1px, solid, $border-light; 60 | background-color: adjust-color($grey-lightest, $lightness:6%); 61 | 62 | } 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/eskv.ui/eskv.ui.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/eskv.client/eskv.client.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | true 6 | preview 7 | true 8 | eskv.client.md 9 | Jérémie Chassaing 10 | thinkbeforecoding 11 | Client library for eskv in-memory key/value and event store, for educational purpose. 12 | https://github.com/thinkbeforecoding/eskv 13 | LICENSE.md 14 | eskv.png 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/eskv.ui/Style.fs: -------------------------------------------------------------------------------- 1 | module Style 2 | 3 | 4 | open Fable.React 5 | open Feliz 6 | 7 | type table = 8 | 9 | static member fixed' xs = 10 | Html.table 11 | [ yield prop.style [ style.tableLayout.fixed'; style.width (length.percent 100) ] 12 | yield! xs ] 13 | 14 | static member fixed' xs = 15 | table.fixed' [ prop.children (xs: ReactElement seq) ] 16 | 17 | static member tdEllipsis xs = 18 | Html.td 19 | [ prop.style [ style.maxWidth 0 ] 20 | prop.children 21 | [ Html.div 22 | [ yield 23 | prop.style [ style.overflow.hidden; style.textOverflow.ellipsis; style.whitespace.nowrap ] 24 | yield! xs 25 | 26 | ] ] ] 27 | 28 | static member tdEllipsis xs = 29 | table.tdEllipsis [ prop.children (xs: ReactElement seq) ] 30 | 31 | static member tdEllipsis(txt: string) = table.tdEllipsis [ prop.text txt ] 32 | 33 | module table = 34 | type tdEllipsis = 35 | 36 | static member a xs = 37 | Html.td 38 | [ prop.style [ style.maxWidth 0 ] 39 | prop.children 40 | [ Html.a 41 | [ yield 42 | prop.style 43 | [ style.display.block 44 | style.overflow.hidden 45 | style.textOverflow.ellipsis 46 | style.whitespace.nowrap ] 47 | yield! xs 48 | 49 | ] ] ] 50 | -------------------------------------------------------------------------------- /src/eskv/eskv.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | preview 7 | true 8 | true 9 | README.md 10 | Jérémie Chassaing 11 | thinkbeforecoding 12 | In-memory key/value and event store, for educational purpose. 13 | https://github.com/thinkbeforecoding/eskv 14 | LICENSE.md 15 | eskv.png 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Never 26 | 27 | 28 | Never 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # _eskv_ 2 | 3 | This code has moved to Codeberg: https://codeberg.org/thinkbeforecoding/eskv 4 | 5 | [![build](https://github.com/thinkbeforecoding/eskv/actions/workflows/dotnet.yml/badge.svg)](https://github.com/thinkbeforecoding/eskv/actions/workflows/dotnet.yml) 6 | 7 | _eskv_ is an in-memory key/value and event store, for educational purpose. 8 | 9 | ## Disclamer 10 | **_eskv_ is not meant to be run in production**. _eskv_ has been created to ease the _learning_ of event sourcing. Use a production-ready event store for production. 11 | 12 | ## Getting Started 13 | 14 | Install _eskv_ as a global dotnet tool: 15 | ``` bash 16 | dotnet tool install eskv -g 17 | ``` 18 | for prerelease version, specify the `--prerelease` flag. 19 | 20 | You can also install it as a local dotnet tool: 21 | ``` bash 22 | dotnet new tool-manifest 23 | dotnet tool install eskv 24 | ``` 25 | 26 | Then run it: 27 | ``` bash 28 | eskv 29 | ``` 30 | or for a local dotnet tool: 31 | ``` bash 32 | dotnet eskv 33 | ``` 34 | 35 | and open [http://localhost:5000](http://localhost:5000) in a browser to access the web ui. 36 | 37 | ## Usage 38 | 39 | ``` 40 | USAGE: eskv.exe [--help] [--endpoint ] [--dev] [--parcel ] 41 | 42 | OPTIONS: 43 | 44 | --endpoint eskv http listener endpoint. default is http://localhost:5000 45 | --dev specify dev mode. 46 | --parcel parcel dev server url. default is http://localhost:1234 47 | --help display this list of options. 48 | ``` 49 | 50 | 51 | --endpoint < string > 52 | : the eskv http listener endpoint. Use http://*:5000 to authorize connections over the network, or use it t change port. Default is http://localhost:5000 53 | 54 | --dev 55 | : activate development mode. Used only when working on _eskv_ UI development. 56 | 57 | --parcel < string > 58 | : The parcel dev server url used in development mode. 59 | 60 | --help 61 | : display help. 62 | 63 | ## eskv.client nuget 64 | 65 | _eskv.client_ nuget contains _eskv_ client library to interact with _eskv_ server. 66 | 67 | Add it to your project using the IDE, or the following command: 68 | ``` bash 69 | dotnet add package eskv.client 70 | ``` 71 | 72 | In an F# script, you can reference it with a #r directive: 73 | ``` fsharp 74 | #r "nuget: eskv.client" 75 | ``` 76 | 77 | Read the full [_eskv.client_ documentation](https://github.com/thinkbeforecoding/eskv/blob/main/docs/eskv.client.md). 78 | 79 | 80 | ## Copyright and License 81 | 82 | Code copyright Jérémie Chassaing. _eskv_ and _eskv.client_ are released under the [Academic Public License](https://github.com/thinkbeforecoding/eskv/blob/main/LICENSE.md). 83 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | pull_request: 5 | release: 6 | types: 7 | - published 8 | env: 9 | # Stop wasting time caching packages 10 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 11 | # Disable sending usage data to Microsoft 12 | DOTNET_CLI_TELEMETRY_OPTOUT: true 13 | # Project name to pack and publish 14 | PROJECT_NAME: eskv 15 | # GitHub Packages Feed settings 16 | GITHUB_FEED: https://nuget.pkg.github.com/thinkbeforecoding/ 17 | GITHUB_USER: thinkbeforecoding 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | # Official NuGet Feed settings 20 | NUGET_FEED: https://api.nuget.org/v3/index.json 21 | NUGET_KEY: ${{ secrets.NUGET_API_KEY }} 22 | jobs: 23 | build: 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | matrix: 27 | os: [ ubuntu-latest ] 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v2 31 | with: 32 | fetch-depth: 0 33 | - name: Setup .NET Core 34 | uses: actions/setup-dotnet@v1 35 | with: 36 | dotnet-version: 6.0.x 37 | include-prerelease: true 38 | - name: Setup Node.js environment 39 | uses: actions/setup-node@v2.4.1 40 | with: 41 | node-version: 16.10.0 42 | - name: Build 43 | run: ./build.sh 44 | - name: Upload Artifact 45 | if: matrix.os == 'ubuntu-latest' 46 | uses: actions/upload-artifact@v2 47 | with: 48 | name: nupkg 49 | path: ./bin/nuget/*.nupkg 50 | prerelease: 51 | needs: build 52 | if: github.ref == 'refs/heads/dev' 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: Download Artifact 56 | uses: actions/download-artifact@v1 57 | with: 58 | name: nupkg 59 | - name: Push to GitHub Feed 60 | run: | 61 | for f in ./bin/nuget/*.nupkg 62 | do 63 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED 64 | done 65 | deploy: 66 | needs: build 67 | if: github.event_name == 'release' 68 | runs-on: ubuntu-latest 69 | steps: 70 | - uses: actions/checkout@v2 71 | with: 72 | fetch-depth: 0 73 | - name: Setup .NET Core 74 | uses: actions/setup-dotnet@v1 75 | with: 76 | dotnet-version: 6.0.x 77 | include-prerelease: true 78 | - name: Setup Node.js environment 79 | uses: actions/setup-node@v2.4.1 80 | with: 81 | node-version: 16.10.0 82 | - name: Build 83 | run: ./build.sh 84 | 85 | - name: Push to GitHub Feed 86 | run: | 87 | for f in ./bin/nuget/*.nupkg 88 | do 89 | curl -vX PUT -u "$GITHUB_USER:$GITHUB_TOKEN" -F package=@$f $GITHUB_FEED 90 | done 91 | - name: Push to NuGet Feed 92 | run: dotnet nuget push ./bin/nuget/*.nupkg --source $NUGET_FEED --skip-duplicate --api-key $NUGET_KEY 93 | -------------------------------------------------------------------------------- /src/eskv/AspNetExtensions.fs: -------------------------------------------------------------------------------- 1 | module AspNetExtensions 2 | 3 | open System 4 | open System.Threading 5 | open System.Threading.Tasks 6 | open System.Net.WebSockets 7 | open System.Buffers 8 | open Microsoft.AspNetCore.Http 9 | open Microsoft.AspNetCore.Hosting 10 | open System.Net.Http 11 | open Microsoft.Extensions.Primitives 12 | 13 | type HttpContext with 14 | 15 | member this.GetService<'t>() = 16 | this.RequestServices.GetService(typeof<'t>) :?> 't 17 | 18 | 19 | member ctx.ProxyWebSocketAsync(upstreamUri: Uri, cancellationToken: CancellationToken) = 20 | task { 21 | let! ws = ctx.WebSockets.AcceptWebSocketAsync() 22 | use upstream = new ClientWebSocket() 23 | do! upstream.ConnectAsync(upstreamUri, cancellationToken) 24 | 25 | let cp1 = 26 | task { 27 | use mem = MemoryPool.Shared.Rent(4096) 28 | 29 | while true do 30 | let! result = upstream.ReceiveAsync(mem.Memory, cancellationToken) 31 | 32 | do! 33 | ws.SendAsync( 34 | mem.Memory.Slice(0, result.Count), 35 | result.MessageType, 36 | result.EndOfMessage, 37 | cancellationToken 38 | ) 39 | 40 | } 41 | 42 | let cp2 = 43 | task { 44 | use mem = MemoryPool.Shared.Rent(4096) 45 | 46 | while true do 47 | let! result = ws.ReceiveAsync(mem.Memory, cancellationToken) 48 | 49 | do! 50 | upstream.SendAsync( 51 | mem.Memory.Slice(0, result.Count), 52 | result.MessageType, 53 | result.EndOfMessage, 54 | cancellationToken 55 | ) 56 | 57 | } 58 | 59 | return! Task.WhenAll [| cp1 :> Task; cp2 |] 60 | } 61 | :> Task 62 | 63 | 64 | 65 | type HttpResponse with 66 | 67 | member this.SendWebFileAsync(subpath) = 68 | task { 69 | let env = this.HttpContext.GetService() 70 | let file = env.WebRootFileProvider.GetFileInfo(subpath) 71 | 72 | if isNull file then 73 | this.StatusCode <- 404 74 | else 75 | return! this.SendFileAsync(file) 76 | } 77 | :> Task 78 | 79 | 80 | 81 | member this.ProxyGetAsync(upstream: Uri) = 82 | task { 83 | use client = new HttpClient() 84 | use! response = client.GetAsync(upstream) 85 | 86 | for h in response.Headers do 87 | this.Headers.Add(h.Key, StringValues(Seq.toArray h.Value)) 88 | 89 | return! response.Content.CopyToAsync(this.Body) 90 | } 91 | :> Task 92 | -------------------------------------------------------------------------------- /eskv.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31717.71 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{FE12110D-0CFE-45ED-927C-E0360362A3B9}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".build", ".build", "{C8B3D20A-DB2F-454F-BBC4-6B994702F886}" 9 | ProjectSection(SolutionItems) = preProject 10 | build.cmd = build.cmd 11 | build.sh = build.sh 12 | dev-server.cmd = dev-server.cmd 13 | dev-server.sh = dev-server.sh 14 | Directory.Build.props = Directory.Build.props 15 | LICENSE.md = LICENSE.md 16 | EndProjectSection 17 | EndProject 18 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "eskv", "src\eskv\eskv.fsproj", "{58E99309-4ED0-407A-B09E-F99F6BFF1B5F}" 19 | EndProject 20 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "eskv.client", "src\eskv.client\eskv.client.fsproj", "{F2ACC5FE-4D1A-4940-979E-5B47DBD568A8}" 21 | EndProject 22 | Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "eskv.ui", "src\eskv.ui\eskv.ui.fsproj", "{4602F4F1-123A-458E-8AAE-5D784E10A616}" 23 | EndProject 24 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{0FE4CE08-F29F-4B67-B2EB-433E177AEFA6}" 25 | EndProject 26 | Global 27 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 28 | Debug|Any CPU = Debug|Any CPU 29 | Release|Any CPU = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 32 | {58E99309-4ED0-407A-B09E-F99F6BFF1B5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {58E99309-4ED0-407A-B09E-F99F6BFF1B5F}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {58E99309-4ED0-407A-B09E-F99F6BFF1B5F}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {58E99309-4ED0-407A-B09E-F99F6BFF1B5F}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {F2ACC5FE-4D1A-4940-979E-5B47DBD568A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {F2ACC5FE-4D1A-4940-979E-5B47DBD568A8}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {F2ACC5FE-4D1A-4940-979E-5B47DBD568A8}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {F2ACC5FE-4D1A-4940-979E-5B47DBD568A8}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {4602F4F1-123A-458E-8AAE-5D784E10A616}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {4602F4F1-123A-458E-8AAE-5D784E10A616}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {4602F4F1-123A-458E-8AAE-5D784E10A616}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {4602F4F1-123A-458E-8AAE-5D784E10A616}.Release|Any CPU.Build.0 = Release|Any CPU 44 | EndGlobalSection 45 | GlobalSection(SolutionProperties) = preSolution 46 | HideSolutionNode = FALSE 47 | EndGlobalSection 48 | GlobalSection(NestedProjects) = preSolution 49 | {58E99309-4ED0-407A-B09E-F99F6BFF1B5F} = {FE12110D-0CFE-45ED-927C-E0360362A3B9} 50 | {F2ACC5FE-4D1A-4940-979E-5B47DBD568A8} = {FE12110D-0CFE-45ED-927C-E0360362A3B9} 51 | {4602F4F1-123A-458E-8AAE-5D784E10A616} = {FE12110D-0CFE-45ED-927C-E0360362A3B9} 52 | EndGlobalSection 53 | GlobalSection(ExtensibilityGlobals) = postSolution 54 | SolutionGuid = {0AA59D48-B93A-4F8F-BC62-FAA52AA3E7A9} 55 | EndGlobalSection 56 | EndGlobal 57 | -------------------------------------------------------------------------------- /src/eskv/Streams.fs: -------------------------------------------------------------------------------- 1 | module Streams 2 | 3 | open System 4 | open System.Collections.Generic 5 | 6 | 7 | type AppendList<'t>() = 8 | let mutable items = Array.zeroCreate<'t> 32 9 | let mutable length = 0 10 | 11 | member _.Count = length 12 | 13 | member this.AddRange(newItems: 't[]) = 14 | let newLength = length + newItems.Length 15 | 16 | if newLength > items.Length then 17 | this.Grow(newLength) 18 | 19 | Array.Copy(newItems, 0, items, length, newItems.Length) 20 | length <- newLength 21 | 22 | member _.Grow(newLength) = 23 | let newCapatcity = max (items.Length * 2) newLength 24 | let nextItems = Array.zeroCreate (newCapatcity) 25 | Array.Copy(items, nextItems, items.Length) 26 | items <- nextItems 27 | 28 | member _.GetRange(start, count) : ReadOnlyMemory<'t> = 29 | if start >= length then 30 | ReadOnlyMemory.Empty 31 | else 32 | items.AsMemory(start, min count (length - start)) |> Memory.op_Implicit 33 | 34 | 35 | 36 | type EventData = 37 | { Type: string 38 | Data: byte[] 39 | ContentType: string } 40 | 41 | type EventRecord = 42 | { StreamId: string 43 | EventNumber: int 44 | Event: EventData } 45 | 46 | type Link = 47 | { StreamId: string 48 | EventNumber: int 49 | OriginEvent: EventRecord } 50 | 51 | type Event = 52 | | Record of EventRecord 53 | | Link of Link 54 | 55 | type Stream = 56 | { Id: string 57 | Events: AppendList } 58 | 59 | 60 | 61 | type Action = 62 | | Append of 63 | streamId: string * 64 | EventData[] * 65 | expectedVersion: int * 66 | reply: ((int * EventRecord[] * Event[]) ValueOption -> unit) 67 | | ReadStream of 68 | streamId: string * 69 | start: int * 70 | count: int * 71 | reply: (ValueOption -> unit) 72 | | ReadAll of start: int * count: int * reply: (Event ReadOnlyMemory -> unit) 73 | 74 | 75 | [] 76 | let StreamsId = "$streams" 77 | 78 | let streams = 79 | MailboxProcessor.Start(fun mailbox -> 80 | let all = AppendList() 81 | let streams = Dictionary() 82 | 83 | let getStream streamId = 84 | match streams.TryGetValue(streamId) with 85 | | true, s -> s 86 | | false, _ -> 87 | let s = 88 | { Id = streamId 89 | Events = AppendList() } 90 | 91 | streams.Add(streamId, s) 92 | s 93 | 94 | let rec loop () = 95 | async { 96 | match! mailbox.Receive() with 97 | | Append(streamId, events, expectedVersion, reply) -> 98 | let stream = getStream streamId 99 | 100 | 101 | if expectedVersion = -2 || expectedVersion = stream.Events.Count - 1 then 102 | let first = stream.Events.Count 103 | 104 | let records = 105 | events 106 | |> Array.mapi (fun i e -> 107 | { StreamId = stream.Id 108 | EventNumber = first + i 109 | Event = e }) 110 | 111 | stream.Events.AddRange(Array.map Record records) 112 | 113 | let firstAll = all.Count 114 | 115 | let allLinks = 116 | records 117 | |> Array.mapi (fun i r -> 118 | Link 119 | { StreamId = "$all" 120 | EventNumber = firstAll + i 121 | OriginEvent = r }) 122 | 123 | all.AddRange(allLinks) 124 | 125 | if first = 0 && records.Length > 0 then 126 | let streams = getStream StreamsId 127 | 128 | streams.Events.AddRange( 129 | [| Link 130 | { StreamId = StreamsId 131 | EventNumber = streams.Events.Count 132 | OriginEvent = records.[0] } |] 133 | ) 134 | 135 | 136 | reply (ValueSome(stream.Events.Count - 1, records, allLinks)) 137 | 138 | else 139 | reply (ValueNone) 140 | | ReadStream(streamId, start, count, reply) -> 141 | match streams.TryGetValue(streamId) with 142 | | false, _ -> reply (ValueNone) 143 | | true, stream -> 144 | 145 | let mem = stream.Events.GetRange(start, count) 146 | let length = stream.Events.Count 147 | ValueSome(mem, length) |> reply 148 | | ReadAll(start, count, reply) -> all.GetRange(start, count) |> reply 149 | 150 | 151 | return! loop () 152 | } 153 | 154 | 155 | 156 | loop ()) 157 | 158 | 159 | let appendAsync streamId events expectedVersion = 160 | streams.PostAndAsyncReply(fun c -> Append(streamId, events, expectedVersion, c.Reply)) 161 | |> Async.StartAsTask 162 | 163 | 164 | let readStreamAsync streamId start count = 165 | streams.PostAndAsyncReply(fun c -> ReadStream(streamId, start, count, c.Reply)) 166 | |> Async.StartAsTask 167 | 168 | 169 | let readAllAsync start count = 170 | streams.PostAndAsyncReply(fun c -> ReadAll(start, count, c.Reply)) 171 | |> Async.StartAsTask 172 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # eskv License 2 | 3 | Copyright (c) Jérémie Chassaing 4 | 5 | The following license governs the use of eskv in non-commercial academic environments. Commercial use requires a commercial license (). 6 | 7 | ## ACADEMIC PUBLIC LICENSE (eskv, v1.0) 8 | 9 | ### Preamble 10 | This license contains the terms and conditions of using eskv in non-commercial settings: at academic institutions for teaching and research use, and at not-for-profit research organizations. You will find that this license provides non-commercial users of eskv with rights that are similar to the well-known GNU General Public License 3.0, yet it retains the possibility for eskv authors to financially support the development by selling commercial licenses. In fact, if you intend to use eskv in a "for-profit" environment, where eskv simulations are conducted to develop or enhance a product (including commercial or industry-sponsored research at academic institutions), or to use eskv in a commercial service offering, then you need to obtain a commercial license for eskv. In that case, please contact to inquire about commercial licenses. 11 | 12 | What are the rights given to non-commercial users? Similarly, to GPL 3.0, you have the right to use the software, to distribute copies, to receive source code, to change the software and distribute your modifications or the modified software. Also, similarly to the GPL 3.0, if you distribute verbatim or modified copies of this software, they must be distributed under this license. 13 | 14 | By modeling the GPL 3.0, this license guarantees that you’re safe when using eskv in your work, for teaching, and research. This license guarantees that eskv will remain available free of charge for non-profit use. You can modify eskv to your purposes, and you can also share your modifications. Even in the unlikely case of the authors abandoning eskv entirely, this license permits anyone to continue developing it from the last release, and to create further releases under this license. 15 | 16 | We believe that the combination of non-commercial open source and commercial licensing will be beneficial for the whole user community, because income from commercial licenses will enable sustained and faster development and a higher level of software quality, while further enjoying the informal, open communication and collaboration channels of open source development. 17 | 18 | The precise terms and conditions for using, copying, distribution and modification follow. 19 | 20 | ### Terms and Conditions for Use, Copying, Distribution and Modification 21 | 22 | Definitions 23 | * "Program" means a copy of eskv, which is said to be distributed under this Academic Public License. 24 | * "Work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) 25 | * "Using the Program" means any act of creating executables that contain or directly use libraries that are part of the Program, running any of the tools that are part of the Program, or creating works based on the Program. 26 | * Each licensee is addressed as "you". 27 | 28 | §1. Permission is hereby granted to use the Program free of charge for any non-commercial purpose, including teaching and research at universities, colleges and other educational institutions, non-commercial research at organizations that are either not-for-profit or reinvest all profits in their scientific research, and personal not-for-profit purposes. For using the Program for commercial purposes, including but not restricted to commercial research at academic institutions, industrially sponsored research at academic institutions, consulting activities, and design of commercial hardware or software products or services, you have to contact for an appropriate license. 29 | 30 | §2. You may copy and distribute verbatim copies of the source code of the program via any medium, provided that you add a conspicuous and appropriate copyright notice and a warranty disclaimer to each copy. Retain all notices relating to this license and the lack of any warranty. Forward a copy of this license to all other recipients of the program. 31 | 32 | §3. You are entitled to change your copies of the Program or a part thereof and thus create a work based on the Program. You may copy and distribute changes or work in accordance with the provisions of Section 2 provided you also meet all of the following conditions: 33 | a) You must ensure that the changed files are provided with noticeable comments stating the author of the change and when this change was made. 34 | b) You must ensure that all work that you distribute or publish, that contains or is derived from the Program or parts thereof, as a whole, is licensed under the conditions of this license. 35 | 36 | These requirements apply to the changed work as a whole. If identifiable sections of this work do not come from the Program and can be considered separate, this license and its terms do not apply to those sections if you distribute them as separate work. However, if you distribute the same sections as part of a whole that is based on the Program, the distribution of the whole must be done in accordance with the terms of this license as outlined in §2, independently of who wrote it. 37 | 38 | The mere merging of another work that is not based on the Program with the Program (or a work based on the Program) does not bring the other work into the scope of this license. 39 | 40 | §4. You may copy and distribute the Program (or a work based on it, in accordance with §3, in object code or executable form in accordance with the provisions of above Sections 2 and 3, provided that you also add the complete corresponding machine-readable source code. For an executable program, complete source code means the entire source code for all modules contained therein, as well as all associated interface definition files and scripts, with which the compilation and installation of the executable file is controlled. 41 | 42 | §5. Any attempt to copy, modify, sublicense or distribute the Program in any other way than specified in this license is void, and will automatically terminate your rights under this license. However, parties who have received copies or rights from you under this license will not lose their license as long as these parties fully comply with the terms. 43 | 44 | §6. You do not have to accept this license because you have not signed it. However, if you want to change or distribute the Program (or a work based on the Program), you automatically consent to this license and all its terms for copying, distributing or changing the program or the works based upon it. 45 | 46 | §7. Each time you redistribute the Program (or any work based on the Program), the recipient automatically acquires a license from the initial licensor to copy, distribute or modify the Program in accordance with these terms and conditions. You may not impose any further restrictions on the recipient's exercise of the rights granted here. You are not responsible for ensuring that this license is enforced by third parties. 47 | 48 | §8. If, as a result of a court decision or violation of a patent right, or for any other reason (not limited to patent issues), conditions are imposed that conflict with the terms of this license, you will not be released from the terms of this license. If you cannot distribute the Program because you would have to meet obligations under this license and other obligations at the same time, you may not distribute the Program at all. 49 | 50 | §9. If the distribution and/or use of the Program in certain countries is restricted either by patents or by copyrighted interfaces, the original copyright holder who puts the Program under this license may add an explicit geographic distribution restriction that excludes these countries. In this case, this license contains the restriction as if it was written in the body of this license. 51 | 52 | ### No Warranty 53 | 54 | §10. Since the Program is licensed for free, there is no guarantee for the Program to the extent permitted by applicable law. Unless otherwise specified in writing, the copyright holders and/or other parties provide the Program "as is" without any expressed or implied guarantee, including but not limited to the implied warranties of merchantability and fitness for a particular purpose. The entire risk to the quality and performance of the Program is yours. If the Program turns out to be faulty, you are responsible for the costs for all necessary maintenance, repair or correction work. 55 | 56 | §11. Under no circumstances will a copyright holder or any other party who can modify and/or redistribute the Program as permitted above be liable for damage, including general, special, accidental or other damage, unless this is required by law or agreed in writing. The disclaimer also includes consequential damages that result from using the Program alone or in conjunction with other programs, including but not limited to the loss or corruption of data. 57 | 58 | In case the above text differs from the license file in the source distribution, the latter is the valid one. 59 | 60 | Initially written by Andras Varga (public domain) for OMNeT++ , adapted by the [openCARP project](https://www.openCARP.org), and [Jérémie Chassaing](https://thinkbeforecoding.com). The adaptation is licensed under CC0 1.0 (Public Domain Dedication). 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # Tye 66 | .tye/ 67 | 68 | # ASP.NET Scaffolding 69 | ScaffoldingReadMe.txt 70 | 71 | # StyleCop 72 | StyleCopReport.xml 73 | 74 | # Files built by Visual Studio 75 | *_i.c 76 | *_p.c 77 | *_h.h 78 | *.ilk 79 | *.meta 80 | *.obj 81 | *.iobj 82 | *.pch 83 | *.pdb 84 | *.ipdb 85 | *.pgc 86 | *.pgd 87 | *.rsp 88 | *.sbr 89 | *.tlb 90 | *.tli 91 | *.tlh 92 | *.tmp 93 | *.tmp_proj 94 | *_wpftmp.csproj 95 | *.log 96 | *.vspscc 97 | *.vssscc 98 | .builds 99 | *.pidb 100 | *.svclog 101 | *.scc 102 | 103 | # Chutzpah Test files 104 | _Chutzpah* 105 | 106 | # Visual C++ cache files 107 | ipch/ 108 | *.aps 109 | *.ncb 110 | *.opendb 111 | *.opensdf 112 | *.sdf 113 | *.cachefile 114 | *.VC.db 115 | *.VC.VC.opendb 116 | 117 | # Visual Studio profiler 118 | *.psess 119 | *.vsp 120 | *.vspx 121 | *.sap 122 | 123 | # Visual Studio Trace Files 124 | *.e2e 125 | 126 | # TFS 2012 Local Workspace 127 | $tf/ 128 | 129 | # Guidance Automation Toolkit 130 | *.gpState 131 | 132 | # ReSharper is a .NET coding add-in 133 | _ReSharper*/ 134 | *.[Rr]e[Ss]harper 135 | *.DotSettings.user 136 | 137 | # TeamCity is a build add-in 138 | _TeamCity* 139 | 140 | # DotCover is a Code Coverage Tool 141 | *.dotCover 142 | 143 | # AxoCover is a Code Coverage Tool 144 | .axoCover/* 145 | !.axoCover/settings.json 146 | 147 | # Coverlet is a free, cross platform Code Coverage Tool 148 | coverage*.json 149 | coverage*.xml 150 | coverage*.info 151 | 152 | # Visual Studio code coverage results 153 | *.coverage 154 | *.coveragexml 155 | 156 | # NCrunch 157 | _NCrunch_* 158 | .*crunch*.local.xml 159 | nCrunchTemp_* 160 | 161 | # MightyMoose 162 | *.mm.* 163 | AutoTest.Net/ 164 | 165 | # Web workbench (sass) 166 | .sass-cache/ 167 | 168 | # Installshield output folder 169 | [Ee]xpress/ 170 | 171 | # DocProject is a documentation generator add-in 172 | DocProject/buildhelp/ 173 | DocProject/Help/*.HxT 174 | DocProject/Help/*.HxC 175 | DocProject/Help/*.hhc 176 | DocProject/Help/*.hhk 177 | DocProject/Help/*.hhp 178 | DocProject/Help/Html2 179 | DocProject/Help/html 180 | 181 | # Click-Once directory 182 | publish/ 183 | 184 | # Publish Web Output 185 | *.[Pp]ublish.xml 186 | *.azurePubxml 187 | # Note: Comment the next line if you want to checkin your web deploy settings, 188 | # but database connection strings (with potential passwords) will be unencrypted 189 | *.pubxml 190 | *.publishproj 191 | 192 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 193 | # checkin your Azure Web App publish settings, but sensitive information contained 194 | # in these scripts will be unencrypted 195 | PublishScripts/ 196 | 197 | # NuGet Packages 198 | *.nupkg 199 | # NuGet Symbol Packages 200 | *.snupkg 201 | # The packages folder can be ignored because of Package Restore 202 | **/[Pp]ackages/* 203 | # except build/, which is used as an MSBuild target. 204 | !**/[Pp]ackages/build/ 205 | # Uncomment if necessary however generally it will be regenerated when needed 206 | #!**/[Pp]ackages/repositories.config 207 | # NuGet v3's project.json files produces more ignorable files 208 | *.nuget.props 209 | *.nuget.targets 210 | 211 | # Microsoft Azure Build Output 212 | csx/ 213 | *.build.csdef 214 | 215 | # Microsoft Azure Emulator 216 | ecf/ 217 | rcf/ 218 | 219 | # Windows Store app package directories and files 220 | AppPackages/ 221 | BundleArtifacts/ 222 | Package.StoreAssociation.xml 223 | _pkginfo.txt 224 | *.appx 225 | *.appxbundle 226 | *.appxupload 227 | 228 | # Visual Studio cache files 229 | # files ending in .cache can be ignored 230 | *.[Cc]ache 231 | # but keep track of directories ending in .cache 232 | !?*.[Cc]ache/ 233 | 234 | # Others 235 | ClientBin/ 236 | ~$* 237 | *~ 238 | *.dbmdl 239 | *.dbproj.schemaview 240 | *.jfm 241 | *.pfx 242 | *.publishsettings 243 | orleans.codegen.cs 244 | 245 | # Including strong name files can present a security risk 246 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 247 | #*.snk 248 | 249 | # Since there are multiple workflows, uncomment next line to ignore bower_components 250 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 251 | #bower_components/ 252 | 253 | # RIA/Silverlight projects 254 | Generated_Code/ 255 | 256 | # Backup & report files from converting an old project file 257 | # to a newer Visual Studio version. Backup files are not needed, 258 | # because we have git ;-) 259 | _UpgradeReport_Files/ 260 | Backup*/ 261 | UpgradeLog*.XML 262 | UpgradeLog*.htm 263 | ServiceFabricBackup/ 264 | *.rptproj.bak 265 | 266 | # SQL Server files 267 | *.mdf 268 | *.ldf 269 | *.ndf 270 | 271 | # Business Intelligence projects 272 | *.rdl.data 273 | *.bim.layout 274 | *.bim_*.settings 275 | *.rptproj.rsuser 276 | *- [Bb]ackup.rdl 277 | *- [Bb]ackup ([0-9]).rdl 278 | *- [Bb]ackup ([0-9][0-9]).rdl 279 | 280 | # Microsoft Fakes 281 | FakesAssemblies/ 282 | 283 | # GhostDoc plugin setting file 284 | *.GhostDoc.xml 285 | 286 | # Node.js Tools for Visual Studio 287 | .ntvs_analysis.dat 288 | node_modules/ 289 | 290 | # Visual Studio 6 build log 291 | *.plg 292 | 293 | # Visual Studio 6 workspace options file 294 | *.opt 295 | 296 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 297 | *.vbw 298 | 299 | # Visual Studio LightSwitch build output 300 | **/*.HTMLClient/GeneratedArtifacts 301 | **/*.DesktopClient/GeneratedArtifacts 302 | **/*.DesktopClient/ModelManifest.xml 303 | **/*.Server/GeneratedArtifacts 304 | **/*.Server/ModelManifest.xml 305 | _Pvt_Extensions 306 | 307 | # Paket dependency manager 308 | .paket/paket.exe 309 | paket-files/ 310 | 311 | # FAKE - F# Make 312 | .fake/ 313 | 314 | # CodeRush personal settings 315 | .cr/personal 316 | 317 | # Python Tools for Visual Studio (PTVS) 318 | __pycache__/ 319 | *.pyc 320 | 321 | # Cake - Uncomment if you are using it 322 | # tools/** 323 | # !tools/packages.config 324 | 325 | # Tabs Studio 326 | *.tss 327 | 328 | # Telerik's JustMock configuration file 329 | *.jmconfig 330 | 331 | # BizTalk build output 332 | *.btp.cs 333 | *.btm.cs 334 | *.odx.cs 335 | *.xsd.cs 336 | 337 | # OpenCover UI analysis results 338 | OpenCover/ 339 | 340 | # Azure Stream Analytics local run output 341 | ASALocalRun/ 342 | 343 | # MSBuild Binary and Structured Log 344 | *.binlog 345 | 346 | # NVidia Nsight GPU debugger configuration file 347 | *.nvuser 348 | 349 | # MFractors (Xamarin productivity tool) working folder 350 | .mfractor/ 351 | 352 | # Local History for Visual Studio 353 | .localhistory/ 354 | 355 | # BeatPulse healthcheck temp database 356 | healthchecksdb 357 | 358 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 359 | MigrationBackup/ 360 | 361 | # Ionide (cross platform F# VS Code tools) working folder 362 | .ionide/ 363 | 364 | # Fody - auto-generated XML schema 365 | FodyWeavers.xsd 366 | 367 | ## 368 | ## Visual studio for Mac 369 | ## 370 | 371 | 372 | # globs 373 | Makefile.in 374 | *.userprefs 375 | *.usertasks 376 | config.make 377 | config.status 378 | aclocal.m4 379 | install-sh 380 | autom4te.cache/ 381 | *.tar.gz 382 | tarballs/ 383 | test-results/ 384 | 385 | # Mac bundle stuff 386 | *.dmg 387 | *.app 388 | 389 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 390 | # General 391 | .DS_Store 392 | .AppleDouble 393 | .LSOverride 394 | 395 | # Icon must end with two \r 396 | Icon 397 | 398 | 399 | # Thumbnails 400 | ._* 401 | 402 | # Files that might appear in the root of a volume 403 | .DocumentRevisions-V100 404 | .fseventsd 405 | .Spotlight-V100 406 | .TemporaryItems 407 | .Trashes 408 | .VolumeIcon.icns 409 | .com.apple.timemachine.donotpresent 410 | 411 | # Directories potentially created on remote AFP share 412 | .AppleDB 413 | .AppleDesktop 414 | Network Trash Folder 415 | Temporary Items 416 | .apdisk 417 | 418 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 419 | # Windows thumbnail cache files 420 | Thumbs.db 421 | ehthumbs.db 422 | ehthumbs_vista.db 423 | 424 | # Dump file 425 | *.stackdump 426 | 427 | # Folder config file 428 | [Dd]esktop.ini 429 | 430 | # Recycle Bin used on file shares 431 | $RECYCLE.BIN/ 432 | 433 | # Windows Installer files 434 | *.cab 435 | *.msi 436 | *.msix 437 | *.msm 438 | *.msp 439 | 440 | # Windows shortcuts 441 | *.lnk 442 | 443 | # JetBrains Rider 444 | .idea/ 445 | *.sln.iml 446 | 447 | ## 448 | ## Visual Studio Code 449 | ## 450 | .vscode/* 451 | !.vscode/settings.json 452 | !.vscode/tasks.json 453 | !.vscode/launch.json 454 | !.vscode/extensions.json 455 | 456 | 457 | *.fs.js 458 | 459 | .parcel-cache/ 460 | src/eskv.ui/dist/ 461 | dist/ 462 | .vscode/ -------------------------------------------------------------------------------- /docs/eskv.client.md: -------------------------------------------------------------------------------- 1 | # _eskv.client_ 2 | 3 | _eskv.client_ is a client library for [_eskv_](https://www.nuget.org/packages/eskv) 4 | 5 | ## Getting started 6 | 7 | Add it to your project using the IDE, or the following command: 8 | ``` bash 9 | dotnet add package eskv.client 10 | ``` 11 | 12 | In an F# script, you can reference it with a #r directive: 13 | ``` fsharp 14 | #r "nuget: eskv.client" 15 | ``` 16 | 17 | For beta versions, use the `--prerelease` flag on the cli or specify the version: 18 | ``` fsharp 19 | #r "nuget: eskv.client, Version=*-beta*" 20 | ``` 21 | 22 | Save your first value in the _eskv_ with the following lines in a eskv.fsx script file: 23 | 24 | ``` fsharp 25 | #r "nuget: eskv.client" 26 | open eskv 27 | 28 | let client = EskvClient() 29 | 30 | client.Save("Hello","World") 31 | ``` 32 | 33 | Execute it in F# interactive or with `dotnet fsi eskv.fsx` with eskv server running, and a browser open on http://localhost:5000. You should see a new `Hello` key with value `World` appear in the default container. 34 | 35 | ## Copyright and License 36 | 37 | Code copyright Jérémie Chassaing. _eskv_ and _eskv.client_ are released under the [Academic Public License](https://github.com/thinkbeforecoding/eskv/blob/main/LICENSE.md). 38 | 39 | 40 | ## API 41 | 42 | This documentation presents only synchronous operations. All methods have a asynchronous version using an `Async` suffix and returning a `Task` or a `Task<'t>`. 43 | 44 | ### Constructor 45 | 46 | ``` 47 | let client = EskvClient() 48 | ``` 49 | Instanciate a new EskvClient using default `http://localhost:5000` url. 50 | 51 | ``` 52 | EskvClient(uri: Uri) 53 | ``` 54 | Instanciate a new EskVClient using specified url. 55 | 56 | ### Key Value Store 57 | 58 | The key value store saved value under specified keys. **Keys** are grouped by **container**. When the container is not specified, `default` is used. 59 | 60 | The key value store supports [ETags](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) for version control. 61 | 62 | #### Save 63 | 64 | ``` 65 | Save(key: string, value: string) : unit 66 | ``` 67 | Saves value under specified key. If the key doesn't exist, it is created. If it already exist, value is updated. No version control is done. 68 | 69 | ``` 70 | Save(container: string,key: string, value: string) : unit 71 | ``` 72 | Saves value under specified key in given container. If the key doesn't exist, it is created. If it already exist, value is updated. No version control is done. 73 | 74 | #### TrySave 75 | 76 | ``` 77 | TrySave(key: string, value: string, etag: string) : string 78 | ``` 79 | Saves value under specified key with version control. If the key doesn't exist, pass `null` in the `etag` argument to create it. 80 | 81 | If the key already exist, pass current ETag value obtain from a call to `Load` or a previous call to `TrySave` to upate the value. 82 | 83 | If the Etag matches, the key is created or updated with the specified value, and the new Etag is returned. Otherwise the method returns `null`. 84 | 85 | ``` 86 | TrySave(container: string,key: string, value: string, etag: string) 87 | ``` 88 | Saves value under specified key in given container with version control. If the key doesn't exist, pass `null` in the `etag` argument to create it. 89 | 90 | If the key already exist, pass current ETag value obtain from a call to `Load` or a previous call to `TrySave` to upate the value. 91 | 92 | If the Etag matches, the key is created or updated with the specified value, and the new Etag is returned. Otherwise the method returns `null`. 93 | 94 | 95 | #### TryLoad 96 | 97 | ``` 98 | TryLoad(key: string) : LoadResult 99 | ``` 100 | Tries to load the value from specified key. 101 | 102 | if the key exists, the `LoadResult`'s `KeyExists` property is true, `Value` contains the key value, and the `Etag` is current key's Etag. 103 | 104 | if the key doesn't exists, the `LoadResult`'s `KeyExists` property is false, `Value` and `Etag` are `null`. This `null` Etag can be used in a call to `TrySave` to indicates that the key should not exist. 105 | 106 | ``` 107 | TryLoad(container: string, key: string) : LoadResult 108 | ``` 109 | Tries to load the value from specified key in given container. 110 | 111 | The `LoadResult` is similar to the overload without the container. 112 | 113 | #### DeleteKey 114 | 115 | ``` 116 | DeleteKey(key: string) : unit 117 | ``` 118 | 119 | Deletes a key if it exists, does nothing otherwise. 120 | 121 | ``` 122 | DeleteKey(container: string, key: string) : unit 123 | ``` 124 | 125 | Deletes a key from specified container if it exists, does nothing otherwise. 126 | 127 | #### GetKeys 128 | 129 | ``` 130 | GetKeys() : string[] 131 | ``` 132 | 133 | Returns all the keys in the `default` container. 134 | 135 | Throws an exception if the container does not exist. 136 | 137 | ``` 138 | GetKeys(container: string) : string[] 139 | ``` 140 | Returns all the keys in the `specified` container. 141 | 142 | Throws an exception if the container does not exist. 143 | 144 | #### GetContainers 145 | 146 | ``` 147 | GetContainers() : string[] 148 | ``` 149 | 150 | Returns all the containers names. Empty if no container exists. 151 | 152 | #### DeleteContainer 153 | 154 | ``` 155 | DeleteContainer(container: string) : unit 156 | ``` 157 | 158 | Delete specified container if it exists, does nothing otherwise. 159 | 160 | 161 | ### Event Store 162 | 163 | #### Append 164 | 165 | ``` 166 | Append(stream: string, events: EventData seq) : unit 167 | ``` 168 | 169 | Append events at the end of specified stream. 170 | 171 | The `EventData` structure contains a `EventType` string property and a `Data` string property. 172 | 173 | #### TryAppend 174 | 175 | 176 | ``` 177 | TryAppend(stream: string, expectedVersion: int events: EventData seq) : AppendResult 178 | ``` 179 | 180 | Tries to append events at the end of specified stream. The operation succeeds if the stream current version is equal to specified `expectedVersion`. 181 | 182 | The `EventData` structure contains a `EventType` string property and a `Data` string property. 183 | 184 | Provide `ExpectedVersion.NoStream` as the expectedVersion argement when the stream does not exist yet. 185 | 186 | When the operation succeeds, `AppendResult` has the `Success`property equal to `true`. The `ExpectedVersion` property indicates the current version of the stream. It can be passed to TryAppend to append new events to the stream. The `NextEventNumber` property indicates the event number to use to read events just after the ones that have been appened. 187 | 188 | #### ReadStreamForward 189 | 190 | ``` 191 | ReadStreamForward(stream: string, start: int) : Slice 192 | ``` 193 | 194 | Reads all events from specified stream starting at specified event number. Use 0 for `start` to read all events from the start. 195 | 196 | Returns a `Slice` structure: 197 | 198 | * `State` : `NoStream` when the stream doesn't exist, or `StreamExists` otherwise. 199 | * `Events` : An array of `EventRecord` containing read events. 200 | * `EndOfStream` : Indicates whether the end of the stream has been reached 201 | * `ExpectedVersion`: The event number of the last event read. Can be passed to `TryAppend` to append new events at the end of the stream. 202 | * `NextEventNumber`: the next event number that can be used to load the rest of the stream. 203 | 204 | The `EventRecord` structure has the following properties: 205 | * `EventNumber`: the number of the event in the stream 206 | * `EventType`: The type of the event 207 | * `Data`: The event data 208 | * `OriginalEventNumber`: The number of the event in the original stream in case of projections (like for `$streams`), otherwise equal to `EventNumber`. 209 | * 'OriginalStream': The original strema name in case of projections (like from `$streams`), otherwise equal to provided stream name. 210 | 211 | ``` 212 | ReadStreamForward(stream: string, start: int, count: int) : Slice 213 | ``` 214 | 215 | Reads a maximum of `count` events from specified stream starting at specified event number. Use 0 for `start` to read events from the start. 216 | 217 | The returned `Slice` is similar to the previous overload. 218 | 219 | Use `NextEventNumber` from the slice as the `start` argument of the next call to `ReadStreamForward` to get the next slice. 220 | 221 | ``` 222 | ReadStreamForward(stream: string, start: int, count: int, linkOnly: bool) : Slice 223 | ``` 224 | Reads a maximum of `count` events from specified stream starting at specified event number. Use 0 for `start` to read events from the start. 225 | 226 | The returned `Slice` is similar to the previous overload. 227 | 228 | When `linkOnly` is true, and the stream is a projection (like `stream`), the `EventRecord` `Data` property is null. 229 | 230 | ``` 231 | ReadStreamForward(stream: string, start: int, count: int, linkOnly: bool, startExcluded: bool) : Slice 232 | ``` 233 | 234 | Reads a maximum of `count` events from specified stream starting at specified event number. Use 0 for `start` to read events from the start. 235 | 236 | The returned `Slice` is similar to the previous overload. 237 | 238 | When `startExcluded` is true, the first event returned is the one just after `start`. This can be used when maintaining an expected version, and reading events that follow. 239 | 240 | #### ReadStreamSince 241 | 242 | ``` 243 | ReadStreamSince(stream: string, start: int) 244 | ``` 245 | 246 | Equivalent to `ReadStreamForward` 247 | 248 | ``` 249 | ReadStreamSince(stream: string, start: int, count: int) 250 | ``` 251 | 252 | Equivalent to `ReadStreamForward` 253 | 254 | ``` 255 | ReadStreamSince(stream: string, start: int, count: int, linkOnly: bool) 256 | ``` 257 | 258 | Equivalent to `ReadStreamForward` 259 | 260 | #### GetStreamAsync 261 | 262 | ``` 263 | GetStreamAsync(stream: string, start: int, count: int, linkOnly: bool, startExcluded: bool) : Task 264 | ``` 265 | 266 | This method exists only in Async version, as it returns a `ReadResult` that contains an `IAsyncEnumerable`. 267 | 268 | Arguments are similar to `ReadStreamForward`. 269 | 270 | The returned `ReadResult` has the following properties: 271 | 272 | * `State` : `NoStream` when the stream doesn't exist, or `StreamExists` otherwise. 273 | * `Events` : An `IAsyncEnumerable` containing read events. 274 | * `ExpectedVersion`: The event number of the last event read. Can be passed to `TryAppend` to append new events at the end of the stream. 275 | * `NextEventNumber`: the next event number that can be used to load the rest of the stream. 276 | 277 | #### TryAppendOrRead 278 | 279 | 280 | ``` 281 | TryAppendOrRead(stream: string, expectedVersion: int events: EventData seq) : AppendOrReadResult 282 | ``` 283 | 284 | Arguments are similar to `TryAppend`. When `expectedVersion` does not match current stream version, events following `expectedVersion` are returned in the `AppendOrReadResult` structure's `NewEvents` property. In this case, the `ExpectedVersion` and `NextEventVersion` property relate to the last returned event. 285 | 286 | #### GetStreams 287 | 288 | ``` 289 | GetStreams(start: int, count: int) : StreamSlice 290 | ``` 291 | 292 | Get `count` streams names, starting at `start`. 293 | 294 | The `StreamSlice` structure has the following properties: 295 | 296 | * `State`: `NoStream` if no stream exists yet, otherwise `StreamExists`. 297 | * `Streams`: An array that contains streams names. 298 | * `LastEventNumber`: The event number of the last returned stream creation. 299 | * `NextEventNumber`: The event number of the next stream creation. 300 | 301 | #### Subscribe 302 | 303 | ``` 304 | Subscribe(stream: string, start: int, handler: EventRecord -> unit) : IDisposable 305 | ``` 306 | 307 | Subscribe to `stream` from `start` event. 308 | 309 | Events already persisted are sent as soon as subscribing, new events are sent as they are added to the stream. 310 | 311 | Call the `Dispose` on the returned `IDisposable`, to stop the subscription. 312 | 313 | 314 | ``` 315 | SubscribeAsync(stream: string, start: int, handler: EventRecord -> Task, cancellationToken: CancellationToken) : Task 316 | ``` 317 | 318 | Subscribe to `stream` from `start` event asynchronously. 319 | 320 | Events already persisted are sent as soon as subscribing, new events are sent as they are added to the stream. 321 | 322 | Cancel the `cancellationToken` to stop the subscription. 323 | 324 | #### SubscribeAll 325 | 326 | ``` 327 | SubscribeAll(start: int, handler: EventRecord -> unit) : IDisposable 328 | ``` 329 | 330 | Subscribe to all events from `start` event. 331 | 332 | Events already persisted are sent as soon as subscribing, new events are sent as they are added to the stream. 333 | 334 | Call the `Dispose` on the returned `IDisposable`, to stop the subscription. 335 | 336 | 337 | ``` 338 | SubscribeAllAsync(start: int, handler: EventRecord -> Task, cancellationToken: CancellationToken) : Task 339 | ``` 340 | 341 | Subscribe to all events from `start` event asynchronously. 342 | 343 | Events already persisted are sent as soon as subscribing, new events are sent as they are added to the stream. 344 | 345 | Cancel the `cancellationToken` to stop the subscription. 346 | 347 | -------------------------------------------------------------------------------- /src/eskv.client/Hashing.fs: -------------------------------------------------------------------------------- 1 | namespace eskv.Hashing 2 | 3 | open System 4 | open Microsoft.FSharp.Quotations 5 | open Microsoft.FSharp.Quotations.Patterns 6 | open System.Security.Cryptography 7 | open System.Buffers 8 | open System.Runtime.InteropServices 9 | open System.Runtime.CompilerServices 10 | open System.Collections.Generic 11 | open FSharp.NativeInterop 12 | 13 | 14 | #nowarn "9" 15 | 16 | module private Native = 17 | let inline asRef (x: 'a inref) = &Unsafe.AsRef(&x) 18 | 19 | let inline asSpan (x: 'a inref) = 20 | MemoryMarshal.CreateReadOnlySpan(&asRef &x, 1) 21 | 22 | let inline asBytes (x: 'a inref) = MemoryMarshal.AsBytes(asSpan &x) 23 | 24 | let inline stackalloc<'t when 't: unmanaged> n = 25 | Span<'t>(NativePtr.toVoidPtr (NativePtr.stackalloc<'t> (n)), n) 26 | 27 | #warn "9" 28 | 29 | open Native 30 | 31 | module private Base64 = 32 | let base64 = 33 | [| yield! [| 'A' .. 'Z' |] 34 | yield! [| 'a' .. 'z' |] 35 | yield! [| '0' .. '9' |] 36 | '-' 37 | '_' |] 38 | 39 | let encode (data: ReadOnlySpan) = 40 | let str = stackalloc (((data.Length + 2) / 3) * 4) 41 | let blocks = data.Length / 3 42 | let srcEnd = blocks * 3 43 | let safe = data.Slice(0, srcEnd) 44 | 45 | for i in 0 .. blocks - 1 do 46 | let n = i * 3 47 | let d1 = safe[n] 48 | let d2 = safe[n + 1] 49 | let d3 = safe[n + 2] 50 | 51 | let j = i * 4 52 | str[j] <- base64[int (d1 >>> 2)] 53 | str[j + 1] <- base64[int (((d1 &&& 0b11uy) <<< 4) ||| (d2 >>> 4))] 54 | str[j + 2] <- base64[int ((d2 &&& 0b1111uy) <<< 2 ||| (d3 >>> 6))] 55 | str[j + 3] <- base64[int (d3 &&& 0b111111uy)] 56 | 57 | let rest = data.Slice(srcEnd) 58 | let strEnd = str.Slice(blocks * 4) 59 | 60 | let slice = 61 | if rest.Length > 0 then 62 | let d1 = rest[0] 63 | strEnd[0] <- base64[int (d1 >>> 2)] 64 | let d1' = (d1 &&& 0b11uy) <<< 4 65 | 66 | if rest.Length > 1 then 67 | let d2 = rest[1] 68 | strEnd[1] <- base64[int (d1' ||| (d2 >>> 4))] 69 | strEnd[2] <- base64[int ((d2 &&& 0b1111uy) <<< 2)] 70 | str.Slice(0, str.Length - 1) 71 | else 72 | strEnd[1] <- base64[int d1'] 73 | str.Slice(0, str.Length - 2) 74 | else 75 | str 76 | 77 | String(Span.op_Implicit slice) 78 | 79 | 80 | [] 81 | type HasherOptions = 82 | | Default = 0 83 | | ShortTypeNames = 1 84 | 85 | type Hasher(h: IncrementalHash, options: HasherOptions) = 86 | let buffer = MemoryPool.Shared.Rent(4096) 87 | let visitedMethods = HashSet() 88 | 89 | member _.Add(x: ReadOnlySpan) = h.AppendData(x) 90 | 91 | member this.Add(x: string) = 92 | let bytes = buffer.Memory.Span 93 | let len = Text.Encoding.UTF8.GetBytes(x.AsSpan(), bytes) 94 | this.Add(Span.op_Implicit (bytes.Slice(0, len))) 95 | 96 | member this.Add(x: int32) = this.Add(asBytes &x) 97 | 98 | member this.Add(b: bool) = 99 | let x = if b then 1uy else 0uy 100 | this.Add(asSpan &x) 101 | 102 | 103 | member this.AddValue(v: obj) = 104 | match v with 105 | | null -> this.Add 0 106 | | :? int as v -> this.Add v 107 | | :? string as s -> this.Add s 108 | | :? uint as v -> this.Add(asBytes &v) 109 | | :? int64 as v -> this.Add(asBytes &v) 110 | | :? uint64 as v -> this.Add(asBytes &v) 111 | | :? int16 as v -> this.Add(asBytes &v) 112 | | :? uint16 as v -> this.Add(asBytes &v) 113 | | :? byte as v -> this.Add(asBytes &v) 114 | | :? sbyte as v -> this.Add(asBytes &v) 115 | | :? float32 as v -> this.Add(asBytes &v) 116 | | :? float as v -> this.Add(asBytes &v) 117 | | :? bool as b -> this.Add b 118 | | _ -> failwith "Value type not supported" 119 | 120 | 121 | member this.AddType(t: Type) = 122 | if options.HasFlag HasherOptions.ShortTypeNames then 123 | this.Add t.Name 124 | else 125 | this.Add t.FullName 126 | 127 | member this.AddVar(v: Var) = 128 | this.Add v.IsMutable 129 | this.AddType v.Type 130 | 131 | member this.AddMethod(m: Reflection.MethodInfo) = 132 | 133 | this.Add m.Name 134 | this.AddType m.DeclaringType 135 | 136 | if m.IsGenericMethod then 137 | for t in m.GetGenericArguments() do 138 | this.AddType t 139 | 140 | for p in m.GetParameters() do 141 | this.AddType p.ParameterType 142 | 143 | this.AddType m.ReturnType 144 | 145 | member this.AddCtor(m: Reflection.ConstructorInfo) = 146 | 147 | this.Add m.Name 148 | this.AddType m.DeclaringType 149 | 150 | if m.IsGenericMethod then 151 | for t in m.GetGenericArguments() do 152 | this.AddType t 153 | 154 | for p in m.GetParameters() do 155 | this.AddType p.ParameterType 156 | 157 | 158 | 159 | member this.ComputeHash(m: Reflection.MethodInfo) = 160 | 161 | let rec addExpr = 162 | function 163 | | Patterns.Lambda(v, c) -> 164 | this.Add "Lambda" 165 | this.AddVar v 166 | addExpr c 167 | 168 | | Patterns.Call(t, m, args) -> 169 | 170 | this.Add "Call" 171 | addTarget t 172 | addMethodOrDef m 173 | addExprs args 174 | | Patterns.Var v -> 175 | this.Add "Var" 176 | this.AddVar v 177 | | Patterns.Value(o, t) -> 178 | this.Add "Value" 179 | this.AddValue o 180 | this.AddType t 181 | | Patterns.AddressOf x -> 182 | this.Add "AddressOf" 183 | addExpr x 184 | | Patterns.AddressSet(x, v) -> 185 | this.Add "AddressSet" 186 | addExpr x 187 | addExpr v 188 | | Patterns.Application(x, v) -> 189 | this.Add "Application" 190 | addExpr x 191 | addExpr v 192 | | Patterns.CallWithWitnesses(t, m, m2, args, args2) -> 193 | 194 | this.Add "CallWithWitnesses" 195 | addTarget t 196 | addMethodOrDef m 197 | addMethodOrDef m2 198 | addExprs args 199 | addExprs args2 200 | | Patterns.Coerce(e, t) -> 201 | this.Add "Coerce" 202 | addExpr e 203 | this.AddType t 204 | | Patterns.DefaultValue(t) -> 205 | this.Add "DefaultValue" 206 | this.AddType t 207 | | Patterns.FieldGet(t, f) -> 208 | this.Add "FieldGet" 209 | addTarget t 210 | this.Add f.Name 211 | this.AddType f.FieldType 212 | | Patterns.FieldSet(t, f, e) -> 213 | this.Add "FieldSet" 214 | addTarget t 215 | this.Add f.Name 216 | this.AddType f.FieldType 217 | addExpr e 218 | 219 | | Patterns.ForIntegerRangeLoop(v, es, ei, ee) -> 220 | this.Add "ForIntegerRangeLoop" 221 | this.AddVar v 222 | addExpr es 223 | addExpr ei 224 | addExpr ee 225 | | Patterns.IfThenElse(c, t, f) -> 226 | this.Add "IfThenElse" 227 | addExpr c 228 | addExpr t 229 | addExpr f 230 | | Patterns.Let(v, e, body) -> 231 | this.Add "Let" 232 | this.AddVar v 233 | addExpr e 234 | addExpr body 235 | | Patterns.LetRecursive(defs, body) -> 236 | 237 | this.Add "LetRecursive" 238 | addExpr body 239 | 240 | for v, e in defs do 241 | this.AddVar v 242 | addExpr e 243 | 244 | | Patterns.NewArray(t, xs) -> 245 | this.Add "NewArray" 246 | this.AddType t 247 | addExprs xs 248 | 249 | | Patterns.NewDelegate(t, vs, e) -> 250 | this.Add "NewDelegate" 251 | this.AddType t 252 | 253 | for v in vs do 254 | this.AddVar v 255 | 256 | addExpr e 257 | 258 | | Patterns.NewObject(c, args) -> 259 | this.Add "NewObject" 260 | this.AddCtor c 261 | addExprs args 262 | 263 | | Patterns.NewRecord(t, args) -> 264 | this.Add "NewRecord" 265 | this.AddType t 266 | addExprs args 267 | 268 | | Patterns.NewStructTuple(args) -> 269 | this.Add "NewStructTuple" 270 | addExprs args 271 | | Patterns.NewTuple(args) -> 272 | this.Add "NewTuple" 273 | addExprs args 274 | | Patterns.NewUnionCase(c, args) -> 275 | this.Add "NewUnionCase" 276 | this.AddType c.DeclaringType 277 | this.Add c.Name 278 | this.Add c.Tag 279 | addExprs args 280 | 281 | | Patterns.PropertyGet(t, p, args) -> 282 | this.Add "PropertyGet" 283 | addTarget t 284 | this.AddMethod p.GetMethod 285 | addExprs args 286 | 287 | | Patterns.PropertySet(t, p, args, e) -> 288 | this.Add "PropertySet" 289 | addTarget t 290 | this.AddMethod p.GetMethod 291 | addExprs args 292 | addExpr e 293 | 294 | | Patterns.QuoteRaw e -> 295 | this.Add "QuoteRaw" 296 | addExpr e 297 | | Patterns.QuoteTyped e -> 298 | this.Add "QuoteTyped" 299 | addExpr e 300 | 301 | | Patterns.Sequential(e1, e2) -> 302 | this.Add "Sequential" 303 | addExpr e1 304 | addExpr e2 305 | 306 | | Patterns.TryFinally(body, fclause) -> 307 | this.Add "TryFinally" 308 | addExpr body 309 | addExpr fclause 310 | 311 | | Patterns.TryWith(body, v, wclause, v2, wclause2) -> 312 | this.Add "TryWith" 313 | addExpr body 314 | this.AddVar v 315 | addExpr wclause 316 | this.AddVar v2 317 | addExpr wclause2 318 | | Patterns.TupleGet(e, i) -> 319 | this.Add "TupleGet" 320 | addExpr e 321 | this.Add i 322 | | Patterns.TypeTest(e, t) -> 323 | this.Add "TypeTest" 324 | addExpr e 325 | this.AddType t 326 | | Patterns.UnionCaseTest(e, c) -> 327 | this.Add "UnionCaseTest" 328 | addExpr e 329 | this.AddType c.DeclaringType 330 | this.Add c.Name 331 | this.Add c.Tag 332 | | Patterns.ValueWithName(v, t, n) -> 333 | this.Add "ValueWithName" 334 | this.AddValue v 335 | this.AddType t 336 | this.Add n 337 | | Patterns.VarSet(v, e) -> 338 | this.Add "VarSet" 339 | this.AddVar v 340 | addExpr e 341 | | Patterns.WhileLoop(body, cond) -> 342 | this.Add "WhileLoop" 343 | addExpr body 344 | addExpr cond 345 | | Patterns.WithValue(v, t, e) -> 346 | this.Add "WithValue" 347 | this.AddValue v 348 | this.AddType t 349 | addExpr e 350 | | x -> failwithf "Unsupported expression: %A" x 351 | 352 | and addMethodOrDef (m: Reflection.MethodInfo) = 353 | match Expr.TryGetReflectedDefinition(m) with 354 | | Some e -> if visitedMethods.Add m then this.AddMethod m else addExpr e 355 | | None -> this.AddMethod m 356 | 357 | and addTarget (t: Expr option) = Option.iter addExpr t 358 | 359 | and addExprs (es: Expr list) = 360 | for e in es do 361 | addExpr e 362 | 363 | match Expr.TryGetReflectedDefinition(m) with 364 | | Some e -> 365 | visitedMethods.Add m |> ignore 366 | addExpr e 367 | let span = buffer.Memory.Span 368 | let len = h.GetCurrentHash(span) 369 | Base64.encode (Span.op_Implicit (span.Slice(0, min len 15))) 370 | | None -> failwithf "No def" 371 | 372 | interface IDisposable with 373 | member _.Dispose() = 374 | buffer.Dispose() 375 | h.Dispose() 376 | 377 | static member Compute([] x: Expr<_ -> _>) = 378 | Hasher.ComputeInternal(null, x, HasherOptions.Default) 379 | 380 | static member Compute([] x: Expr<_ -> _>, options) = 381 | Hasher.ComputeInternal(null, x, options) 382 | 383 | static member Compute(key: string, [] x: Expr<_ -> _>) = 384 | Hasher.ComputeInternal(key, x, HasherOptions.Default) 385 | 386 | static member Compute(key: string, [] x: Expr<_ -> _>, options) = 387 | Hasher.ComputeInternal(key, x, options) 388 | 389 | static member private ComputeInternal(key: string, x: Expr<_ -> _>, options) = 390 | 391 | let rec findCall = 392 | function 393 | | Call(None, m, _) -> m 394 | | Lambda(_, body) -> findCall body 395 | | expr -> failwithf "Unsupported expression %A. Pass a function" expr 396 | 397 | let m = findCall x 398 | 399 | use h = new Hasher(IncrementalHash.CreateHash(HashAlgorithmName.MD5), options) 400 | 401 | if not (isNull key) then 402 | h.Add(key) 403 | 404 | h.ComputeHash m 405 | -------------------------------------------------------------------------------- /src/eskv/Program.fs: -------------------------------------------------------------------------------- 1 | open System 2 | open Microsoft.AspNetCore.Builder 3 | open System.Collections.Concurrent 4 | open System.Threading.Tasks 5 | open System.Buffers 6 | open System.Security.Cryptography 7 | open Microsoft.AspNetCore.Http 8 | open System.Net.WebSockets 9 | open System.Threading 10 | open Thoth.Json.Net 11 | open Shared 12 | open Microsoft.AspNetCore.WebUtilities 13 | open Microsoft.Net.Http.Headers 14 | open Microsoft.Extensions.FileProviders 15 | open System.Net.Http 16 | open Argu 17 | open AspNetExtensions 18 | open Streams 19 | 20 | type Cmd = 21 | | EndPoint of string 22 | | Dev 23 | | Parcel of string 24 | 25 | interface IArgParserTemplate with 26 | member this.Usage = 27 | match this with 28 | | Dev -> "specify dev mode." 29 | | Parcel _ -> "parcel dev server url. default is http://localhost:1234" 30 | | EndPoint _ -> "eskv http listener endpoint. default is http://localhost:5000" 31 | 32 | let parser = ArgumentParser.Create() 33 | 34 | let cmd = 35 | try 36 | parser.ParseCommandLine() 37 | with ex -> 38 | printfn "%s" ex.Message 39 | exit 0 40 | 41 | let isDevMode = cmd.Contains Dev 42 | 43 | let parcelUrl = 44 | cmd.TryGetResult Parcel |> Option.defaultValue "http://localhost:1234" 45 | 46 | let endpoint = 47 | cmd.TryGetResult EndPoint |> Option.defaultValue "http://localhost:5000" 48 | 49 | module Etag = 50 | let ofBytes (data: byte[]) = 51 | let bytes = MD5.HashData(data) 52 | $"\"{Convert.ToBase64String(bytes)}\"" 53 | 54 | [] 55 | type Entry = 56 | { Etag: ETag 57 | Bytes: byte[] 58 | ContentType: string } 59 | 60 | interface IEquatable with 61 | member this.Equals(other) = this.Etag = other.Etag 62 | 63 | 64 | let cts = new CancellationTokenSource() 65 | let data = ConcurrentDictionary>() 66 | 67 | let sessions = ConcurrentDictionary() 68 | let subscriptions = ConcurrentDictionary() 69 | 70 | 71 | let publish msg = 72 | task { 73 | let payload = Encode.Auto.toString (0, msg) |> Text.Encoding.UTF8.GetBytes 74 | 75 | for KeyValue(ws, _) in sessions do 76 | do! ws.SendAsync(payload.AsMemory(), WebSocketMessageType.Text, true, cts.Token) 77 | } 78 | 79 | let send (ws: WebSocket) msg = 80 | task { 81 | let payload = Encode.Auto.toString (0, msg) |> Text.Encoding.UTF8.GetBytes 82 | 83 | do! ws.SendAsync(payload.AsMemory(), WebSocketMessageType.Text, true, cts.Token) 84 | } 85 | 86 | let writeEvent (ws: WebSocket) (e: Event) = 87 | task { 88 | use s = new IO.MemoryStream() 89 | use w = new IO.StreamWriter(s) 90 | 91 | match e with 92 | | Event.Record e -> 93 | w.WriteLine($"ESKV-Event-Type: %s{e.Event.Type}") 94 | w.WriteLine($"ESKV-Event-Number: %d{e.EventNumber}") 95 | w.WriteLine() 96 | w.Flush() 97 | s.Write(e.Event.Data) 98 | | Event.Link l -> 99 | w.WriteLine($"ESKV-Event-Type: %s{l.OriginEvent.Event.Type}") 100 | w.WriteLine($"ESKV-Event-Number: %d{l.EventNumber}") 101 | w.WriteLine($"ESKV-Origin-Event-Number: %d{l.OriginEvent.EventNumber}") 102 | w.WriteLine($"ESKV-Origin-Stream: %s{l.OriginEvent.StreamId}") 103 | w.WriteLine() 104 | w.Flush() 105 | s.Write(l.OriginEvent.Event.Data) 106 | 107 | do! ws.SendAsync(s.ToArray().AsMemory(), WebSocketMessageType.Binary, true, cts.Token) 108 | } 109 | 110 | let publishEvent (e: Event) = 111 | task { 112 | let streamId = 113 | match e with 114 | | Record r -> r.StreamId 115 | | Link l -> l.StreamId 116 | 117 | for KeyValue(ws, stream) in subscriptions do 118 | if stream = streamId then 119 | try 120 | do! writeEvent ws e 121 | with ex -> 122 | printfn "%O" ex 123 | } 124 | 125 | 126 | let builder = WebApplication.CreateBuilder() 127 | 128 | if not isDevMode then 129 | builder.Environment.WebRootFileProvider <- 130 | new EmbeddedFileProvider(Reflection.Assembly.GetExecutingAssembly(), "eskv.wwwroot") 131 | 132 | let app = builder.Build() 133 | app.UseWebSockets() |> ignore 134 | 135 | if not isDevMode then 136 | app.UseStaticFiles().UseDefaultFiles() |> ignore 137 | 138 | app.MapGet( 139 | "/", 140 | fun (ctx: HttpContext) -> 141 | if isDevMode && ctx.WebSockets.IsWebSocketRequest then 142 | ctx.ProxyWebSocketAsync(UriBuilder(parcelUrl, Scheme = "ws://").Uri, cts.Token) 143 | else 144 | ctx.Response.Headers.ContentType <- "text/html" 145 | ctx.Response.SendWebFileAsync("index.html") 146 | ) 147 | |> ignore 148 | 149 | app.MapGet( 150 | "/favicon.ico", 151 | fun (ctx: HttpContext) -> 152 | ctx.Response.Headers.ContentType <- "image/png" 153 | ctx.Response.SendWebFileAsync("favicon.ico") 154 | ) 155 | |> ignore 156 | 157 | 158 | if isDevMode then 159 | app.MapGet( 160 | "/content/{**path}", 161 | fun ctx -> 162 | let path = ctx.Request.RouteValues.["path"] |> string 163 | ctx.Response.ProxyGetAsync(UriBuilder(parcelUrl, Path = path).Uri) 164 | ) 165 | |> ignore 166 | 167 | 168 | 169 | app.MapGet( 170 | "/kv/", 171 | fun ctx -> 172 | task { 173 | 174 | ctx.Response.ContentType <- "text/plain" 175 | 176 | for key in data.Keys do 177 | 178 | let len = Text.Encoding.UTF8.GetByteCount(key) 179 | let data = ctx.Response.BodyWriter.GetSpan(len + 1) 180 | let written = Text.Encoding.UTF8.GetBytes(key.AsSpan(), data) 181 | data.[written] <- 10uy 182 | 183 | ctx.Response.BodyWriter.Advance(written + 1) 184 | 185 | do! ctx.Response.BodyWriter.CompleteAsync() 186 | 187 | 188 | } 189 | :> Task 190 | 191 | ) 192 | |> ignore 193 | 194 | app.MapGet( 195 | "/kv/{container}", 196 | fun ctx -> 197 | task { 198 | 199 | let containerKey = ctx.Request.RouteValues.["container"] |> string 200 | ctx.Response.ContentType <- "text/plain" 201 | 202 | match data.TryGetValue(containerKey) with 203 | | true, container -> 204 | for key in container.Keys do 205 | 206 | let len = Text.Encoding.UTF8.GetByteCount(key) 207 | let data = ctx.Response.BodyWriter.GetSpan(len + 1) 208 | let written = Text.Encoding.UTF8.GetBytes(key.AsSpan(), data) 209 | data.[written] <- 10uy 210 | 211 | ctx.Response.BodyWriter.Advance(written + 1) 212 | 213 | do! ctx.Response.BodyWriter.CompleteAsync() 214 | | false, _ -> ctx.Response.StatusCode <- 404 215 | } 216 | :> Task 217 | 218 | ) 219 | |> ignore 220 | 221 | app.MapGet( 222 | "/kv/{container}/{key}", 223 | fun ctx -> 224 | task { 225 | let key = ctx.Request.RouteValues.["key"] |> string 226 | let containerKey = ctx.Request.RouteValues.["container"] |> string 227 | 228 | let container = data.GetOrAdd(containerKey, (fun _ -> ConcurrentDictionary())) 229 | 230 | match container.TryGetValue(key) with 231 | | false, _ -> ctx.Response.StatusCode <- 404 232 | | true, entry -> 233 | ctx.Response.StatusCode <- 200 234 | ctx.Response.ContentType <- entry.ContentType 235 | ctx.Response.Headers.ETag <- entry.Etag 236 | ctx.Response.Headers.ContentLength <- entry.Bytes.LongLength 237 | 238 | let! _ = ctx.Response.BodyWriter.WriteAsync(entry.Bytes) 239 | do! ctx.Response.BodyWriter.CompleteAsync() 240 | } 241 | :> Task 242 | ) 243 | |> ignore 244 | 245 | 246 | 247 | app.MapPut( 248 | "/kv/{container}/{key}", 249 | fun ctx -> 250 | task { 251 | let key = ctx.Request.RouteValues.["key"] |> string 252 | let containerKey = ctx.Request.RouteValues.["container"] |> string 253 | 254 | let contentType = ctx.Request.ContentType 255 | let length = ctx.Request.Headers.ContentLength 256 | 257 | let! result = 258 | if length.HasValue then 259 | ctx.Request.BodyReader.ReadAtLeastAsync(int length.Value) 260 | else 261 | ctx.Request.BodyReader.ReadAsync() 262 | 263 | 264 | let bytes = result.Buffer.ToArray() 265 | 266 | ctx.Request.BodyReader.AdvanceTo(result.Buffer.End) 267 | 268 | let newEtag = Etag.ofBytes bytes 269 | 270 | let container = data.GetOrAdd(containerKey, (fun _ -> ConcurrentDictionary())) 271 | 272 | if 273 | ctx.Request.Headers.IfNoneMatch.Count = 1 274 | && ctx.Request.Headers.IfNoneMatch.[0] = "*" 275 | then 276 | // the data should not already exist: 277 | if 278 | container.TryAdd( 279 | key, 280 | { Etag = newEtag 281 | Bytes = bytes 282 | ContentType = contentType } 283 | ) 284 | then 285 | ctx.Response.StatusCode <- 204 286 | ctx.Response.Headers.ETag <- newEtag 287 | do! publish (Shared.KeyChanged(containerKey, key, (Text.Encoding.UTF8.GetString(bytes), newEtag))) 288 | else 289 | ctx.Response.StatusCode <- 409 // conflict 290 | else 291 | let etags = ctx.Request.Headers.IfMatch 292 | 293 | if etags.Count = 1 then 294 | if 295 | container.TryUpdate( 296 | key, 297 | { Etag = newEtag 298 | Bytes = bytes 299 | ContentType = contentType }, 300 | { Etag = etags.[0] 301 | Bytes = null 302 | ContentType = null } 303 | ) 304 | then 305 | ctx.Response.StatusCode <- 204 306 | ctx.Response.Headers.ETag <- newEtag 307 | 308 | do! 309 | publish ( 310 | Shared.KeyChanged(containerKey, key, (Text.Encoding.UTF8.GetString(bytes), newEtag)) 311 | ) 312 | else 313 | ctx.Response.StatusCode <- 409 // conflict 314 | 315 | 316 | else 317 | // save data without etag check 318 | container.[key] <- 319 | { Etag = newEtag 320 | Bytes = bytes 321 | ContentType = contentType } 322 | 323 | ctx.Response.StatusCode <- 204 324 | ctx.Response.Headers.ETag <- newEtag 325 | do! publish (Shared.KeyChanged(containerKey, key, (Text.Encoding.UTF8.GetString(bytes), newEtag))) 326 | 327 | 328 | } 329 | :> Task 330 | ) 331 | |> ignore 332 | 333 | app.MapDelete( 334 | "/kv/{container}/{key}", 335 | fun ctx -> 336 | task { 337 | let key = ctx.Request.RouteValues.["key"] |> string 338 | let containerKey = ctx.Request.RouteValues.["container"] |> string 339 | 340 | let etags = ctx.Request.Headers.IfMatch 341 | 342 | match data.TryGetValue(containerKey) with 343 | | true, container -> 344 | if etags.Count = 1 then 345 | if 346 | container.TryUpdate( 347 | key, 348 | { Etag = "" 349 | Bytes = null 350 | ContentType = null }, 351 | { Etag = etags.[0] 352 | Bytes = null 353 | ContentType = null } 354 | ) 355 | then 356 | container.TryRemove(key) |> ignore 357 | ctx.Response.StatusCode <- 204 358 | do! publish (Shared.KeyDeleted(containerKey, key)) 359 | else 360 | ctx.Response.StatusCode <- 409 // conflict 361 | 362 | 363 | else 364 | // save data without etag check 365 | container.TryRemove(key) |> ignore 366 | ctx.Response.StatusCode <- 204 367 | do! publish (Shared.KeyDeleted(containerKey, key)) 368 | | false, _ -> ctx.Response.StatusCode <- 204 369 | 370 | 371 | } 372 | :> Task 373 | ) 374 | |> ignore 375 | 376 | app.MapDelete( 377 | "/kv/{container}", 378 | fun ctx -> 379 | task { 380 | let containerKey = ctx.Request.RouteValues.["container"] |> string 381 | 382 | match data.TryRemove(containerKey) with 383 | | true, container -> 384 | 385 | do! publish (Shared.ContainerDeleted(containerKey)) 386 | | false, _ -> () 387 | 388 | ctx.Response.StatusCode <- 204 389 | 390 | 391 | } 392 | :> Task 393 | ) 394 | |> ignore 395 | 396 | 397 | let decode (bytes: byte[]) (contentType: string) = 398 | match System.Net.Http.Headers.MediaTypeHeaderValue.TryParse(contentType) with 399 | | true, ct -> 400 | if ct.MediaType.StartsWith("text/") then 401 | Text.Encoding.UTF8.GetString(bytes) 402 | else 403 | match contentType with 404 | | "application/json" 405 | | "application/xml" -> Text.Encoding.UTF8.GetString(bytes) 406 | | _ -> null 407 | 408 | 409 | | _ -> null 410 | 411 | app.MapGet( 412 | "/ws", 413 | fun ctx -> 414 | task { 415 | if ctx.WebSockets.IsWebSocketRequest then 416 | let! webSocket = ctx.WebSockets.AcceptWebSocketAsync() 417 | 418 | sessions.TryAdd(webSocket, obj ()) |> ignore 419 | 420 | let msg = 421 | [ for KeyValue(container, keys) in data do 422 | container, 423 | [ for KeyValue(key, entry) in keys do 424 | if entry.Etag.Length <> 0 then 425 | key, (decode entry.Bytes entry.ContentType, entry.Etag) ] 426 | 427 | 428 | 429 | ] 430 | |> Shared.KeysLoaded 431 | 432 | do! send webSocket msg 433 | 434 | let! events = Streams.readAllAsync 0 Int32.MaxValue 435 | 436 | let msg = 437 | let data = Array.zeroCreate events.Length 438 | let s = events.Span 439 | 440 | for i in 0 .. s.Length - 1 do 441 | let event = s.[i] 442 | 443 | data.[i] <- 444 | match event with 445 | | Link e -> 446 | { StreamId = e.OriginEvent.StreamId 447 | EventNumber = e.OriginEvent.EventNumber 448 | EventType = e.OriginEvent.Event.Type 449 | EventData = decode e.OriginEvent.Event.Data e.OriginEvent.Event.ContentType } 450 | | Record r -> 451 | { StreamId = r.StreamId 452 | EventNumber = r.EventNumber 453 | EventType = r.Event.Type 454 | EventData = decode r.Event.Data r.Event.ContentType } 455 | 456 | 457 | 458 | data 459 | 460 | do! send webSocket (StreamLoaded msg) 461 | 462 | use buffer = MemoryPool.Shared.Rent (4096) 463 | let mutable closed = false 464 | 465 | while not closed do 466 | 467 | try 468 | let! result = webSocket.ReceiveAsync(buffer.Memory, cts.Token) 469 | () 470 | with _ -> 471 | sessions.TryRemove(webSocket) |> ignore 472 | closed <- true 473 | 474 | 475 | else 476 | ctx.Response.StatusCode <- 400 477 | } 478 | :> Task 479 | ) 480 | |> ignore 481 | 482 | 483 | let renderSliceContent includeLink (slice: ReadOnlyMemory) = 484 | let content = new MultipartContent() 485 | let span = slice.Span 486 | 487 | for i in 0 .. span.Length - 1 do 488 | let e = span.[i] 489 | 490 | match e with 491 | | Record record -> 492 | let part = new ByteArrayContent(record.Event.Data) 493 | part.Headers.ContentType <- Headers.MediaTypeHeaderValue.Parse(record.Event.ContentType) 494 | part.Headers.Add("ESKV-Event-Type", record.Event.Type) 495 | part.Headers.Add("ESKV-Event-Number", string record.EventNumber) 496 | 497 | content.Add(part) 498 | | Link l -> 499 | 500 | let part = 501 | 502 | if includeLink then 503 | new ByteArrayContent(l.OriginEvent.Event.Data) 504 | else 505 | new ByteArrayContent([||]) 506 | 507 | part.Headers.ContentType <- Headers.MediaTypeHeaderValue.Parse(l.OriginEvent.Event.ContentType) 508 | part.Headers.Add("ESKV-Event-Number", string l.EventNumber) 509 | part.Headers.Add("ESKV-Origin-Stream", string l.OriginEvent.StreamId) 510 | 511 | if includeLink then 512 | part.Headers.Add("ESKV-Origin-Event-Number", string l.OriginEvent.EventNumber) 513 | part.Headers.Add("ESKV-Event-Type", l.OriginEvent.Event.Type) 514 | 515 | content.Add(part) 516 | 517 | content 518 | 519 | let renderSlice 520 | (response: HttpResponse) 521 | (streamId: string) 522 | includeLink 523 | start 524 | (slice: ReadOnlyMemory) 525 | (lengthOfStream: int) 526 | = 527 | task { 528 | response.Headers.Add("ESKV-Stream", streamId) 529 | response.Headers.Add("ESKV-Expected-Version", string (start + slice.Length - 1)) 530 | response.Headers.Add("ESKV-Next-Event-Number", string (start + slice.Length)) 531 | response.Headers.Add("ESKV-Stream-Expected-Version", string (lengthOfStream - 1)) 532 | response.Headers.Add("ESKV-Stream-Next-Event-Number", string (lengthOfStream)) 533 | 534 | if start + slice.Length = lengthOfStream then 535 | response.Headers.Add("ESKV-End-Of-Stream", "true") 536 | 537 | if slice.Length = 0 then 538 | response.StatusCode <- 204 539 | else 540 | 541 | let content = renderSliceContent includeLink slice 542 | 543 | response.ContentType <- string content.Headers.ContentType 544 | 545 | do! content.CopyToAsync(response.Body) 546 | } 547 | 548 | app.MapPost( 549 | "/es/{stream}", 550 | fun ctx -> 551 | task { 552 | let streamId = ctx.Request.RouteValues.["stream"] |> string 553 | 554 | let expectedVersion = 555 | let h = ctx.Request.Headers.["ESKV-Expected-Version"] 556 | 557 | if h.Count = 0 then 558 | ValueSome -2 559 | else 560 | let v = int h.[0] 561 | if v < -2 then ValueNone else ValueSome v 562 | 563 | let returnNewEvents = ctx.Request.Headers.ContainsKey("ESKV-Return-New-Events") 564 | 565 | 566 | 567 | match expectedVersion with 568 | | ValueNone -> ctx.Response.StatusCode <- 400 569 | | ValueSome expectedVersion -> 570 | let boundary = 571 | HeaderUtilities 572 | .RemoveQuotes( 573 | MediaTypeHeaderValue.Parse(ctx.Request.ContentType).Boundary 574 | ) 575 | .Value 576 | 577 | let events = ResizeArray() 578 | 579 | if not (isNull boundary) then 580 | let reader = MultipartReader(boundary, ctx.Request.Body) 581 | 582 | let mutable quit = false 583 | 584 | while not quit do 585 | let! section = reader.ReadNextSectionAsync() 586 | 587 | if isNull section then 588 | quit <- true 589 | else 590 | match section.Headers.TryGetValue("ESKV-Event-Type") with 591 | | true, et -> 592 | let eventType = et.[0] 593 | 594 | let! data = 595 | task { 596 | use memory = new IO.MemoryStream() 597 | do! section.Body.CopyToAsync(memory) 598 | return memory.ToArray() 599 | } 600 | 601 | events.Add 602 | { Type = eventType 603 | Data = data 604 | ContentType = section.ContentType } 605 | | false, _ -> quit <- true 606 | 607 | 608 | let! nextExpectedVersion = Streams.appendAsync streamId (events.ToArray()) expectedVersion 609 | 610 | match nextExpectedVersion with 611 | | ValueSome(nextExpectedVersion, records, allEvents) -> 612 | 613 | 614 | 615 | 616 | do! 617 | publish ( 618 | StreamUpdated( 619 | records 620 | |> Array.map (fun e -> 621 | { StreamId = e.StreamId 622 | EventNumber = e.EventNumber 623 | EventType = e.Event.Type 624 | EventData = decode e.Event.Data e.Event.ContentType }) 625 | ) 626 | ) 627 | 628 | for e in records do 629 | do! publishEvent (Event.Record e) 630 | 631 | for e in allEvents do 632 | do! publishEvent e 633 | 634 | ctx.Response.StatusCode <- 204 635 | ctx.Response.Headers.Add("ESKV-Expected-Version", string nextExpectedVersion) 636 | ctx.Response.Headers.Add("ESKV-Next-Event-Number", string (nextExpectedVersion + 1)) 637 | 638 | | ValueNone -> 639 | ctx.Response.StatusCode <- 409 640 | 641 | if returnNewEvents then 642 | match! Streams.readStreamAsync streamId (expectedVersion + 1) (Int32.MaxValue) with 643 | | ValueSome(slice, lengthOfStream) -> 644 | do! renderSlice ctx.Response streamId true (expectedVersion + 1) slice lengthOfStream 645 | | ValueNone -> () 646 | 647 | 648 | 649 | 650 | } 651 | :> Task 652 | ) 653 | |> ignore 654 | 655 | 656 | app.MapGet( 657 | "/es/{stream}/{start:int}/{count:int}", 658 | fun ctx -> 659 | task { 660 | let streamId = ctx.Request.RouteValues.["stream"] |> string 661 | let start = ctx.Request.RouteValues.["start"] |> string |> int 662 | let count = ctx.Request.RouteValues.["count"] |> string |> int 663 | 664 | let includeLink = not (ctx.Request.Headers.ContainsKey("ESKV-Link-Only")) 665 | 666 | if streamId <> "$all" then 667 | match! Streams.readStreamAsync streamId start count with 668 | | ValueSome(slice, lengthOfStream) -> 669 | do! renderSlice ctx.Response streamId includeLink start slice lengthOfStream 670 | 671 | | ValueNone -> ctx.Response.StatusCode <- 404 672 | else 673 | let! all = Streams.readAllAsync start count 674 | do! renderSlice ctx.Response "$all" includeLink start all 0 675 | 676 | () 677 | } 678 | :> Task 679 | 680 | ) 681 | |> ignore 682 | 683 | app.MapGet( 684 | "/es/{stream}/{start:int}", 685 | fun ctx -> 686 | task { 687 | let streamId = ctx.Request.RouteValues.["stream"] |> string 688 | let start = ctx.Request.RouteValues.["start"] |> string |> int 689 | 690 | if ctx.WebSockets.IsWebSocketRequest then 691 | 692 | let! webSocket = ctx.WebSockets.AcceptWebSocketAsync() 693 | 694 | subscriptions.TryAdd(webSocket, streamId) |> ignore 695 | 696 | let! slice = 697 | task { 698 | if streamId = "$all" then 699 | let! slice = Streams.readAllAsync start Int32.MaxValue 700 | return ValueSome(slice, slice.Length) 701 | else 702 | return! Streams.readStreamAsync streamId 0 Int32.MaxValue 703 | } 704 | 705 | 706 | match slice with 707 | | ValueSome(events, lengthOfStream) -> 708 | 709 | for i in 0 .. events.Length - 1 do 710 | 711 | let e = events.Span[i] 712 | 713 | do! writeEvent webSocket e 714 | 715 | | ValueNone -> () 716 | 717 | let buffer = MemoryPool.Shared.Rent (4096) 718 | let mutable closed = false 719 | 720 | while not closed do 721 | 722 | try 723 | let! result = webSocket.ReceiveAsync(buffer.Memory, cts.Token) 724 | () 725 | with _ -> 726 | subscriptions.TryRemove(webSocket) |> ignore 727 | closed <- true 728 | 729 | 730 | else 731 | ctx.Response.StatusCode <- 400 732 | } 733 | :> Task 734 | ) 735 | |> ignore 736 | 737 | 738 | 739 | 740 | app.Run(endpoint) 741 | -------------------------------------------------------------------------------- /src/eskv.ui/App.fs: -------------------------------------------------------------------------------- 1 | module kv.ui 2 | 3 | open Fable.Core 4 | open Elmish.React 5 | 6 | open Elmish 7 | open Feliz 8 | open Fetch 9 | open Feliz.Bulma 10 | open Browser 11 | open Shared 12 | open Style 13 | 14 | type Editing = 15 | { Container: Container 16 | Key: Key 17 | Value: string 18 | ETag: ETag 19 | Saving: bool } 20 | 21 | type EditState = 22 | | NoEdit 23 | | Editing of Editing 24 | 25 | type EventView = 26 | | All 27 | | Stream of string 28 | 29 | type Command = 30 | | ChangeNewKey of string 31 | | ChangeNewValue of string 32 | | CreateNew 33 | | OpenNew 34 | | CloseNew 35 | | DeleteKey of Container * Key * ETag 36 | | DeleteContainer of Container 37 | | Deleted 38 | | Saved 39 | | Edit of Editing 40 | | EditChanged of string 41 | | CancelEdit 42 | | SaveEdit 43 | | EditSaved 44 | | Server of Shared.ServerCmd 45 | | ChangeEventView of EventView 46 | 47 | 48 | type Model = 49 | { NewKey: string 50 | NewValue: string 51 | ShowCreateNew: bool 52 | Saving: bool 53 | Keys: Map> 54 | Editing: EditState 55 | Events: (string * int * string * string) list 56 | EventView: EventView } 57 | 58 | let init () = 59 | { NewKey = "" 60 | NewValue = "" 61 | ShowCreateNew = false 62 | Saving = false 63 | Keys = Map.empty 64 | Editing = NoEdit 65 | Events = [] 66 | EventView = All }, 67 | Cmd.none 68 | 69 | let update (command: Command) (model: Model) = 70 | match command with 71 | | ChangeNewKey txt -> { model with NewKey = txt }, Cmd.none 72 | | ChangeNewValue txt -> { model with NewValue = txt }, Cmd.none 73 | | CreateNew -> 74 | { model with Saving = true }, 75 | Cmd.OfAsync.result ( 76 | async { 77 | let container, key = 78 | match model.NewKey.Split('/', 2) |> Array.toList with 79 | | container :: key :: _ -> container, key 80 | | [ key ] -> "default", key 81 | | [] -> "default", "key" 82 | 83 | 84 | 85 | let! response = 86 | fetch 87 | $"/kv/{container}/{key}" 88 | [ requestHeaders [ IfNoneMatch "*" ] 89 | RequestProperties.Method HttpMethod.PUT 90 | RequestProperties.Body(U3.Case3 model.NewValue) ] 91 | |> Async.AwaitPromise 92 | 93 | return Saved 94 | } 95 | ) 96 | | OpenNew -> { model with ShowCreateNew = true }, Cmd.none 97 | | CloseNew -> { model with ShowCreateNew = false }, Cmd.none 98 | | Saved -> 99 | { model with 100 | NewKey = "" 101 | NewValue = "" 102 | ShowCreateNew = false 103 | Saving = false }, 104 | Cmd.none 105 | 106 | | DeleteKey(container, key, etag) -> 107 | { model with Saving = true }, 108 | Cmd.OfAsync.result ( 109 | async { 110 | let! response = 111 | fetch 112 | $"/kv/{container}/{key}" 113 | [ requestHeaders [ IfMatch etag ]; RequestProperties.Method HttpMethod.DELETE ] 114 | |> Async.AwaitPromise 115 | 116 | return Saved 117 | } 118 | ) 119 | | DeleteContainer(container) -> 120 | { model with Saving = true }, 121 | Cmd.OfAsync.result ( 122 | async { 123 | let! response = 124 | fetch $"/kv/{container}" [ RequestProperties.Method HttpMethod.DELETE ] 125 | |> Async.AwaitPromise 126 | 127 | return Saved 128 | } 129 | ) 130 | | Deleted -> { model with Saving = false }, Cmd.none 131 | | Edit edit -> { model with Editing = Editing edit }, Cmd.none 132 | | EditChanged text -> 133 | match model.Editing with 134 | | Editing edit -> { model with Editing = Editing { edit with Value = text } }, Cmd.none 135 | | _ -> model, Cmd.none 136 | 137 | | CancelEdit -> { model with Editing = NoEdit }, Cmd.none 138 | | SaveEdit -> 139 | match model.Editing with 140 | | Editing edit -> 141 | { model with Editing = Editing { edit with Editing.Saving = true } }, 142 | Cmd.OfAsync.result ( 143 | async { 144 | let! response = 145 | fetch 146 | $"/kv/{edit.Container}/{edit.Key}" 147 | [ requestHeaders [ IfMatch edit.ETag ] 148 | RequestProperties.Method HttpMethod.PUT 149 | RequestProperties.Body(U3.Case3 edit.Value) ] 150 | |> Async.AwaitPromise 151 | 152 | return EditSaved 153 | } 154 | ) 155 | | _ -> model, Cmd.none 156 | | EditSaved -> { model with Editing = NoEdit }, Cmd.none 157 | | ChangeEventView view -> { model with EventView = view }, Cmd.none 158 | | Server(Shared.KeyChanged(containerKey, key, value)) -> 159 | { model with 160 | Keys = 161 | let container = Map.tryFind containerKey model.Keys |> Option.defaultValue Map.empty 162 | 163 | Map.add containerKey (Map.add key value container) model.Keys 164 | 165 | }, 166 | Cmd.none 167 | | Server(Shared.KeyDeleted(containerKey, key)) -> 168 | { model with 169 | Keys = 170 | model.Keys 171 | |> Map.change containerKey (function 172 | | None -> None 173 | | Some container -> Some(Map.remove key container)) }, 174 | Cmd.none 175 | | Server(Shared.ContainerDeleted containerKey) -> 176 | { model with Keys = model.Keys |> Map.remove containerKey }, Cmd.none 177 | | Server(Shared.KeysLoaded keys) -> 178 | { model with 179 | Keys = 180 | keys 181 | |> List.map (fun (container, keys) -> container, Map.ofList keys) 182 | |> Map.ofList }, 183 | Cmd.none 184 | | Server(Shared.StreamUpdated stream) -> 185 | { model with 186 | Events = 187 | model.Events 188 | @ [ for e in stream -> e.StreamId, e.EventNumber, e.EventType, e.EventData ] }, 189 | Cmd.none 190 | | Server(Shared.StreamLoaded events) -> 191 | { model with 192 | Events = 193 | events 194 | |> Seq.map (fun s -> s.StreamId, s.EventNumber, s.EventType, s.EventData) 195 | |> Seq.toList }, 196 | Cmd.none 197 | 198 | 199 | 200 | 201 | let view model dispatch = 202 | Html.div 203 | [ 204 | //prop.style [style.overflow.hidden] 205 | prop.children 206 | [ 207 | 208 | Bulma.navbar 209 | [ Bulma.color.isPrimary 210 | prop.children 211 | [ Bulma.navbarBrand.div 212 | [ prop.style 213 | [ style.width (length.percent 100) 214 | style.display.block 215 | style.verticalAlign.top ] 216 | 217 | prop.children 218 | [ Bulma.navbarItem.div 219 | [ prop.style [ style.alignItems.stretch ] 220 | prop.children 221 | [ Html.div 222 | [ prop.text "eskv" 223 | 224 | prop.style 225 | [ style.fontSize (length.em 3) 226 | style.fontWeight.bold 227 | style.fontStyle.italic ] ] 228 | Html.div 229 | [ prop.children 230 | [ Html.div "in memory event stream / key value store " 231 | Html.div " - for learning purpose." ] 232 | prop.style 233 | [ style.fontStyle.italic 234 | style.display.flex 235 | style.flexDirection.row 236 | style.flexWrap.wrap 237 | style.overflow.hidden 238 | //style.position.relative 239 | style.marginLeft (length.pt -59) 240 | style.marginTop (length.pt 39) 241 | style.lineHeight (length.em 1) 242 | 243 | style.width (length.calc "100%") 244 | 245 | ] ] ] ] ] ] ] ] 246 | 247 | Bulma.panel 248 | [ Bulma.panelHeading 249 | [ Html.div 250 | [ prop.style 251 | [ style.display.flex 252 | style.flexDirection.row 253 | 254 | ] 255 | 256 | prop.children 257 | [ Html.div 258 | [ prop.style [ style.flexGrow 1; style.textAlign.center ] 259 | prop.text "key / value" ] 260 | Html.a 261 | [ prop.className "icon-text" 262 | Bulma.size.isSize6 263 | prop.style [ style.flexGrow 0 ] 264 | 265 | prop.children 266 | [ Bulma.icon 267 | [ 268 | 269 | Html.i 270 | [ if model.ShowCreateNew then 271 | prop.className "fa fa-minus" 272 | else 273 | prop.className "fa fa-plus" ] ] 274 | Html.text "New" ] 275 | 276 | prop.onClick (fun _ -> 277 | if model.ShowCreateNew then 278 | dispatch CloseNew 279 | else 280 | dispatch OpenNew) ] ] 281 | 282 | ] ] 283 | 284 | table.fixed' 285 | [ if model.ShowCreateNew || not (Map.isEmpty model.Keys) then 286 | Html.tr 287 | [ Html.th 288 | [ prop.text "Key"; prop.style [ style.width (length.calc ("30% - 5em")) ] ] 289 | Html.th 290 | [ prop.text "Value"; prop.style [ style.width (length.calc ("70% - 5em")) ] ] 291 | Html.th [ prop.style [ style.width (length.em 5) ] ] ] 292 | 293 | if model.ShowCreateNew then 294 | Html.tr 295 | [ Html.td 296 | [ Bulma.input.text 297 | [ prop.disabled model.Saving 298 | prop.valueOrDefault model.NewKey 299 | prop.onChange (dispatch << ChangeNewKey) 300 | prop.onKeyDown (fun e -> 301 | match e.key with 302 | | "Enter" -> dispatch CreateNew 303 | | "Escape" -> dispatch CloseNew 304 | | _ -> ()) 305 | prop.placeholder "container/key" ] ] 306 | Html.td 307 | [ Bulma.input.text 308 | [ prop.disabled model.Saving 309 | prop.valueOrDefault model.NewValue 310 | prop.onChange (dispatch << ChangeNewValue) 311 | prop.onKeyDown (fun e -> 312 | match e.key with 313 | | "Enter" -> dispatch CreateNew 314 | | "Escape" -> dispatch CloseNew 315 | | _ -> ()) ] ] 316 | 317 | Html.td 318 | [ Bulma.column.isNarrow 319 | prop.children 320 | [ if model.NewKey = "" then 321 | Html.a 322 | [ prop.children 323 | [ Bulma.icon 324 | [ prop.children 325 | [ Html.i 326 | [ prop.className 327 | "fas fa-exclamation-triangle" ] ] 328 | 329 | Bulma.color.hasTextDanger ] 330 | 331 | ] 332 | Bulma.tooltip.text "You should provide a key" 333 | Bulma.tooltip.hasTooltipDanger 334 | prop.className "has-tooltip-danger" 335 | prop.disabled true ] 336 | 337 | else 338 | Html.a 339 | [ 340 | 341 | prop.disabled model.Saving 342 | 343 | prop.children 344 | [ Bulma.icon [ Html.i [ prop.className "fas fa-plus" ] ] ] 345 | prop.onClick (fun _ -> dispatch CreateNew) ] 346 | 347 | Html.a 348 | [ prop.disabled model.Saving 349 | prop.children 350 | [ Bulma.icon [ Html.i [ prop.className "fas fa-times" ] ] ] 351 | prop.onClick (fun _ -> dispatch CloseNew) ] 352 | 353 | ] 354 | 355 | 356 | ] ] 357 | 358 | if not model.ShowCreateNew && Map.isEmpty model.Keys then 359 | Html.tr 360 | [ Html.td 361 | [ prop.colSpan 3 362 | prop.children 363 | [ Bulma.block 364 | [ Bulma.color.hasTextGreyLight 365 | Bulma.text.hasTextCentered 366 | Bulma.text.isItalic 367 | prop.className "is-justify-content-center" 368 | prop.text "no entry yet..." ] ] ] ] 369 | else 370 | for container, keys in Map.toSeq model.Keys do 371 | Html.tr 372 | [ prop.className "key-container" 373 | prop.children 374 | [ Html.td 375 | [ prop.style 376 | [ style.paddingLeft (length.em 0.75) 377 | style.paddingTop (length.em 0.25) 378 | style.paddingBottom (length.em 0.25) 379 | style.paddingRight (length.em 0.75) ] 380 | Bulma.text.hasTextWeightSemibold 381 | 382 | prop.colSpan 2 383 | prop.text (container + "/") 384 | 385 | ] 386 | Html.td 387 | [ Bulma.column.isNarrow 388 | prop.children 389 | [ Html.a 390 | [ prop.children 391 | [ Bulma.icon 392 | [ Html.i [ prop.className "fas fa-trash" ] ] ] 393 | prop.onClick (fun _ -> 394 | dispatch (DeleteContainer(container))) ] 395 | 396 | ] ] ] ] 397 | 398 | for key, (v, etag) in Map.toSeq keys do 399 | Html.tr 400 | [ table.tdEllipsis [ prop.className "key"; prop.text key ] 401 | 402 | table.tdEllipsis 403 | [ match model.Editing with 404 | | Editing { Key = k; Value = v; Saving = saving } when k = key -> 405 | prop.children 406 | [ Bulma.input.text 407 | [ prop.disabled saving 408 | prop.valueOrDefault v 409 | prop.onChange (dispatch << EditChanged) 410 | prop.onKeyDown (fun e -> 411 | match e.key with 412 | | "Enter" -> dispatch SaveEdit 413 | | "Escape" -> dispatch CancelEdit 414 | | _ -> ()) ] ] 415 | 416 | | _ -> 417 | prop.onClick (fun _ -> 418 | dispatch ( 419 | Edit 420 | { Container = container 421 | Key = key 422 | Value = v 423 | ETag = etag 424 | Saving = false } 425 | )) 426 | 427 | prop.text v 428 | prop.className "editable" 429 | 430 | 431 | ] 432 | Html.td 433 | [ Bulma.column.isNarrow 434 | prop.children 435 | [ match model.Editing with 436 | | Editing { Key = k 437 | Saving = saving 438 | ETag = oldEtag } when k = key -> 439 | if oldEtag <> etag then 440 | Html.a 441 | [ prop.children 442 | [ Bulma.icon 443 | [ prop.children 444 | [ Html.i 445 | [ prop.className 446 | "fas fa-exclamation-triangle" ] ] 447 | 448 | Bulma.color.hasTextDanger ] 449 | 450 | ] 451 | Bulma.tooltip.text "Value has changed" 452 | Bulma.tooltip.hasTooltipDanger 453 | prop.className "has-tooltip-danger" 454 | prop.disabled true ] 455 | 456 | else 457 | Html.a 458 | [ 459 | 460 | prop.disabled (saving) 461 | 462 | prop.children 463 | [ Bulma.icon 464 | [ Html.i 465 | [ prop.className "fas fa-check" ] ] ] 466 | prop.onClick (fun _ -> dispatch SaveEdit) ] 467 | 468 | Html.a 469 | [ prop.disabled saving 470 | prop.children 471 | [ Bulma.icon 472 | [ Html.i [ prop.className "fas fa-times" ] ] ] 473 | prop.onClick (fun _ -> dispatch CancelEdit) ] 474 | | _ -> 475 | Html.a 476 | [ prop.children 477 | [ Bulma.icon 478 | [ Html.i [ prop.className "fas fa-trash" ] ] ] 479 | prop.onClick (fun _ -> 480 | dispatch (DeleteKey(container, key, etag))) ] 481 | 482 | Html.a 483 | [ prop.children 484 | [ Bulma.icon 485 | [ 486 | 487 | ] ] ] 488 | 489 | 490 | ] ] ] ] ] 491 | Bulma.panel 492 | [ prop.children 493 | [ Bulma.panelHeading 494 | [ Bulma.text.hasTextCentered 495 | match model.EventView with 496 | | All -> prop.text "events / all" 497 | | Stream stream -> 498 | prop.style [ style.display.flex; style.flexDirection.row ] 499 | 500 | prop.children 501 | [ Html.span 502 | [ prop.style 503 | [ style.flexGrow 1 504 | style.textAlign.center 505 | style.overflow.hidden 506 | style.textOverflow.ellipsis 507 | style.whitespace.nowrap ] 508 | prop.text $"events / {stream}" ] 509 | Bulma.delete 510 | [ prop.style [ style.flexGrow 0 ] 511 | prop.onClick (fun _ -> dispatch (ChangeEventView All)) ] ] 512 | 513 | 514 | ] 515 | 516 | if List.isEmpty model.Events then 517 | 518 | 519 | Bulma.panelBlock.div 520 | [ 521 | 522 | Bulma.color.hasTextGreyLight 523 | Bulma.text.hasTextCentered 524 | Bulma.text.isItalic 525 | prop.className "is-justify-content-center" 526 | prop.text "no event yet..." ] 527 | else 528 | 529 | table.fixed' 530 | [ match model.EventView with 531 | | All -> 532 | Html.tr 533 | [ Html.th 534 | [ prop.style [ style.width (length.calc "30% - 5em") ] 535 | prop.text "Stream" ] 536 | Html.th [ prop.style [ style.width (length.em 5) ]; prop.text "Event#" ] 537 | Html.th 538 | [ prop.style [ style.width (length.calc "40% - 5em") ] 539 | prop.text "Type" ] 540 | Html.th 541 | [ prop.style [ style.width (length.calc "30% - 5em") ] 542 | prop.text "Data" ] ] 543 | 544 | for streamid, eventNumber, eventType, eventData in List.rev model.Events do 545 | Html.tr 546 | [ table.tdEllipsis.a 547 | [ prop.className "is-cell" 548 | prop.text streamid 549 | prop.onClick (fun _ -> 550 | dispatch (ChangeEventView(Stream streamid))) ] 551 | table.tdEllipsis 552 | [ prop.text eventNumber 553 | prop.style 554 | [ style.textAlign.right 555 | style.paddingRight (length.em 1) ] ] 556 | table.tdEllipsis [ prop.className "is-cell"; prop.text eventType ] 557 | table.tdEllipsis [ prop.className "is-cell"; prop.text eventData ] ] 558 | | Stream stream -> 559 | Html.tr 560 | [ Html.th [ prop.style [ style.width (length.em 5) ]; prop.text "Event#" ] 561 | Html.th 562 | [ prop.style [ style.width (length.calc "65% - 5em") ] 563 | prop.text "Type" ] 564 | Html.th 565 | [ prop.style [ style.width (length.calc "35% - 5em") ] 566 | prop.text "Data" ] ] 567 | 568 | for _, eventNumber, eventType, eventData in 569 | model.Events |> List.filter (fun (id, _, _, _) -> id = stream) |> List.rev do 570 | Html.tr 571 | [ Html.td 572 | [ prop.text eventNumber 573 | prop.style 574 | [ style.textAlign.right 575 | style.paddingRight (length.em 1) ] ] 576 | table.tdEllipsis [ prop.className "is-cell"; prop.text eventType ] 577 | table.tdEllipsis [ prop.className "is-cell"; prop.text eventData ] ] 578 | 579 | 580 | ] 581 | 582 | ] 583 | 584 | 585 | 586 | ] 587 | Bulma.footer 588 | [ Bulma.content 589 | [ Bulma.text.hasTextCentered 590 | prop.children 591 | [ Html.a 592 | [ prop.style [ style.fontStyle.italic ] 593 | //Bulma.color.hasTextGrey 594 | 595 | prop.text "// thinkbeforecoding" 596 | prop.href "https://thinkbeforecoding.com" ] 597 | Html.text " " 598 | Html.a 599 | [ prop.children [ Bulma.icon [ Html.i [ prop.className "fab fa-twitter" ] ] ] 600 | prop.href "https://twitter.com/thinkb4coding" ] 601 | Html.text " " 602 | Html.a 603 | [ prop.children [ Bulma.icon [ Html.i [ prop.className "fab fa-mastodon" ] ] ] 604 | prop.href "https://mastodon.social/@thinkb4coding" ] 605 | Html.text " " 606 | Html.a 607 | [ prop.children [ Bulma.icon [ Html.i [ prop.className "fab fa-github" ] ] ] 608 | prop.href "https://github.com/thinkbeforecoding/eskv" ] ] ] ] ] ] 609 | 610 | #if DEBUG 611 | open Elmish.HMR 612 | #endif 613 | 614 | let ws model = 615 | let sub dispatch = 616 | let loc = window.location 617 | let ws = WebSocket.Create($"ws://{loc.hostname}:{loc.port}/ws") 618 | 619 | ws.onmessage <- 620 | (fun e -> 621 | 622 | match Thoth.Json.Decode.Auto.fromString (string e.data) with 623 | | Ok msg -> dispatch (Server msg) 624 | | Error e -> console.error (e)) 625 | 626 | Cmd.ofSub sub 627 | 628 | 629 | Program.mkProgram init update view 630 | |> Program.withReactBatched "elmish-app" 631 | |> Program.withSubscription ws 632 | |> Program.run 633 | -------------------------------------------------------------------------------- /src/eskv.client/eskv.fs: -------------------------------------------------------------------------------- 1 | namespace eskv 2 | 3 | open FSharp.Control 4 | open System 5 | open System.Net.Http 6 | 7 | open System.Net 8 | open System.Threading 9 | open System.Threading.Tasks 10 | open System.Linq 11 | open System.Collections.Generic 12 | open HttpMultipartParser 13 | 14 | /// Contains result information for a operation. 15 | type LoadResult = 16 | { KeyExists: bool 17 | Value: string 18 | ETag: string } 19 | 20 | 21 | /// Contains event data for a operation. 22 | [] 23 | type EventData = { EventType: string; Data: string } 24 | 25 | /// Contains event data returned by . 26 | [] 27 | type EventRecord = 28 | { EventType: string 29 | EventNumber: int 30 | Data: string 31 | Stream: string 32 | OriginStream: string 33 | OriginEventNumber: int } 34 | 35 | 36 | /// Indicates whether a stream exists. 37 | type StreamState = 38 | | NoStream = 0 39 | | StreamExists = 1 40 | 41 | /// A slice of stream returned by . 42 | type Slice = 43 | { State: StreamState 44 | Events: EventRecord[] 45 | ExpectedVersion: int 46 | NextEventNumber: int 47 | EndOfStream: bool } 48 | 49 | /// A slice of stream list returned by . 50 | type StreamsSlice = 51 | { State: StreamState 52 | Streams: string[] 53 | LastEventNumber: int 54 | NextEventNumber: int } 55 | 56 | /// Contains result information for a operation. 57 | type AppendResult = 58 | { Success: bool 59 | ExpectedVersion: int 60 | NextEventNumber: int } 61 | 62 | /// Contains result information for a operation. 63 | type AppendOrReadResult = 64 | { Success: bool 65 | NewEvents: EventRecord[] 66 | ExpectedVersion: int 67 | NextEventNumber: int } 68 | 69 | type StreamResult = 70 | { State: StreamState 71 | Events: IAsyncEnumerable 72 | ExpectedVersion: int 73 | NextEventNumber: int } 74 | 75 | module private Http = 76 | 77 | let (|Success|Failure|) (statusCode: HttpStatusCode) = 78 | if int statusCode >= 200 && int statusCode <= 299 then 79 | Success 80 | else 81 | Failure 82 | 83 | module HttpMultipartParser = 84 | type FilePart with 85 | 86 | member part.TryGetHeader(header) = 87 | if part.AdditionalProperties.ContainsKey(header) then 88 | Some part.AdditionalProperties[header] 89 | else 90 | None 91 | 92 | open HttpMultipartParser 93 | open System.Buffers 94 | 95 | module Headers = 96 | [] 97 | let EventType = "ESKV-Event-Type: " 98 | 99 | [] 100 | let EventNumber = "ESKV-Event-Number: " 101 | 102 | [] 103 | let OriginStream = "ESKV-Origin-Stream: " 104 | 105 | [] 106 | let OriginEventNumber = "ESKV-Origin-Event-Number: " 107 | 108 | module ExpectedVersion = 109 | [] 110 | let Start = 0 111 | 112 | [] 113 | let NoStream = -1 114 | 115 | [] 116 | let Any = -2 117 | 118 | module EventNumber = 119 | [] 120 | let Start = 0 121 | 122 | module EventCount = 123 | [] 124 | let All = Int32.MaxValue 125 | 126 | 127 | 128 | /// Creates a new instance of to use 129 | /// eskv in-memory key/value and event store. 130 | /// The uri of the eskv server. 131 | type EskvClient(uri: Uri) = 132 | let kv = Uri(uri, "kv/") 133 | let es = Uri(uri, "es/") 134 | 135 | [] 136 | let defaultContainer = "default" 137 | 138 | let raiseHttpException (response: HttpResponseMessage) = 139 | raise (HttpRequestException(response.ReasonPhrase, null, Nullable response.StatusCode)) 140 | 141 | let readEvents (response: HttpResponseMessage) = 142 | task { 143 | if response.StatusCode = HttpStatusCode.NoContent then 144 | return [||] 145 | else 146 | let streamId = response.Headers.GetValues("ESKV-Stream").First() 147 | 148 | use! stream = response.Content.ReadAsStreamAsync() 149 | let! reader = MultipartFormDataParser.ParseAsync(stream) 150 | 151 | 152 | let events = ResizeArray() 153 | 154 | for section in reader.Files do 155 | 156 | 157 | let! eventType, data = 158 | task { 159 | match section.TryGetHeader("eskv-event-type") with 160 | | Some eth -> 161 | let eventType = eth.ToString() 162 | 163 | 164 | return! 165 | task { 166 | use streamReader = new IO.StreamReader(section.Data) 167 | let! data = streamReader.ReadToEndAsync() 168 | return eventType, data 169 | } 170 | 171 | | None -> return "$>", null 172 | } 173 | 174 | let eventNumber = 175 | section.AdditionalProperties[ "eskv-event-number" ].ToString() |> int 176 | 177 | let originEventNumber = 178 | match section.TryGetHeader("eskv-origin-event-number") with 179 | | Some h -> h.ToString() |> int 180 | | None -> eventNumber 181 | 182 | let originStream = 183 | match section.TryGetHeader("eskv-origin-stream") with 184 | | Some h -> h.ToString() 185 | | None -> streamId 186 | 187 | events.Add( 188 | { EventType = eventType 189 | EventNumber = eventNumber 190 | Data = data 191 | Stream = streamId 192 | OriginEventNumber = originEventNumber 193 | OriginStream = originStream } 194 | ) 195 | 196 | return events.ToArray() 197 | } 198 | 199 | let readStreamForwardAsync stream start count linkOnly = 200 | task { 201 | use client = new HttpClient() 202 | 203 | let request = 204 | new HttpRequestMessage(HttpMethod.Get, Uri(es, $"%s{stream}/%d{start}/%d{count}")) 205 | 206 | if linkOnly then 207 | request.Headers.Add("ESKV-Link-Only", "") 208 | 209 | let! response = client.SendAsync(request) 210 | 211 | if response.IsSuccessStatusCode then 212 | let nextExpectedVersion = 213 | response.Headers.GetValues("ESKV-Expected-Version").First() |> int 214 | 215 | let nextEventNumber = 216 | response.Headers.GetValues("ESKV-Next-Event-Number").First() |> int 217 | 218 | let endOfStream = 219 | match response.Headers.TryGetValues("ESKV-End-Of-Stream") with 220 | | true, values -> values |> Seq.map Boolean.Parse |> Seq.head 221 | | false, _ -> false 222 | 223 | let! events = readEvents response 224 | 225 | return 226 | { State = StreamState.StreamExists 227 | Events = events 228 | ExpectedVersion = nextExpectedVersion 229 | NextEventNumber = nextEventNumber 230 | EndOfStream = endOfStream } 231 | elif response.StatusCode = HttpStatusCode.NotFound then 232 | return 233 | { State = StreamState.NoStream 234 | Events = [||] 235 | ExpectedVersion = -1 236 | NextEventNumber = 0 237 | EndOfStream = true } 238 | else 239 | return failwithf "%s" response.ReasonPhrase 240 | } 241 | 242 | let getStreamAsync eskv stream start linkOnly = 243 | task { 244 | use client = new HttpClient() 245 | 246 | let request = 247 | new HttpRequestMessage(HttpMethod.Get, Uri(es, $"%s{stream}/%d{start}/100")) 248 | 249 | if linkOnly then 250 | request.Headers.Add("ESKV-Link-Only", "") 251 | 252 | let! response = client.SendAsync(request) 253 | 254 | if response.IsSuccessStatusCode then 255 | let nextExpectedVersion = 256 | response.Headers.GetValues("ESKV-Expected-Version").First() |> int 257 | 258 | let nextEventNumber = 259 | response.Headers.GetValues("ESKV-Next-Event-Number").First() |> int 260 | 261 | let nextStreamExpectedVersion = 262 | response.Headers.GetValues("ESKV-Stream-Expected-Version").First() |> int 263 | 264 | let nextStreamEventNumber = 265 | response.Headers.GetValues("ESKV-Stream-Next-Event-Number").First() |> int 266 | 267 | let endOfStream = 268 | match response.Headers.TryGetValues("ESKV-End-Of-Stream") with 269 | | true, values -> values |> Seq.map Boolean.Parse |> Seq.head 270 | | false, _ -> false 271 | 272 | let! events = readEvents response 273 | 274 | let slice = 275 | { Slice.State = StreamState.StreamExists 276 | Slice.Events = events 277 | Slice.ExpectedVersion = nextExpectedVersion 278 | Slice.NextEventNumber = nextEventNumber 279 | Slice.EndOfStream = endOfStream } 280 | 281 | return 282 | { State = slice.State 283 | Events = new SliceEnumerable(eskv, stream, linkOnly, slice) 284 | ExpectedVersion = nextStreamExpectedVersion 285 | NextEventNumber = nextStreamEventNumber } 286 | elif response.StatusCode = HttpStatusCode.NotFound then 287 | return 288 | { State = StreamState.NoStream 289 | Events = 290 | { new IAsyncEnumerable with 291 | member _.GetAsyncEnumerator(_) = 292 | { new IAsyncEnumerator with 293 | member _.MoveNextAsync() = ValueTask.FromResult(false) 294 | member _.Current = failwith "The collection is empty" 295 | member _.DisposeAsync() = ValueTask.CompletedTask } } 296 | ExpectedVersion = -1 297 | NextEventNumber = 0 } 298 | else 299 | return failwithf "%s" response.ReasonPhrase 300 | } 301 | 302 | 303 | 304 | 305 | /// Creates a new instance of to use 306 | /// eskv in-memory key/value and event store. Use the default 307 | /// http://localhost:5000 eskv server uri. 308 | new() = EskvClient(Uri "http://localhost:5000") 309 | 310 | /// Loads the value from the key in the specified container if it exists. 311 | /// The name of the container of the key. 312 | /// The key used to store the value. 313 | /// Returns a indicating if the key exists, and its 314 | /// value if any. 315 | member _.TryLoadAsync(container: string, key: string) = 316 | task { 317 | use client = new HttpClient() 318 | let! response = client.GetAsync(Uri(kv, container + "/" + key)) 319 | 320 | match response.StatusCode with 321 | | HttpStatusCode.NotFound -> 322 | return 323 | { KeyExists = false 324 | Value = null 325 | ETag = null } 326 | | Http.Success -> 327 | let etag = 328 | match response.Headers.ETag with 329 | | null -> null 330 | | t -> t.Tag 331 | 332 | let! data = response.Content.ReadAsStringAsync() 333 | 334 | 335 | return 336 | { KeyExists = true 337 | Value = data 338 | ETag = etag } 339 | | _ -> return raiseHttpException (response) 340 | 341 | } 342 | 343 | 344 | /// Loads the value from the key in the default container if it exists. 345 | /// The key used to store the value. 346 | /// Returns a indicating if the key exists, and its 347 | /// value if any. 348 | member this.TryLoadAsync(key: string) = 349 | this.TryLoadAsync(defaultContainer, key) 350 | 351 | /// Loads the value from the key in the specified container if it exists. 352 | /// The name of the container of the key. 353 | /// The key used to store the value. 354 | /// Returns a indicating if the key exists, and its 355 | /// value if any. 356 | member this.TryLoad(container, key) = 357 | this.TryLoadAsync(container, key).Result 358 | 359 | /// Loads the value from the key in the default container if it exists. 360 | /// The key used to store the value. 361 | /// Returns a indicating if the key exists, and its 362 | /// value if any. 363 | member this.TryLoad(key) = this.TryLoadAsync(key).Result 364 | 365 | /// Saves the given value under specified container/key. The value is actually 366 | /// updated only if the etag corresponds to current value. Etag can be obtained either 367 | /// from this function returned value, or by calling . 368 | /// The name of the container of the key. 369 | /// The key used to store the value. 370 | /// The value to store under container/key 371 | /// The etag value used to validate that the value did not change. 372 | /// Use null to check that the key does not already exist. 373 | /// The new etag, or null if the etag did not match 374 | member _.TrySaveAsync(container: string, key: string, value: string, etag: string) = 375 | task { 376 | use client = new HttpClient() 377 | 378 | if not (isNull etag) then 379 | client.DefaultRequestHeaders.IfMatch.Add(Headers.EntityTagHeaderValue.Parse(etag)) 380 | else 381 | client.DefaultRequestHeaders.IfNoneMatch.Add(Headers.EntityTagHeaderValue.Any) 382 | 383 | let! response = client.PutAsync(Uri(kv, container + "/" + key), new StringContent(value)) 384 | 385 | match response.StatusCode with 386 | | HttpStatusCode.Conflict -> return null 387 | | Http.Success -> return response.Headers.ETag.Tag 388 | | _ -> return raiseHttpException (response) 389 | 390 | } 391 | 392 | /// Saves the given value under specified key in the default container. The value is actually 393 | /// updated only if the etag corresponds to current value. Etag can be obtained either 394 | /// from this function returned value, or by calling . 395 | /// The key used to store the value. 396 | /// The value to store under key 397 | /// The etag value used to validate that the value did not change. 398 | /// Use null to check that the key does not already exist. 399 | /// The new etag, or null if the etag did not match 400 | member this.TrySaveAsync(key: string, value: string, etag: string) = 401 | this.TrySaveAsync(defaultContainer, key, value, etag) 402 | 403 | /// Saves the given value under specified container/key. The value is actually 404 | /// updated only if the etag corresponds to current value. Etag can be obtained either 405 | /// from this function returned value, or by calling . 406 | /// The name of the container of the key. 407 | /// The key used to store the value. 408 | /// The value to store under container/key 409 | /// The etag value used to validate that the value did not change. 410 | /// Use null to check that the key does not already exist. 411 | /// The new etag, or null if the etag did not match 412 | member this.TrySave(container, key, value, etag) = 413 | this.TrySaveAsync(container, key, value, etag).Result 414 | 415 | /// Saves the given value under specified key in the default container. The value is actually 416 | /// updated only if the etag corresponds to current value. Etag can be obtained either 417 | /// from this function returned value, or by calling . 418 | /// The key used to store the value. 419 | /// The value to store under key 420 | /// The etag value used to validate that the value did not change. 421 | /// Use null to check that the key does not already exist. 422 | /// The new etag, or null if the etag did not match 423 | member this.TrySave(key, value, etag) = 424 | this.TrySaveAsync(key, value, etag).Result 425 | 426 | 427 | 428 | /// Saves the given value under specified container/key. The value is 429 | /// updated without checking the etag. 430 | /// The name of the container of the key. 431 | /// The key used to store the value. 432 | /// The value to store under container/key 433 | member _.SaveAsync(container: string, key: string, value: string) = 434 | task { 435 | use client = new HttpClient() 436 | 437 | let! response = client.PutAsync(Uri(kv, container + "/" + key), new StringContent(value)) 438 | 439 | if not response.IsSuccessStatusCode then 440 | return raiseHttpException (response) 441 | } 442 | :> Task 443 | 444 | /// Saves the given value under specified key in the default container. 445 | /// The value is updated without checking the etag. 446 | /// The key used to store the value. 447 | /// The value to store under key 448 | member this.SaveAsync(key: string, value: string) = 449 | this.SaveAsync(defaultContainer, key, value) 450 | 451 | /// Saves the given value under specified container/key. The value is 452 | /// updated without checking the etag. 453 | /// The name of the container of the key. 454 | /// The key used to store the value. 455 | /// The value to store under container/key 456 | member this.Save(container, key, value) = 457 | this.SaveAsync(container, key, value).Wait() 458 | 459 | /// Saves the given value under specified key in the default container. 460 | /// The value is updated without checking the etag. 461 | /// The key used to store the value. 462 | /// The value to store under key 463 | member this.Save(key, value) = this.SaveAsync(key, value).Wait() 464 | 465 | 466 | /// Deletes a key from a container. 467 | /// The name of the container of the key. 468 | /// The key used to store the value. 469 | member _.DeleteKeyAsync(container: string, key: string) = 470 | task { 471 | use client = new HttpClient() 472 | 473 | let! response = client.DeleteAsync(Uri(kv, container + "/" + key)) 474 | 475 | if 476 | not response.IsSuccessStatusCode 477 | && response.StatusCode <> HttpStatusCode.NotFound 478 | then 479 | return raiseHttpException (response) 480 | } 481 | :> Task 482 | 483 | /// Deletes a key from the default container. 484 | /// The key used to store the value. 485 | member this.DeleteKeyAsync(key: string) = 486 | this.DeleteKeyAsync(defaultContainer, key) 487 | 488 | /// Deletes a key from a container. 489 | /// The name of the container of the key. 490 | /// The key used to store the value. 491 | member this.DeleteKey(container, key: string) = 492 | this.DeleteKeyAsync(container, key).Wait() 493 | 494 | /// Deletes a key from the default container. 495 | /// The key used to store the value. 496 | member this.DeleteKey(key: string) = this.DeleteKeyAsync(key).Wait() 497 | 498 | /// Deletes a container and all keys it contains. 499 | /// The name of the container. 500 | member _.DeleteContainerAsync(container: string) = 501 | task { 502 | use client = new HttpClient() 503 | 504 | let! response = client.DeleteAsync(Uri(kv, container)) 505 | 506 | if 507 | not response.IsSuccessStatusCode 508 | && response.StatusCode <> HttpStatusCode.NotFound 509 | then 510 | return raiseHttpException (response) 511 | } 512 | :> Task 513 | 514 | /// Deletes a container and all keys it contains. 515 | /// The name of the container. 516 | member this.DeleteContainer(container: string) = 517 | this.DeleteContainerAsync(container).Wait() 518 | 519 | /// Get all the keys from specified container. 520 | /// The name of the container. 521 | /// Returns an array with all the keys. 522 | member _.GetKeysAsync(container: string) = 523 | task { 524 | use client = new HttpClient() 525 | 526 | let! response = client.GetStringAsync(Uri(kv, container)) 527 | return response.Split("\n", StringSplitOptions.RemoveEmptyEntries) 528 | } 529 | 530 | /// Get all the keys from the default container. 531 | /// Returns an array with all the keys. 532 | member this.GetKeysAsync() = this.GetKeysAsync(defaultContainer) 533 | 534 | /// Get all the keys from specified container. 535 | /// The name of the container. 536 | /// Returns an array with all the keys. 537 | member this.GetKeys(container) = this.GetKeysAsync(container).Result 538 | 539 | /// Get all the keys from the default container. 540 | /// Returns an array with all the keys. 541 | member this.GetKeys() = this.GetKeysAsync().Result 542 | 543 | /// Get a list of all containers names. 544 | /// Returns an array with all the containers names. 545 | member _.GetContainersAsync() = 546 | task { 547 | use client = new HttpClient() 548 | 549 | let! response = client.GetStringAsync(kv) 550 | return response.Split("\n", StringSplitOptions.RemoveEmptyEntries) 551 | } 552 | 553 | /// Get a list of all containers names. 554 | /// Returns an array with all the containers names. 555 | member this.GetContainers() = this.GetContainersAsync().Result 556 | 557 | /// Appends specified events to the end of a stream. 558 | /// The name of the stream. 559 | /// A sequence of structures containing 560 | /// events informaiton. 561 | member _.AppendAsync(stream: string, events: EventData seq) = 562 | task { 563 | match events.TryGetNonEnumeratedCount() with 564 | | true, 0 -> () 565 | | _ -> 566 | use client = new HttpClient() 567 | 568 | let content = new MultipartContent() 569 | let mutable hasParts = false 570 | 571 | for e in events do 572 | hasParts <- true 573 | let part = new StringContent(e.Data) 574 | part.Headers.Add("ESKV-Event-Type", e.EventType) 575 | part.Headers.ContentType.MediaType <- "text/plain" 576 | 577 | content.Add(part) 578 | 579 | 580 | if hasParts then 581 | let! response = client.PostAsync(Uri(es, stream), content) 582 | 583 | if not response.IsSuccessStatusCode then 584 | return raiseHttpException (response) 585 | 586 | } 587 | :> Task 588 | 589 | 590 | /// Appends specified events to the end of a stream. 591 | /// The name of the stream. 592 | /// A sequence of structures containing 593 | /// events informaiton. 594 | member this.Append(stream: string, events) = this.AppendAsync(stream, events).Wait() 595 | 596 | /// Appends specified events to the end of a stream, if the stream's 597 | /// last event number matches provided expected version. 598 | /// The name of the stream. 599 | /// The expected number of the last event in the stream. 600 | /// A sequence of structures containing 601 | /// events informaiton. 602 | /// Returns an indicating if the operation 603 | /// succeeded, and in case of success, the new version to expect, as well as next event number. 604 | member _.TryAppendAsync(stream: string, expectedVersion: int, events: EventData seq) = 605 | task { 606 | use client = new HttpClient() 607 | 608 | let content = new MultipartContent() 609 | content.Headers.Add("ESKV-Expected-Version", string expectedVersion) 610 | 611 | for e in events do 612 | let part = new StringContent(e.Data) 613 | part.Headers.Add("ESKV-Event-Type", e.EventType) 614 | part.Headers.ContentType.MediaType <- "text/plain" 615 | 616 | content.Add(part) 617 | 618 | 619 | let! response = client.PostAsync(Uri(es, stream), content) 620 | 621 | if response.IsSuccessStatusCode then 622 | let nextExpectedVersion = 623 | response.Headers.GetValues("ESKV-Expected-Version").First() |> int 624 | 625 | let nextEventNumber = 626 | response.Headers.GetValues("ESKV-Next-Event-Number").First() |> int 627 | 628 | return 629 | { Success = true 630 | ExpectedVersion = nextExpectedVersion 631 | NextEventNumber = nextEventNumber } 632 | elif response.StatusCode = HttpStatusCode.Conflict then 633 | return 634 | { Success = false 635 | ExpectedVersion = 0 636 | NextEventNumber = 0 } 637 | else 638 | return raiseHttpException (response) 639 | 640 | } 641 | 642 | 643 | /// Appends specified events to the end of a stream, if the stream's 644 | /// last event number matches provided expected version. 645 | /// The name of the stream. 646 | /// The expected number of the last event in the stream. 647 | /// A sequence of structures containing 648 | /// events informaiton. 649 | /// Returns an indicating if the operation 650 | /// succeeded, and in case of success, the new version to expect, as well as next event number. 651 | member this.TryAppend(stream: string, expectedVersion, events) = 652 | this.TryAppendAsync(stream, expectedVersion, events).Result 653 | 654 | 655 | /// Appends specified events to the end of a stream, if the stream's 656 | /// last event number matches provided expected version. If the expected version does not 657 | /// match, returns the events that have been added since expected version. 658 | /// The name of the stream. 659 | /// The expected number of the last event in the stream. 660 | /// A sequence of structures containing 661 | /// events informaiton. 662 | /// Returns an indicating if the operation 663 | /// succeeded, the new version to expect, as well as next event number, 664 | /// and in case of failure, the list of new events. 665 | member _.TryAppendOrReadAsync(stream: string, expectedVersion: int, events: EventData seq) = 666 | task { 667 | use client = new HttpClient() 668 | 669 | let content = new MultipartContent() 670 | content.Headers.Add("ESKV-Expected-Version", string expectedVersion) 671 | content.Headers.Add("ESKV-Return-New-Events", "") 672 | 673 | for e in events do 674 | let part = new StringContent(e.Data) 675 | part.Headers.Add("ESKV-Event-Type", e.EventType) 676 | part.Headers.ContentType.MediaType <- "text/plain" 677 | content.Add(part) 678 | 679 | 680 | let! response = client.PostAsync(Uri(es, stream), content) 681 | 682 | if response.IsSuccessStatusCode then 683 | let nextExpectedVersion = 684 | response.Headers.GetValues("ESKV-Expected-Version").First() |> int 685 | 686 | let nextEventNumber = 687 | response.Headers.GetValues("ESKV-Next-Event-Number").First() |> int 688 | 689 | return 690 | { Success = true 691 | NewEvents = Array.Empty() 692 | ExpectedVersion = nextExpectedVersion 693 | NextEventNumber = nextEventNumber } 694 | elif response.StatusCode = HttpStatusCode.Conflict then 695 | let! newEvents = readEvents response 696 | 697 | let nextExpectedVersion = 698 | response.Headers.GetValues("ESKV-Expected-Version").First() |> int 699 | 700 | let nextEventNumber = 701 | response.Headers.GetValues("ESKV-Next-Event-Number").First() |> int 702 | 703 | return 704 | { Success = false 705 | NewEvents = newEvents 706 | ExpectedVersion = nextExpectedVersion 707 | NextEventNumber = nextEventNumber } 708 | else 709 | return raiseHttpException (response) 710 | 711 | } 712 | 713 | 714 | /// Appends specified events to the end of a stream, if the stream's 715 | /// last event number matches provided expected version. If the expected version does not 716 | /// match, returns the events that have been added since expected version. 717 | /// The name of the stream. 718 | /// The expected number of the last event in the stream. 719 | /// A sequence of structures containing 720 | /// events informaiton. 721 | /// Returns an indicating if the operation 722 | /// succeeded, the new version to expect, as well as next event number, 723 | /// and in case of failure, the list of new events. 724 | member this.TryAppendOrRead(stream: string, expectedVersion, events) = 725 | this.TryAppendOrReadAsync(stream, expectedVersion, events).Result 726 | 727 | /// Read events from stream starting from specified event number. 728 | /// The name of the stream. 729 | /// The number of the first event included. Use 730 | /// to start from the begining. 731 | /// The number of events to return. Use 732 | /// to return all events. 733 | /// Return only link information without original event data for links. 734 | /// Returns events starting after start position 735 | /// Returns a that contains events, the state of the stream, 736 | /// the version to expect on next operation, and the next event number. 737 | member _.ReadStreamForwardAsync(stream: string, start: int, count: int, linkOnly: bool, startExcluded: bool) = 738 | let start = if startExcluded then start + 1 else start 739 | readStreamForwardAsync stream start count linkOnly 740 | 741 | /// Read events from stream starting from specified event number. 742 | /// The name of the stream. 743 | /// The number of the first event included. Use 744 | /// to start from the begining. 745 | /// The number of events to return. Use 746 | /// to return all events. 747 | /// Return only link information without original event data for links. 748 | /// Returns a that contains events, the state of the stream, 749 | /// the version to expect on next operation, and the next event number. 750 | member this.ReadStreamForward(stream: string, start: int, count: int, linkOnly: bool, startExcluded: bool) = 751 | this 752 | .ReadStreamForwardAsync( 753 | stream, 754 | start, 755 | count, 756 | linkOnly, 757 | startExcluded 758 | ) 759 | .Result 760 | 761 | /// Read events from stream starting from specified event number. 762 | /// The name of the stream. 763 | /// The number of the first event included. Use 764 | /// to start from the begining. 765 | /// The number of events to return. Use 766 | /// to return all events. 767 | /// Return only link information without original event data for links. 768 | /// Returns a that contains events, the state of the stream, 769 | /// the version to expect on next operation, and the next event number. 770 | member this.ReadStreamForwardAsync(stream: string, start: int, count: int, linkOnly: bool) = 771 | this.ReadStreamForwardAsync(stream, start, count, linkOnly, false) 772 | 773 | 774 | /// Read events from stream starting from specified event number. 775 | /// The name of the stream. 776 | /// The number of the first event included. Use 777 | /// to start from the begining. 778 | /// The number of events to return. Use 779 | /// to return all events. 780 | /// Return only link information without original event data for links. 781 | /// Returns a that contains events, the state of the stream, 782 | /// the version to expect on next operation, and the next event number. 783 | member this.ReadStreamForward(stream: string, start: int, count: int, linkOnly: bool) = 784 | this.ReadStreamForwardAsync(stream, start, count, linkOnly).Result 785 | 786 | /// Read events from stream starting from specified event number. 787 | /// The name of the stream. 788 | /// The number of the first event included. Use 789 | /// to start from the begining. 790 | /// The number of events to return. Use 791 | /// to return all events. 792 | /// Returns a that contains events, the state of the stream, 793 | /// the version to expect on next operation, and the next event number. 794 | member this.ReadStreamForwardAsync(stream: string, start: int, count: int) = 795 | this.ReadStreamForwardAsync(stream, start, count, false) 796 | 797 | 798 | 799 | 800 | /// Read events from stream starting from specified event number. 801 | /// The name of the stream. 802 | /// The number of the first event included. Use 803 | /// to start from the begining. 804 | /// The number of events to return. Use 805 | /// to return all events. 806 | /// Returns a that contains events, the state of the stream, 807 | /// the version to expect on next operation, and the next event number. 808 | member this.ReadStreamForward(stream: string, start: int, count: int) = 809 | this.ReadStreamForwardAsync(stream, start, count).Result 810 | 811 | /// Read events from stream starting from specified event number. 812 | /// The name of the stream. 813 | /// The number of the first event included. Use 814 | /// to start from the begining. 815 | /// The number of events to return. Use 816 | /// to return all events. 817 | /// Returns a that contains events, the state of the stream, 818 | /// the version to expect on next operation, and the next event number. 819 | member this.ReadStreamForwardAsync(stream: string, start: int) = 820 | this.ReadStreamForwardAsync(stream, start, Int32.MaxValue) 821 | 822 | 823 | 824 | 825 | /// Read events from stream starting from specified event number. 826 | /// The name of the stream. 827 | /// The number of the first event included. Use 828 | /// to start from the begining. 829 | /// The number of events to return. Use 830 | /// to return all events. 831 | /// Returns a that contains events, the state of the stream, 832 | /// the version to expect on next operation, and the next event number. 833 | member this.ReadStreamForward(stream: string, start: int) = 834 | this.ReadStreamForwardAsync(stream, start).Result 835 | 836 | 837 | /// Read events from stream starting from specified event number. 838 | /// The name of the stream. 839 | /// The number of the first event included. Use 840 | /// to start from the begining. 841 | /// The number of events to return. Use 842 | /// to return all events. 843 | /// Return only link information without original event data for links. 844 | /// Returns a that contains events, the state of the stream, 845 | /// the version to expect on next operation, and the next event number. 846 | member this.ReadStreamSinceAsync(stream: string, start: int, count: int, linkOnly: bool) = 847 | this.ReadStreamForwardAsync(stream, start, count, linkOnly, true) 848 | 849 | /// Read events from stream starting from specified event number. 850 | /// The name of the stream. 851 | /// The number of the first event included. Use 852 | /// to start from the begining. 853 | /// The number of events to return. Use 854 | /// to return all events. 855 | /// Return only link information without original event data for links. 856 | /// Returns a that contains events, the state of the stream, 857 | /// the version to expect on next operation, and the next event number. 858 | member this.ReadStreamSince(stream: string, start: int, count: int, linkOnly: bool) = 859 | this.ReadStreamSinceAsync(stream, start, count, linkOnly).Result 860 | 861 | 862 | /// Read events from stream starting from specified event number. 863 | /// The name of the stream. 864 | /// The number of the first event included. Use 865 | /// to start from the begining. 866 | /// The number of events to return. Use 867 | /// to return all events. 868 | /// Return only link information without original event data for links. 869 | /// Returns a that contains events, the state of the stream, 870 | /// the version to expect on next operation, and the next event number. 871 | member this.ReadStreamSinceAsync(stream: string, start: int, count: int) = 872 | this.ReadStreamForwardAsync(stream, start, count, false) 873 | 874 | /// Read events from stream starting from specified event number. 875 | /// The name of the stream. 876 | /// The number of the first event included. Use 877 | /// to start from the begining. 878 | /// The number of events to return. Use 879 | /// to return all events. 880 | /// Return only link information without original event data for links. 881 | /// Returns a that contains events, the state of the stream, 882 | /// the version to expect on next operation, and the next event number. 883 | member this.ReadStreamSince(stream: string, start: int, count: int) = 884 | this.ReadStreamSinceAsync(stream, start, count).Result 885 | 886 | /// Read events from stream starting from specified event number. 887 | /// The name of the stream. 888 | /// The number of the first event included. Use 889 | /// to start from the begining. 890 | /// The number of events to return. Use 891 | /// to return all events. 892 | /// Return only link information without original event data for links. 893 | /// Returns a that contains events, the state of the stream, 894 | /// the version to expect on next operation, and the next event number. 895 | member this.ReadStreamSinceAsync(stream: string, start: int) = 896 | this.ReadStreamForwardAsync(stream, start, Int32.MaxValue, false) 897 | 898 | /// Read events from stream starting from specified event number. 899 | /// The name of the stream. 900 | /// The number of the first event included. Use 901 | /// to start from the begining. 902 | /// The number of events to return. Use 903 | /// to return all events. 904 | /// Return only link information without original event data for links. 905 | /// Returns a that contains events, the state of the stream, 906 | /// the version to expect on next operation, and the next event number. 907 | member this.ReadStreamSince(stream: string, start: int) = 908 | this.ReadStreamSinceAsync(stream, start).Result 909 | 910 | 911 | /// Read events from stream starting from specified event number. 912 | /// The name of the stream. 913 | /// The number of the first event included. Use 914 | /// to start from the begining. 915 | /// The number of events to return. Use 916 | /// to return all events. 917 | /// Return only link information without original event data for links. 918 | /// Returns events starting after start position 919 | /// Returns a that contains events, the state of the stream, 920 | /// the version to expect on next operation, and the next event number. 921 | member this.GetStreamAsync(stream: string, start: int, linkOnly: bool, startExcluded: bool) = 922 | let start = if startExcluded then start + 1 else start 923 | getStreamAsync this stream start linkOnly 924 | 925 | 926 | 927 | 928 | 929 | 930 | 931 | 932 | /// Gets the list of the stream names in creation order, starting from specified one. 933 | /// The number of the first stream included. Use 934 | /// to start from the begining. 935 | /// The number of streamss to return. Use 936 | /// to return all events. 937 | /// Returns a that contains stream names, 938 | /// the last stream number, and the next stream number. 939 | member this.GetStreamsAsync(start: int, count: int) = 940 | task { 941 | let! slice = this.ReadStreamForwardAsync("$streams", start, count, true) 942 | 943 | return 944 | { State = slice.State 945 | Streams = slice.Events |> Array.map (fun e -> e.OriginStream) 946 | LastEventNumber = slice.ExpectedVersion 947 | NextEventNumber = slice.NextEventNumber } 948 | } 949 | 950 | /// Gets the list of the stream names in creation order, starting from specified one. 951 | /// The number of the first stream included. Use 952 | /// to start from the begining. 953 | /// The number of streamss to return. Use 954 | /// to return all events. 955 | /// Returns a that contains stream names, 956 | /// the last stream number, and the next stream number. 957 | member this.GetStreams(start: int, count: int) = 958 | this.GetStreamsAsync(start, count).Result 959 | 960 | 961 | member this.SubscribeAsync 962 | ( 963 | stream: string, 964 | start: int, 965 | handler: EventRecord -> Task, 966 | cancellationToken: CancellationToken 967 | ) = 968 | task { 969 | let ws = new WebSockets.ClientWebSocket() 970 | let uri = UriBuilder(Uri(es, $"%s{stream}/%d{start}"), Scheme = "ws:").Uri 971 | do! ws.ConnectAsync(uri, cancellationToken) 972 | 973 | Task.Factory.StartNew(fun () -> 974 | task { 975 | use bytes = MemoryPool.Shared.Rent(4096) 976 | 977 | try 978 | while true do 979 | 980 | use memstream = new IO.MemoryStream() 981 | let mutable quit = false 982 | 983 | while not quit do 984 | let! result = ws.ReceiveAsync(bytes.Memory, cancellationToken) 985 | memstream.Write(bytes.Memory.Span.Slice(0, result.Count)) 986 | quit <- result.EndOfMessage 987 | 988 | memstream.Position <- 0 989 | use reader = new IO.StreamReader(memstream) 990 | let mutable line = reader.ReadLine() 991 | let mutable eventType = null 992 | let mutable eventNumber = 0 993 | let mutable originStream = null 994 | let mutable originEventNumber = 0 995 | 996 | while not (isNull line) && line.Length > 0 do 997 | if line.StartsWith(Headers.EventType) then 998 | eventType <- line.Substring(Headers.EventType.Length).Trim() 999 | 1000 | if line.StartsWith(Headers.EventNumber) then 1001 | let s = line.AsSpan().Slice(Headers.EventNumber.Length) 1002 | 1003 | match Int32.TryParse(s) with 1004 | | true, n -> eventNumber <- n 1005 | | false, _ -> () 1006 | 1007 | if line.StartsWith(Headers.OriginStream) then 1008 | originStream <- line.Substring(Headers.OriginStream.Length).Trim() 1009 | 1010 | if line.StartsWith(Headers.OriginEventNumber) then 1011 | let s = line.AsSpan().Slice(Headers.OriginEventNumber.Length) 1012 | 1013 | match Int32.TryParse(s) with 1014 | | true, n -> originEventNumber <- n 1015 | | false, _ -> () 1016 | 1017 | 1018 | line <- reader.ReadLine() 1019 | 1020 | 1021 | let data = reader.ReadToEnd() 1022 | 1023 | let event = 1024 | { EventRecord.Data = data 1025 | EventType = eventType 1026 | EventNumber = eventNumber 1027 | Stream = stream 1028 | OriginEventNumber = 1029 | if isNull originStream then 1030 | eventNumber 1031 | else 1032 | originEventNumber 1033 | OriginStream = if isNull originStream then stream else originStream } 1034 | 1035 | try 1036 | do! handler event 1037 | with ex -> 1038 | printfn "%O" ex 1039 | with 1040 | | :? TaskCanceledException -> () 1041 | | ex -> 1042 | 1043 | printfn "%O" ex 1044 | } 1045 | :> Task) 1046 | |> ignore 1047 | 1048 | } 1049 | :> Task 1050 | 1051 | member this.Subscribe(stream: string, start: int, handler: EventRecord -> unit) = 1052 | let cts = new CancellationTokenSource() 1053 | 1054 | this 1055 | .SubscribeAsync(stream, start, (fun e -> Task.FromResult(handler e)), cts.Token) 1056 | .Wait() 1057 | 1058 | { new IDisposable with 1059 | member _.Dispose() = 1060 | cts.Cancel() 1061 | cts.Dispose() } 1062 | 1063 | 1064 | member this.SubscribeAllAsync(start, handler, cancellationToken) = 1065 | this.SubscribeAsync("$all", start, handler, cancellationToken) 1066 | 1067 | member this.SubscribeAll(start, handler) = this.Subscribe("$all", start, handler) 1068 | 1069 | and SliceEnumerable(client, stream, linkOnly, slice) = 1070 | interface IAsyncEnumerable with 1071 | member _.GetAsyncEnumerator(cancellationToken: CancellationToken) = 1072 | new SliceEnumerator(client, stream, linkOnly, slice, cancellationToken) 1073 | 1074 | 1075 | and SliceEnumerator(client: EskvClient, stream, linkOnly, slice, cancellationToken: CancellationToken) = 1076 | let mutable slice = slice 1077 | let mutable pos = -1 1078 | let mutable ended = false 1079 | 1080 | 1081 | interface IAsyncEnumerator with 1082 | member _.MoveNextAsync() = 1083 | task { 1084 | cancellationToken.ThrowIfCancellationRequested() 1085 | 1086 | if pos >= slice.Events.Length - 1 then 1087 | if slice.EndOfStream then 1088 | ended <- true 1089 | return false 1090 | else 1091 | let! s = client.ReadStreamForwardAsync(stream, slice.NextEventNumber, 100, linkOnly) 1092 | slice <- s 1093 | 1094 | pos <- 0 1095 | return s.Events.Length <> 0 1096 | else 1097 | pos <- pos + 1 1098 | return true 1099 | } 1100 | |> ValueTask 1101 | 1102 | member _.Current = 1103 | if not ended then 1104 | slice.Events[pos] 1105 | else 1106 | failwith "Stream is already terminated" 1107 | 1108 | member _.DisposeAsync() = ValueTask.CompletedTask 1109 | --------------------------------------------------------------------------------