├── 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 | [](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 |
--------------------------------------------------------------------------------